@node9/proxy 0.2.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -319
- package/dist/cli.js +1372 -540
- package/dist/cli.mjs +1372 -540
- package/dist/index.js +744 -260
- package/dist/index.mjs +744 -260
- package/package.json +15 -8
package/dist/cli.mjs
CHANGED
|
@@ -11,6 +11,270 @@ 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
|
+
var isTestEnv = () => {
|
|
18
|
+
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";
|
|
19
|
+
};
|
|
20
|
+
function sendDesktopNotification(title, body) {
|
|
21
|
+
if (isTestEnv()) return;
|
|
22
|
+
try {
|
|
23
|
+
const safeTitle = title.replace(/"/g, '\\"');
|
|
24
|
+
const safeBody = body.replace(/"/g, '\\"');
|
|
25
|
+
if (process.platform === "darwin") {
|
|
26
|
+
const script = `display notification "${safeBody}" with title "${safeTitle}"`;
|
|
27
|
+
spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
|
|
28
|
+
} else if (process.platform === "linux") {
|
|
29
|
+
spawn("notify-send", [safeTitle, safeBody, "--icon=dialog-warning"], {
|
|
30
|
+
detached: true,
|
|
31
|
+
stdio: "ignore"
|
|
32
|
+
}).unref();
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function formatArgs(args) {
|
|
38
|
+
if (args === null || args === void 0) return "(none)";
|
|
39
|
+
if (typeof args !== "object" || Array.isArray(args)) {
|
|
40
|
+
const str = typeof args === "string" ? args : JSON.stringify(args);
|
|
41
|
+
return str.length > 200 ? str.slice(0, 200) + "\u2026" : str;
|
|
42
|
+
}
|
|
43
|
+
const entries = Object.entries(args).filter(
|
|
44
|
+
([, v]) => v !== null && v !== void 0 && v !== ""
|
|
45
|
+
);
|
|
46
|
+
if (entries.length === 0) return "(none)";
|
|
47
|
+
const MAX_FIELDS = 5;
|
|
48
|
+
const MAX_VALUE_LEN = 120;
|
|
49
|
+
const lines = entries.slice(0, MAX_FIELDS).map(([key, val]) => {
|
|
50
|
+
const str = typeof val === "string" ? val : JSON.stringify(val);
|
|
51
|
+
const truncated = str.length > MAX_VALUE_LEN ? str.slice(0, MAX_VALUE_LEN) + "\u2026" : str;
|
|
52
|
+
return ` ${key}: ${truncated}`;
|
|
53
|
+
});
|
|
54
|
+
if (entries.length > MAX_FIELDS) {
|
|
55
|
+
lines.push(` \u2026 and ${entries.length - MAX_FIELDS} more field(s)`);
|
|
56
|
+
}
|
|
57
|
+
return lines.join("\n");
|
|
58
|
+
}
|
|
59
|
+
async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
|
|
60
|
+
if (isTestEnv()) return "deny";
|
|
61
|
+
if (process.env.NODE9_DEBUG === "1" || process.env.VITEST) {
|
|
62
|
+
console.log(`[DEBUG Native] askNativePopup called for: ${toolName}`);
|
|
63
|
+
console.log(`[DEBUG Native] isTestEnv check:`, {
|
|
64
|
+
VITEST: process.env.VITEST,
|
|
65
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
66
|
+
CI: process.env.CI,
|
|
67
|
+
isTest: isTestEnv()
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const title = locked ? `\u26A1 Node9 \u2014 Locked by Admin Policy` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Requires Approval`;
|
|
71
|
+
let message = "";
|
|
72
|
+
if (locked) {
|
|
73
|
+
message += `\u26A1 Awaiting remote approval via Slack. Local override is disabled.
|
|
74
|
+
`;
|
|
75
|
+
message += `\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
76
|
+
`;
|
|
77
|
+
}
|
|
78
|
+
message += `Tool: ${toolName}
|
|
79
|
+
`;
|
|
80
|
+
message += `Agent: ${agent || "AI Agent"}
|
|
81
|
+
`;
|
|
82
|
+
if (explainableLabel) {
|
|
83
|
+
message += `Reason: ${explainableLabel}
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
message += `
|
|
87
|
+
Arguments:
|
|
88
|
+
${formatArgs(args)}`;
|
|
89
|
+
if (!locked) {
|
|
90
|
+
message += `
|
|
91
|
+
|
|
92
|
+
Enter = Allow | Click "Block" to deny`;
|
|
93
|
+
}
|
|
94
|
+
const safeMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "'");
|
|
95
|
+
const safeTitle = title.replace(/"/g, '\\"');
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
let childProcess = null;
|
|
98
|
+
const onAbort = () => {
|
|
99
|
+
if (childProcess) {
|
|
100
|
+
try {
|
|
101
|
+
process.kill(childProcess.pid, "SIGKILL");
|
|
102
|
+
} catch {
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
resolve("deny");
|
|
106
|
+
};
|
|
107
|
+
if (signal) {
|
|
108
|
+
if (signal.aborted) return resolve("deny");
|
|
109
|
+
signal.addEventListener("abort", onAbort);
|
|
110
|
+
}
|
|
111
|
+
const cleanup = () => {
|
|
112
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
if (process.platform === "darwin") {
|
|
116
|
+
const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"`;
|
|
117
|
+
const script = `
|
|
118
|
+
tell application "System Events"
|
|
119
|
+
activate
|
|
120
|
+
display dialog "${safeMessage}" with title "${safeTitle}" ${buttons}
|
|
121
|
+
end tell`;
|
|
122
|
+
childProcess = spawn("osascript", ["-e", script]);
|
|
123
|
+
let output = "";
|
|
124
|
+
childProcess.stdout?.on("data", (d) => output += d.toString());
|
|
125
|
+
childProcess.on("close", (code) => {
|
|
126
|
+
cleanup();
|
|
127
|
+
if (locked) return resolve("deny");
|
|
128
|
+
if (code === 0) {
|
|
129
|
+
if (output.includes("Always Allow")) return resolve("always_allow");
|
|
130
|
+
if (output.includes("Allow")) return resolve("allow");
|
|
131
|
+
}
|
|
132
|
+
resolve("deny");
|
|
133
|
+
});
|
|
134
|
+
} else if (process.platform === "linux") {
|
|
135
|
+
const argsList = locked ? [
|
|
136
|
+
"--info",
|
|
137
|
+
"--title",
|
|
138
|
+
title,
|
|
139
|
+
"--text",
|
|
140
|
+
safeMessage,
|
|
141
|
+
"--ok-label",
|
|
142
|
+
"Waiting for Slack\u2026",
|
|
143
|
+
"--timeout",
|
|
144
|
+
"300"
|
|
145
|
+
] : [
|
|
146
|
+
"--question",
|
|
147
|
+
"--title",
|
|
148
|
+
title,
|
|
149
|
+
"--text",
|
|
150
|
+
safeMessage,
|
|
151
|
+
"--ok-label",
|
|
152
|
+
"Allow",
|
|
153
|
+
"--cancel-label",
|
|
154
|
+
"Block",
|
|
155
|
+
"--extra-button",
|
|
156
|
+
"Always Allow",
|
|
157
|
+
"--timeout",
|
|
158
|
+
"300"
|
|
159
|
+
];
|
|
160
|
+
childProcess = spawn("zenity", argsList);
|
|
161
|
+
let output = "";
|
|
162
|
+
childProcess.stdout?.on("data", (d) => output += d.toString());
|
|
163
|
+
childProcess.on("close", (code) => {
|
|
164
|
+
cleanup();
|
|
165
|
+
if (locked) return resolve("deny");
|
|
166
|
+
if (output.trim() === "Always Allow") return resolve("always_allow");
|
|
167
|
+
if (code === 0) return resolve("allow");
|
|
168
|
+
resolve("deny");
|
|
169
|
+
});
|
|
170
|
+
} else if (process.platform === "win32") {
|
|
171
|
+
const buttonType = locked ? "OK" : "YesNo";
|
|
172
|
+
const ps = `
|
|
173
|
+
Add-Type -AssemblyName PresentationFramework;
|
|
174
|
+
$res = [System.Windows.MessageBox]::Show("${safeMessage}", "${safeTitle}", "${buttonType}", "Warning", "Button2", "DefaultDesktopOnly");
|
|
175
|
+
if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
|
|
176
|
+
childProcess = spawn("powershell", ["-Command", ps]);
|
|
177
|
+
childProcess.on("close", (code) => {
|
|
178
|
+
cleanup();
|
|
179
|
+
if (locked) return resolve("deny");
|
|
180
|
+
resolve(code === 0 ? "allow" : "deny");
|
|
181
|
+
});
|
|
182
|
+
} else {
|
|
183
|
+
cleanup();
|
|
184
|
+
resolve("deny");
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
cleanup();
|
|
188
|
+
resolve("deny");
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/core.ts
|
|
194
|
+
var PAUSED_FILE = path.join(os.homedir(), ".node9", "PAUSED");
|
|
195
|
+
var TRUST_FILE = path.join(os.homedir(), ".node9", "trust.json");
|
|
196
|
+
function checkPause() {
|
|
197
|
+
try {
|
|
198
|
+
if (!fs.existsSync(PAUSED_FILE)) return { paused: false };
|
|
199
|
+
const state = JSON.parse(fs.readFileSync(PAUSED_FILE, "utf-8"));
|
|
200
|
+
if (state.expiry > 0 && Date.now() >= state.expiry) {
|
|
201
|
+
try {
|
|
202
|
+
fs.unlinkSync(PAUSED_FILE);
|
|
203
|
+
} catch {
|
|
204
|
+
}
|
|
205
|
+
return { paused: false };
|
|
206
|
+
}
|
|
207
|
+
return { paused: true, expiresAt: state.expiry, duration: state.duration };
|
|
208
|
+
} catch {
|
|
209
|
+
return { paused: false };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
function atomicWriteSync(filePath, data, options) {
|
|
213
|
+
const dir = path.dirname(filePath);
|
|
214
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
215
|
+
const tmpPath = `${filePath}.${os.hostname()}.${process.pid}.tmp`;
|
|
216
|
+
fs.writeFileSync(tmpPath, data, options);
|
|
217
|
+
fs.renameSync(tmpPath, filePath);
|
|
218
|
+
}
|
|
219
|
+
function pauseNode9(durationMs, durationStr) {
|
|
220
|
+
const state = { expiry: Date.now() + durationMs, duration: durationStr };
|
|
221
|
+
atomicWriteSync(PAUSED_FILE, JSON.stringify(state, null, 2));
|
|
222
|
+
}
|
|
223
|
+
function resumeNode9() {
|
|
224
|
+
try {
|
|
225
|
+
if (fs.existsSync(PAUSED_FILE)) fs.unlinkSync(PAUSED_FILE);
|
|
226
|
+
} catch {
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function getActiveTrustSession(toolName) {
|
|
230
|
+
try {
|
|
231
|
+
if (!fs.existsSync(TRUST_FILE)) return false;
|
|
232
|
+
const trust = JSON.parse(fs.readFileSync(TRUST_FILE, "utf-8"));
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const active = trust.entries.filter((e) => e.expiry > now);
|
|
235
|
+
if (active.length !== trust.entries.length) {
|
|
236
|
+
fs.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
|
|
237
|
+
}
|
|
238
|
+
return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
|
|
239
|
+
} catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function writeTrustSession(toolName, durationMs) {
|
|
244
|
+
try {
|
|
245
|
+
let trust = { entries: [] };
|
|
246
|
+
try {
|
|
247
|
+
if (fs.existsSync(TRUST_FILE)) {
|
|
248
|
+
trust = JSON.parse(fs.readFileSync(TRUST_FILE, "utf-8"));
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
}
|
|
252
|
+
const now = Date.now();
|
|
253
|
+
trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
|
|
254
|
+
trust.entries.push({ tool: toolName, expiry: now + durationMs });
|
|
255
|
+
atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
|
|
256
|
+
} catch (err) {
|
|
257
|
+
if (process.env.NODE9_DEBUG === "1") {
|
|
258
|
+
console.error("[Node9 Trust Error]:", err);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function appendAuditModeEntry(toolName, args) {
|
|
263
|
+
try {
|
|
264
|
+
const entry = JSON.stringify({
|
|
265
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
266
|
+
tool: toolName,
|
|
267
|
+
args,
|
|
268
|
+
decision: "would-have-blocked",
|
|
269
|
+
source: "audit-mode"
|
|
270
|
+
});
|
|
271
|
+
const logPath = path.join(os.homedir(), ".node9", "audit.log");
|
|
272
|
+
const dir = path.dirname(logPath);
|
|
273
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
274
|
+
fs.appendFileSync(logPath, entry + "\n");
|
|
275
|
+
} catch {
|
|
276
|
+
}
|
|
277
|
+
}
|
|
14
278
|
var DANGEROUS_WORDS = [
|
|
15
279
|
"delete",
|
|
16
280
|
"drop",
|
|
@@ -28,10 +292,6 @@ var DANGEROUS_WORDS = [
|
|
|
28
292
|
function tokenize(toolName) {
|
|
29
293
|
return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
|
|
30
294
|
}
|
|
31
|
-
function containsDangerousWord(toolName, dangerousWords) {
|
|
32
|
-
const tokens = tokenize(toolName);
|
|
33
|
-
return dangerousWords.some((word) => tokens.includes(word.toLowerCase()));
|
|
34
|
-
}
|
|
35
295
|
function matchesPattern(text, patterns) {
|
|
36
296
|
const p = Array.isArray(patterns) ? patterns : [patterns];
|
|
37
297
|
if (p.length === 0) return false;
|
|
@@ -42,9 +302,9 @@ function matchesPattern(text, patterns) {
|
|
|
42
302
|
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
43
303
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
44
304
|
}
|
|
45
|
-
function getNestedValue(obj,
|
|
305
|
+
function getNestedValue(obj, path6) {
|
|
46
306
|
if (!obj || typeof obj !== "object") return null;
|
|
47
|
-
return
|
|
307
|
+
return path6.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
48
308
|
}
|
|
49
309
|
function extractShellCommand(toolName, args, toolInspection) {
|
|
50
310
|
const patterns = Object.keys(toolInspection);
|
|
@@ -136,8 +396,15 @@ function redactSecrets(text) {
|
|
|
136
396
|
return redacted;
|
|
137
397
|
}
|
|
138
398
|
var DEFAULT_CONFIG = {
|
|
139
|
-
settings: {
|
|
399
|
+
settings: {
|
|
400
|
+
mode: "standard",
|
|
401
|
+
autoStartDaemon: true,
|
|
402
|
+
enableUndo: false,
|
|
403
|
+
enableHookLogDebug: false,
|
|
404
|
+
approvers: { native: true, browser: true, cloud: true, terminal: true }
|
|
405
|
+
},
|
|
140
406
|
policy: {
|
|
407
|
+
sandboxPaths: [],
|
|
141
408
|
dangerousWords: DANGEROUS_WORDS,
|
|
142
409
|
ignoredTools: [
|
|
143
410
|
"list_*",
|
|
@@ -145,34 +412,19 @@ var DEFAULT_CONFIG = {
|
|
|
145
412
|
"read_*",
|
|
146
413
|
"describe_*",
|
|
147
414
|
"read",
|
|
148
|
-
"write",
|
|
149
|
-
"edit",
|
|
150
|
-
"multiedit",
|
|
151
|
-
"glob",
|
|
152
415
|
"grep",
|
|
153
416
|
"ls",
|
|
154
|
-
"notebookread",
|
|
155
|
-
"notebookedit",
|
|
156
|
-
"todoread",
|
|
157
|
-
"todowrite",
|
|
158
|
-
"webfetch",
|
|
159
|
-
"websearch",
|
|
160
|
-
"exitplanmode",
|
|
161
417
|
"askuserquestion"
|
|
162
418
|
],
|
|
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
|
-
]
|
|
419
|
+
toolInspection: { bash: "command", shell: "command" },
|
|
420
|
+
rules: [{ action: "rm", allowPaths: ["**/node_modules/**", "dist/**", ".DS_Store"] }]
|
|
172
421
|
},
|
|
173
422
|
environments: {}
|
|
174
423
|
};
|
|
175
424
|
var cachedConfig = null;
|
|
425
|
+
function _resetConfigCache() {
|
|
426
|
+
cachedConfig = null;
|
|
427
|
+
}
|
|
176
428
|
function getGlobalSettings() {
|
|
177
429
|
try {
|
|
178
430
|
const globalConfigPath = path.join(os.homedir(), ".node9", "config.json");
|
|
@@ -183,18 +435,19 @@ function getGlobalSettings() {
|
|
|
183
435
|
mode: settings.mode || "standard",
|
|
184
436
|
autoStartDaemon: settings.autoStartDaemon !== false,
|
|
185
437
|
slackEnabled: settings.slackEnabled !== false,
|
|
186
|
-
|
|
187
|
-
|
|
438
|
+
enableTrustSessions: settings.enableTrustSessions === true,
|
|
439
|
+
allowGlobalPause: settings.allowGlobalPause !== false
|
|
188
440
|
};
|
|
189
441
|
}
|
|
190
442
|
} catch {
|
|
191
443
|
}
|
|
192
|
-
return {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
444
|
+
return {
|
|
445
|
+
mode: "standard",
|
|
446
|
+
autoStartDaemon: true,
|
|
447
|
+
slackEnabled: true,
|
|
448
|
+
enableTrustSessions: false,
|
|
449
|
+
allowGlobalPause: true
|
|
450
|
+
};
|
|
198
451
|
}
|
|
199
452
|
function getInternalToken() {
|
|
200
453
|
try {
|
|
@@ -207,51 +460,83 @@ function getInternalToken() {
|
|
|
207
460
|
return null;
|
|
208
461
|
}
|
|
209
462
|
}
|
|
210
|
-
async function evaluatePolicy(toolName, args) {
|
|
463
|
+
async function evaluatePolicy(toolName, args, agent) {
|
|
211
464
|
const config = getConfig();
|
|
212
|
-
if (matchesPattern(toolName, config.policy.ignoredTools)) return "allow";
|
|
465
|
+
if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
|
|
466
|
+
let allTokens = [];
|
|
467
|
+
let actionTokens = [];
|
|
468
|
+
let pathTokens = [];
|
|
213
469
|
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
214
470
|
if (shellCommand) {
|
|
215
|
-
const
|
|
471
|
+
const analyzed = await analyzeShellCommand(shellCommand);
|
|
472
|
+
allTokens = analyzed.allTokens;
|
|
473
|
+
actionTokens = analyzed.actions;
|
|
474
|
+
pathTokens = analyzed.paths;
|
|
216
475
|
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
|
-
}
|
|
476
|
+
if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
|
|
477
|
+
return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
|
|
232
478
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
479
|
+
} else {
|
|
480
|
+
allTokens = tokenize(toolName);
|
|
481
|
+
actionTokens = [toolName];
|
|
482
|
+
}
|
|
483
|
+
const isManual = agent === "Terminal";
|
|
484
|
+
if (isManual) {
|
|
485
|
+
const NUCLEAR_COMMANDS = [
|
|
486
|
+
"drop",
|
|
487
|
+
"destroy",
|
|
488
|
+
"purge",
|
|
489
|
+
"rmdir",
|
|
490
|
+
"format",
|
|
491
|
+
"truncate",
|
|
492
|
+
"alter",
|
|
493
|
+
"grant",
|
|
494
|
+
"revoke",
|
|
495
|
+
"docker"
|
|
496
|
+
];
|
|
497
|
+
const hasNuclear = allTokens.some((t) => NUCLEAR_COMMANDS.includes(t.toLowerCase()));
|
|
498
|
+
if (!hasNuclear) return { decision: "allow" };
|
|
499
|
+
}
|
|
500
|
+
if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
|
|
501
|
+
const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
|
|
502
|
+
if (allInSandbox) return { decision: "allow" };
|
|
503
|
+
}
|
|
504
|
+
for (const action of actionTokens) {
|
|
505
|
+
const rule = config.policy.rules.find(
|
|
506
|
+
(r) => r.action === action || matchesPattern(action, r.action)
|
|
243
507
|
);
|
|
244
|
-
if (
|
|
245
|
-
|
|
246
|
-
|
|
508
|
+
if (rule) {
|
|
509
|
+
if (pathTokens.length > 0) {
|
|
510
|
+
const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
|
|
511
|
+
if (anyBlocked)
|
|
512
|
+
return { decision: "review", blockedByLabel: "Project/Global Config (Rule Block)" };
|
|
513
|
+
const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
|
|
514
|
+
if (allAllowed) return { decision: "allow" };
|
|
515
|
+
}
|
|
516
|
+
return { decision: "review", blockedByLabel: "Project/Global Config (Rule Default Block)" };
|
|
517
|
+
}
|
|
247
518
|
}
|
|
248
|
-
const isDangerous =
|
|
249
|
-
|
|
519
|
+
const isDangerous = allTokens.some(
|
|
520
|
+
(token) => config.policy.dangerousWords.some((word) => {
|
|
521
|
+
const w = word.toLowerCase();
|
|
522
|
+
if (token === w) return true;
|
|
523
|
+
try {
|
|
524
|
+
return new RegExp(`\\b${w}\\b`, "i").test(token);
|
|
525
|
+
} catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
);
|
|
530
|
+
if (isDangerous) {
|
|
531
|
+
const label = isManual ? "Manual Nuclear Protection" : "Project/Global Config (Dangerous Word)";
|
|
532
|
+
return { decision: "review", blockedByLabel: label };
|
|
533
|
+
}
|
|
534
|
+
if (config.settings.mode === "strict") {
|
|
250
535
|
const envConfig = getActiveEnvironment(config);
|
|
251
|
-
if (envConfig?.requireApproval === false) return "allow";
|
|
252
|
-
return "review";
|
|
536
|
+
if (envConfig?.requireApproval === false) return { decision: "allow" };
|
|
537
|
+
return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
|
|
253
538
|
}
|
|
254
|
-
return "allow";
|
|
539
|
+
return { decision: "allow" };
|
|
255
540
|
}
|
|
256
541
|
function isIgnoredTool(toolName) {
|
|
257
542
|
const config = getConfig();
|
|
@@ -282,22 +567,40 @@ function getPersistentDecision(toolName) {
|
|
|
282
567
|
}
|
|
283
568
|
return null;
|
|
284
569
|
}
|
|
285
|
-
async function askDaemon(toolName, args, meta) {
|
|
570
|
+
async function askDaemon(toolName, args, meta, signal) {
|
|
286
571
|
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
572
|
+
const checkCtrl = new AbortController();
|
|
573
|
+
const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
|
|
574
|
+
const onAbort = () => checkCtrl.abort();
|
|
575
|
+
if (signal) signal.addEventListener("abort", onAbort);
|
|
576
|
+
try {
|
|
577
|
+
const checkRes = await fetch(`${base}/check`, {
|
|
578
|
+
method: "POST",
|
|
579
|
+
headers: { "Content-Type": "application/json" },
|
|
580
|
+
body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
|
|
581
|
+
signal: checkCtrl.signal
|
|
582
|
+
});
|
|
583
|
+
if (!checkRes.ok) throw new Error("Daemon fail");
|
|
584
|
+
const { id } = await checkRes.json();
|
|
585
|
+
const waitCtrl = new AbortController();
|
|
586
|
+
const waitTimer = setTimeout(() => waitCtrl.abort(), 12e4);
|
|
587
|
+
const onWaitAbort = () => waitCtrl.abort();
|
|
588
|
+
if (signal) signal.addEventListener("abort", onWaitAbort);
|
|
589
|
+
try {
|
|
590
|
+
const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
|
|
591
|
+
if (!waitRes.ok) return "deny";
|
|
592
|
+
const { decision } = await waitRes.json();
|
|
593
|
+
if (decision === "allow") return "allow";
|
|
594
|
+
if (decision === "abandoned") return "abandoned";
|
|
595
|
+
return "deny";
|
|
596
|
+
} finally {
|
|
597
|
+
clearTimeout(waitTimer);
|
|
598
|
+
if (signal) signal.removeEventListener("abort", onWaitAbort);
|
|
599
|
+
}
|
|
600
|
+
} finally {
|
|
601
|
+
clearTimeout(checkTimer);
|
|
602
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
603
|
+
}
|
|
301
604
|
}
|
|
302
605
|
async function notifyDaemonViewer(toolName, args, meta) {
|
|
303
606
|
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
@@ -327,176 +630,353 @@ async function resolveViaDaemon(id, decision, internalToken) {
|
|
|
327
630
|
});
|
|
328
631
|
}
|
|
329
632
|
async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
633
|
+
if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
|
|
634
|
+
const pauseState = checkPause();
|
|
635
|
+
if (pauseState.paused) return { approved: true, checkedBy: "paused" };
|
|
636
|
+
const creds = getCredentials();
|
|
637
|
+
const config = getConfig();
|
|
638
|
+
const isTestEnv2 = !!(process.env.VITEST || process.env.NODE_ENV === "test" || process.env.CI || process.env.NODE9_TESTING === "1");
|
|
639
|
+
const approvers = {
|
|
640
|
+
...config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }
|
|
641
|
+
};
|
|
642
|
+
if (isTestEnv2) {
|
|
643
|
+
approvers.native = false;
|
|
644
|
+
approvers.browser = false;
|
|
645
|
+
approvers.terminal = false;
|
|
646
|
+
}
|
|
647
|
+
const isManual = meta?.agent === "Terminal";
|
|
648
|
+
let explainableLabel = "Local Config";
|
|
649
|
+
if (config.settings.mode === "audit") {
|
|
650
|
+
if (!isIgnoredTool(toolName)) {
|
|
651
|
+
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
652
|
+
if (policyResult.decision === "review") {
|
|
653
|
+
appendAuditModeEntry(toolName, args);
|
|
654
|
+
sendDesktopNotification(
|
|
655
|
+
"Node9 Audit Mode",
|
|
656
|
+
`Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return { approved: true, checkedBy: "audit" };
|
|
661
|
+
}
|
|
662
|
+
if (!isIgnoredTool(toolName)) {
|
|
663
|
+
if (getActiveTrustSession(toolName)) {
|
|
664
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
665
|
+
return { approved: true, checkedBy: "trust" };
|
|
666
|
+
}
|
|
667
|
+
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
668
|
+
if (policyResult.decision === "allow") {
|
|
669
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
|
|
670
|
+
return { approved: true, checkedBy: "local-policy" };
|
|
671
|
+
}
|
|
672
|
+
explainableLabel = policyResult.blockedByLabel || "Local Config";
|
|
336
673
|
const persistent = getPersistentDecision(toolName);
|
|
337
|
-
if (persistent === "allow")
|
|
338
|
-
|
|
674
|
+
if (persistent === "allow") {
|
|
675
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
|
|
676
|
+
return { approved: true, checkedBy: "persistent" };
|
|
677
|
+
}
|
|
678
|
+
if (persistent === "deny") {
|
|
339
679
|
return {
|
|
340
680
|
approved: false,
|
|
341
|
-
reason: `
|
|
681
|
+
reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
|
|
342
682
|
blockedBy: "persistent-deny",
|
|
343
|
-
|
|
683
|
+
blockedByLabel: "Persistent User Rule"
|
|
344
684
|
};
|
|
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
685
|
}
|
|
354
|
-
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
}
|
|
358
|
-
return {
|
|
359
|
-
approved,
|
|
360
|
-
checkedBy: approved ? "cloud" : void 0,
|
|
361
|
-
blockedBy: approved ? void 0 : "team-policy",
|
|
362
|
-
changeHint: approved ? void 0 : `Visit your Node9 dashboard \u2192 Policy Studio to change this rule`
|
|
363
|
-
};
|
|
686
|
+
} else {
|
|
687
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
|
|
688
|
+
return { approved: true };
|
|
364
689
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
690
|
+
let cloudRequestId = null;
|
|
691
|
+
let isRemoteLocked = false;
|
|
692
|
+
const cloudEnforced = approvers.cloud && !!creds?.apiKey;
|
|
693
|
+
if (cloudEnforced) {
|
|
369
694
|
try {
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
} else {
|
|
695
|
+
const envConfig = getActiveEnvironment(getConfig());
|
|
696
|
+
const initResult = await initNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
|
|
697
|
+
if (!initResult.pending) {
|
|
374
698
|
return {
|
|
375
|
-
approved:
|
|
376
|
-
reason:
|
|
377
|
-
checkedBy:
|
|
378
|
-
blockedBy:
|
|
379
|
-
|
|
699
|
+
approved: !!initResult.approved,
|
|
700
|
+
reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
|
|
701
|
+
checkedBy: initResult.approved ? "cloud" : void 0,
|
|
702
|
+
blockedBy: initResult.approved ? void 0 : "team-policy",
|
|
703
|
+
blockedByLabel: "Organization Policy (SaaS)"
|
|
380
704
|
};
|
|
381
705
|
}
|
|
382
|
-
|
|
706
|
+
cloudRequestId = initResult.requestId || null;
|
|
707
|
+
isRemoteLocked = !!initResult.remoteApprovalOnly;
|
|
708
|
+
explainableLabel = "Organization Policy (SaaS)";
|
|
709
|
+
} catch (err) {
|
|
710
|
+
const error = err;
|
|
711
|
+
const isAuthError = error.message.includes("401") || error.message.includes("403");
|
|
712
|
+
const isNetworkError = error.message.includes("fetch") || error.name === "AbortError" || error.message.includes("ECONNREFUSED");
|
|
713
|
+
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;
|
|
714
|
+
console.error(
|
|
715
|
+
chalk.yellow(`
|
|
716
|
+
\u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + chalk.dim(`
|
|
717
|
+
Falling back to local rules...
|
|
718
|
+
`)
|
|
719
|
+
);
|
|
383
720
|
}
|
|
384
721
|
}
|
|
385
|
-
if (
|
|
386
|
-
console.
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
console.
|
|
390
|
-
|
|
391
|
-
|
|
722
|
+
if (cloudEnforced && cloudRequestId) {
|
|
723
|
+
console.error(
|
|
724
|
+
chalk.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
|
|
725
|
+
);
|
|
726
|
+
console.error(chalk.cyan(" Dashboard \u2192 ") + chalk.bold("Mission Control > Activity Feed\n"));
|
|
727
|
+
} else if (!cloudEnforced) {
|
|
728
|
+
const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
|
|
729
|
+
console.error(
|
|
730
|
+
chalk.dim(`
|
|
731
|
+
\u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
|
|
732
|
+
`)
|
|
392
733
|
);
|
|
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
734
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
735
|
+
const abortController = new AbortController();
|
|
736
|
+
const { signal } = abortController;
|
|
737
|
+
const racePromises = [];
|
|
738
|
+
let viewerId = null;
|
|
739
|
+
const internalToken = getInternalToken();
|
|
740
|
+
if (cloudEnforced && cloudRequestId) {
|
|
741
|
+
racePromises.push(
|
|
742
|
+
(async () => {
|
|
743
|
+
try {
|
|
744
|
+
if (isDaemonRunning() && internalToken) {
|
|
745
|
+
viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
|
|
746
|
+
}
|
|
747
|
+
const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
|
|
748
|
+
return {
|
|
749
|
+
approved: cloudResult.approved,
|
|
750
|
+
reason: cloudResult.approved ? void 0 : cloudResult.reason || "Action rejected by organization administrator via Slack.",
|
|
751
|
+
checkedBy: cloudResult.approved ? "cloud" : void 0,
|
|
752
|
+
blockedBy: cloudResult.approved ? void 0 : "team-policy",
|
|
753
|
+
blockedByLabel: "Organization Policy (SaaS)"
|
|
754
|
+
};
|
|
755
|
+
} catch (err) {
|
|
756
|
+
const error = err;
|
|
757
|
+
if (error.name === "AbortError" || error.message?.includes("Aborted")) throw err;
|
|
758
|
+
throw err;
|
|
759
|
+
}
|
|
760
|
+
})()
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
if (approvers.native && !isManual) {
|
|
764
|
+
racePromises.push(
|
|
765
|
+
(async () => {
|
|
766
|
+
const decision = await askNativePopup(
|
|
767
|
+
toolName,
|
|
768
|
+
args,
|
|
769
|
+
meta?.agent,
|
|
770
|
+
explainableLabel,
|
|
771
|
+
isRemoteLocked,
|
|
772
|
+
signal
|
|
773
|
+
);
|
|
774
|
+
if (decision === "always_allow") {
|
|
775
|
+
writeTrustSession(toolName, 36e5);
|
|
776
|
+
return { approved: true, checkedBy: "trust" };
|
|
777
|
+
}
|
|
778
|
+
const isApproved = decision === "allow";
|
|
779
|
+
return {
|
|
780
|
+
approved: isApproved,
|
|
781
|
+
reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
|
|
782
|
+
checkedBy: isApproved ? "daemon" : void 0,
|
|
783
|
+
blockedBy: isApproved ? void 0 : "local-decision",
|
|
784
|
+
blockedByLabel: "User Decision (Native)"
|
|
785
|
+
};
|
|
786
|
+
})()
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
if (approvers.browser && isDaemonRunning()) {
|
|
790
|
+
racePromises.push(
|
|
791
|
+
(async () => {
|
|
792
|
+
try {
|
|
793
|
+
if (!approvers.native && !cloudEnforced) {
|
|
794
|
+
console.error(
|
|
795
|
+
chalk.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
|
|
796
|
+
);
|
|
797
|
+
console.error(chalk.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
|
|
798
|
+
`));
|
|
799
|
+
}
|
|
800
|
+
const daemonDecision = await askDaemon(toolName, args, meta, signal);
|
|
801
|
+
if (daemonDecision === "abandoned") throw new Error("Abandoned");
|
|
802
|
+
const isApproved = daemonDecision === "allow";
|
|
803
|
+
return {
|
|
804
|
+
approved: isApproved,
|
|
805
|
+
reason: isApproved ? void 0 : "The human user rejected this action via the Node9 Browser Dashboard.",
|
|
806
|
+
checkedBy: isApproved ? "daemon" : void 0,
|
|
807
|
+
blockedBy: isApproved ? void 0 : "local-decision",
|
|
808
|
+
blockedByLabel: "User Decision (Browser)"
|
|
809
|
+
};
|
|
810
|
+
} catch (err) {
|
|
811
|
+
throw err;
|
|
812
|
+
}
|
|
813
|
+
})()
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
if (approvers.terminal && allowTerminalFallback && process.stdout.isTTY) {
|
|
817
|
+
racePromises.push(
|
|
818
|
+
(async () => {
|
|
819
|
+
try {
|
|
820
|
+
console.log(chalk.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
|
|
821
|
+
console.log(`${chalk.bold("Action:")} ${chalk.red(toolName)}`);
|
|
822
|
+
console.log(`${chalk.bold("Flagged By:")} ${chalk.yellow(explainableLabel)}`);
|
|
823
|
+
if (isRemoteLocked) {
|
|
824
|
+
console.log(chalk.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
|
|
825
|
+
`));
|
|
826
|
+
await new Promise((_, reject) => {
|
|
827
|
+
signal.addEventListener("abort", () => reject(new Error("Aborted by SaaS")));
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
const TIMEOUT_MS = 6e4;
|
|
831
|
+
let timer;
|
|
832
|
+
const result = await new Promise((resolve, reject) => {
|
|
833
|
+
timer = setTimeout(() => reject(new Error("Terminal Timeout")), TIMEOUT_MS);
|
|
834
|
+
confirm(
|
|
835
|
+
{ message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
|
|
836
|
+
{ signal }
|
|
837
|
+
).then(resolve).catch(reject);
|
|
838
|
+
});
|
|
839
|
+
clearTimeout(timer);
|
|
840
|
+
return {
|
|
841
|
+
approved: result,
|
|
842
|
+
reason: result ? void 0 : "The human user typed 'N' in the terminal to reject this action.",
|
|
843
|
+
checkedBy: result ? "terminal" : void 0,
|
|
844
|
+
blockedBy: result ? void 0 : "local-decision",
|
|
845
|
+
blockedByLabel: "User Decision (Terminal)"
|
|
846
|
+
};
|
|
847
|
+
} catch (err) {
|
|
848
|
+
const error = err;
|
|
849
|
+
if (error.name === "AbortError" || error.message?.includes("Prompt was canceled") || error.message?.includes("Aborted by SaaS"))
|
|
850
|
+
throw err;
|
|
851
|
+
if (error.message === "Terminal Timeout") {
|
|
852
|
+
return {
|
|
853
|
+
approved: false,
|
|
854
|
+
reason: "The terminal prompt timed out without a human response.",
|
|
855
|
+
blockedBy: "local-decision"
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
throw err;
|
|
859
|
+
}
|
|
860
|
+
})()
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
if (racePromises.length === 0) {
|
|
864
|
+
return {
|
|
865
|
+
approved: false,
|
|
866
|
+
noApprovalMechanism: true,
|
|
867
|
+
reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
|
|
868
|
+
REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
|
|
869
|
+
blockedBy: "no-approval-mechanism",
|
|
870
|
+
blockedByLabel: explainableLabel
|
|
871
|
+
};
|
|
425
872
|
}
|
|
426
|
-
|
|
873
|
+
const finalResult = await new Promise((resolve) => {
|
|
874
|
+
let resolved = false;
|
|
875
|
+
let failures = 0;
|
|
876
|
+
const total = racePromises.length;
|
|
877
|
+
const finish = (res) => {
|
|
878
|
+
if (!resolved) {
|
|
879
|
+
resolved = true;
|
|
880
|
+
abortController.abort();
|
|
881
|
+
if (viewerId && internalToken) {
|
|
882
|
+
resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
|
|
883
|
+
() => null
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
resolve(res);
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
for (const p of racePromises) {
|
|
890
|
+
p.then(finish).catch((err) => {
|
|
891
|
+
if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
|
|
892
|
+
return;
|
|
893
|
+
if (err.message === "Abandoned") {
|
|
894
|
+
finish({
|
|
895
|
+
approved: false,
|
|
896
|
+
reason: "Browser dashboard closed without making a decision.",
|
|
897
|
+
blockedBy: "local-decision",
|
|
898
|
+
blockedByLabel: "Browser Dashboard (Abandoned)"
|
|
899
|
+
});
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
failures++;
|
|
903
|
+
if (failures === total && !resolved) {
|
|
904
|
+
finish({ approved: false, reason: "All approval channels failed or disconnected." });
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
|
|
910
|
+
await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
|
|
911
|
+
}
|
|
912
|
+
return finalResult;
|
|
427
913
|
}
|
|
428
914
|
function getConfig() {
|
|
429
915
|
if (cachedConfig) return cachedConfig;
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
916
|
+
const globalPath = path.join(os.homedir(), ".node9", "config.json");
|
|
917
|
+
const projectPath = path.join(process.cwd(), "node9.config.json");
|
|
918
|
+
const globalConfig = tryLoadConfig(globalPath);
|
|
919
|
+
const projectConfig = tryLoadConfig(projectPath);
|
|
920
|
+
const mergedSettings = {
|
|
921
|
+
...DEFAULT_CONFIG.settings,
|
|
922
|
+
approvers: { ...DEFAULT_CONFIG.settings.approvers }
|
|
923
|
+
};
|
|
924
|
+
const mergedPolicy = {
|
|
925
|
+
sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
|
|
926
|
+
dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
|
|
927
|
+
ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
|
|
928
|
+
toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
|
|
929
|
+
rules: [...DEFAULT_CONFIG.policy.rules]
|
|
930
|
+
};
|
|
931
|
+
const applyLayer = (source) => {
|
|
932
|
+
if (!source) return;
|
|
933
|
+
const s = source.settings || {};
|
|
934
|
+
const p = source.policy || {};
|
|
935
|
+
if (s.mode !== void 0) mergedSettings.mode = s.mode;
|
|
936
|
+
if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
|
|
937
|
+
if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
|
|
938
|
+
if (s.enableHookLogDebug !== void 0)
|
|
939
|
+
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
940
|
+
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
941
|
+
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
942
|
+
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
943
|
+
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
944
|
+
if (p.toolInspection)
|
|
945
|
+
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
946
|
+
if (p.rules) mergedPolicy.rules.push(...p.rules);
|
|
947
|
+
};
|
|
948
|
+
applyLayer(globalConfig);
|
|
949
|
+
applyLayer(projectConfig);
|
|
950
|
+
if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
|
|
951
|
+
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
952
|
+
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
953
|
+
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
954
|
+
cachedConfig = {
|
|
955
|
+
settings: mergedSettings,
|
|
956
|
+
policy: mergedPolicy,
|
|
957
|
+
environments: {}
|
|
958
|
+
};
|
|
441
959
|
return cachedConfig;
|
|
442
960
|
}
|
|
443
961
|
function tryLoadConfig(filePath) {
|
|
444
962
|
if (!fs.existsSync(filePath)) return null;
|
|
445
963
|
try {
|
|
446
|
-
|
|
447
|
-
validateConfig(config, filePath);
|
|
448
|
-
return config;
|
|
964
|
+
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
449
965
|
} catch {
|
|
450
966
|
return null;
|
|
451
967
|
}
|
|
452
968
|
}
|
|
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
969
|
function getActiveEnvironment(config) {
|
|
478
970
|
const env = process.env.NODE_ENV || "development";
|
|
479
971
|
return config.environments[env] ?? null;
|
|
480
972
|
}
|
|
481
973
|
function getCredentials() {
|
|
482
974
|
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
483
|
-
if (process.env.NODE9_API_KEY)
|
|
975
|
+
if (process.env.NODE9_API_KEY) {
|
|
484
976
|
return {
|
|
485
977
|
apiKey: process.env.NODE9_API_KEY,
|
|
486
978
|
apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
|
|
487
979
|
};
|
|
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
980
|
}
|
|
501
981
|
try {
|
|
502
982
|
const credPath = path.join(os.homedir(), ".node9", "credentials.json");
|
|
@@ -521,14 +1001,32 @@ function getCredentials() {
|
|
|
521
1001
|
}
|
|
522
1002
|
return null;
|
|
523
1003
|
}
|
|
524
|
-
|
|
525
|
-
const
|
|
526
|
-
|
|
1004
|
+
function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
|
|
1005
|
+
const controller = new AbortController();
|
|
1006
|
+
setTimeout(() => controller.abort(), 5e3);
|
|
1007
|
+
fetch(`${creds.apiUrl}/audit`, {
|
|
1008
|
+
method: "POST",
|
|
1009
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1010
|
+
body: JSON.stringify({
|
|
1011
|
+
toolName,
|
|
1012
|
+
args,
|
|
1013
|
+
checkedBy,
|
|
1014
|
+
context: {
|
|
1015
|
+
agent: meta?.agent,
|
|
1016
|
+
mcpServer: meta?.mcpServer,
|
|
1017
|
+
hostname: os.hostname(),
|
|
1018
|
+
cwd: process.cwd(),
|
|
1019
|
+
platform: os.platform()
|
|
1020
|
+
}
|
|
1021
|
+
}),
|
|
1022
|
+
signal: controller.signal
|
|
1023
|
+
}).catch(() => {
|
|
1024
|
+
});
|
|
527
1025
|
}
|
|
528
|
-
async function
|
|
1026
|
+
async function initNode9SaaS(toolName, args, creds, slackChannel, meta) {
|
|
1027
|
+
const controller = new AbortController();
|
|
1028
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
529
1029
|
try {
|
|
530
|
-
const controller = new AbortController();
|
|
531
|
-
const timeout = setTimeout(() => controller.abort(), 35e3);
|
|
532
1030
|
const response = await fetch(creds.apiUrl, {
|
|
533
1031
|
method: "POST",
|
|
534
1032
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
@@ -546,46 +1044,55 @@ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
|
|
|
546
1044
|
}),
|
|
547
1045
|
signal: controller.signal
|
|
548
1046
|
});
|
|
1047
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1048
|
+
return await response.json();
|
|
1049
|
+
} finally {
|
|
549
1050
|
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 {
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async function pollNode9SaaS(requestId, creds, signal) {
|
|
1054
|
+
const statusUrl = `${creds.apiUrl}/status/${requestId}`;
|
|
1055
|
+
const POLL_INTERVAL_MS = 1e3;
|
|
1056
|
+
const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
|
|
1057
|
+
while (Date.now() < POLL_DEADLINE) {
|
|
1058
|
+
if (signal.aborted) throw new Error("Aborted");
|
|
1059
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
1060
|
+
try {
|
|
1061
|
+
const pollCtrl = new AbortController();
|
|
1062
|
+
const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
|
|
1063
|
+
const statusRes = await fetch(statusUrl, {
|
|
1064
|
+
headers: { Authorization: `Bearer ${creds.apiKey}` },
|
|
1065
|
+
signal: pollCtrl.signal
|
|
1066
|
+
});
|
|
1067
|
+
clearTimeout(pollTimer);
|
|
1068
|
+
if (!statusRes.ok) continue;
|
|
1069
|
+
const { status, reason } = await statusRes.json();
|
|
1070
|
+
if (status === "APPROVED") {
|
|
1071
|
+
console.error(chalk.green("\u2705 Approved via Cloud.\n"));
|
|
1072
|
+
return { approved: true, reason };
|
|
1073
|
+
}
|
|
1074
|
+
if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
|
|
1075
|
+
console.error(chalk.red("\u274C Denied via Cloud.\n"));
|
|
1076
|
+
return { approved: false, reason };
|
|
583
1077
|
}
|
|
1078
|
+
} catch {
|
|
584
1079
|
}
|
|
585
|
-
|
|
586
|
-
|
|
1080
|
+
}
|
|
1081
|
+
return { approved: false, reason: "Cloud approval timed out after 10 minutes." };
|
|
1082
|
+
}
|
|
1083
|
+
async function resolveNode9SaaS(requestId, creds, approved) {
|
|
1084
|
+
try {
|
|
1085
|
+
const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
|
|
1086
|
+
const ctrl = new AbortController();
|
|
1087
|
+
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
1088
|
+
await fetch(resolveUrl, {
|
|
1089
|
+
method: "PATCH",
|
|
1090
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1091
|
+
body: JSON.stringify({ decision: approved ? "APPROVED" : "DENIED" }),
|
|
1092
|
+
signal: ctrl.signal
|
|
1093
|
+
});
|
|
1094
|
+
clearTimeout(timer);
|
|
587
1095
|
} catch {
|
|
588
|
-
return false;
|
|
589
1096
|
}
|
|
590
1097
|
}
|
|
591
1098
|
|
|
@@ -597,7 +1104,7 @@ import chalk2 from "chalk";
|
|
|
597
1104
|
import { confirm as confirm2 } from "@inquirer/prompts";
|
|
598
1105
|
function printDaemonTip() {
|
|
599
1106
|
console.log(
|
|
600
|
-
chalk2.cyan("\n \u{1F4A1}
|
|
1107
|
+
chalk2.cyan("\n \u{1F4A1} Node9 will protect you automatically using Native OS popups.") + chalk2.white("\n To view your history or manage persistent rules, run:") + chalk2.green("\n node9 daemon --openui")
|
|
601
1108
|
);
|
|
602
1109
|
}
|
|
603
1110
|
function fullPathCommand(subcommand) {
|
|
@@ -648,7 +1155,7 @@ async function setupClaude() {
|
|
|
648
1155
|
if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
|
|
649
1156
|
settings.hooks.PostToolUse.push({
|
|
650
1157
|
matcher: ".*",
|
|
651
|
-
hooks: [{ type: "command", command: fullPathCommand("log") }]
|
|
1158
|
+
hooks: [{ type: "command", command: fullPathCommand("log"), timeout: 600 }]
|
|
652
1159
|
});
|
|
653
1160
|
console.log(chalk2.green(" \u2705 PostToolUse hook added \u2192 node9 log"));
|
|
654
1161
|
anythingChanged = true;
|
|
@@ -712,7 +1219,12 @@ async function setupGemini() {
|
|
|
712
1219
|
settings.hooks.BeforeTool.push({
|
|
713
1220
|
matcher: ".*",
|
|
714
1221
|
hooks: [
|
|
715
|
-
{
|
|
1222
|
+
{
|
|
1223
|
+
name: "node9-check",
|
|
1224
|
+
type: "command",
|
|
1225
|
+
command: fullPathCommand("check"),
|
|
1226
|
+
timeout: 6e5
|
|
1227
|
+
}
|
|
716
1228
|
]
|
|
717
1229
|
});
|
|
718
1230
|
console.log(chalk2.green(" \u2705 BeforeTool hook added \u2192 node9 check"));
|
|
@@ -1109,6 +1621,27 @@ var ui_default = `<!doctype html>
|
|
|
1109
1621
|
font-size: 12px;
|
|
1110
1622
|
font-weight: 500;
|
|
1111
1623
|
}
|
|
1624
|
+
.btn-trust {
|
|
1625
|
+
background: rgba(240, 136, 62, 0.1);
|
|
1626
|
+
border: 1px solid rgba(240, 136, 62, 0.35);
|
|
1627
|
+
color: var(--primary);
|
|
1628
|
+
font-size: 12px;
|
|
1629
|
+
font-weight: 600;
|
|
1630
|
+
}
|
|
1631
|
+
.btn-trust:hover:not(:disabled) {
|
|
1632
|
+
background: rgba(240, 136, 62, 0.2);
|
|
1633
|
+
filter: none;
|
|
1634
|
+
transform: translateY(-1px);
|
|
1635
|
+
}
|
|
1636
|
+
.trust-row {
|
|
1637
|
+
display: none;
|
|
1638
|
+
grid-column: span 2;
|
|
1639
|
+
grid-template-columns: 1fr 1fr;
|
|
1640
|
+
gap: 8px;
|
|
1641
|
+
}
|
|
1642
|
+
.trust-row.show {
|
|
1643
|
+
display: grid;
|
|
1644
|
+
}
|
|
1112
1645
|
button:hover:not(:disabled) {
|
|
1113
1646
|
filter: brightness(1.15);
|
|
1114
1647
|
transform: translateY(-1px);
|
|
@@ -1412,15 +1945,31 @@ var ui_default = `<!doctype html>
|
|
|
1412
1945
|
<span class="slider"></span>
|
|
1413
1946
|
</label>
|
|
1414
1947
|
</div>
|
|
1948
|
+
<div class="setting-row">
|
|
1949
|
+
<div class="setting-text">
|
|
1950
|
+
<div class="setting-label">Trust Sessions</div>
|
|
1951
|
+
<div class="setting-desc">
|
|
1952
|
+
Show "Trust 30m / 1h" buttons \u2014 allow a tool without interruption for a set time.
|
|
1953
|
+
</div>
|
|
1954
|
+
</div>
|
|
1955
|
+
<label class="toggle">
|
|
1956
|
+
<input
|
|
1957
|
+
type="checkbox"
|
|
1958
|
+
id="trustSessionsToggle"
|
|
1959
|
+
onchange="onTrustToggle(this.checked)"
|
|
1960
|
+
/>
|
|
1961
|
+
<span class="slider"></span>
|
|
1962
|
+
</label>
|
|
1963
|
+
</div>
|
|
1415
1964
|
</div>
|
|
1416
1965
|
|
|
1417
1966
|
<div class="panel">
|
|
1418
|
-
<div class="panel-title">\u{1F4AC}
|
|
1967
|
+
<div class="panel-title">\u{1F4AC} Cloud Approvals</div>
|
|
1419
1968
|
<div class="setting-row">
|
|
1420
1969
|
<div class="setting-text">
|
|
1421
|
-
<div class="setting-label">Enable
|
|
1970
|
+
<div class="setting-label">Enable Cloud</div>
|
|
1422
1971
|
<div class="setting-desc">
|
|
1423
|
-
Use Slack as the approval authority when a key is saved.
|
|
1972
|
+
Use Cloud/Slack as the approval authority when a key is saved.
|
|
1424
1973
|
</div>
|
|
1425
1974
|
</div>
|
|
1426
1975
|
<label class="toggle">
|
|
@@ -1474,6 +2023,7 @@ var ui_default = `<!doctype html>
|
|
|
1474
2023
|
const requests = new Set();
|
|
1475
2024
|
let orgName = null;
|
|
1476
2025
|
let autoDenyMs = 120000;
|
|
2026
|
+
let trustEnabled = false;
|
|
1477
2027
|
|
|
1478
2028
|
function highlightSyntax(code) {
|
|
1479
2029
|
if (typeof code !== 'string') return esc(code);
|
|
@@ -1526,6 +2076,21 @@ var ui_default = `<!doctype html>
|
|
|
1526
2076
|
}, 200);
|
|
1527
2077
|
}
|
|
1528
2078
|
|
|
2079
|
+
function sendTrust(id, duration) {
|
|
2080
|
+
const card = document.getElementById('c-' + id);
|
|
2081
|
+
if (card) card.style.opacity = '0.5';
|
|
2082
|
+
fetch('/decision/' + id, {
|
|
2083
|
+
method: 'POST',
|
|
2084
|
+
headers: { 'Content-Type': 'application/json', 'X-Node9-Token': CSRF_TOKEN },
|
|
2085
|
+
body: JSON.stringify({ decision: 'trust', trustDuration: duration }),
|
|
2086
|
+
});
|
|
2087
|
+
setTimeout(() => {
|
|
2088
|
+
card?.remove();
|
|
2089
|
+
requests.delete(id);
|
|
2090
|
+
refresh();
|
|
2091
|
+
}, 200);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
1529
2094
|
function addCard(req) {
|
|
1530
2095
|
if (requests.has(req.id)) return;
|
|
1531
2096
|
requests.add(req.id);
|
|
@@ -1545,6 +2110,7 @@ var ui_default = `<!doctype html>
|
|
|
1545
2110
|
card.id = 'c-' + req.id;
|
|
1546
2111
|
const agentLabel = req.agent ? esc(req.agent) : 'AI Agent';
|
|
1547
2112
|
const mcpLabel = req.mcpServer ? esc(req.mcpServer) : null;
|
|
2113
|
+
const dis = isSlack ? 'disabled' : '';
|
|
1548
2114
|
card.innerHTML = \`
|
|
1549
2115
|
<div class="source-row">
|
|
1550
2116
|
<span class="agent-badge">\${agentLabel}</span>
|
|
@@ -1554,11 +2120,15 @@ var ui_default = `<!doctype html>
|
|
|
1554
2120
|
\${isSlack ? '<div class="slack-indicator">\u26A1 Awaiting Slack approval \u2014 view only</div>' : ''}
|
|
1555
2121
|
<span class="label">Input Payload</span>
|
|
1556
2122
|
<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
|
-
|
|
2123
|
+
<div class="actions" id="act-\${req.id}">
|
|
2124
|
+
<button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${dis}>Approve Execution</button>
|
|
2125
|
+
<button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${dis}>Block Action</button>
|
|
2126
|
+
<div class="trust-row\${trustEnabled ? ' show' : ''}" id="tr-\${req.id}">
|
|
2127
|
+
<button class="btn-trust" onclick="sendTrust('\${req.id}','30m')" \${dis}>\u23F1 Trust 30m</button>
|
|
2128
|
+
<button class="btn-trust" onclick="sendTrust('\${req.id}','1h')" \${dis}>\u23F1 Trust 1h</button>
|
|
2129
|
+
</div>
|
|
2130
|
+
<button class="btn-secondary" onclick="sendDecision('\${req.id}','allow',true)" \${dis}>Always Allow</button>
|
|
2131
|
+
<button class="btn-secondary" onclick="sendDecision('\${req.id}','deny',true)" \${dis}>Always Deny</button>
|
|
1562
2132
|
</div>
|
|
1563
2133
|
\`;
|
|
1564
2134
|
list.appendChild(card);
|
|
@@ -1590,8 +2160,10 @@ var ui_default = `<!doctype html>
|
|
|
1590
2160
|
autoDenyMs = data.autoDenyMs;
|
|
1591
2161
|
if (orgName) {
|
|
1592
2162
|
const b = document.getElementById('cloudBadge');
|
|
1593
|
-
b
|
|
1594
|
-
|
|
2163
|
+
if (b) {
|
|
2164
|
+
b.innerText = orgName;
|
|
2165
|
+
b.classList.add('online');
|
|
2166
|
+
}
|
|
1595
2167
|
}
|
|
1596
2168
|
data.requests.forEach(addCard);
|
|
1597
2169
|
});
|
|
@@ -1621,6 +2193,14 @@ var ui_default = `<!doctype html>
|
|
|
1621
2193
|
}).catch(() => {});
|
|
1622
2194
|
}
|
|
1623
2195
|
|
|
2196
|
+
function onTrustToggle(checked) {
|
|
2197
|
+
trustEnabled = checked;
|
|
2198
|
+
saveSetting('enableTrustSessions', checked);
|
|
2199
|
+
document.querySelectorAll('[id^="tr-"]').forEach((el) => {
|
|
2200
|
+
el.classList.toggle('show', checked);
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
|
|
1624
2204
|
fetch('/settings')
|
|
1625
2205
|
.then((r) => r.json())
|
|
1626
2206
|
.then((s) => {
|
|
@@ -1633,6 +2213,13 @@ var ui_default = `<!doctype html>
|
|
|
1633
2213
|
if (!s.autoStartDaemon && !s.autoStarted) {
|
|
1634
2214
|
document.getElementById('warnBanner').classList.add('show');
|
|
1635
2215
|
}
|
|
2216
|
+
trustEnabled = !!s.enableTrustSessions;
|
|
2217
|
+
const trustTog = document.getElementById('trustSessionsToggle');
|
|
2218
|
+
if (trustTog) trustTog.checked = trustEnabled;
|
|
2219
|
+
// Show/hide trust rows on any cards already rendered
|
|
2220
|
+
document.querySelectorAll('[id^="tr-"]').forEach((el) => {
|
|
2221
|
+
el.classList.toggle('show', trustEnabled);
|
|
2222
|
+
});
|
|
1636
2223
|
})
|
|
1637
2224
|
.catch(() => {});
|
|
1638
2225
|
|
|
@@ -1742,7 +2329,7 @@ import http from "http";
|
|
|
1742
2329
|
import fs3 from "fs";
|
|
1743
2330
|
import path3 from "path";
|
|
1744
2331
|
import os3 from "os";
|
|
1745
|
-
import {
|
|
2332
|
+
import { spawn as spawn2 } from "child_process";
|
|
1746
2333
|
import { randomUUID } from "crypto";
|
|
1747
2334
|
import chalk3 from "chalk";
|
|
1748
2335
|
var DAEMON_PORT2 = 7391;
|
|
@@ -1753,6 +2340,33 @@ var DECISIONS_FILE = path3.join(homeDir, ".node9", "decisions.json");
|
|
|
1753
2340
|
var GLOBAL_CONFIG_FILE = path3.join(homeDir, ".node9", "config.json");
|
|
1754
2341
|
var CREDENTIALS_FILE = path3.join(homeDir, ".node9", "credentials.json");
|
|
1755
2342
|
var AUDIT_LOG_FILE = path3.join(homeDir, ".node9", "audit.log");
|
|
2343
|
+
var TRUST_FILE2 = path3.join(homeDir, ".node9", "trust.json");
|
|
2344
|
+
function atomicWriteSync2(filePath, data, options) {
|
|
2345
|
+
const dir = path3.dirname(filePath);
|
|
2346
|
+
if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
|
|
2347
|
+
const tmpPath = `${filePath}.${randomUUID()}.tmp`;
|
|
2348
|
+
fs3.writeFileSync(tmpPath, data, options);
|
|
2349
|
+
fs3.renameSync(tmpPath, filePath);
|
|
2350
|
+
}
|
|
2351
|
+
function writeTrustEntry(toolName, durationMs) {
|
|
2352
|
+
try {
|
|
2353
|
+
let trust = { entries: [] };
|
|
2354
|
+
try {
|
|
2355
|
+
if (fs3.existsSync(TRUST_FILE2))
|
|
2356
|
+
trust = JSON.parse(fs3.readFileSync(TRUST_FILE2, "utf-8"));
|
|
2357
|
+
} catch {
|
|
2358
|
+
}
|
|
2359
|
+
trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > Date.now());
|
|
2360
|
+
trust.entries.push({ tool: toolName, expiry: Date.now() + durationMs });
|
|
2361
|
+
atomicWriteSync2(TRUST_FILE2, JSON.stringify(trust, null, 2));
|
|
2362
|
+
} catch {
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
var TRUST_DURATIONS = {
|
|
2366
|
+
"30m": 30 * 6e4,
|
|
2367
|
+
"1h": 60 * 6e4,
|
|
2368
|
+
"2h": 2 * 60 * 6e4
|
|
2369
|
+
};
|
|
1756
2370
|
var SECRET_KEY_RE = /password|secret|token|key|apikey|credential|auth/i;
|
|
1757
2371
|
function redactArgs(value) {
|
|
1758
2372
|
if (!value || typeof value !== "object") return value;
|
|
@@ -1765,10 +2379,16 @@ function redactArgs(value) {
|
|
|
1765
2379
|
}
|
|
1766
2380
|
function appendAuditLog(data) {
|
|
1767
2381
|
try {
|
|
1768
|
-
const entry =
|
|
2382
|
+
const entry = {
|
|
2383
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2384
|
+
tool: data.toolName,
|
|
2385
|
+
args: redactArgs(data.args),
|
|
2386
|
+
decision: data.decision,
|
|
2387
|
+
source: "daemon"
|
|
2388
|
+
};
|
|
1769
2389
|
const dir = path3.dirname(AUDIT_LOG_FILE);
|
|
1770
2390
|
if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
|
|
1771
|
-
fs3.appendFileSync(AUDIT_LOG_FILE, entry);
|
|
2391
|
+
fs3.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + "\n");
|
|
1772
2392
|
} catch {
|
|
1773
2393
|
}
|
|
1774
2394
|
}
|
|
@@ -1793,21 +2413,6 @@ function getOrgName() {
|
|
|
1793
2413
|
return null;
|
|
1794
2414
|
}
|
|
1795
2415
|
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
2416
|
function hasStoredSlackKey() {
|
|
1812
2417
|
return fs3.existsSync(CREDENTIALS_FILE);
|
|
1813
2418
|
}
|
|
@@ -1821,14 +2426,13 @@ function writeGlobalSetting(key, value) {
|
|
|
1821
2426
|
}
|
|
1822
2427
|
if (!config.settings || typeof config.settings !== "object") config.settings = {};
|
|
1823
2428
|
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 });
|
|
2429
|
+
atomicWriteSync2(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
|
|
1827
2430
|
}
|
|
1828
2431
|
var pending = /* @__PURE__ */ new Map();
|
|
1829
2432
|
var sseClients = /* @__PURE__ */ new Set();
|
|
1830
2433
|
var abandonTimer = null;
|
|
1831
2434
|
var daemonServer = null;
|
|
2435
|
+
var hadBrowserClient = false;
|
|
1832
2436
|
function abandonPending() {
|
|
1833
2437
|
abandonTimer = null;
|
|
1834
2438
|
pending.forEach((entry, id) => {
|
|
@@ -1864,10 +2468,8 @@ data: ${JSON.stringify(data)}
|
|
|
1864
2468
|
}
|
|
1865
2469
|
function openBrowser(url) {
|
|
1866
2470
|
try {
|
|
1867
|
-
const
|
|
1868
|
-
|
|
1869
|
-
else if (process.platform === "win32") execSync(`cmd /c start "" "${url}"`, opts);
|
|
1870
|
-
else execSync(`xdg-open "${url}"`, opts);
|
|
2471
|
+
const args = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
|
|
2472
|
+
spawn2(args[0], args.slice(1), { detached: true, stdio: "ignore" }).unref();
|
|
1871
2473
|
} catch {
|
|
1872
2474
|
}
|
|
1873
2475
|
}
|
|
@@ -1889,11 +2491,9 @@ function readPersistentDecisions() {
|
|
|
1889
2491
|
}
|
|
1890
2492
|
function writePersistentDecision(toolName, decision) {
|
|
1891
2493
|
try {
|
|
1892
|
-
const dir = path3.dirname(DECISIONS_FILE);
|
|
1893
|
-
if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
|
|
1894
2494
|
const decisions = readPersistentDecisions();
|
|
1895
2495
|
decisions[toolName] = decision;
|
|
1896
|
-
|
|
2496
|
+
atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
|
|
1897
2497
|
broadcast("decisions", decisions);
|
|
1898
2498
|
} catch {
|
|
1899
2499
|
}
|
|
@@ -1903,6 +2503,22 @@ function startDaemon() {
|
|
|
1903
2503
|
const internalToken = randomUUID();
|
|
1904
2504
|
const UI_HTML = UI_HTML_TEMPLATE.replace("{{CSRF_TOKEN}}", csrfToken);
|
|
1905
2505
|
const validToken = (req) => req.headers["x-node9-token"] === csrfToken;
|
|
2506
|
+
const IDLE_TIMEOUT_MS = 12 * 60 * 60 * 1e3;
|
|
2507
|
+
let idleTimer;
|
|
2508
|
+
function resetIdleTimer() {
|
|
2509
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2510
|
+
idleTimer = setTimeout(() => {
|
|
2511
|
+
if (autoStarted) {
|
|
2512
|
+
try {
|
|
2513
|
+
fs3.unlinkSync(DAEMON_PID_FILE);
|
|
2514
|
+
} catch {
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
process.exit(0);
|
|
2518
|
+
}, IDLE_TIMEOUT_MS);
|
|
2519
|
+
idleTimer.unref();
|
|
2520
|
+
}
|
|
2521
|
+
resetIdleTimer();
|
|
1906
2522
|
const server = http.createServer(async (req, res) => {
|
|
1907
2523
|
const { pathname } = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
1908
2524
|
if (req.method === "GET" && pathname === "/") {
|
|
@@ -1919,6 +2535,7 @@ function startDaemon() {
|
|
|
1919
2535
|
clearTimeout(abandonTimer);
|
|
1920
2536
|
abandonTimer = null;
|
|
1921
2537
|
}
|
|
2538
|
+
hadBrowserClient = true;
|
|
1922
2539
|
sseClients.add(res);
|
|
1923
2540
|
res.write(
|
|
1924
2541
|
`event: init
|
|
@@ -1945,12 +2562,13 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
1945
2562
|
return req.on("close", () => {
|
|
1946
2563
|
sseClients.delete(res);
|
|
1947
2564
|
if (sseClients.size === 0 && pending.size > 0) {
|
|
1948
|
-
abandonTimer = setTimeout(abandonPending,
|
|
2565
|
+
abandonTimer = setTimeout(abandonPending, hadBrowserClient ? 1e4 : 15e3);
|
|
1949
2566
|
}
|
|
1950
2567
|
});
|
|
1951
2568
|
}
|
|
1952
2569
|
if (req.method === "POST" && pathname === "/check") {
|
|
1953
2570
|
try {
|
|
2571
|
+
resetIdleTimer();
|
|
1954
2572
|
const body = await readBody(req);
|
|
1955
2573
|
if (body.length > 65536) return res.writeHead(413).end();
|
|
1956
2574
|
const { toolName, args, slackDelegated = false, agent, mcpServer } = JSON.parse(body);
|
|
@@ -1971,8 +2589,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
1971
2589
|
appendAuditLog({
|
|
1972
2590
|
toolName: e.toolName,
|
|
1973
2591
|
args: e.args,
|
|
1974
|
-
decision: "auto-deny"
|
|
1975
|
-
timestamp: Date.now()
|
|
2592
|
+
decision: "auto-deny"
|
|
1976
2593
|
});
|
|
1977
2594
|
if (e.waiter) e.waiter("deny");
|
|
1978
2595
|
else e.earlyDecision = "deny";
|
|
@@ -1990,7 +2607,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
1990
2607
|
agent: entry.agent,
|
|
1991
2608
|
mcpServer: entry.mcpServer
|
|
1992
2609
|
});
|
|
1993
|
-
if (sseClients.size === 0) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
|
|
2610
|
+
if (sseClients.size === 0 && !autoStarted) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
|
|
1994
2611
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
1995
2612
|
return res.end(JSON.stringify({ id }));
|
|
1996
2613
|
} catch {
|
|
@@ -2017,17 +2634,33 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2017
2634
|
const id = pathname.split("/").pop();
|
|
2018
2635
|
const entry = pending.get(id);
|
|
2019
2636
|
if (!entry) return res.writeHead(404).end();
|
|
2020
|
-
const { decision, persist } = JSON.parse(await readBody(req));
|
|
2021
|
-
if (
|
|
2637
|
+
const { decision, persist, trustDuration } = JSON.parse(await readBody(req));
|
|
2638
|
+
if (decision === "trust" && trustDuration) {
|
|
2639
|
+
const ms = TRUST_DURATIONS[trustDuration] ?? 60 * 6e4;
|
|
2640
|
+
writeTrustEntry(entry.toolName, ms);
|
|
2641
|
+
appendAuditLog({
|
|
2642
|
+
toolName: entry.toolName,
|
|
2643
|
+
args: entry.args,
|
|
2644
|
+
decision: `trust:${trustDuration}`
|
|
2645
|
+
});
|
|
2646
|
+
clearTimeout(entry.timer);
|
|
2647
|
+
if (entry.waiter) entry.waiter("allow");
|
|
2648
|
+
else entry.earlyDecision = "allow";
|
|
2649
|
+
pending.delete(id);
|
|
2650
|
+
broadcast("remove", { id });
|
|
2651
|
+
res.writeHead(200);
|
|
2652
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
2653
|
+
}
|
|
2654
|
+
const resolvedDecision = decision === "allow" || decision === "deny" ? decision : "deny";
|
|
2655
|
+
if (persist) writePersistentDecision(entry.toolName, resolvedDecision);
|
|
2022
2656
|
appendAuditLog({
|
|
2023
2657
|
toolName: entry.toolName,
|
|
2024
2658
|
args: entry.args,
|
|
2025
|
-
decision
|
|
2026
|
-
timestamp: Date.now()
|
|
2659
|
+
decision: resolvedDecision
|
|
2027
2660
|
});
|
|
2028
2661
|
clearTimeout(entry.timer);
|
|
2029
|
-
if (entry.waiter) entry.waiter(
|
|
2030
|
-
else entry.earlyDecision =
|
|
2662
|
+
if (entry.waiter) entry.waiter(resolvedDecision);
|
|
2663
|
+
else entry.earlyDecision = resolvedDecision;
|
|
2031
2664
|
pending.delete(id);
|
|
2032
2665
|
broadcast("remove", { id });
|
|
2033
2666
|
res.writeHead(200);
|
|
@@ -2037,7 +2670,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2037
2670
|
}
|
|
2038
2671
|
}
|
|
2039
2672
|
if (req.method === "GET" && pathname === "/settings") {
|
|
2040
|
-
const s =
|
|
2673
|
+
const s = getGlobalSettings();
|
|
2041
2674
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2042
2675
|
return res.end(JSON.stringify({ ...s, autoStarted }));
|
|
2043
2676
|
}
|
|
@@ -2049,7 +2682,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2049
2682
|
if (data.autoStartDaemon !== void 0)
|
|
2050
2683
|
writeGlobalSetting("autoStartDaemon", data.autoStartDaemon);
|
|
2051
2684
|
if (data.slackEnabled !== void 0) writeGlobalSetting("slackEnabled", data.slackEnabled);
|
|
2052
|
-
if (data.
|
|
2685
|
+
if (data.enableTrustSessions !== void 0)
|
|
2686
|
+
writeGlobalSetting("enableTrustSessions", data.enableTrustSessions);
|
|
2687
|
+
if (data.enableUndo !== void 0) writeGlobalSetting("enableUndo", data.enableUndo);
|
|
2688
|
+
if (data.enableHookLogDebug !== void 0)
|
|
2689
|
+
writeGlobalSetting("enableHookLogDebug", data.enableHookLogDebug);
|
|
2690
|
+
if (data.approvers !== void 0) writeGlobalSetting("approvers", data.approvers);
|
|
2053
2691
|
res.writeHead(200);
|
|
2054
2692
|
return res.end(JSON.stringify({ ok: true }));
|
|
2055
2693
|
} catch {
|
|
@@ -2057,7 +2695,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2057
2695
|
}
|
|
2058
2696
|
}
|
|
2059
2697
|
if (req.method === "GET" && pathname === "/slack-status") {
|
|
2060
|
-
const s =
|
|
2698
|
+
const s = getGlobalSettings();
|
|
2061
2699
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2062
2700
|
return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
|
|
2063
2701
|
}
|
|
@@ -2065,14 +2703,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2065
2703
|
if (!validToken(req)) return res.writeHead(403).end();
|
|
2066
2704
|
try {
|
|
2067
2705
|
const { apiKey } = JSON.parse(await readBody(req));
|
|
2068
|
-
|
|
2069
|
-
fs3.mkdirSync(path3.dirname(CREDENTIALS_FILE), { recursive: true });
|
|
2070
|
-
fs3.writeFileSync(
|
|
2706
|
+
atomicWriteSync2(
|
|
2071
2707
|
CREDENTIALS_FILE,
|
|
2072
2708
|
JSON.stringify({ apiKey, apiUrl: "https://api.node9.ai/api/v1/intercept" }, null, 2),
|
|
2073
2709
|
{ mode: 384 }
|
|
2074
2710
|
);
|
|
2075
|
-
broadcast("slack-status", { hasKey: true, enabled:
|
|
2711
|
+
broadcast("slack-status", { hasKey: true, enabled: getGlobalSettings().slackEnabled });
|
|
2076
2712
|
res.writeHead(200);
|
|
2077
2713
|
return res.end(JSON.stringify({ ok: true }));
|
|
2078
2714
|
} catch {
|
|
@@ -2085,7 +2721,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2085
2721
|
const toolName = decodeURIComponent(pathname.split("/").pop());
|
|
2086
2722
|
const decisions = readPersistentDecisions();
|
|
2087
2723
|
delete decisions[toolName];
|
|
2088
|
-
|
|
2724
|
+
atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
|
|
2089
2725
|
broadcast("decisions", decisions);
|
|
2090
2726
|
res.writeHead(200);
|
|
2091
2727
|
return res.end(JSON.stringify({ ok: true }));
|
|
@@ -2104,8 +2740,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2104
2740
|
appendAuditLog({
|
|
2105
2741
|
toolName: entry.toolName,
|
|
2106
2742
|
args: entry.args,
|
|
2107
|
-
decision
|
|
2108
|
-
timestamp: Date.now()
|
|
2743
|
+
decision
|
|
2109
2744
|
});
|
|
2110
2745
|
clearTimeout(entry.timer);
|
|
2111
2746
|
if (entry.waiter) entry.waiter(decision);
|
|
@@ -2125,10 +2760,28 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2125
2760
|
res.writeHead(404).end();
|
|
2126
2761
|
});
|
|
2127
2762
|
daemonServer = server;
|
|
2763
|
+
server.on("error", (e) => {
|
|
2764
|
+
if (e.code === "EADDRINUSE") {
|
|
2765
|
+
try {
|
|
2766
|
+
if (fs3.existsSync(DAEMON_PID_FILE)) {
|
|
2767
|
+
const { pid } = JSON.parse(fs3.readFileSync(DAEMON_PID_FILE, "utf-8"));
|
|
2768
|
+
process.kill(pid, 0);
|
|
2769
|
+
return process.exit(0);
|
|
2770
|
+
}
|
|
2771
|
+
} catch {
|
|
2772
|
+
try {
|
|
2773
|
+
fs3.unlinkSync(DAEMON_PID_FILE);
|
|
2774
|
+
} catch {
|
|
2775
|
+
}
|
|
2776
|
+
server.listen(DAEMON_PORT2, DAEMON_HOST2);
|
|
2777
|
+
return;
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
console.error(chalk3.red("\n\u{1F6D1} Node9 Daemon Error:"), e.message);
|
|
2781
|
+
process.exit(1);
|
|
2782
|
+
});
|
|
2128
2783
|
server.listen(DAEMON_PORT2, DAEMON_HOST2, () => {
|
|
2129
|
-
|
|
2130
|
-
fs3.mkdirSync(path3.dirname(DAEMON_PID_FILE), { recursive: true });
|
|
2131
|
-
fs3.writeFileSync(
|
|
2784
|
+
atomicWriteSync2(
|
|
2132
2785
|
DAEMON_PID_FILE,
|
|
2133
2786
|
JSON.stringify({ pid: process.pid, port: DAEMON_PORT2, internalToken, autoStarted }),
|
|
2134
2787
|
{ mode: 384 }
|
|
@@ -2164,17 +2817,97 @@ function daemonStatus() {
|
|
|
2164
2817
|
}
|
|
2165
2818
|
|
|
2166
2819
|
// src/cli.ts
|
|
2167
|
-
import { spawn, execSync
|
|
2820
|
+
import { spawn as spawn3, execSync } from "child_process";
|
|
2168
2821
|
import { parseCommandString } from "execa";
|
|
2169
2822
|
import { execa } from "execa";
|
|
2170
2823
|
import chalk4 from "chalk";
|
|
2171
2824
|
import readline from "readline";
|
|
2825
|
+
import fs5 from "fs";
|
|
2826
|
+
import path5 from "path";
|
|
2827
|
+
import os5 from "os";
|
|
2828
|
+
|
|
2829
|
+
// src/undo.ts
|
|
2830
|
+
import { spawnSync } from "child_process";
|
|
2172
2831
|
import fs4 from "fs";
|
|
2173
2832
|
import path4 from "path";
|
|
2174
2833
|
import os4 from "os";
|
|
2834
|
+
var UNDO_LATEST_PATH = path4.join(os4.homedir(), ".node9", "undo_latest.txt");
|
|
2835
|
+
async function createShadowSnapshot() {
|
|
2836
|
+
try {
|
|
2837
|
+
const cwd = process.cwd();
|
|
2838
|
+
if (!fs4.existsSync(path4.join(cwd, ".git"))) return null;
|
|
2839
|
+
const tempIndex = path4.join(cwd, ".git", `node9_index_${Date.now()}`);
|
|
2840
|
+
const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
|
|
2841
|
+
spawnSync("git", ["add", "-A"], { env });
|
|
2842
|
+
const treeRes = spawnSync("git", ["write-tree"], { env });
|
|
2843
|
+
const treeHash = treeRes.stdout.toString().trim();
|
|
2844
|
+
if (fs4.existsSync(tempIndex)) fs4.unlinkSync(tempIndex);
|
|
2845
|
+
if (!treeHash || treeRes.status !== 0) return null;
|
|
2846
|
+
const commitRes = spawnSync("git", [
|
|
2847
|
+
"commit-tree",
|
|
2848
|
+
treeHash,
|
|
2849
|
+
"-m",
|
|
2850
|
+
`Node9 AI Snapshot: ${(/* @__PURE__ */ new Date()).toISOString()}`
|
|
2851
|
+
]);
|
|
2852
|
+
const commitHash = commitRes.stdout.toString().trim();
|
|
2853
|
+
if (commitHash && commitRes.status === 0) {
|
|
2854
|
+
const dir = path4.dirname(UNDO_LATEST_PATH);
|
|
2855
|
+
if (!fs4.existsSync(dir)) fs4.mkdirSync(dir, { recursive: true });
|
|
2856
|
+
fs4.writeFileSync(UNDO_LATEST_PATH, commitHash);
|
|
2857
|
+
return commitHash;
|
|
2858
|
+
}
|
|
2859
|
+
} catch (err) {
|
|
2860
|
+
if (process.env.NODE9_DEBUG === "1") {
|
|
2861
|
+
console.error("[Node9 Undo Engine Error]:", err);
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
return null;
|
|
2865
|
+
}
|
|
2866
|
+
function applyUndo(hash) {
|
|
2867
|
+
try {
|
|
2868
|
+
const restore = spawnSync("git", ["restore", "--source", hash, "--staged", "--worktree", "."]);
|
|
2869
|
+
if (restore.status !== 0) return false;
|
|
2870
|
+
const lsTree = spawnSync("git", ["ls-tree", "-r", "--name-only", hash]);
|
|
2871
|
+
const snapshotFiles = new Set(lsTree.stdout.toString().trim().split("\n").filter(Boolean));
|
|
2872
|
+
const tracked = spawnSync("git", ["ls-files"]).stdout.toString().trim().split("\n").filter(Boolean);
|
|
2873
|
+
const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard"]).stdout.toString().trim().split("\n").filter(Boolean);
|
|
2874
|
+
for (const file of [...tracked, ...untracked]) {
|
|
2875
|
+
if (!snapshotFiles.has(file) && fs4.existsSync(file)) {
|
|
2876
|
+
fs4.unlinkSync(file);
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
return true;
|
|
2880
|
+
} catch {
|
|
2881
|
+
return false;
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
function getLatestSnapshotHash() {
|
|
2885
|
+
if (!fs4.existsSync(UNDO_LATEST_PATH)) return null;
|
|
2886
|
+
return fs4.readFileSync(UNDO_LATEST_PATH, "utf-8").trim();
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
// src/cli.ts
|
|
2890
|
+
import { confirm as confirm3 } from "@inquirer/prompts";
|
|
2175
2891
|
var { version } = JSON.parse(
|
|
2176
|
-
|
|
2892
|
+
fs5.readFileSync(path5.join(__dirname, "../package.json"), "utf-8")
|
|
2177
2893
|
);
|
|
2894
|
+
function parseDuration(str) {
|
|
2895
|
+
const m = str.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i);
|
|
2896
|
+
if (!m) return null;
|
|
2897
|
+
const n = parseFloat(m[1]);
|
|
2898
|
+
switch ((m[2] ?? "m").toLowerCase()) {
|
|
2899
|
+
case "s":
|
|
2900
|
+
return Math.round(n * 1e3);
|
|
2901
|
+
case "m":
|
|
2902
|
+
return Math.round(n * 6e4);
|
|
2903
|
+
case "h":
|
|
2904
|
+
return Math.round(n * 36e5);
|
|
2905
|
+
case "d":
|
|
2906
|
+
return Math.round(n * 864e5);
|
|
2907
|
+
default:
|
|
2908
|
+
return null;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2178
2911
|
function sanitize(value) {
|
|
2179
2912
|
return value.replace(/[\x00-\x1F\x7F]/g, "");
|
|
2180
2913
|
}
|
|
@@ -2182,23 +2915,33 @@ function openBrowserLocal() {
|
|
|
2182
2915
|
const url = `http://${DAEMON_HOST2}:${DAEMON_PORT2}/`;
|
|
2183
2916
|
try {
|
|
2184
2917
|
const opts = { stdio: "ignore" };
|
|
2185
|
-
if (process.platform === "darwin")
|
|
2186
|
-
else if (process.platform === "win32")
|
|
2187
|
-
else
|
|
2918
|
+
if (process.platform === "darwin") execSync(`open "${url}"`, opts);
|
|
2919
|
+
else if (process.platform === "win32") execSync(`cmd /c start "" "${url}"`, opts);
|
|
2920
|
+
else execSync(`xdg-open "${url}"`, opts);
|
|
2188
2921
|
} catch {
|
|
2189
2922
|
}
|
|
2190
2923
|
}
|
|
2191
2924
|
async function autoStartDaemonAndWait() {
|
|
2192
2925
|
try {
|
|
2193
|
-
const child =
|
|
2926
|
+
const child = spawn3("node9", ["daemon"], {
|
|
2194
2927
|
detached: true,
|
|
2195
2928
|
stdio: "ignore",
|
|
2196
2929
|
env: { ...process.env, NODE9_AUTO_STARTED: "1" }
|
|
2197
2930
|
});
|
|
2198
2931
|
child.unref();
|
|
2199
|
-
for (let i = 0; i <
|
|
2932
|
+
for (let i = 0; i < 20; i++) {
|
|
2200
2933
|
await new Promise((r) => setTimeout(r, 250));
|
|
2201
|
-
if (isDaemonRunning())
|
|
2934
|
+
if (!isDaemonRunning()) continue;
|
|
2935
|
+
try {
|
|
2936
|
+
const res = await fetch("http://127.0.0.1:7391/settings", {
|
|
2937
|
+
signal: AbortSignal.timeout(500)
|
|
2938
|
+
});
|
|
2939
|
+
if (res.ok) {
|
|
2940
|
+
openBrowserLocal();
|
|
2941
|
+
return true;
|
|
2942
|
+
}
|
|
2943
|
+
} catch {
|
|
2944
|
+
}
|
|
2202
2945
|
}
|
|
2203
2946
|
} catch {
|
|
2204
2947
|
}
|
|
@@ -2217,47 +2960,71 @@ async function runProxy(targetCommand) {
|
|
|
2217
2960
|
} catch {
|
|
2218
2961
|
}
|
|
2219
2962
|
console.log(chalk4.green(`\u{1F680} Node9 Proxy Active: Monitoring [${targetCommand}]`));
|
|
2220
|
-
const child =
|
|
2963
|
+
const child = spawn3(executable, args, {
|
|
2221
2964
|
stdio: ["pipe", "pipe", "inherit"],
|
|
2965
|
+
// We control STDIN and STDOUT
|
|
2222
2966
|
shell: true,
|
|
2223
|
-
env: { ...process.env, FORCE_COLOR: "1"
|
|
2967
|
+
env: { ...process.env, FORCE_COLOR: "1" }
|
|
2224
2968
|
});
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2969
|
+
const agentIn = readline.createInterface({ input: process.stdin, terminal: false });
|
|
2970
|
+
agentIn.on("line", async (line) => {
|
|
2971
|
+
let message;
|
|
2228
2972
|
try {
|
|
2229
|
-
|
|
2230
|
-
|
|
2973
|
+
message = JSON.parse(line);
|
|
2974
|
+
} catch {
|
|
2975
|
+
child.stdin.write(line + "\n");
|
|
2976
|
+
return;
|
|
2977
|
+
}
|
|
2978
|
+
if (message.method === "call_tool" || message.method === "tools/call" || message.method === "use_tool") {
|
|
2979
|
+
agentIn.pause();
|
|
2980
|
+
try {
|
|
2231
2981
|
const name = message.params?.name || message.params?.tool_name || "unknown";
|
|
2232
2982
|
const toolArgs = message.params?.arguments || message.params?.tool_input || {};
|
|
2233
|
-
const
|
|
2234
|
-
|
|
2983
|
+
const result = await authorizeHeadless(sanitize(name), toolArgs, true, {
|
|
2984
|
+
agent: "Proxy/MCP"
|
|
2985
|
+
});
|
|
2986
|
+
if (!result.approved) {
|
|
2235
2987
|
const errorResponse = {
|
|
2236
2988
|
jsonrpc: "2.0",
|
|
2237
2989
|
id: message.id,
|
|
2238
|
-
error: {
|
|
2990
|
+
error: {
|
|
2991
|
+
code: -32e3,
|
|
2992
|
+
message: `Node9: Action denied. ${result.reason || ""}`
|
|
2993
|
+
}
|
|
2239
2994
|
};
|
|
2240
|
-
|
|
2995
|
+
process.stdout.write(JSON.stringify(errorResponse) + "\n");
|
|
2241
2996
|
return;
|
|
2242
2997
|
}
|
|
2998
|
+
} catch {
|
|
2999
|
+
const errorResponse = {
|
|
3000
|
+
jsonrpc: "2.0",
|
|
3001
|
+
id: message.id,
|
|
3002
|
+
error: {
|
|
3003
|
+
code: -32e3,
|
|
3004
|
+
message: `Node9: Security engine encountered an error. Action blocked for safety.`
|
|
3005
|
+
}
|
|
3006
|
+
};
|
|
3007
|
+
process.stdout.write(JSON.stringify(errorResponse) + "\n");
|
|
3008
|
+
return;
|
|
3009
|
+
} finally {
|
|
3010
|
+
agentIn.resume();
|
|
2243
3011
|
}
|
|
2244
|
-
process.stdout.write(line + "\n");
|
|
2245
|
-
} catch {
|
|
2246
|
-
process.stdout.write(line + "\n");
|
|
2247
3012
|
}
|
|
3013
|
+
child.stdin.write(line + "\n");
|
|
2248
3014
|
});
|
|
3015
|
+
child.stdout.pipe(process.stdout);
|
|
2249
3016
|
child.on("exit", (code) => process.exit(code || 0));
|
|
2250
3017
|
}
|
|
2251
3018
|
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
3019
|
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
2253
|
-
const credPath =
|
|
2254
|
-
if (!
|
|
2255
|
-
|
|
3020
|
+
const credPath = path5.join(os5.homedir(), ".node9", "credentials.json");
|
|
3021
|
+
if (!fs5.existsSync(path5.dirname(credPath)))
|
|
3022
|
+
fs5.mkdirSync(path5.dirname(credPath), { recursive: true });
|
|
2256
3023
|
const profileName = options.profile || "default";
|
|
2257
3024
|
let existingCreds = {};
|
|
2258
3025
|
try {
|
|
2259
|
-
if (
|
|
2260
|
-
const raw = JSON.parse(
|
|
3026
|
+
if (fs5.existsSync(credPath)) {
|
|
3027
|
+
const raw = JSON.parse(fs5.readFileSync(credPath, "utf-8"));
|
|
2261
3028
|
if (raw.apiKey) {
|
|
2262
3029
|
existingCreds = {
|
|
2263
3030
|
default: { apiKey: raw.apiKey, apiUrl: raw.apiUrl || DEFAULT_API_URL }
|
|
@@ -2269,40 +3036,38 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
|
|
|
2269
3036
|
} catch {
|
|
2270
3037
|
}
|
|
2271
3038
|
existingCreds[profileName] = { apiKey, apiUrl: DEFAULT_API_URL };
|
|
2272
|
-
|
|
3039
|
+
fs5.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
|
|
2273
3040
|
if (profileName === "default") {
|
|
2274
|
-
const configPath =
|
|
3041
|
+
const configPath = path5.join(os5.homedir(), ".node9", "config.json");
|
|
2275
3042
|
let config = {};
|
|
2276
3043
|
try {
|
|
2277
|
-
if (
|
|
2278
|
-
config = JSON.parse(
|
|
3044
|
+
if (fs5.existsSync(configPath))
|
|
3045
|
+
config = JSON.parse(fs5.readFileSync(configPath, "utf-8"));
|
|
2279
3046
|
} catch {
|
|
2280
3047
|
}
|
|
2281
3048
|
if (!config.settings || typeof config.settings !== "object") config.settings = {};
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
3049
|
+
const s = config.settings;
|
|
3050
|
+
const approvers = s.approvers || {
|
|
3051
|
+
native: true,
|
|
3052
|
+
browser: true,
|
|
3053
|
+
cloud: true,
|
|
3054
|
+
terminal: true
|
|
3055
|
+
};
|
|
3056
|
+
approvers.cloud = !options.local;
|
|
3057
|
+
s.approvers = approvers;
|
|
3058
|
+
if (!fs5.existsSync(path5.dirname(configPath)))
|
|
3059
|
+
fs5.mkdirSync(path5.dirname(configPath), { recursive: true });
|
|
3060
|
+
fs5.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
2286
3061
|
}
|
|
2287
3062
|
if (options.profile && profileName !== "default") {
|
|
2288
3063
|
console.log(chalk4.green(`\u2705 Profile "${profileName}" saved`));
|
|
2289
3064
|
console.log(chalk4.gray(` Switch to it per-session: NODE9_PROFILE=${profileName} claude`));
|
|
2290
|
-
console.log(
|
|
2291
|
-
chalk4.gray(
|
|
2292
|
-
` Or lock a project to it: add "apiKey": "<your-api-key>" to node9.config.json`
|
|
2293
|
-
)
|
|
2294
|
-
);
|
|
2295
3065
|
} else if (options.local) {
|
|
2296
3066
|
console.log(chalk4.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
|
|
2297
3067
|
console.log(chalk4.gray(` All decisions stay on this machine.`));
|
|
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>`));
|
|
2302
3068
|
} else {
|
|
2303
3069
|
console.log(chalk4.green(`\u2705 Logged in \u2014 agent mode`));
|
|
2304
3070
|
console.log(chalk4.gray(` Team policy enforced for all calls via Node9 cloud.`));
|
|
2305
|
-
console.log(chalk4.gray(` To keep local control only: node9 login <apiKey> --local`));
|
|
2306
3071
|
}
|
|
2307
3072
|
});
|
|
2308
3073
|
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) => {
|
|
@@ -2313,16 +3078,23 @@ program.command("addto").description("Integrate Node9 with an AI agent").addHelp
|
|
|
2313
3078
|
process.exit(1);
|
|
2314
3079
|
});
|
|
2315
3080
|
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 (
|
|
3081
|
+
const configPath = path5.join(os5.homedir(), ".node9", "config.json");
|
|
3082
|
+
if (fs5.existsSync(configPath) && !options.force) {
|
|
2318
3083
|
console.log(chalk4.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
|
|
2319
3084
|
console.log(chalk4.gray(` Run with --force to overwrite.`));
|
|
2320
3085
|
return;
|
|
2321
3086
|
}
|
|
2322
3087
|
const defaultConfig = {
|
|
2323
3088
|
version: "1.0",
|
|
2324
|
-
settings: {
|
|
3089
|
+
settings: {
|
|
3090
|
+
mode: "standard",
|
|
3091
|
+
autoStartDaemon: true,
|
|
3092
|
+
enableUndo: true,
|
|
3093
|
+
enableHookLogDebug: false,
|
|
3094
|
+
approvers: { native: true, browser: true, cloud: true, terminal: true }
|
|
3095
|
+
},
|
|
2325
3096
|
policy: {
|
|
3097
|
+
sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
|
|
2326
3098
|
dangerousWords: DANGEROUS_WORDS,
|
|
2327
3099
|
ignoredTools: [
|
|
2328
3100
|
"list_*",
|
|
@@ -2332,60 +3104,65 @@ program.command("init").description("Create ~/.node9/config.json with default po
|
|
|
2332
3104
|
"read",
|
|
2333
3105
|
"write",
|
|
2334
3106
|
"edit",
|
|
2335
|
-
"multiedit",
|
|
2336
3107
|
"glob",
|
|
2337
3108
|
"grep",
|
|
2338
3109
|
"ls",
|
|
2339
3110
|
"notebookread",
|
|
2340
3111
|
"notebookedit",
|
|
2341
|
-
"todoread",
|
|
2342
|
-
"todowrite",
|
|
2343
3112
|
"webfetch",
|
|
2344
3113
|
"websearch",
|
|
2345
3114
|
"exitplanmode",
|
|
2346
|
-
"askuserquestion"
|
|
3115
|
+
"askuserquestion",
|
|
3116
|
+
"agent",
|
|
3117
|
+
"task*"
|
|
2347
3118
|
],
|
|
2348
3119
|
toolInspection: {
|
|
2349
3120
|
bash: "command",
|
|
2350
3121
|
shell: "command",
|
|
2351
3122
|
run_shell_command: "command",
|
|
2352
|
-
"terminal.execute": "command"
|
|
3123
|
+
"terminal.execute": "command",
|
|
3124
|
+
"postgres:query": "sql"
|
|
2353
3125
|
},
|
|
2354
3126
|
rules: [
|
|
2355
3127
|
{
|
|
2356
3128
|
action: "rm",
|
|
2357
|
-
allowPaths: [
|
|
3129
|
+
allowPaths: [
|
|
3130
|
+
"**/node_modules/**",
|
|
3131
|
+
"dist/**",
|
|
3132
|
+
"build/**",
|
|
3133
|
+
".next/**",
|
|
3134
|
+
"coverage/**",
|
|
3135
|
+
".cache/**",
|
|
3136
|
+
"tmp/**",
|
|
3137
|
+
"temp/**",
|
|
3138
|
+
".DS_Store"
|
|
3139
|
+
]
|
|
2358
3140
|
}
|
|
2359
3141
|
]
|
|
2360
3142
|
}
|
|
2361
3143
|
};
|
|
2362
|
-
if (!
|
|
2363
|
-
|
|
2364
|
-
|
|
3144
|
+
if (!fs5.existsSync(path5.dirname(configPath)))
|
|
3145
|
+
fs5.mkdirSync(path5.dirname(configPath), { recursive: true });
|
|
3146
|
+
fs5.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
|
|
2365
3147
|
console.log(chalk4.green(`\u2705 Global config created: ${configPath}`));
|
|
2366
3148
|
console.log(chalk4.gray(` Edit this file to add custom tool inspection or security rules.`));
|
|
2367
3149
|
});
|
|
2368
3150
|
program.command("status").description("Show current Node9 mode, policy source, and persistent decisions").action(() => {
|
|
2369
3151
|
const creds = getCredentials();
|
|
2370
3152
|
const daemonRunning = isDaemonRunning();
|
|
2371
|
-
const
|
|
3153
|
+
const mergedConfig = getConfig();
|
|
3154
|
+
const settings = mergedConfig.settings;
|
|
2372
3155
|
console.log("");
|
|
2373
|
-
if (creds && settings.
|
|
3156
|
+
if (creds && settings.approvers.cloud) {
|
|
2374
3157
|
console.log(chalk4.green(" \u25CF Agent mode") + chalk4.gray(" \u2014 cloud team policy enforced"));
|
|
2375
|
-
|
|
2376
|
-
console.log(chalk4.gray(" Switch to local control: node9 login <apiKey> --local"));
|
|
2377
|
-
} else if (creds && !settings.agentMode) {
|
|
3158
|
+
} else if (creds && !settings.approvers.cloud) {
|
|
2378
3159
|
console.log(
|
|
2379
3160
|
chalk4.blue(" \u25CF Privacy mode \u{1F6E1}\uFE0F") + chalk4.gray(" \u2014 all decisions stay on this machine")
|
|
2380
3161
|
);
|
|
3162
|
+
} else {
|
|
2381
3163
|
console.log(
|
|
2382
|
-
chalk4.
|
|
3164
|
+
chalk4.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + chalk4.gray(" \u2014 no API key (Local rules only)")
|
|
2383
3165
|
);
|
|
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
3166
|
}
|
|
2390
3167
|
console.log("");
|
|
2391
3168
|
if (daemonRunning) {
|
|
@@ -2394,64 +3171,39 @@ program.command("status").description("Show current Node9 mode, policy source, a
|
|
|
2394
3171
|
);
|
|
2395
3172
|
} else {
|
|
2396
3173
|
console.log(chalk4.gray(" \u25CB Daemon stopped"));
|
|
2397
|
-
console.log(chalk4.gray(" Start: node9 daemon --background"));
|
|
2398
3174
|
}
|
|
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)}`);
|
|
3175
|
+
if (settings.enableUndo) {
|
|
2410
3176
|
console.log(
|
|
2411
|
-
|
|
3177
|
+
chalk4.magenta(" \u25CF Undo Engine") + chalk4.gray(` \u2192 Auto-snapshotting Git repos on AI change`)
|
|
2412
3178
|
);
|
|
2413
|
-
console.log(chalk4.gray(` Switch: NODE9_PROFILE=<name> claude`));
|
|
2414
|
-
}
|
|
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
3179
|
}
|
|
2422
|
-
const keys = Object.keys(decisions);
|
|
2423
3180
|
console.log("");
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
3181
|
+
const modeLabel = settings.mode === "audit" ? chalk4.blue("audit") : settings.mode === "strict" ? chalk4.red("strict") : chalk4.white("standard");
|
|
3182
|
+
console.log(` Mode: ${modeLabel}`);
|
|
3183
|
+
const projectConfig = path5.join(process.cwd(), "node9.config.json");
|
|
3184
|
+
const globalConfig = path5.join(os5.homedir(), ".node9", "config.json");
|
|
3185
|
+
console.log(
|
|
3186
|
+
` Local: ${fs5.existsSync(projectConfig) ? chalk4.green("Active (node9.config.json)") : chalk4.gray("Not present")}`
|
|
3187
|
+
);
|
|
3188
|
+
console.log(
|
|
3189
|
+
` Global: ${fs5.existsSync(globalConfig) ? chalk4.green("Active (~/.node9/config.json)") : chalk4.gray("Not present")}`
|
|
3190
|
+
);
|
|
3191
|
+
if (mergedConfig.policy.sandboxPaths.length > 0) {
|
|
3192
|
+
console.log(
|
|
3193
|
+
` Sandbox: ${chalk4.green(`${mergedConfig.policy.sandboxPaths.length} safe zones active`)}`
|
|
3194
|
+
);
|
|
2434
3195
|
}
|
|
2435
|
-
const
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
);
|
|
2443
|
-
}
|
|
2444
|
-
} catch {
|
|
3196
|
+
const pauseState = checkPause();
|
|
3197
|
+
if (pauseState.paused) {
|
|
3198
|
+
const expiresAt = pauseState.expiresAt ? new Date(pauseState.expiresAt).toLocaleTimeString() : "indefinitely";
|
|
3199
|
+
console.log("");
|
|
3200
|
+
console.log(
|
|
3201
|
+
chalk4.yellow(` \u23F8 PAUSED until ${expiresAt}`) + chalk4.gray(" \u2014 all tool calls allowed")
|
|
3202
|
+
);
|
|
2445
3203
|
}
|
|
2446
3204
|
console.log("");
|
|
2447
3205
|
});
|
|
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(
|
|
3206
|
+
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
3207
|
async (action, options) => {
|
|
2456
3208
|
const cmd = (action ?? "start").toLowerCase();
|
|
2457
3209
|
if (cmd === "stop") return stopDaemon();
|
|
@@ -2466,7 +3218,7 @@ program.command("daemon").description("Run the local approval server (browser HI
|
|
|
2466
3218
|
console.log(chalk4.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
|
|
2467
3219
|
process.exit(0);
|
|
2468
3220
|
}
|
|
2469
|
-
const child =
|
|
3221
|
+
const child = spawn3("node9", ["daemon"], { detached: true, stdio: "ignore" });
|
|
2470
3222
|
child.unref();
|
|
2471
3223
|
for (let i = 0; i < 12; i++) {
|
|
2472
3224
|
await new Promise((r) => setTimeout(r, 250));
|
|
@@ -2475,18 +3227,13 @@ program.command("daemon").description("Run the local approval server (browser HI
|
|
|
2475
3227
|
openBrowserLocal();
|
|
2476
3228
|
console.log(chalk4.green(`
|
|
2477
3229
|
\u{1F6E1}\uFE0F Node9 daemon started + browser opened`));
|
|
2478
|
-
console.log(chalk4.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
|
|
2479
3230
|
process.exit(0);
|
|
2480
3231
|
}
|
|
2481
3232
|
if (options.background) {
|
|
2482
|
-
const child =
|
|
3233
|
+
const child = spawn3("node9", ["daemon"], { detached: true, stdio: "ignore" });
|
|
2483
3234
|
child.unref();
|
|
2484
3235
|
console.log(chalk4.green(`
|
|
2485
3236
|
\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
3237
|
process.exit(0);
|
|
2491
3238
|
}
|
|
2492
3239
|
startDaemon();
|
|
@@ -2496,53 +3243,81 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
|
|
|
2496
3243
|
const processPayload = async (raw) => {
|
|
2497
3244
|
try {
|
|
2498
3245
|
if (!raw || raw.trim() === "") process.exit(0);
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
3246
|
+
let payload = JSON.parse(raw);
|
|
3247
|
+
try {
|
|
3248
|
+
payload = JSON.parse(raw);
|
|
3249
|
+
} catch (err) {
|
|
3250
|
+
const tempConfig = getConfig();
|
|
3251
|
+
if (process.env.NODE9_DEBUG === "1" || tempConfig.settings.enableHookLogDebug) {
|
|
3252
|
+
const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
|
|
3253
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3254
|
+
fs5.appendFileSync(
|
|
3255
|
+
logPath,
|
|
3256
|
+
`[${(/* @__PURE__ */ new Date()).toISOString()}] JSON_PARSE_ERROR: ${errMsg}
|
|
3257
|
+
RAW: ${raw}
|
|
2508
3258
|
`
|
|
2509
|
-
|
|
3259
|
+
);
|
|
3260
|
+
}
|
|
3261
|
+
process.exit(0);
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
if (payload.cwd) {
|
|
3265
|
+
try {
|
|
3266
|
+
process.chdir(payload.cwd);
|
|
3267
|
+
_resetConfigCache();
|
|
3268
|
+
} catch {
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
const config = getConfig();
|
|
3272
|
+
if (process.env.NODE9_DEBUG === "1" || config.settings.enableHookLogDebug) {
|
|
3273
|
+
const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
|
|
3274
|
+
if (!fs5.existsSync(path5.dirname(logPath)))
|
|
3275
|
+
fs5.mkdirSync(path5.dirname(logPath), { recursive: true });
|
|
3276
|
+
fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
|
|
3277
|
+
`);
|
|
2510
3278
|
}
|
|
2511
|
-
const payload = JSON.parse(raw);
|
|
2512
3279
|
const toolName = sanitize(payload.tool_name ?? payload.name ?? "");
|
|
2513
3280
|
const toolInput = payload.tool_input ?? payload.args ?? {};
|
|
2514
|
-
const agent = payload.
|
|
3281
|
+
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
3282
|
const mcpMatch = toolName.match(/^mcp__([^_](?:[^_]|_(?!_))*?)__/i);
|
|
2516
3283
|
const mcpServer = mcpMatch?.[1];
|
|
2517
3284
|
const sendBlock = (msg, result2) => {
|
|
2518
|
-
const
|
|
2519
|
-
|
|
2520
|
-
"persistent-deny": "you set this tool to always deny",
|
|
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
|
-
};
|
|
3285
|
+
const blockedByContext = result2?.blockedByLabel || result2?.blockedBy || "Local Security Policy";
|
|
3286
|
+
const isHumanDecision = blockedByContext.toLowerCase().includes("user") || blockedByContext.toLowerCase().includes("daemon") || blockedByContext.toLowerCase().includes("decision");
|
|
2525
3287
|
console.error(chalk4.red(`
|
|
2526
3288
|
\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
|
-
}
|
|
3289
|
+
console.error(chalk4.gray(` Triggered by: ${blockedByContext}`));
|
|
3290
|
+
if (result2?.changeHint) console.error(chalk4.cyan(` To change: ${result2.changeHint}`));
|
|
2537
3291
|
console.error("");
|
|
3292
|
+
let aiFeedbackMessage = "";
|
|
3293
|
+
if (isHumanDecision) {
|
|
3294
|
+
aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: The human user specifically REJECTED this action.
|
|
3295
|
+
REASON: ${msg || "No specific reason provided by user."}
|
|
3296
|
+
|
|
3297
|
+
INSTRUCTIONS FOR AI AGENT:
|
|
3298
|
+
- Do NOT retry this exact command immediately.
|
|
3299
|
+
- Explain to the user that you understand they blocked the action.
|
|
3300
|
+
- Ask the user if there is an alternative approach they would prefer, or if they intended to block this action entirely.
|
|
3301
|
+
- 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.`;
|
|
3302
|
+
} else {
|
|
3303
|
+
aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${blockedByContext}].
|
|
3304
|
+
REASON: ${msg}
|
|
3305
|
+
|
|
3306
|
+
INSTRUCTIONS FOR AI AGENT:
|
|
3307
|
+
- This command violates the current security configuration.
|
|
3308
|
+
- Do NOT attempt to bypass this rule with bash syntax tricks; it will be blocked again.
|
|
3309
|
+
- Pivot to a non-destructive or read-only alternative.
|
|
3310
|
+
- Inform the user which security rule was triggered.`;
|
|
3311
|
+
}
|
|
2538
3312
|
process.stdout.write(
|
|
2539
3313
|
JSON.stringify({
|
|
2540
3314
|
decision: "block",
|
|
2541
|
-
reason:
|
|
3315
|
+
reason: aiFeedbackMessage,
|
|
3316
|
+
// This is the core instruction
|
|
2542
3317
|
hookSpecificOutput: {
|
|
2543
3318
|
hookEventName: "PreToolUse",
|
|
2544
3319
|
permissionDecision: "deny",
|
|
2545
|
-
permissionDecisionReason:
|
|
3320
|
+
permissionDecisionReason: aiFeedbackMessage
|
|
2546
3321
|
}
|
|
2547
3322
|
}) + "\n"
|
|
2548
3323
|
);
|
|
@@ -2553,36 +3328,53 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
|
|
|
2553
3328
|
return;
|
|
2554
3329
|
}
|
|
2555
3330
|
const meta = { agent, mcpServer };
|
|
3331
|
+
const STATE_CHANGING_TOOLS_PRE = [
|
|
3332
|
+
"bash",
|
|
3333
|
+
"shell",
|
|
3334
|
+
"write_file",
|
|
3335
|
+
"edit_file",
|
|
3336
|
+
"replace",
|
|
3337
|
+
"terminal.execute",
|
|
3338
|
+
"str_replace_based_edit_tool",
|
|
3339
|
+
"create_file"
|
|
3340
|
+
];
|
|
3341
|
+
if (config.settings.enableUndo && STATE_CHANGING_TOOLS_PRE.includes(toolName.toLowerCase())) {
|
|
3342
|
+
await createShadowSnapshot();
|
|
3343
|
+
}
|
|
2556
3344
|
const result = await authorizeHeadless(toolName, toolInput, false, meta);
|
|
2557
3345
|
if (result.approved) {
|
|
2558
|
-
if (result.checkedBy)
|
|
3346
|
+
if (result.checkedBy)
|
|
2559
3347
|
process.stderr.write(`\u2713 node9 [${result.checkedBy}]: "${toolName}" allowed
|
|
2560
3348
|
`);
|
|
2561
|
-
}
|
|
2562
3349
|
process.exit(0);
|
|
2563
3350
|
}
|
|
2564
|
-
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY &&
|
|
3351
|
+
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && config.settings.autoStartDaemon) {
|
|
2565
3352
|
console.error(chalk4.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
|
|
2566
3353
|
const daemonReady = await autoStartDaemonAndWait();
|
|
2567
3354
|
if (daemonReady) {
|
|
2568
3355
|
const retry = await authorizeHeadless(toolName, toolInput, false, meta);
|
|
2569
3356
|
if (retry.approved) {
|
|
2570
|
-
if (retry.checkedBy)
|
|
3357
|
+
if (retry.checkedBy)
|
|
2571
3358
|
process.stderr.write(`\u2713 node9 [${retry.checkedBy}]: "${toolName}" allowed
|
|
2572
3359
|
`);
|
|
2573
|
-
}
|
|
2574
3360
|
process.exit(0);
|
|
2575
3361
|
}
|
|
2576
|
-
sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`,
|
|
3362
|
+
sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, {
|
|
3363
|
+
...retry,
|
|
3364
|
+
blockedByLabel: retry.blockedByLabel
|
|
3365
|
+
});
|
|
2577
3366
|
return;
|
|
2578
3367
|
}
|
|
2579
3368
|
}
|
|
2580
|
-
sendBlock(result.reason ?? `Node9 blocked "${toolName}".`,
|
|
3369
|
+
sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, {
|
|
3370
|
+
...result,
|
|
3371
|
+
blockedByLabel: result.blockedByLabel
|
|
3372
|
+
});
|
|
2581
3373
|
} catch (err) {
|
|
2582
3374
|
if (process.env.NODE9_DEBUG === "1") {
|
|
2583
|
-
const logPath =
|
|
3375
|
+
const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
|
|
2584
3376
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2585
|
-
|
|
3377
|
+
fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
|
|
2586
3378
|
`);
|
|
2587
3379
|
}
|
|
2588
3380
|
process.exit(0);
|
|
@@ -2593,48 +3385,101 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
|
|
|
2593
3385
|
} else {
|
|
2594
3386
|
let raw = "";
|
|
2595
3387
|
let processed = false;
|
|
3388
|
+
let inactivityTimer = null;
|
|
2596
3389
|
const done = async () => {
|
|
2597
3390
|
if (processed) return;
|
|
2598
3391
|
processed = true;
|
|
3392
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
2599
3393
|
if (!raw.trim()) return process.exit(0);
|
|
2600
3394
|
await processPayload(raw);
|
|
2601
3395
|
};
|
|
2602
3396
|
process.stdin.setEncoding("utf-8");
|
|
2603
|
-
process.stdin.on("data", (chunk) =>
|
|
2604
|
-
|
|
2605
|
-
|
|
3397
|
+
process.stdin.on("data", (chunk) => {
|
|
3398
|
+
raw += chunk;
|
|
3399
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
3400
|
+
inactivityTimer = setTimeout(() => void done(), 2e3);
|
|
3401
|
+
});
|
|
3402
|
+
process.stdin.on("end", () => {
|
|
3403
|
+
void done();
|
|
3404
|
+
});
|
|
3405
|
+
inactivityTimer = setTimeout(() => void done(), 5e3);
|
|
2606
3406
|
}
|
|
2607
3407
|
});
|
|
2608
3408
|
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) => {
|
|
3409
|
+
const logPayload = async (raw) => {
|
|
2610
3410
|
try {
|
|
2611
3411
|
if (!raw || raw.trim() === "") process.exit(0);
|
|
2612
3412
|
const payload = JSON.parse(raw);
|
|
3413
|
+
const tool = sanitize(payload.tool_name ?? payload.name ?? "unknown");
|
|
3414
|
+
const rawInput = payload.tool_input ?? payload.args ?? {};
|
|
2613
3415
|
const entry = {
|
|
2614
3416
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2615
|
-
tool
|
|
2616
|
-
|
|
3417
|
+
tool,
|
|
3418
|
+
args: JSON.parse(redactSecrets(JSON.stringify(rawInput))),
|
|
3419
|
+
decision: "allowed",
|
|
3420
|
+
source: "post-hook"
|
|
2617
3421
|
};
|
|
2618
|
-
const logPath =
|
|
2619
|
-
if (!
|
|
2620
|
-
|
|
2621
|
-
|
|
3422
|
+
const logPath = path5.join(os5.homedir(), ".node9", "audit.log");
|
|
3423
|
+
if (!fs5.existsSync(path5.dirname(logPath)))
|
|
3424
|
+
fs5.mkdirSync(path5.dirname(logPath), { recursive: true });
|
|
3425
|
+
fs5.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
3426
|
+
const config = getConfig();
|
|
3427
|
+
const STATE_CHANGING_TOOLS = [
|
|
3428
|
+
"bash",
|
|
3429
|
+
"shell",
|
|
3430
|
+
"write_file",
|
|
3431
|
+
"edit_file",
|
|
3432
|
+
"replace",
|
|
3433
|
+
"terminal.execute"
|
|
3434
|
+
];
|
|
3435
|
+
if (config.settings.enableUndo && STATE_CHANGING_TOOLS.includes(tool.toLowerCase())) {
|
|
3436
|
+
await createShadowSnapshot();
|
|
3437
|
+
}
|
|
2622
3438
|
} catch {
|
|
2623
3439
|
}
|
|
2624
3440
|
process.exit(0);
|
|
2625
3441
|
};
|
|
2626
3442
|
if (data) {
|
|
2627
|
-
logPayload(data);
|
|
3443
|
+
await logPayload(data);
|
|
2628
3444
|
} else {
|
|
2629
3445
|
let raw = "";
|
|
2630
3446
|
process.stdin.setEncoding("utf-8");
|
|
2631
3447
|
process.stdin.on("data", (chunk) => raw += chunk);
|
|
2632
|
-
process.stdin.on("end", () =>
|
|
3448
|
+
process.stdin.on("end", () => {
|
|
3449
|
+
void logPayload(raw);
|
|
3450
|
+
});
|
|
2633
3451
|
setTimeout(() => {
|
|
2634
3452
|
if (!raw) process.exit(0);
|
|
2635
3453
|
}, 500);
|
|
2636
3454
|
}
|
|
2637
3455
|
});
|
|
3456
|
+
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) => {
|
|
3457
|
+
const ms = parseDuration(options.duration);
|
|
3458
|
+
if (ms === null) {
|
|
3459
|
+
console.error(
|
|
3460
|
+
chalk4.red(`
|
|
3461
|
+
\u274C Invalid duration: "${options.duration}". Use format like 15m, 1h, 30s.
|
|
3462
|
+
`)
|
|
3463
|
+
);
|
|
3464
|
+
process.exit(1);
|
|
3465
|
+
}
|
|
3466
|
+
pauseNode9(ms, options.duration);
|
|
3467
|
+
const expiresAt = new Date(Date.now() + ms).toLocaleTimeString();
|
|
3468
|
+
console.log(chalk4.yellow(`
|
|
3469
|
+
\u23F8 Node9 paused until ${expiresAt}`));
|
|
3470
|
+
console.log(chalk4.gray(` All tool calls will be allowed without review.`));
|
|
3471
|
+
console.log(chalk4.gray(` Run "node9 resume" to re-enable early.
|
|
3472
|
+
`));
|
|
3473
|
+
});
|
|
3474
|
+
program.command("resume").description("Re-enable Node9 protection immediately").action(() => {
|
|
3475
|
+
const { paused } = checkPause();
|
|
3476
|
+
if (!paused) {
|
|
3477
|
+
console.log(chalk4.gray("\nNode9 is already active \u2014 nothing to resume.\n"));
|
|
3478
|
+
return;
|
|
3479
|
+
}
|
|
3480
|
+
resumeNode9();
|
|
3481
|
+
console.log(chalk4.green("\n\u25B6 Node9 resumed \u2014 protection is active.\n"));
|
|
3482
|
+
});
|
|
2638
3483
|
var HOOK_BASED_AGENTS = {
|
|
2639
3484
|
claude: "claude",
|
|
2640
3485
|
gemini: "gemini",
|
|
@@ -2649,35 +3494,17 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
|
|
|
2649
3494
|
chalk4.yellow(`
|
|
2650
3495
|
\u26A0\uFE0F Node9 proxy mode does not support "${target}" directly.`)
|
|
2651
3496
|
);
|
|
2652
|
-
console.error(
|
|
2653
|
-
chalk4.white(`
|
|
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
3497
|
console.error(chalk4.white(`
|
|
2669
|
-
|
|
3498
|
+
"${target}" uses its own hook system. Use:`));
|
|
2670
3499
|
console.error(
|
|
2671
|
-
chalk4.green(` node9
|
|
2672
|
-
);
|
|
2673
|
-
console.error(
|
|
2674
|
-
chalk4.green(` ${target} `) + chalk4.gray("# Node9 will open browser on dangerous actions\n")
|
|
3500
|
+
chalk4.green(` node9 addto ${target} `) + chalk4.gray("# one-time setup")
|
|
2675
3501
|
);
|
|
3502
|
+
console.error(chalk4.green(` ${target} `) + chalk4.gray("# run normally"));
|
|
2676
3503
|
process.exit(1);
|
|
2677
3504
|
}
|
|
2678
3505
|
const fullCommand = commandArgs.join(" ");
|
|
2679
3506
|
let result = await authorizeHeadless("shell", { command: fullCommand });
|
|
2680
|
-
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON &&
|
|
3507
|
+
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getConfig().settings.autoStartDaemon) {
|
|
2681
3508
|
console.error(chalk4.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
|
|
2682
3509
|
const daemonReady = await autoStartDaemonAndWait();
|
|
2683
3510
|
if (daemonReady) result = await authorizeHeadless("shell", { command: fullCommand });
|
|
@@ -2690,21 +3517,6 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
|
|
|
2690
3517
|
chalk4.red(`
|
|
2691
3518
|
\u274C Node9 Blocked: ${result.reason || "Dangerous command detected."}`)
|
|
2692
3519
|
);
|
|
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
3520
|
process.exit(1);
|
|
2709
3521
|
}
|
|
2710
3522
|
console.error(chalk4.green("\n\u2705 Approved \u2014 running command...\n"));
|
|
@@ -2713,13 +3525,33 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
|
|
|
2713
3525
|
program.help();
|
|
2714
3526
|
}
|
|
2715
3527
|
});
|
|
3528
|
+
program.command("undo").description("Revert the project to the state before the last AI action").action(async () => {
|
|
3529
|
+
const hash = getLatestSnapshotHash();
|
|
3530
|
+
if (!hash) {
|
|
3531
|
+
console.log(chalk4.yellow("\n\u2139\uFE0F No Undo snapshot found for this machine.\n"));
|
|
3532
|
+
return;
|
|
3533
|
+
}
|
|
3534
|
+
console.log(chalk4.magenta.bold("\n\u23EA NODE9 UNDO ENGINE"));
|
|
3535
|
+
console.log(chalk4.white(`Target Snapshot: ${chalk4.gray(hash.slice(0, 7))}`));
|
|
3536
|
+
const proceed = await confirm3({
|
|
3537
|
+
message: "Revert all files to the state before the last AI action?",
|
|
3538
|
+
default: false
|
|
3539
|
+
});
|
|
3540
|
+
if (proceed) {
|
|
3541
|
+
if (applyUndo(hash)) {
|
|
3542
|
+
console.log(chalk4.green("\u2705 Project reverted successfully.\n"));
|
|
3543
|
+
} else {
|
|
3544
|
+
console.error(chalk4.red("\u274C Undo failed. Ensure you are in a Git repository.\n"));
|
|
3545
|
+
}
|
|
3546
|
+
}
|
|
3547
|
+
});
|
|
2716
3548
|
process.on("unhandledRejection", (reason) => {
|
|
2717
3549
|
const isCheckHook = process.argv[2] === "check";
|
|
2718
3550
|
if (isCheckHook) {
|
|
2719
|
-
if (process.env.NODE9_DEBUG === "1") {
|
|
2720
|
-
const logPath =
|
|
3551
|
+
if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
|
|
3552
|
+
const logPath = path5.join(os5.homedir(), ".node9", "hook-debug.log");
|
|
2721
3553
|
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
2722
|
-
|
|
3554
|
+
fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
|
|
2723
3555
|
`);
|
|
2724
3556
|
}
|
|
2725
3557
|
process.exit(0);
|