@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.js
CHANGED
|
@@ -34,6 +34,270 @@ var import_path = __toESM(require("path"));
|
|
|
34
34
|
var import_os = __toESM(require("os"));
|
|
35
35
|
var import_picomatch = __toESM(require("picomatch"));
|
|
36
36
|
var import_sh_syntax = require("sh-syntax");
|
|
37
|
+
|
|
38
|
+
// src/ui/native.ts
|
|
39
|
+
var import_child_process = require("child_process");
|
|
40
|
+
var isTestEnv = () => {
|
|
41
|
+
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";
|
|
42
|
+
};
|
|
43
|
+
function sendDesktopNotification(title, body) {
|
|
44
|
+
if (isTestEnv()) return;
|
|
45
|
+
try {
|
|
46
|
+
const safeTitle = title.replace(/"/g, '\\"');
|
|
47
|
+
const safeBody = body.replace(/"/g, '\\"');
|
|
48
|
+
if (process.platform === "darwin") {
|
|
49
|
+
const script = `display notification "${safeBody}" with title "${safeTitle}"`;
|
|
50
|
+
(0, import_child_process.spawn)("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
|
|
51
|
+
} else if (process.platform === "linux") {
|
|
52
|
+
(0, import_child_process.spawn)("notify-send", [safeTitle, safeBody, "--icon=dialog-warning"], {
|
|
53
|
+
detached: true,
|
|
54
|
+
stdio: "ignore"
|
|
55
|
+
}).unref();
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function formatArgs(args) {
|
|
61
|
+
if (args === null || args === void 0) return "(none)";
|
|
62
|
+
if (typeof args !== "object" || Array.isArray(args)) {
|
|
63
|
+
const str = typeof args === "string" ? args : JSON.stringify(args);
|
|
64
|
+
return str.length > 200 ? str.slice(0, 200) + "\u2026" : str;
|
|
65
|
+
}
|
|
66
|
+
const entries = Object.entries(args).filter(
|
|
67
|
+
([, v]) => v !== null && v !== void 0 && v !== ""
|
|
68
|
+
);
|
|
69
|
+
if (entries.length === 0) return "(none)";
|
|
70
|
+
const MAX_FIELDS = 5;
|
|
71
|
+
const MAX_VALUE_LEN = 120;
|
|
72
|
+
const lines = entries.slice(0, MAX_FIELDS).map(([key, val]) => {
|
|
73
|
+
const str = typeof val === "string" ? val : JSON.stringify(val);
|
|
74
|
+
const truncated = str.length > MAX_VALUE_LEN ? str.slice(0, MAX_VALUE_LEN) + "\u2026" : str;
|
|
75
|
+
return ` ${key}: ${truncated}`;
|
|
76
|
+
});
|
|
77
|
+
if (entries.length > MAX_FIELDS) {
|
|
78
|
+
lines.push(` \u2026 and ${entries.length - MAX_FIELDS} more field(s)`);
|
|
79
|
+
}
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
82
|
+
async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
|
|
83
|
+
if (isTestEnv()) return "deny";
|
|
84
|
+
if (process.env.NODE9_DEBUG === "1" || process.env.VITEST) {
|
|
85
|
+
console.log(`[DEBUG Native] askNativePopup called for: ${toolName}`);
|
|
86
|
+
console.log(`[DEBUG Native] isTestEnv check:`, {
|
|
87
|
+
VITEST: process.env.VITEST,
|
|
88
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
89
|
+
CI: process.env.CI,
|
|
90
|
+
isTest: isTestEnv()
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
const title = locked ? `\u26A1 Node9 \u2014 Locked by Admin Policy` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Requires Approval`;
|
|
94
|
+
let message = "";
|
|
95
|
+
if (locked) {
|
|
96
|
+
message += `\u26A1 Awaiting remote approval via Slack. Local override is disabled.
|
|
97
|
+
`;
|
|
98
|
+
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
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
message += `Tool: ${toolName}
|
|
102
|
+
`;
|
|
103
|
+
message += `Agent: ${agent || "AI Agent"}
|
|
104
|
+
`;
|
|
105
|
+
if (explainableLabel) {
|
|
106
|
+
message += `Reason: ${explainableLabel}
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
message += `
|
|
110
|
+
Arguments:
|
|
111
|
+
${formatArgs(args)}`;
|
|
112
|
+
if (!locked) {
|
|
113
|
+
message += `
|
|
114
|
+
|
|
115
|
+
Enter = Allow | Click "Block" to deny`;
|
|
116
|
+
}
|
|
117
|
+
const safeMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/`/g, "'");
|
|
118
|
+
const safeTitle = title.replace(/"/g, '\\"');
|
|
119
|
+
return new Promise((resolve) => {
|
|
120
|
+
let childProcess = null;
|
|
121
|
+
const onAbort = () => {
|
|
122
|
+
if (childProcess) {
|
|
123
|
+
try {
|
|
124
|
+
process.kill(childProcess.pid, "SIGKILL");
|
|
125
|
+
} catch {
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
resolve("deny");
|
|
129
|
+
};
|
|
130
|
+
if (signal) {
|
|
131
|
+
if (signal.aborted) return resolve("deny");
|
|
132
|
+
signal.addEventListener("abort", onAbort);
|
|
133
|
+
}
|
|
134
|
+
const cleanup = () => {
|
|
135
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
136
|
+
};
|
|
137
|
+
try {
|
|
138
|
+
if (process.platform === "darwin") {
|
|
139
|
+
const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"`;
|
|
140
|
+
const script = `
|
|
141
|
+
tell application "System Events"
|
|
142
|
+
activate
|
|
143
|
+
display dialog "${safeMessage}" with title "${safeTitle}" ${buttons}
|
|
144
|
+
end tell`;
|
|
145
|
+
childProcess = (0, import_child_process.spawn)("osascript", ["-e", script]);
|
|
146
|
+
let output = "";
|
|
147
|
+
childProcess.stdout?.on("data", (d) => output += d.toString());
|
|
148
|
+
childProcess.on("close", (code) => {
|
|
149
|
+
cleanup();
|
|
150
|
+
if (locked) return resolve("deny");
|
|
151
|
+
if (code === 0) {
|
|
152
|
+
if (output.includes("Always Allow")) return resolve("always_allow");
|
|
153
|
+
if (output.includes("Allow")) return resolve("allow");
|
|
154
|
+
}
|
|
155
|
+
resolve("deny");
|
|
156
|
+
});
|
|
157
|
+
} else if (process.platform === "linux") {
|
|
158
|
+
const argsList = locked ? [
|
|
159
|
+
"--info",
|
|
160
|
+
"--title",
|
|
161
|
+
title,
|
|
162
|
+
"--text",
|
|
163
|
+
safeMessage,
|
|
164
|
+
"--ok-label",
|
|
165
|
+
"Waiting for Slack\u2026",
|
|
166
|
+
"--timeout",
|
|
167
|
+
"300"
|
|
168
|
+
] : [
|
|
169
|
+
"--question",
|
|
170
|
+
"--title",
|
|
171
|
+
title,
|
|
172
|
+
"--text",
|
|
173
|
+
safeMessage,
|
|
174
|
+
"--ok-label",
|
|
175
|
+
"Allow",
|
|
176
|
+
"--cancel-label",
|
|
177
|
+
"Block",
|
|
178
|
+
"--extra-button",
|
|
179
|
+
"Always Allow",
|
|
180
|
+
"--timeout",
|
|
181
|
+
"300"
|
|
182
|
+
];
|
|
183
|
+
childProcess = (0, import_child_process.spawn)("zenity", argsList);
|
|
184
|
+
let output = "";
|
|
185
|
+
childProcess.stdout?.on("data", (d) => output += d.toString());
|
|
186
|
+
childProcess.on("close", (code) => {
|
|
187
|
+
cleanup();
|
|
188
|
+
if (locked) return resolve("deny");
|
|
189
|
+
if (output.trim() === "Always Allow") return resolve("always_allow");
|
|
190
|
+
if (code === 0) return resolve("allow");
|
|
191
|
+
resolve("deny");
|
|
192
|
+
});
|
|
193
|
+
} else if (process.platform === "win32") {
|
|
194
|
+
const buttonType = locked ? "OK" : "YesNo";
|
|
195
|
+
const ps = `
|
|
196
|
+
Add-Type -AssemblyName PresentationFramework;
|
|
197
|
+
$res = [System.Windows.MessageBox]::Show("${safeMessage}", "${safeTitle}", "${buttonType}", "Warning", "Button2", "DefaultDesktopOnly");
|
|
198
|
+
if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
|
|
199
|
+
childProcess = (0, import_child_process.spawn)("powershell", ["-Command", ps]);
|
|
200
|
+
childProcess.on("close", (code) => {
|
|
201
|
+
cleanup();
|
|
202
|
+
if (locked) return resolve("deny");
|
|
203
|
+
resolve(code === 0 ? "allow" : "deny");
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
cleanup();
|
|
207
|
+
resolve("deny");
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
cleanup();
|
|
211
|
+
resolve("deny");
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// src/core.ts
|
|
217
|
+
var PAUSED_FILE = import_path.default.join(import_os.default.homedir(), ".node9", "PAUSED");
|
|
218
|
+
var TRUST_FILE = import_path.default.join(import_os.default.homedir(), ".node9", "trust.json");
|
|
219
|
+
function checkPause() {
|
|
220
|
+
try {
|
|
221
|
+
if (!import_fs.default.existsSync(PAUSED_FILE)) return { paused: false };
|
|
222
|
+
const state = JSON.parse(import_fs.default.readFileSync(PAUSED_FILE, "utf-8"));
|
|
223
|
+
if (state.expiry > 0 && Date.now() >= state.expiry) {
|
|
224
|
+
try {
|
|
225
|
+
import_fs.default.unlinkSync(PAUSED_FILE);
|
|
226
|
+
} catch {
|
|
227
|
+
}
|
|
228
|
+
return { paused: false };
|
|
229
|
+
}
|
|
230
|
+
return { paused: true, expiresAt: state.expiry, duration: state.duration };
|
|
231
|
+
} catch {
|
|
232
|
+
return { paused: false };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function atomicWriteSync(filePath, data, options) {
|
|
236
|
+
const dir = import_path.default.dirname(filePath);
|
|
237
|
+
if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
|
|
238
|
+
const tmpPath = `${filePath}.${import_os.default.hostname()}.${process.pid}.tmp`;
|
|
239
|
+
import_fs.default.writeFileSync(tmpPath, data, options);
|
|
240
|
+
import_fs.default.renameSync(tmpPath, filePath);
|
|
241
|
+
}
|
|
242
|
+
function pauseNode9(durationMs, durationStr) {
|
|
243
|
+
const state = { expiry: Date.now() + durationMs, duration: durationStr };
|
|
244
|
+
atomicWriteSync(PAUSED_FILE, JSON.stringify(state, null, 2));
|
|
245
|
+
}
|
|
246
|
+
function resumeNode9() {
|
|
247
|
+
try {
|
|
248
|
+
if (import_fs.default.existsSync(PAUSED_FILE)) import_fs.default.unlinkSync(PAUSED_FILE);
|
|
249
|
+
} catch {
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function getActiveTrustSession(toolName) {
|
|
253
|
+
try {
|
|
254
|
+
if (!import_fs.default.existsSync(TRUST_FILE)) return false;
|
|
255
|
+
const trust = JSON.parse(import_fs.default.readFileSync(TRUST_FILE, "utf-8"));
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
const active = trust.entries.filter((e) => e.expiry > now);
|
|
258
|
+
if (active.length !== trust.entries.length) {
|
|
259
|
+
import_fs.default.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
|
|
260
|
+
}
|
|
261
|
+
return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
|
|
262
|
+
} catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function writeTrustSession(toolName, durationMs) {
|
|
267
|
+
try {
|
|
268
|
+
let trust = { entries: [] };
|
|
269
|
+
try {
|
|
270
|
+
if (import_fs.default.existsSync(TRUST_FILE)) {
|
|
271
|
+
trust = JSON.parse(import_fs.default.readFileSync(TRUST_FILE, "utf-8"));
|
|
272
|
+
}
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
const now = Date.now();
|
|
276
|
+
trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
|
|
277
|
+
trust.entries.push({ tool: toolName, expiry: now + durationMs });
|
|
278
|
+
atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
|
|
279
|
+
} catch (err) {
|
|
280
|
+
if (process.env.NODE9_DEBUG === "1") {
|
|
281
|
+
console.error("[Node9 Trust Error]:", err);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function appendAuditModeEntry(toolName, args) {
|
|
286
|
+
try {
|
|
287
|
+
const entry = JSON.stringify({
|
|
288
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
289
|
+
tool: toolName,
|
|
290
|
+
args,
|
|
291
|
+
decision: "would-have-blocked",
|
|
292
|
+
source: "audit-mode"
|
|
293
|
+
});
|
|
294
|
+
const logPath = import_path.default.join(import_os.default.homedir(), ".node9", "audit.log");
|
|
295
|
+
const dir = import_path.default.dirname(logPath);
|
|
296
|
+
if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
|
|
297
|
+
import_fs.default.appendFileSync(logPath, entry + "\n");
|
|
298
|
+
} catch {
|
|
299
|
+
}
|
|
300
|
+
}
|
|
37
301
|
var DANGEROUS_WORDS = [
|
|
38
302
|
"delete",
|
|
39
303
|
"drop",
|
|
@@ -51,10 +315,6 @@ var DANGEROUS_WORDS = [
|
|
|
51
315
|
function tokenize(toolName) {
|
|
52
316
|
return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
|
|
53
317
|
}
|
|
54
|
-
function containsDangerousWord(toolName, dangerousWords) {
|
|
55
|
-
const tokens = tokenize(toolName);
|
|
56
|
-
return dangerousWords.some((word) => tokens.includes(word.toLowerCase()));
|
|
57
|
-
}
|
|
58
318
|
function matchesPattern(text, patterns) {
|
|
59
319
|
const p = Array.isArray(patterns) ? patterns : [patterns];
|
|
60
320
|
if (p.length === 0) return false;
|
|
@@ -65,9 +325,9 @@ function matchesPattern(text, patterns) {
|
|
|
65
325
|
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
66
326
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
67
327
|
}
|
|
68
|
-
function getNestedValue(obj,
|
|
328
|
+
function getNestedValue(obj, path6) {
|
|
69
329
|
if (!obj || typeof obj !== "object") return null;
|
|
70
|
-
return
|
|
330
|
+
return path6.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
71
331
|
}
|
|
72
332
|
function extractShellCommand(toolName, args, toolInspection) {
|
|
73
333
|
const patterns = Object.keys(toolInspection);
|
|
@@ -159,8 +419,15 @@ function redactSecrets(text) {
|
|
|
159
419
|
return redacted;
|
|
160
420
|
}
|
|
161
421
|
var DEFAULT_CONFIG = {
|
|
162
|
-
settings: {
|
|
422
|
+
settings: {
|
|
423
|
+
mode: "standard",
|
|
424
|
+
autoStartDaemon: true,
|
|
425
|
+
enableUndo: false,
|
|
426
|
+
enableHookLogDebug: false,
|
|
427
|
+
approvers: { native: true, browser: true, cloud: true, terminal: true }
|
|
428
|
+
},
|
|
163
429
|
policy: {
|
|
430
|
+
sandboxPaths: [],
|
|
164
431
|
dangerousWords: DANGEROUS_WORDS,
|
|
165
432
|
ignoredTools: [
|
|
166
433
|
"list_*",
|
|
@@ -168,34 +435,19 @@ var DEFAULT_CONFIG = {
|
|
|
168
435
|
"read_*",
|
|
169
436
|
"describe_*",
|
|
170
437
|
"read",
|
|
171
|
-
"write",
|
|
172
|
-
"edit",
|
|
173
|
-
"multiedit",
|
|
174
|
-
"glob",
|
|
175
438
|
"grep",
|
|
176
439
|
"ls",
|
|
177
|
-
"notebookread",
|
|
178
|
-
"notebookedit",
|
|
179
|
-
"todoread",
|
|
180
|
-
"todowrite",
|
|
181
|
-
"webfetch",
|
|
182
|
-
"websearch",
|
|
183
|
-
"exitplanmode",
|
|
184
440
|
"askuserquestion"
|
|
185
441
|
],
|
|
186
|
-
toolInspection: {
|
|
187
|
-
|
|
188
|
-
run_shell_command: "command",
|
|
189
|
-
shell: "command",
|
|
190
|
-
"terminal.execute": "command"
|
|
191
|
-
},
|
|
192
|
-
rules: [
|
|
193
|
-
{ action: "rm", allowPaths: ["**/node_modules/**", "dist/**", "build/**", ".DS_Store"] }
|
|
194
|
-
]
|
|
442
|
+
toolInspection: { bash: "command", shell: "command" },
|
|
443
|
+
rules: [{ action: "rm", allowPaths: ["**/node_modules/**", "dist/**", ".DS_Store"] }]
|
|
195
444
|
},
|
|
196
445
|
environments: {}
|
|
197
446
|
};
|
|
198
447
|
var cachedConfig = null;
|
|
448
|
+
function _resetConfigCache() {
|
|
449
|
+
cachedConfig = null;
|
|
450
|
+
}
|
|
199
451
|
function getGlobalSettings() {
|
|
200
452
|
try {
|
|
201
453
|
const globalConfigPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
|
|
@@ -206,18 +458,19 @@ function getGlobalSettings() {
|
|
|
206
458
|
mode: settings.mode || "standard",
|
|
207
459
|
autoStartDaemon: settings.autoStartDaemon !== false,
|
|
208
460
|
slackEnabled: settings.slackEnabled !== false,
|
|
209
|
-
|
|
210
|
-
|
|
461
|
+
enableTrustSessions: settings.enableTrustSessions === true,
|
|
462
|
+
allowGlobalPause: settings.allowGlobalPause !== false
|
|
211
463
|
};
|
|
212
464
|
}
|
|
213
465
|
} catch {
|
|
214
466
|
}
|
|
215
|
-
return {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
467
|
+
return {
|
|
468
|
+
mode: "standard",
|
|
469
|
+
autoStartDaemon: true,
|
|
470
|
+
slackEnabled: true,
|
|
471
|
+
enableTrustSessions: false,
|
|
472
|
+
allowGlobalPause: true
|
|
473
|
+
};
|
|
221
474
|
}
|
|
222
475
|
function getInternalToken() {
|
|
223
476
|
try {
|
|
@@ -230,51 +483,83 @@ function getInternalToken() {
|
|
|
230
483
|
return null;
|
|
231
484
|
}
|
|
232
485
|
}
|
|
233
|
-
async function evaluatePolicy(toolName, args) {
|
|
486
|
+
async function evaluatePolicy(toolName, args, agent) {
|
|
234
487
|
const config = getConfig();
|
|
235
|
-
if (matchesPattern(toolName, config.policy.ignoredTools)) return "allow";
|
|
488
|
+
if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
|
|
489
|
+
let allTokens = [];
|
|
490
|
+
let actionTokens = [];
|
|
491
|
+
let pathTokens = [];
|
|
236
492
|
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
237
493
|
if (shellCommand) {
|
|
238
|
-
const
|
|
494
|
+
const analyzed = await analyzeShellCommand(shellCommand);
|
|
495
|
+
allTokens = analyzed.allTokens;
|
|
496
|
+
actionTokens = analyzed.actions;
|
|
497
|
+
pathTokens = analyzed.paths;
|
|
239
498
|
const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
|
|
240
|
-
if (INLINE_EXEC_PATTERN.test(shellCommand.trim()))
|
|
241
|
-
|
|
242
|
-
const basename = action.includes("/") ? action.split("/").pop() : action;
|
|
243
|
-
const rule = config.policy.rules.find(
|
|
244
|
-
(r) => r.action === action || matchesPattern(action, r.action) || basename && (r.action === basename || matchesPattern(basename, r.action))
|
|
245
|
-
);
|
|
246
|
-
if (rule) {
|
|
247
|
-
if (paths.length > 0) {
|
|
248
|
-
const anyBlocked = paths.some((p) => matchesPattern(p, rule.blockPaths || []));
|
|
249
|
-
if (anyBlocked) return "review";
|
|
250
|
-
const allAllowed = paths.every((p) => matchesPattern(p, rule.allowPaths || []));
|
|
251
|
-
if (allAllowed) return "allow";
|
|
252
|
-
}
|
|
253
|
-
return "review";
|
|
254
|
-
}
|
|
499
|
+
if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
|
|
500
|
+
return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
|
|
255
501
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
502
|
+
} else {
|
|
503
|
+
allTokens = tokenize(toolName);
|
|
504
|
+
actionTokens = [toolName];
|
|
505
|
+
}
|
|
506
|
+
const isManual = agent === "Terminal";
|
|
507
|
+
if (isManual) {
|
|
508
|
+
const NUCLEAR_COMMANDS = [
|
|
509
|
+
"drop",
|
|
510
|
+
"destroy",
|
|
511
|
+
"purge",
|
|
512
|
+
"rmdir",
|
|
513
|
+
"format",
|
|
514
|
+
"truncate",
|
|
515
|
+
"alter",
|
|
516
|
+
"grant",
|
|
517
|
+
"revoke",
|
|
518
|
+
"docker"
|
|
519
|
+
];
|
|
520
|
+
const hasNuclear = allTokens.some((t) => NUCLEAR_COMMANDS.includes(t.toLowerCase()));
|
|
521
|
+
if (!hasNuclear) return { decision: "allow" };
|
|
522
|
+
}
|
|
523
|
+
if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
|
|
524
|
+
const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
|
|
525
|
+
if (allInSandbox) return { decision: "allow" };
|
|
526
|
+
}
|
|
527
|
+
for (const action of actionTokens) {
|
|
528
|
+
const rule = config.policy.rules.find(
|
|
529
|
+
(r) => r.action === action || matchesPattern(action, r.action)
|
|
266
530
|
);
|
|
267
|
-
if (
|
|
268
|
-
|
|
269
|
-
|
|
531
|
+
if (rule) {
|
|
532
|
+
if (pathTokens.length > 0) {
|
|
533
|
+
const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
|
|
534
|
+
if (anyBlocked)
|
|
535
|
+
return { decision: "review", blockedByLabel: "Project/Global Config (Rule Block)" };
|
|
536
|
+
const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
|
|
537
|
+
if (allAllowed) return { decision: "allow" };
|
|
538
|
+
}
|
|
539
|
+
return { decision: "review", blockedByLabel: "Project/Global Config (Rule Default Block)" };
|
|
540
|
+
}
|
|
270
541
|
}
|
|
271
|
-
const isDangerous =
|
|
272
|
-
|
|
542
|
+
const isDangerous = allTokens.some(
|
|
543
|
+
(token) => config.policy.dangerousWords.some((word) => {
|
|
544
|
+
const w = word.toLowerCase();
|
|
545
|
+
if (token === w) return true;
|
|
546
|
+
try {
|
|
547
|
+
return new RegExp(`\\b${w}\\b`, "i").test(token);
|
|
548
|
+
} catch {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
})
|
|
552
|
+
);
|
|
553
|
+
if (isDangerous) {
|
|
554
|
+
const label = isManual ? "Manual Nuclear Protection" : "Project/Global Config (Dangerous Word)";
|
|
555
|
+
return { decision: "review", blockedByLabel: label };
|
|
556
|
+
}
|
|
557
|
+
if (config.settings.mode === "strict") {
|
|
273
558
|
const envConfig = getActiveEnvironment(config);
|
|
274
|
-
if (envConfig?.requireApproval === false) return "allow";
|
|
275
|
-
return "review";
|
|
559
|
+
if (envConfig?.requireApproval === false) return { decision: "allow" };
|
|
560
|
+
return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
|
|
276
561
|
}
|
|
277
|
-
return "allow";
|
|
562
|
+
return { decision: "allow" };
|
|
278
563
|
}
|
|
279
564
|
function isIgnoredTool(toolName) {
|
|
280
565
|
const config = getConfig();
|
|
@@ -305,22 +590,40 @@ function getPersistentDecision(toolName) {
|
|
|
305
590
|
}
|
|
306
591
|
return null;
|
|
307
592
|
}
|
|
308
|
-
async function askDaemon(toolName, args, meta) {
|
|
593
|
+
async function askDaemon(toolName, args, meta, signal) {
|
|
309
594
|
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
595
|
+
const checkCtrl = new AbortController();
|
|
596
|
+
const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
|
|
597
|
+
const onAbort = () => checkCtrl.abort();
|
|
598
|
+
if (signal) signal.addEventListener("abort", onAbort);
|
|
599
|
+
try {
|
|
600
|
+
const checkRes = await fetch(`${base}/check`, {
|
|
601
|
+
method: "POST",
|
|
602
|
+
headers: { "Content-Type": "application/json" },
|
|
603
|
+
body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
|
|
604
|
+
signal: checkCtrl.signal
|
|
605
|
+
});
|
|
606
|
+
if (!checkRes.ok) throw new Error("Daemon fail");
|
|
607
|
+
const { id } = await checkRes.json();
|
|
608
|
+
const waitCtrl = new AbortController();
|
|
609
|
+
const waitTimer = setTimeout(() => waitCtrl.abort(), 12e4);
|
|
610
|
+
const onWaitAbort = () => waitCtrl.abort();
|
|
611
|
+
if (signal) signal.addEventListener("abort", onWaitAbort);
|
|
612
|
+
try {
|
|
613
|
+
const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
|
|
614
|
+
if (!waitRes.ok) return "deny";
|
|
615
|
+
const { decision } = await waitRes.json();
|
|
616
|
+
if (decision === "allow") return "allow";
|
|
617
|
+
if (decision === "abandoned") return "abandoned";
|
|
618
|
+
return "deny";
|
|
619
|
+
} finally {
|
|
620
|
+
clearTimeout(waitTimer);
|
|
621
|
+
if (signal) signal.removeEventListener("abort", onWaitAbort);
|
|
622
|
+
}
|
|
623
|
+
} finally {
|
|
624
|
+
clearTimeout(checkTimer);
|
|
625
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
626
|
+
}
|
|
324
627
|
}
|
|
325
628
|
async function notifyDaemonViewer(toolName, args, meta) {
|
|
326
629
|
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
@@ -350,176 +653,353 @@ async function resolveViaDaemon(id, decision, internalToken) {
|
|
|
350
653
|
});
|
|
351
654
|
}
|
|
352
655
|
async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
656
|
+
if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
|
|
657
|
+
const pauseState = checkPause();
|
|
658
|
+
if (pauseState.paused) return { approved: true, checkedBy: "paused" };
|
|
659
|
+
const creds = getCredentials();
|
|
660
|
+
const config = getConfig();
|
|
661
|
+
const isTestEnv2 = !!(process.env.VITEST || process.env.NODE_ENV === "test" || process.env.CI || process.env.NODE9_TESTING === "1");
|
|
662
|
+
const approvers = {
|
|
663
|
+
...config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }
|
|
664
|
+
};
|
|
665
|
+
if (isTestEnv2) {
|
|
666
|
+
approvers.native = false;
|
|
667
|
+
approvers.browser = false;
|
|
668
|
+
approvers.terminal = false;
|
|
669
|
+
}
|
|
670
|
+
const isManual = meta?.agent === "Terminal";
|
|
671
|
+
let explainableLabel = "Local Config";
|
|
672
|
+
if (config.settings.mode === "audit") {
|
|
673
|
+
if (!isIgnoredTool(toolName)) {
|
|
674
|
+
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
675
|
+
if (policyResult.decision === "review") {
|
|
676
|
+
appendAuditModeEntry(toolName, args);
|
|
677
|
+
sendDesktopNotification(
|
|
678
|
+
"Node9 Audit Mode",
|
|
679
|
+
`Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
return { approved: true, checkedBy: "audit" };
|
|
684
|
+
}
|
|
685
|
+
if (!isIgnoredTool(toolName)) {
|
|
686
|
+
if (getActiveTrustSession(toolName)) {
|
|
687
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
688
|
+
return { approved: true, checkedBy: "trust" };
|
|
689
|
+
}
|
|
690
|
+
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
691
|
+
if (policyResult.decision === "allow") {
|
|
692
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
|
|
693
|
+
return { approved: true, checkedBy: "local-policy" };
|
|
694
|
+
}
|
|
695
|
+
explainableLabel = policyResult.blockedByLabel || "Local Config";
|
|
359
696
|
const persistent = getPersistentDecision(toolName);
|
|
360
|
-
if (persistent === "allow")
|
|
361
|
-
|
|
697
|
+
if (persistent === "allow") {
|
|
698
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
|
|
699
|
+
return { approved: true, checkedBy: "persistent" };
|
|
700
|
+
}
|
|
701
|
+
if (persistent === "deny") {
|
|
362
702
|
return {
|
|
363
703
|
approved: false,
|
|
364
|
-
reason: `
|
|
704
|
+
reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
|
|
365
705
|
blockedBy: "persistent-deny",
|
|
366
|
-
|
|
706
|
+
blockedByLabel: "Persistent User Rule"
|
|
367
707
|
};
|
|
368
|
-
}
|
|
369
|
-
if (cloudEnforced) {
|
|
370
|
-
const creds = getCredentials();
|
|
371
|
-
const envConfig = getActiveEnvironment(getConfig());
|
|
372
|
-
let viewerId = null;
|
|
373
|
-
const internalToken = getInternalToken();
|
|
374
|
-
if (isDaemonRunning() && internalToken) {
|
|
375
|
-
viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
|
|
376
708
|
}
|
|
377
|
-
|
|
378
|
-
if (
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
return {
|
|
382
|
-
approved,
|
|
383
|
-
checkedBy: approved ? "cloud" : void 0,
|
|
384
|
-
blockedBy: approved ? void 0 : "team-policy",
|
|
385
|
-
changeHint: approved ? void 0 : `Visit your Node9 dashboard \u2192 Policy Studio to change this rule`
|
|
386
|
-
};
|
|
709
|
+
} else {
|
|
710
|
+
if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
|
|
711
|
+
return { approved: true };
|
|
387
712
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
713
|
+
let cloudRequestId = null;
|
|
714
|
+
let isRemoteLocked = false;
|
|
715
|
+
const cloudEnforced = approvers.cloud && !!creds?.apiKey;
|
|
716
|
+
if (cloudEnforced) {
|
|
392
717
|
try {
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
} else {
|
|
718
|
+
const envConfig = getActiveEnvironment(getConfig());
|
|
719
|
+
const initResult = await initNode9SaaS(toolName, args, creds, envConfig?.slackChannel, meta);
|
|
720
|
+
if (!initResult.pending) {
|
|
397
721
|
return {
|
|
398
|
-
approved:
|
|
399
|
-
reason:
|
|
400
|
-
checkedBy:
|
|
401
|
-
blockedBy:
|
|
402
|
-
|
|
722
|
+
approved: !!initResult.approved,
|
|
723
|
+
reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
|
|
724
|
+
checkedBy: initResult.approved ? "cloud" : void 0,
|
|
725
|
+
blockedBy: initResult.approved ? void 0 : "team-policy",
|
|
726
|
+
blockedByLabel: "Organization Policy (SaaS)"
|
|
403
727
|
};
|
|
404
728
|
}
|
|
405
|
-
|
|
729
|
+
cloudRequestId = initResult.requestId || null;
|
|
730
|
+
isRemoteLocked = !!initResult.remoteApprovalOnly;
|
|
731
|
+
explainableLabel = "Organization Policy (SaaS)";
|
|
732
|
+
} catch (err) {
|
|
733
|
+
const error = err;
|
|
734
|
+
const isAuthError = error.message.includes("401") || error.message.includes("403");
|
|
735
|
+
const isNetworkError = error.message.includes("fetch") || error.name === "AbortError" || error.message.includes("ECONNREFUSED");
|
|
736
|
+
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;
|
|
737
|
+
console.error(
|
|
738
|
+
import_chalk.default.yellow(`
|
|
739
|
+
\u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + import_chalk.default.dim(`
|
|
740
|
+
Falling back to local rules...
|
|
741
|
+
`)
|
|
742
|
+
);
|
|
406
743
|
}
|
|
407
744
|
}
|
|
408
|
-
if (
|
|
409
|
-
console.
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
console.
|
|
413
|
-
|
|
414
|
-
|
|
745
|
+
if (cloudEnforced && cloudRequestId) {
|
|
746
|
+
console.error(
|
|
747
|
+
import_chalk.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
|
|
748
|
+
);
|
|
749
|
+
console.error(import_chalk.default.cyan(" Dashboard \u2192 ") + import_chalk.default.bold("Mission Control > Activity Feed\n"));
|
|
750
|
+
} else if (!cloudEnforced) {
|
|
751
|
+
const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
|
|
752
|
+
console.error(
|
|
753
|
+
import_chalk.default.dim(`
|
|
754
|
+
\u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
|
|
755
|
+
`)
|
|
415
756
|
);
|
|
416
|
-
const controller = new AbortController();
|
|
417
|
-
const TIMEOUT_MS = 3e4;
|
|
418
|
-
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
419
|
-
try {
|
|
420
|
-
const approved = await (0, import_prompts.confirm)(
|
|
421
|
-
{ message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
|
|
422
|
-
{ signal: controller.signal }
|
|
423
|
-
);
|
|
424
|
-
clearTimeout(timer);
|
|
425
|
-
return { approved };
|
|
426
|
-
} catch {
|
|
427
|
-
clearTimeout(timer);
|
|
428
|
-
console.error(import_chalk.default.yellow("\n\u23F1 Prompt timed out \u2014 action denied by default."));
|
|
429
|
-
return { approved: false };
|
|
430
|
-
}
|
|
431
757
|
}
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
758
|
+
const abortController = new AbortController();
|
|
759
|
+
const { signal } = abortController;
|
|
760
|
+
const racePromises = [];
|
|
761
|
+
let viewerId = null;
|
|
762
|
+
const internalToken = getInternalToken();
|
|
763
|
+
if (cloudEnforced && cloudRequestId) {
|
|
764
|
+
racePromises.push(
|
|
765
|
+
(async () => {
|
|
766
|
+
try {
|
|
767
|
+
if (isDaemonRunning() && internalToken) {
|
|
768
|
+
viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
|
|
769
|
+
}
|
|
770
|
+
const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
|
|
771
|
+
return {
|
|
772
|
+
approved: cloudResult.approved,
|
|
773
|
+
reason: cloudResult.approved ? void 0 : cloudResult.reason || "Action rejected by organization administrator via Slack.",
|
|
774
|
+
checkedBy: cloudResult.approved ? "cloud" : void 0,
|
|
775
|
+
blockedBy: cloudResult.approved ? void 0 : "team-policy",
|
|
776
|
+
blockedByLabel: "Organization Policy (SaaS)"
|
|
777
|
+
};
|
|
778
|
+
} catch (err) {
|
|
779
|
+
const error = err;
|
|
780
|
+
if (error.name === "AbortError" || error.message?.includes("Aborted")) throw err;
|
|
781
|
+
throw err;
|
|
782
|
+
}
|
|
783
|
+
})()
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
if (approvers.native && !isManual) {
|
|
787
|
+
racePromises.push(
|
|
788
|
+
(async () => {
|
|
789
|
+
const decision = await askNativePopup(
|
|
790
|
+
toolName,
|
|
791
|
+
args,
|
|
792
|
+
meta?.agent,
|
|
793
|
+
explainableLabel,
|
|
794
|
+
isRemoteLocked,
|
|
795
|
+
signal
|
|
796
|
+
);
|
|
797
|
+
if (decision === "always_allow") {
|
|
798
|
+
writeTrustSession(toolName, 36e5);
|
|
799
|
+
return { approved: true, checkedBy: "trust" };
|
|
800
|
+
}
|
|
801
|
+
const isApproved = decision === "allow";
|
|
802
|
+
return {
|
|
803
|
+
approved: isApproved,
|
|
804
|
+
reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
|
|
805
|
+
checkedBy: isApproved ? "daemon" : void 0,
|
|
806
|
+
blockedBy: isApproved ? void 0 : "local-decision",
|
|
807
|
+
blockedByLabel: "User Decision (Native)"
|
|
808
|
+
};
|
|
809
|
+
})()
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
if (approvers.browser && isDaemonRunning()) {
|
|
813
|
+
racePromises.push(
|
|
814
|
+
(async () => {
|
|
815
|
+
try {
|
|
816
|
+
if (!approvers.native && !cloudEnforced) {
|
|
817
|
+
console.error(
|
|
818
|
+
import_chalk.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
|
|
819
|
+
);
|
|
820
|
+
console.error(import_chalk.default.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
|
|
821
|
+
`));
|
|
822
|
+
}
|
|
823
|
+
const daemonDecision = await askDaemon(toolName, args, meta, signal);
|
|
824
|
+
if (daemonDecision === "abandoned") throw new Error("Abandoned");
|
|
825
|
+
const isApproved = daemonDecision === "allow";
|
|
826
|
+
return {
|
|
827
|
+
approved: isApproved,
|
|
828
|
+
reason: isApproved ? void 0 : "The human user rejected this action via the Node9 Browser Dashboard.",
|
|
829
|
+
checkedBy: isApproved ? "daemon" : void 0,
|
|
830
|
+
blockedBy: isApproved ? void 0 : "local-decision",
|
|
831
|
+
blockedByLabel: "User Decision (Browser)"
|
|
832
|
+
};
|
|
833
|
+
} catch (err) {
|
|
834
|
+
throw err;
|
|
835
|
+
}
|
|
836
|
+
})()
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
if (approvers.terminal && allowTerminalFallback && process.stdout.isTTY) {
|
|
840
|
+
racePromises.push(
|
|
841
|
+
(async () => {
|
|
842
|
+
try {
|
|
843
|
+
console.log(import_chalk.default.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
|
|
844
|
+
console.log(`${import_chalk.default.bold("Action:")} ${import_chalk.default.red(toolName)}`);
|
|
845
|
+
console.log(`${import_chalk.default.bold("Flagged By:")} ${import_chalk.default.yellow(explainableLabel)}`);
|
|
846
|
+
if (isRemoteLocked) {
|
|
847
|
+
console.log(import_chalk.default.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
|
|
848
|
+
`));
|
|
849
|
+
await new Promise((_, reject) => {
|
|
850
|
+
signal.addEventListener("abort", () => reject(new Error("Aborted by SaaS")));
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
const TIMEOUT_MS = 6e4;
|
|
854
|
+
let timer;
|
|
855
|
+
const result = await new Promise((resolve, reject) => {
|
|
856
|
+
timer = setTimeout(() => reject(new Error("Terminal Timeout")), TIMEOUT_MS);
|
|
857
|
+
(0, import_prompts.confirm)(
|
|
858
|
+
{ message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
|
|
859
|
+
{ signal }
|
|
860
|
+
).then(resolve).catch(reject);
|
|
861
|
+
});
|
|
862
|
+
clearTimeout(timer);
|
|
863
|
+
return {
|
|
864
|
+
approved: result,
|
|
865
|
+
reason: result ? void 0 : "The human user typed 'N' in the terminal to reject this action.",
|
|
866
|
+
checkedBy: result ? "terminal" : void 0,
|
|
867
|
+
blockedBy: result ? void 0 : "local-decision",
|
|
868
|
+
blockedByLabel: "User Decision (Terminal)"
|
|
869
|
+
};
|
|
870
|
+
} catch (err) {
|
|
871
|
+
const error = err;
|
|
872
|
+
if (error.name === "AbortError" || error.message?.includes("Prompt was canceled") || error.message?.includes("Aborted by SaaS"))
|
|
873
|
+
throw err;
|
|
874
|
+
if (error.message === "Terminal Timeout") {
|
|
875
|
+
return {
|
|
876
|
+
approved: false,
|
|
877
|
+
reason: "The terminal prompt timed out without a human response.",
|
|
878
|
+
blockedBy: "local-decision"
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
throw err;
|
|
882
|
+
}
|
|
883
|
+
})()
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
if (racePromises.length === 0) {
|
|
887
|
+
return {
|
|
888
|
+
approved: false,
|
|
889
|
+
noApprovalMechanism: true,
|
|
890
|
+
reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
|
|
891
|
+
REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
|
|
892
|
+
blockedBy: "no-approval-mechanism",
|
|
893
|
+
blockedByLabel: explainableLabel
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
const finalResult = await new Promise((resolve) => {
|
|
897
|
+
let resolved = false;
|
|
898
|
+
let failures = 0;
|
|
899
|
+
const total = racePromises.length;
|
|
900
|
+
const finish = (res) => {
|
|
901
|
+
if (!resolved) {
|
|
902
|
+
resolved = true;
|
|
903
|
+
abortController.abort();
|
|
904
|
+
if (viewerId && internalToken) {
|
|
905
|
+
resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
|
|
906
|
+
() => null
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
resolve(res);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
for (const p of racePromises) {
|
|
913
|
+
p.then(finish).catch((err) => {
|
|
914
|
+
if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
|
|
915
|
+
return;
|
|
916
|
+
if (err.message === "Abandoned") {
|
|
917
|
+
finish({
|
|
918
|
+
approved: false,
|
|
919
|
+
reason: "Browser dashboard closed without making a decision.",
|
|
920
|
+
blockedBy: "local-decision",
|
|
921
|
+
blockedByLabel: "Browser Dashboard (Abandoned)"
|
|
922
|
+
});
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
failures++;
|
|
926
|
+
if (failures === total && !resolved) {
|
|
927
|
+
finish({ approved: false, reason: "All approval channels failed or disconnected." });
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
});
|
|
932
|
+
if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
|
|
933
|
+
await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
|
|
448
934
|
}
|
|
449
|
-
return
|
|
935
|
+
return finalResult;
|
|
450
936
|
}
|
|
451
937
|
function getConfig() {
|
|
452
938
|
if (cachedConfig) return cachedConfig;
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
939
|
+
const globalPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
|
|
940
|
+
const projectPath = import_path.default.join(process.cwd(), "node9.config.json");
|
|
941
|
+
const globalConfig = tryLoadConfig(globalPath);
|
|
942
|
+
const projectConfig = tryLoadConfig(projectPath);
|
|
943
|
+
const mergedSettings = {
|
|
944
|
+
...DEFAULT_CONFIG.settings,
|
|
945
|
+
approvers: { ...DEFAULT_CONFIG.settings.approvers }
|
|
946
|
+
};
|
|
947
|
+
const mergedPolicy = {
|
|
948
|
+
sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
|
|
949
|
+
dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
|
|
950
|
+
ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
|
|
951
|
+
toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
|
|
952
|
+
rules: [...DEFAULT_CONFIG.policy.rules]
|
|
953
|
+
};
|
|
954
|
+
const applyLayer = (source) => {
|
|
955
|
+
if (!source) return;
|
|
956
|
+
const s = source.settings || {};
|
|
957
|
+
const p = source.policy || {};
|
|
958
|
+
if (s.mode !== void 0) mergedSettings.mode = s.mode;
|
|
959
|
+
if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
|
|
960
|
+
if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
|
|
961
|
+
if (s.enableHookLogDebug !== void 0)
|
|
962
|
+
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
963
|
+
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
964
|
+
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
965
|
+
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
966
|
+
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
967
|
+
if (p.toolInspection)
|
|
968
|
+
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
969
|
+
if (p.rules) mergedPolicy.rules.push(...p.rules);
|
|
970
|
+
};
|
|
971
|
+
applyLayer(globalConfig);
|
|
972
|
+
applyLayer(projectConfig);
|
|
973
|
+
if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
|
|
974
|
+
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
975
|
+
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
976
|
+
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
977
|
+
cachedConfig = {
|
|
978
|
+
settings: mergedSettings,
|
|
979
|
+
policy: mergedPolicy,
|
|
980
|
+
environments: {}
|
|
981
|
+
};
|
|
464
982
|
return cachedConfig;
|
|
465
983
|
}
|
|
466
984
|
function tryLoadConfig(filePath) {
|
|
467
985
|
if (!import_fs.default.existsSync(filePath)) return null;
|
|
468
986
|
try {
|
|
469
|
-
|
|
470
|
-
validateConfig(config, filePath);
|
|
471
|
-
return config;
|
|
987
|
+
return JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
|
|
472
988
|
} catch {
|
|
473
989
|
return null;
|
|
474
990
|
}
|
|
475
991
|
}
|
|
476
|
-
function validateConfig(config, path5) {
|
|
477
|
-
const allowedTopLevel = ["version", "settings", "policy", "environments", "apiKey", "apiUrl"];
|
|
478
|
-
Object.keys(config).forEach((key) => {
|
|
479
|
-
if (!allowedTopLevel.includes(key))
|
|
480
|
-
console.warn(import_chalk.default.yellow(`\u26A0\uFE0F Node9: Unknown top-level key "${key}" in ${path5}`));
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
function buildConfig(parsed) {
|
|
484
|
-
const p = parsed.policy || {};
|
|
485
|
-
const s = parsed.settings || {};
|
|
486
|
-
return {
|
|
487
|
-
settings: {
|
|
488
|
-
mode: s.mode ?? DEFAULT_CONFIG.settings.mode,
|
|
489
|
-
autoStartDaemon: s.autoStartDaemon ?? DEFAULT_CONFIG.settings.autoStartDaemon
|
|
490
|
-
},
|
|
491
|
-
policy: {
|
|
492
|
-
dangerousWords: p.dangerousWords ?? DEFAULT_CONFIG.policy.dangerousWords,
|
|
493
|
-
ignoredTools: p.ignoredTools ?? DEFAULT_CONFIG.policy.ignoredTools,
|
|
494
|
-
toolInspection: p.toolInspection ?? DEFAULT_CONFIG.policy.toolInspection,
|
|
495
|
-
rules: p.rules ?? DEFAULT_CONFIG.policy.rules
|
|
496
|
-
},
|
|
497
|
-
environments: parsed.environments || {}
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
992
|
function getActiveEnvironment(config) {
|
|
501
993
|
const env = process.env.NODE_ENV || "development";
|
|
502
994
|
return config.environments[env] ?? null;
|
|
503
995
|
}
|
|
504
996
|
function getCredentials() {
|
|
505
997
|
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
506
|
-
if (process.env.NODE9_API_KEY)
|
|
998
|
+
if (process.env.NODE9_API_KEY) {
|
|
507
999
|
return {
|
|
508
1000
|
apiKey: process.env.NODE9_API_KEY,
|
|
509
1001
|
apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
|
|
510
1002
|
};
|
|
511
|
-
try {
|
|
512
|
-
const projectConfigPath = import_path.default.join(process.cwd(), "node9.config.json");
|
|
513
|
-
if (import_fs.default.existsSync(projectConfigPath)) {
|
|
514
|
-
const projectConfig = JSON.parse(import_fs.default.readFileSync(projectConfigPath, "utf-8"));
|
|
515
|
-
if (typeof projectConfig.apiKey === "string" && projectConfig.apiKey) {
|
|
516
|
-
return {
|
|
517
|
-
apiKey: projectConfig.apiKey,
|
|
518
|
-
apiUrl: typeof projectConfig.apiUrl === "string" && projectConfig.apiUrl || DEFAULT_API_URL
|
|
519
|
-
};
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
} catch {
|
|
523
1003
|
}
|
|
524
1004
|
try {
|
|
525
1005
|
const credPath = import_path.default.join(import_os.default.homedir(), ".node9", "credentials.json");
|
|
@@ -544,14 +1024,32 @@ function getCredentials() {
|
|
|
544
1024
|
}
|
|
545
1025
|
return null;
|
|
546
1026
|
}
|
|
547
|
-
|
|
548
|
-
const
|
|
549
|
-
|
|
1027
|
+
function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
|
|
1028
|
+
const controller = new AbortController();
|
|
1029
|
+
setTimeout(() => controller.abort(), 5e3);
|
|
1030
|
+
fetch(`${creds.apiUrl}/audit`, {
|
|
1031
|
+
method: "POST",
|
|
1032
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1033
|
+
body: JSON.stringify({
|
|
1034
|
+
toolName,
|
|
1035
|
+
args,
|
|
1036
|
+
checkedBy,
|
|
1037
|
+
context: {
|
|
1038
|
+
agent: meta?.agent,
|
|
1039
|
+
mcpServer: meta?.mcpServer,
|
|
1040
|
+
hostname: import_os.default.hostname(),
|
|
1041
|
+
cwd: process.cwd(),
|
|
1042
|
+
platform: import_os.default.platform()
|
|
1043
|
+
}
|
|
1044
|
+
}),
|
|
1045
|
+
signal: controller.signal
|
|
1046
|
+
}).catch(() => {
|
|
1047
|
+
});
|
|
550
1048
|
}
|
|
551
|
-
async function
|
|
1049
|
+
async function initNode9SaaS(toolName, args, creds, slackChannel, meta) {
|
|
1050
|
+
const controller = new AbortController();
|
|
1051
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
552
1052
|
try {
|
|
553
|
-
const controller = new AbortController();
|
|
554
|
-
const timeout = setTimeout(() => controller.abort(), 35e3);
|
|
555
1053
|
const response = await fetch(creds.apiUrl, {
|
|
556
1054
|
method: "POST",
|
|
557
1055
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
@@ -569,46 +1067,55 @@ async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
|
|
|
569
1067
|
}),
|
|
570
1068
|
signal: controller.signal
|
|
571
1069
|
});
|
|
1070
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1071
|
+
return await response.json();
|
|
1072
|
+
} finally {
|
|
572
1073
|
clearTimeout(timeout);
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
return true;
|
|
600
|
-
}
|
|
601
|
-
if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
|
|
602
|
-
console.error(import_chalk.default.red("\u274C Denied \u2014 action blocked.\n"));
|
|
603
|
-
return false;
|
|
604
|
-
}
|
|
605
|
-
} catch {
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
async function pollNode9SaaS(requestId, creds, signal) {
|
|
1077
|
+
const statusUrl = `${creds.apiUrl}/status/${requestId}`;
|
|
1078
|
+
const POLL_INTERVAL_MS = 1e3;
|
|
1079
|
+
const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
|
|
1080
|
+
while (Date.now() < POLL_DEADLINE) {
|
|
1081
|
+
if (signal.aborted) throw new Error("Aborted");
|
|
1082
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
1083
|
+
try {
|
|
1084
|
+
const pollCtrl = new AbortController();
|
|
1085
|
+
const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
|
|
1086
|
+
const statusRes = await fetch(statusUrl, {
|
|
1087
|
+
headers: { Authorization: `Bearer ${creds.apiKey}` },
|
|
1088
|
+
signal: pollCtrl.signal
|
|
1089
|
+
});
|
|
1090
|
+
clearTimeout(pollTimer);
|
|
1091
|
+
if (!statusRes.ok) continue;
|
|
1092
|
+
const { status, reason } = await statusRes.json();
|
|
1093
|
+
if (status === "APPROVED") {
|
|
1094
|
+
console.error(import_chalk.default.green("\u2705 Approved via Cloud.\n"));
|
|
1095
|
+
return { approved: true, reason };
|
|
1096
|
+
}
|
|
1097
|
+
if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
|
|
1098
|
+
console.error(import_chalk.default.red("\u274C Denied via Cloud.\n"));
|
|
1099
|
+
return { approved: false, reason };
|
|
606
1100
|
}
|
|
1101
|
+
} catch {
|
|
607
1102
|
}
|
|
608
|
-
|
|
609
|
-
|
|
1103
|
+
}
|
|
1104
|
+
return { approved: false, reason: "Cloud approval timed out after 10 minutes." };
|
|
1105
|
+
}
|
|
1106
|
+
async function resolveNode9SaaS(requestId, creds, approved) {
|
|
1107
|
+
try {
|
|
1108
|
+
const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
|
|
1109
|
+
const ctrl = new AbortController();
|
|
1110
|
+
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
1111
|
+
await fetch(resolveUrl, {
|
|
1112
|
+
method: "PATCH",
|
|
1113
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1114
|
+
body: JSON.stringify({ decision: approved ? "APPROVED" : "DENIED" }),
|
|
1115
|
+
signal: ctrl.signal
|
|
1116
|
+
});
|
|
1117
|
+
clearTimeout(timer);
|
|
610
1118
|
} catch {
|
|
611
|
-
return false;
|
|
612
1119
|
}
|
|
613
1120
|
}
|
|
614
1121
|
|
|
@@ -620,7 +1127,7 @@ var import_chalk2 = __toESM(require("chalk"));
|
|
|
620
1127
|
var import_prompts2 = require("@inquirer/prompts");
|
|
621
1128
|
function printDaemonTip() {
|
|
622
1129
|
console.log(
|
|
623
|
-
import_chalk2.default.cyan("\n \u{1F4A1}
|
|
1130
|
+
import_chalk2.default.cyan("\n \u{1F4A1} Node9 will protect you automatically using Native OS popups.") + import_chalk2.default.white("\n To view your history or manage persistent rules, run:") + import_chalk2.default.green("\n node9 daemon --openui")
|
|
624
1131
|
);
|
|
625
1132
|
}
|
|
626
1133
|
function fullPathCommand(subcommand) {
|
|
@@ -671,7 +1178,7 @@ async function setupClaude() {
|
|
|
671
1178
|
if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
|
|
672
1179
|
settings.hooks.PostToolUse.push({
|
|
673
1180
|
matcher: ".*",
|
|
674
|
-
hooks: [{ type: "command", command: fullPathCommand("log") }]
|
|
1181
|
+
hooks: [{ type: "command", command: fullPathCommand("log"), timeout: 600 }]
|
|
675
1182
|
});
|
|
676
1183
|
console.log(import_chalk2.default.green(" \u2705 PostToolUse hook added \u2192 node9 log"));
|
|
677
1184
|
anythingChanged = true;
|
|
@@ -735,7 +1242,12 @@ async function setupGemini() {
|
|
|
735
1242
|
settings.hooks.BeforeTool.push({
|
|
736
1243
|
matcher: ".*",
|
|
737
1244
|
hooks: [
|
|
738
|
-
{
|
|
1245
|
+
{
|
|
1246
|
+
name: "node9-check",
|
|
1247
|
+
type: "command",
|
|
1248
|
+
command: fullPathCommand("check"),
|
|
1249
|
+
timeout: 6e5
|
|
1250
|
+
}
|
|
739
1251
|
]
|
|
740
1252
|
});
|
|
741
1253
|
console.log(import_chalk2.default.green(" \u2705 BeforeTool hook added \u2192 node9 check"));
|
|
@@ -1132,6 +1644,27 @@ var ui_default = `<!doctype html>
|
|
|
1132
1644
|
font-size: 12px;
|
|
1133
1645
|
font-weight: 500;
|
|
1134
1646
|
}
|
|
1647
|
+
.btn-trust {
|
|
1648
|
+
background: rgba(240, 136, 62, 0.1);
|
|
1649
|
+
border: 1px solid rgba(240, 136, 62, 0.35);
|
|
1650
|
+
color: var(--primary);
|
|
1651
|
+
font-size: 12px;
|
|
1652
|
+
font-weight: 600;
|
|
1653
|
+
}
|
|
1654
|
+
.btn-trust:hover:not(:disabled) {
|
|
1655
|
+
background: rgba(240, 136, 62, 0.2);
|
|
1656
|
+
filter: none;
|
|
1657
|
+
transform: translateY(-1px);
|
|
1658
|
+
}
|
|
1659
|
+
.trust-row {
|
|
1660
|
+
display: none;
|
|
1661
|
+
grid-column: span 2;
|
|
1662
|
+
grid-template-columns: 1fr 1fr;
|
|
1663
|
+
gap: 8px;
|
|
1664
|
+
}
|
|
1665
|
+
.trust-row.show {
|
|
1666
|
+
display: grid;
|
|
1667
|
+
}
|
|
1135
1668
|
button:hover:not(:disabled) {
|
|
1136
1669
|
filter: brightness(1.15);
|
|
1137
1670
|
transform: translateY(-1px);
|
|
@@ -1435,15 +1968,31 @@ var ui_default = `<!doctype html>
|
|
|
1435
1968
|
<span class="slider"></span>
|
|
1436
1969
|
</label>
|
|
1437
1970
|
</div>
|
|
1971
|
+
<div class="setting-row">
|
|
1972
|
+
<div class="setting-text">
|
|
1973
|
+
<div class="setting-label">Trust Sessions</div>
|
|
1974
|
+
<div class="setting-desc">
|
|
1975
|
+
Show "Trust 30m / 1h" buttons \u2014 allow a tool without interruption for a set time.
|
|
1976
|
+
</div>
|
|
1977
|
+
</div>
|
|
1978
|
+
<label class="toggle">
|
|
1979
|
+
<input
|
|
1980
|
+
type="checkbox"
|
|
1981
|
+
id="trustSessionsToggle"
|
|
1982
|
+
onchange="onTrustToggle(this.checked)"
|
|
1983
|
+
/>
|
|
1984
|
+
<span class="slider"></span>
|
|
1985
|
+
</label>
|
|
1986
|
+
</div>
|
|
1438
1987
|
</div>
|
|
1439
1988
|
|
|
1440
1989
|
<div class="panel">
|
|
1441
|
-
<div class="panel-title">\u{1F4AC}
|
|
1990
|
+
<div class="panel-title">\u{1F4AC} Cloud Approvals</div>
|
|
1442
1991
|
<div class="setting-row">
|
|
1443
1992
|
<div class="setting-text">
|
|
1444
|
-
<div class="setting-label">Enable
|
|
1993
|
+
<div class="setting-label">Enable Cloud</div>
|
|
1445
1994
|
<div class="setting-desc">
|
|
1446
|
-
Use Slack as the approval authority when a key is saved.
|
|
1995
|
+
Use Cloud/Slack as the approval authority when a key is saved.
|
|
1447
1996
|
</div>
|
|
1448
1997
|
</div>
|
|
1449
1998
|
<label class="toggle">
|
|
@@ -1497,6 +2046,7 @@ var ui_default = `<!doctype html>
|
|
|
1497
2046
|
const requests = new Set();
|
|
1498
2047
|
let orgName = null;
|
|
1499
2048
|
let autoDenyMs = 120000;
|
|
2049
|
+
let trustEnabled = false;
|
|
1500
2050
|
|
|
1501
2051
|
function highlightSyntax(code) {
|
|
1502
2052
|
if (typeof code !== 'string') return esc(code);
|
|
@@ -1549,6 +2099,21 @@ var ui_default = `<!doctype html>
|
|
|
1549
2099
|
}, 200);
|
|
1550
2100
|
}
|
|
1551
2101
|
|
|
2102
|
+
function sendTrust(id, duration) {
|
|
2103
|
+
const card = document.getElementById('c-' + id);
|
|
2104
|
+
if (card) card.style.opacity = '0.5';
|
|
2105
|
+
fetch('/decision/' + id, {
|
|
2106
|
+
method: 'POST',
|
|
2107
|
+
headers: { 'Content-Type': 'application/json', 'X-Node9-Token': CSRF_TOKEN },
|
|
2108
|
+
body: JSON.stringify({ decision: 'trust', trustDuration: duration }),
|
|
2109
|
+
});
|
|
2110
|
+
setTimeout(() => {
|
|
2111
|
+
card?.remove();
|
|
2112
|
+
requests.delete(id);
|
|
2113
|
+
refresh();
|
|
2114
|
+
}, 200);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
1552
2117
|
function addCard(req) {
|
|
1553
2118
|
if (requests.has(req.id)) return;
|
|
1554
2119
|
requests.add(req.id);
|
|
@@ -1568,6 +2133,7 @@ var ui_default = `<!doctype html>
|
|
|
1568
2133
|
card.id = 'c-' + req.id;
|
|
1569
2134
|
const agentLabel = req.agent ? esc(req.agent) : 'AI Agent';
|
|
1570
2135
|
const mcpLabel = req.mcpServer ? esc(req.mcpServer) : null;
|
|
2136
|
+
const dis = isSlack ? 'disabled' : '';
|
|
1571
2137
|
card.innerHTML = \`
|
|
1572
2138
|
<div class="source-row">
|
|
1573
2139
|
<span class="agent-badge">\${agentLabel}</span>
|
|
@@ -1577,11 +2143,15 @@ var ui_default = `<!doctype html>
|
|
|
1577
2143
|
\${isSlack ? '<div class="slack-indicator">\u26A1 Awaiting Slack approval \u2014 view only</div>' : ''}
|
|
1578
2144
|
<span class="label">Input Payload</span>
|
|
1579
2145
|
<pre>\${cmd}</pre>
|
|
1580
|
-
<div class="actions">
|
|
1581
|
-
<button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${
|
|
1582
|
-
<button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${
|
|
1583
|
-
<
|
|
1584
|
-
|
|
2146
|
+
<div class="actions" id="act-\${req.id}">
|
|
2147
|
+
<button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${dis}>Approve Execution</button>
|
|
2148
|
+
<button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${dis}>Block Action</button>
|
|
2149
|
+
<div class="trust-row\${trustEnabled ? ' show' : ''}" id="tr-\${req.id}">
|
|
2150
|
+
<button class="btn-trust" onclick="sendTrust('\${req.id}','30m')" \${dis}>\u23F1 Trust 30m</button>
|
|
2151
|
+
<button class="btn-trust" onclick="sendTrust('\${req.id}','1h')" \${dis}>\u23F1 Trust 1h</button>
|
|
2152
|
+
</div>
|
|
2153
|
+
<button class="btn-secondary" onclick="sendDecision('\${req.id}','allow',true)" \${dis}>Always Allow</button>
|
|
2154
|
+
<button class="btn-secondary" onclick="sendDecision('\${req.id}','deny',true)" \${dis}>Always Deny</button>
|
|
1585
2155
|
</div>
|
|
1586
2156
|
\`;
|
|
1587
2157
|
list.appendChild(card);
|
|
@@ -1613,8 +2183,10 @@ var ui_default = `<!doctype html>
|
|
|
1613
2183
|
autoDenyMs = data.autoDenyMs;
|
|
1614
2184
|
if (orgName) {
|
|
1615
2185
|
const b = document.getElementById('cloudBadge');
|
|
1616
|
-
b
|
|
1617
|
-
|
|
2186
|
+
if (b) {
|
|
2187
|
+
b.innerText = orgName;
|
|
2188
|
+
b.classList.add('online');
|
|
2189
|
+
}
|
|
1618
2190
|
}
|
|
1619
2191
|
data.requests.forEach(addCard);
|
|
1620
2192
|
});
|
|
@@ -1644,6 +2216,14 @@ var ui_default = `<!doctype html>
|
|
|
1644
2216
|
}).catch(() => {});
|
|
1645
2217
|
}
|
|
1646
2218
|
|
|
2219
|
+
function onTrustToggle(checked) {
|
|
2220
|
+
trustEnabled = checked;
|
|
2221
|
+
saveSetting('enableTrustSessions', checked);
|
|
2222
|
+
document.querySelectorAll('[id^="tr-"]').forEach((el) => {
|
|
2223
|
+
el.classList.toggle('show', checked);
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
|
|
1647
2227
|
fetch('/settings')
|
|
1648
2228
|
.then((r) => r.json())
|
|
1649
2229
|
.then((s) => {
|
|
@@ -1656,6 +2236,13 @@ var ui_default = `<!doctype html>
|
|
|
1656
2236
|
if (!s.autoStartDaemon && !s.autoStarted) {
|
|
1657
2237
|
document.getElementById('warnBanner').classList.add('show');
|
|
1658
2238
|
}
|
|
2239
|
+
trustEnabled = !!s.enableTrustSessions;
|
|
2240
|
+
const trustTog = document.getElementById('trustSessionsToggle');
|
|
2241
|
+
if (trustTog) trustTog.checked = trustEnabled;
|
|
2242
|
+
// Show/hide trust rows on any cards already rendered
|
|
2243
|
+
document.querySelectorAll('[id^="tr-"]').forEach((el) => {
|
|
2244
|
+
el.classList.toggle('show', trustEnabled);
|
|
2245
|
+
});
|
|
1659
2246
|
})
|
|
1660
2247
|
.catch(() => {});
|
|
1661
2248
|
|
|
@@ -1765,7 +2352,7 @@ var import_http = __toESM(require("http"));
|
|
|
1765
2352
|
var import_fs3 = __toESM(require("fs"));
|
|
1766
2353
|
var import_path3 = __toESM(require("path"));
|
|
1767
2354
|
var import_os3 = __toESM(require("os"));
|
|
1768
|
-
var
|
|
2355
|
+
var import_child_process2 = require("child_process");
|
|
1769
2356
|
var import_crypto = require("crypto");
|
|
1770
2357
|
var import_chalk3 = __toESM(require("chalk"));
|
|
1771
2358
|
var DAEMON_PORT2 = 7391;
|
|
@@ -1776,6 +2363,33 @@ var DECISIONS_FILE = import_path3.default.join(homeDir, ".node9", "decisions.jso
|
|
|
1776
2363
|
var GLOBAL_CONFIG_FILE = import_path3.default.join(homeDir, ".node9", "config.json");
|
|
1777
2364
|
var CREDENTIALS_FILE = import_path3.default.join(homeDir, ".node9", "credentials.json");
|
|
1778
2365
|
var AUDIT_LOG_FILE = import_path3.default.join(homeDir, ".node9", "audit.log");
|
|
2366
|
+
var TRUST_FILE2 = import_path3.default.join(homeDir, ".node9", "trust.json");
|
|
2367
|
+
function atomicWriteSync2(filePath, data, options) {
|
|
2368
|
+
const dir = import_path3.default.dirname(filePath);
|
|
2369
|
+
if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
|
|
2370
|
+
const tmpPath = `${filePath}.${(0, import_crypto.randomUUID)()}.tmp`;
|
|
2371
|
+
import_fs3.default.writeFileSync(tmpPath, data, options);
|
|
2372
|
+
import_fs3.default.renameSync(tmpPath, filePath);
|
|
2373
|
+
}
|
|
2374
|
+
function writeTrustEntry(toolName, durationMs) {
|
|
2375
|
+
try {
|
|
2376
|
+
let trust = { entries: [] };
|
|
2377
|
+
try {
|
|
2378
|
+
if (import_fs3.default.existsSync(TRUST_FILE2))
|
|
2379
|
+
trust = JSON.parse(import_fs3.default.readFileSync(TRUST_FILE2, "utf-8"));
|
|
2380
|
+
} catch {
|
|
2381
|
+
}
|
|
2382
|
+
trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > Date.now());
|
|
2383
|
+
trust.entries.push({ tool: toolName, expiry: Date.now() + durationMs });
|
|
2384
|
+
atomicWriteSync2(TRUST_FILE2, JSON.stringify(trust, null, 2));
|
|
2385
|
+
} catch {
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
var TRUST_DURATIONS = {
|
|
2389
|
+
"30m": 30 * 6e4,
|
|
2390
|
+
"1h": 60 * 6e4,
|
|
2391
|
+
"2h": 2 * 60 * 6e4
|
|
2392
|
+
};
|
|
1779
2393
|
var SECRET_KEY_RE = /password|secret|token|key|apikey|credential|auth/i;
|
|
1780
2394
|
function redactArgs(value) {
|
|
1781
2395
|
if (!value || typeof value !== "object") return value;
|
|
@@ -1788,10 +2402,16 @@ function redactArgs(value) {
|
|
|
1788
2402
|
}
|
|
1789
2403
|
function appendAuditLog(data) {
|
|
1790
2404
|
try {
|
|
1791
|
-
const entry =
|
|
2405
|
+
const entry = {
|
|
2406
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2407
|
+
tool: data.toolName,
|
|
2408
|
+
args: redactArgs(data.args),
|
|
2409
|
+
decision: data.decision,
|
|
2410
|
+
source: "daemon"
|
|
2411
|
+
};
|
|
1792
2412
|
const dir = import_path3.default.dirname(AUDIT_LOG_FILE);
|
|
1793
2413
|
if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
|
|
1794
|
-
import_fs3.default.appendFileSync(AUDIT_LOG_FILE, entry);
|
|
2414
|
+
import_fs3.default.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + "\n");
|
|
1795
2415
|
} catch {
|
|
1796
2416
|
}
|
|
1797
2417
|
}
|
|
@@ -1816,21 +2436,6 @@ function getOrgName() {
|
|
|
1816
2436
|
return null;
|
|
1817
2437
|
}
|
|
1818
2438
|
var autoStarted = process.env.NODE9_AUTO_STARTED === "1";
|
|
1819
|
-
function readGlobalSettings() {
|
|
1820
|
-
try {
|
|
1821
|
-
if (import_fs3.default.existsSync(GLOBAL_CONFIG_FILE)) {
|
|
1822
|
-
const config = JSON.parse(import_fs3.default.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
|
|
1823
|
-
const s = config?.settings ?? {};
|
|
1824
|
-
return {
|
|
1825
|
-
autoStartDaemon: s.autoStartDaemon !== false,
|
|
1826
|
-
slackEnabled: s.slackEnabled !== false,
|
|
1827
|
-
agentMode: s.agentMode === true
|
|
1828
|
-
};
|
|
1829
|
-
}
|
|
1830
|
-
} catch {
|
|
1831
|
-
}
|
|
1832
|
-
return { autoStartDaemon: true, slackEnabled: true, agentMode: false };
|
|
1833
|
-
}
|
|
1834
2439
|
function hasStoredSlackKey() {
|
|
1835
2440
|
return import_fs3.default.existsSync(CREDENTIALS_FILE);
|
|
1836
2441
|
}
|
|
@@ -1844,14 +2449,13 @@ function writeGlobalSetting(key, value) {
|
|
|
1844
2449
|
}
|
|
1845
2450
|
if (!config.settings || typeof config.settings !== "object") config.settings = {};
|
|
1846
2451
|
config.settings[key] = value;
|
|
1847
|
-
|
|
1848
|
-
if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
|
|
1849
|
-
import_fs3.default.writeFileSync(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
|
|
2452
|
+
atomicWriteSync2(GLOBAL_CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 384 });
|
|
1850
2453
|
}
|
|
1851
2454
|
var pending = /* @__PURE__ */ new Map();
|
|
1852
2455
|
var sseClients = /* @__PURE__ */ new Set();
|
|
1853
2456
|
var abandonTimer = null;
|
|
1854
2457
|
var daemonServer = null;
|
|
2458
|
+
var hadBrowserClient = false;
|
|
1855
2459
|
function abandonPending() {
|
|
1856
2460
|
abandonTimer = null;
|
|
1857
2461
|
pending.forEach((entry, id) => {
|
|
@@ -1887,10 +2491,8 @@ data: ${JSON.stringify(data)}
|
|
|
1887
2491
|
}
|
|
1888
2492
|
function openBrowser(url) {
|
|
1889
2493
|
try {
|
|
1890
|
-
const
|
|
1891
|
-
|
|
1892
|
-
else if (process.platform === "win32") (0, import_child_process.execSync)(`cmd /c start "" "${url}"`, opts);
|
|
1893
|
-
else (0, import_child_process.execSync)(`xdg-open "${url}"`, opts);
|
|
2494
|
+
const args = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? ["cmd", "/c", "start", "", url] : ["xdg-open", url];
|
|
2495
|
+
(0, import_child_process2.spawn)(args[0], args.slice(1), { detached: true, stdio: "ignore" }).unref();
|
|
1894
2496
|
} catch {
|
|
1895
2497
|
}
|
|
1896
2498
|
}
|
|
@@ -1912,11 +2514,9 @@ function readPersistentDecisions() {
|
|
|
1912
2514
|
}
|
|
1913
2515
|
function writePersistentDecision(toolName, decision) {
|
|
1914
2516
|
try {
|
|
1915
|
-
const dir = import_path3.default.dirname(DECISIONS_FILE);
|
|
1916
|
-
if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
|
|
1917
2517
|
const decisions = readPersistentDecisions();
|
|
1918
2518
|
decisions[toolName] = decision;
|
|
1919
|
-
|
|
2519
|
+
atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
|
|
1920
2520
|
broadcast("decisions", decisions);
|
|
1921
2521
|
} catch {
|
|
1922
2522
|
}
|
|
@@ -1926,6 +2526,22 @@ function startDaemon() {
|
|
|
1926
2526
|
const internalToken = (0, import_crypto.randomUUID)();
|
|
1927
2527
|
const UI_HTML = UI_HTML_TEMPLATE.replace("{{CSRF_TOKEN}}", csrfToken);
|
|
1928
2528
|
const validToken = (req) => req.headers["x-node9-token"] === csrfToken;
|
|
2529
|
+
const IDLE_TIMEOUT_MS = 12 * 60 * 60 * 1e3;
|
|
2530
|
+
let idleTimer;
|
|
2531
|
+
function resetIdleTimer() {
|
|
2532
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
2533
|
+
idleTimer = setTimeout(() => {
|
|
2534
|
+
if (autoStarted) {
|
|
2535
|
+
try {
|
|
2536
|
+
import_fs3.default.unlinkSync(DAEMON_PID_FILE);
|
|
2537
|
+
} catch {
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
process.exit(0);
|
|
2541
|
+
}, IDLE_TIMEOUT_MS);
|
|
2542
|
+
idleTimer.unref();
|
|
2543
|
+
}
|
|
2544
|
+
resetIdleTimer();
|
|
1929
2545
|
const server = import_http.default.createServer(async (req, res) => {
|
|
1930
2546
|
const { pathname } = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
1931
2547
|
if (req.method === "GET" && pathname === "/") {
|
|
@@ -1942,6 +2558,7 @@ function startDaemon() {
|
|
|
1942
2558
|
clearTimeout(abandonTimer);
|
|
1943
2559
|
abandonTimer = null;
|
|
1944
2560
|
}
|
|
2561
|
+
hadBrowserClient = true;
|
|
1945
2562
|
sseClients.add(res);
|
|
1946
2563
|
res.write(
|
|
1947
2564
|
`event: init
|
|
@@ -1968,12 +2585,13 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
1968
2585
|
return req.on("close", () => {
|
|
1969
2586
|
sseClients.delete(res);
|
|
1970
2587
|
if (sseClients.size === 0 && pending.size > 0) {
|
|
1971
|
-
abandonTimer = setTimeout(abandonPending,
|
|
2588
|
+
abandonTimer = setTimeout(abandonPending, hadBrowserClient ? 1e4 : 15e3);
|
|
1972
2589
|
}
|
|
1973
2590
|
});
|
|
1974
2591
|
}
|
|
1975
2592
|
if (req.method === "POST" && pathname === "/check") {
|
|
1976
2593
|
try {
|
|
2594
|
+
resetIdleTimer();
|
|
1977
2595
|
const body = await readBody(req);
|
|
1978
2596
|
if (body.length > 65536) return res.writeHead(413).end();
|
|
1979
2597
|
const { toolName, args, slackDelegated = false, agent, mcpServer } = JSON.parse(body);
|
|
@@ -1994,8 +2612,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
1994
2612
|
appendAuditLog({
|
|
1995
2613
|
toolName: e.toolName,
|
|
1996
2614
|
args: e.args,
|
|
1997
|
-
decision: "auto-deny"
|
|
1998
|
-
timestamp: Date.now()
|
|
2615
|
+
decision: "auto-deny"
|
|
1999
2616
|
});
|
|
2000
2617
|
if (e.waiter) e.waiter("deny");
|
|
2001
2618
|
else e.earlyDecision = "deny";
|
|
@@ -2013,7 +2630,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2013
2630
|
agent: entry.agent,
|
|
2014
2631
|
mcpServer: entry.mcpServer
|
|
2015
2632
|
});
|
|
2016
|
-
if (sseClients.size === 0) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
|
|
2633
|
+
if (sseClients.size === 0 && !autoStarted) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
|
|
2017
2634
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2018
2635
|
return res.end(JSON.stringify({ id }));
|
|
2019
2636
|
} catch {
|
|
@@ -2040,17 +2657,33 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2040
2657
|
const id = pathname.split("/").pop();
|
|
2041
2658
|
const entry = pending.get(id);
|
|
2042
2659
|
if (!entry) return res.writeHead(404).end();
|
|
2043
|
-
const { decision, persist } = JSON.parse(await readBody(req));
|
|
2044
|
-
if (
|
|
2660
|
+
const { decision, persist, trustDuration } = JSON.parse(await readBody(req));
|
|
2661
|
+
if (decision === "trust" && trustDuration) {
|
|
2662
|
+
const ms = TRUST_DURATIONS[trustDuration] ?? 60 * 6e4;
|
|
2663
|
+
writeTrustEntry(entry.toolName, ms);
|
|
2664
|
+
appendAuditLog({
|
|
2665
|
+
toolName: entry.toolName,
|
|
2666
|
+
args: entry.args,
|
|
2667
|
+
decision: `trust:${trustDuration}`
|
|
2668
|
+
});
|
|
2669
|
+
clearTimeout(entry.timer);
|
|
2670
|
+
if (entry.waiter) entry.waiter("allow");
|
|
2671
|
+
else entry.earlyDecision = "allow";
|
|
2672
|
+
pending.delete(id);
|
|
2673
|
+
broadcast("remove", { id });
|
|
2674
|
+
res.writeHead(200);
|
|
2675
|
+
return res.end(JSON.stringify({ ok: true }));
|
|
2676
|
+
}
|
|
2677
|
+
const resolvedDecision = decision === "allow" || decision === "deny" ? decision : "deny";
|
|
2678
|
+
if (persist) writePersistentDecision(entry.toolName, resolvedDecision);
|
|
2045
2679
|
appendAuditLog({
|
|
2046
2680
|
toolName: entry.toolName,
|
|
2047
2681
|
args: entry.args,
|
|
2048
|
-
decision
|
|
2049
|
-
timestamp: Date.now()
|
|
2682
|
+
decision: resolvedDecision
|
|
2050
2683
|
});
|
|
2051
2684
|
clearTimeout(entry.timer);
|
|
2052
|
-
if (entry.waiter) entry.waiter(
|
|
2053
|
-
else entry.earlyDecision =
|
|
2685
|
+
if (entry.waiter) entry.waiter(resolvedDecision);
|
|
2686
|
+
else entry.earlyDecision = resolvedDecision;
|
|
2054
2687
|
pending.delete(id);
|
|
2055
2688
|
broadcast("remove", { id });
|
|
2056
2689
|
res.writeHead(200);
|
|
@@ -2060,7 +2693,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2060
2693
|
}
|
|
2061
2694
|
}
|
|
2062
2695
|
if (req.method === "GET" && pathname === "/settings") {
|
|
2063
|
-
const s =
|
|
2696
|
+
const s = getGlobalSettings();
|
|
2064
2697
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2065
2698
|
return res.end(JSON.stringify({ ...s, autoStarted }));
|
|
2066
2699
|
}
|
|
@@ -2072,7 +2705,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2072
2705
|
if (data.autoStartDaemon !== void 0)
|
|
2073
2706
|
writeGlobalSetting("autoStartDaemon", data.autoStartDaemon);
|
|
2074
2707
|
if (data.slackEnabled !== void 0) writeGlobalSetting("slackEnabled", data.slackEnabled);
|
|
2075
|
-
if (data.
|
|
2708
|
+
if (data.enableTrustSessions !== void 0)
|
|
2709
|
+
writeGlobalSetting("enableTrustSessions", data.enableTrustSessions);
|
|
2710
|
+
if (data.enableUndo !== void 0) writeGlobalSetting("enableUndo", data.enableUndo);
|
|
2711
|
+
if (data.enableHookLogDebug !== void 0)
|
|
2712
|
+
writeGlobalSetting("enableHookLogDebug", data.enableHookLogDebug);
|
|
2713
|
+
if (data.approvers !== void 0) writeGlobalSetting("approvers", data.approvers);
|
|
2076
2714
|
res.writeHead(200);
|
|
2077
2715
|
return res.end(JSON.stringify({ ok: true }));
|
|
2078
2716
|
} catch {
|
|
@@ -2080,7 +2718,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2080
2718
|
}
|
|
2081
2719
|
}
|
|
2082
2720
|
if (req.method === "GET" && pathname === "/slack-status") {
|
|
2083
|
-
const s =
|
|
2721
|
+
const s = getGlobalSettings();
|
|
2084
2722
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2085
2723
|
return res.end(JSON.stringify({ hasKey: hasStoredSlackKey(), enabled: s.slackEnabled }));
|
|
2086
2724
|
}
|
|
@@ -2088,14 +2726,12 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2088
2726
|
if (!validToken(req)) return res.writeHead(403).end();
|
|
2089
2727
|
try {
|
|
2090
2728
|
const { apiKey } = JSON.parse(await readBody(req));
|
|
2091
|
-
|
|
2092
|
-
import_fs3.default.mkdirSync(import_path3.default.dirname(CREDENTIALS_FILE), { recursive: true });
|
|
2093
|
-
import_fs3.default.writeFileSync(
|
|
2729
|
+
atomicWriteSync2(
|
|
2094
2730
|
CREDENTIALS_FILE,
|
|
2095
2731
|
JSON.stringify({ apiKey, apiUrl: "https://api.node9.ai/api/v1/intercept" }, null, 2),
|
|
2096
2732
|
{ mode: 384 }
|
|
2097
2733
|
);
|
|
2098
|
-
broadcast("slack-status", { hasKey: true, enabled:
|
|
2734
|
+
broadcast("slack-status", { hasKey: true, enabled: getGlobalSettings().slackEnabled });
|
|
2099
2735
|
res.writeHead(200);
|
|
2100
2736
|
return res.end(JSON.stringify({ ok: true }));
|
|
2101
2737
|
} catch {
|
|
@@ -2108,7 +2744,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2108
2744
|
const toolName = decodeURIComponent(pathname.split("/").pop());
|
|
2109
2745
|
const decisions = readPersistentDecisions();
|
|
2110
2746
|
delete decisions[toolName];
|
|
2111
|
-
|
|
2747
|
+
atomicWriteSync2(DECISIONS_FILE, JSON.stringify(decisions, null, 2));
|
|
2112
2748
|
broadcast("decisions", decisions);
|
|
2113
2749
|
res.writeHead(200);
|
|
2114
2750
|
return res.end(JSON.stringify({ ok: true }));
|
|
@@ -2127,8 +2763,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2127
2763
|
appendAuditLog({
|
|
2128
2764
|
toolName: entry.toolName,
|
|
2129
2765
|
args: entry.args,
|
|
2130
|
-
decision
|
|
2131
|
-
timestamp: Date.now()
|
|
2766
|
+
decision
|
|
2132
2767
|
});
|
|
2133
2768
|
clearTimeout(entry.timer);
|
|
2134
2769
|
if (entry.waiter) entry.waiter(decision);
|
|
@@ -2148,10 +2783,28 @@ data: ${JSON.stringify(readPersistentDecisions())}
|
|
|
2148
2783
|
res.writeHead(404).end();
|
|
2149
2784
|
});
|
|
2150
2785
|
daemonServer = server;
|
|
2786
|
+
server.on("error", (e) => {
|
|
2787
|
+
if (e.code === "EADDRINUSE") {
|
|
2788
|
+
try {
|
|
2789
|
+
if (import_fs3.default.existsSync(DAEMON_PID_FILE)) {
|
|
2790
|
+
const { pid } = JSON.parse(import_fs3.default.readFileSync(DAEMON_PID_FILE, "utf-8"));
|
|
2791
|
+
process.kill(pid, 0);
|
|
2792
|
+
return process.exit(0);
|
|
2793
|
+
}
|
|
2794
|
+
} catch {
|
|
2795
|
+
try {
|
|
2796
|
+
import_fs3.default.unlinkSync(DAEMON_PID_FILE);
|
|
2797
|
+
} catch {
|
|
2798
|
+
}
|
|
2799
|
+
server.listen(DAEMON_PORT2, DAEMON_HOST2);
|
|
2800
|
+
return;
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
console.error(import_chalk3.default.red("\n\u{1F6D1} Node9 Daemon Error:"), e.message);
|
|
2804
|
+
process.exit(1);
|
|
2805
|
+
});
|
|
2151
2806
|
server.listen(DAEMON_PORT2, DAEMON_HOST2, () => {
|
|
2152
|
-
|
|
2153
|
-
import_fs3.default.mkdirSync(import_path3.default.dirname(DAEMON_PID_FILE), { recursive: true });
|
|
2154
|
-
import_fs3.default.writeFileSync(
|
|
2807
|
+
atomicWriteSync2(
|
|
2155
2808
|
DAEMON_PID_FILE,
|
|
2156
2809
|
JSON.stringify({ pid: process.pid, port: DAEMON_PORT2, internalToken, autoStarted }),
|
|
2157
2810
|
{ mode: 384 }
|
|
@@ -2187,17 +2840,97 @@ function daemonStatus() {
|
|
|
2187
2840
|
}
|
|
2188
2841
|
|
|
2189
2842
|
// src/cli.ts
|
|
2190
|
-
var
|
|
2843
|
+
var import_child_process4 = require("child_process");
|
|
2191
2844
|
var import_execa = require("execa");
|
|
2192
2845
|
var import_execa2 = require("execa");
|
|
2193
2846
|
var import_chalk4 = __toESM(require("chalk"));
|
|
2194
2847
|
var import_readline = __toESM(require("readline"));
|
|
2848
|
+
var import_fs5 = __toESM(require("fs"));
|
|
2849
|
+
var import_path5 = __toESM(require("path"));
|
|
2850
|
+
var import_os5 = __toESM(require("os"));
|
|
2851
|
+
|
|
2852
|
+
// src/undo.ts
|
|
2853
|
+
var import_child_process3 = require("child_process");
|
|
2195
2854
|
var import_fs4 = __toESM(require("fs"));
|
|
2196
2855
|
var import_path4 = __toESM(require("path"));
|
|
2197
2856
|
var import_os4 = __toESM(require("os"));
|
|
2857
|
+
var UNDO_LATEST_PATH = import_path4.default.join(import_os4.default.homedir(), ".node9", "undo_latest.txt");
|
|
2858
|
+
async function createShadowSnapshot() {
|
|
2859
|
+
try {
|
|
2860
|
+
const cwd = process.cwd();
|
|
2861
|
+
if (!import_fs4.default.existsSync(import_path4.default.join(cwd, ".git"))) return null;
|
|
2862
|
+
const tempIndex = import_path4.default.join(cwd, ".git", `node9_index_${Date.now()}`);
|
|
2863
|
+
const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
|
|
2864
|
+
(0, import_child_process3.spawnSync)("git", ["add", "-A"], { env });
|
|
2865
|
+
const treeRes = (0, import_child_process3.spawnSync)("git", ["write-tree"], { env });
|
|
2866
|
+
const treeHash = treeRes.stdout.toString().trim();
|
|
2867
|
+
if (import_fs4.default.existsSync(tempIndex)) import_fs4.default.unlinkSync(tempIndex);
|
|
2868
|
+
if (!treeHash || treeRes.status !== 0) return null;
|
|
2869
|
+
const commitRes = (0, import_child_process3.spawnSync)("git", [
|
|
2870
|
+
"commit-tree",
|
|
2871
|
+
treeHash,
|
|
2872
|
+
"-m",
|
|
2873
|
+
`Node9 AI Snapshot: ${(/* @__PURE__ */ new Date()).toISOString()}`
|
|
2874
|
+
]);
|
|
2875
|
+
const commitHash = commitRes.stdout.toString().trim();
|
|
2876
|
+
if (commitHash && commitRes.status === 0) {
|
|
2877
|
+
const dir = import_path4.default.dirname(UNDO_LATEST_PATH);
|
|
2878
|
+
if (!import_fs4.default.existsSync(dir)) import_fs4.default.mkdirSync(dir, { recursive: true });
|
|
2879
|
+
import_fs4.default.writeFileSync(UNDO_LATEST_PATH, commitHash);
|
|
2880
|
+
return commitHash;
|
|
2881
|
+
}
|
|
2882
|
+
} catch (err) {
|
|
2883
|
+
if (process.env.NODE9_DEBUG === "1") {
|
|
2884
|
+
console.error("[Node9 Undo Engine Error]:", err);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
return null;
|
|
2888
|
+
}
|
|
2889
|
+
function applyUndo(hash) {
|
|
2890
|
+
try {
|
|
2891
|
+
const restore = (0, import_child_process3.spawnSync)("git", ["restore", "--source", hash, "--staged", "--worktree", "."]);
|
|
2892
|
+
if (restore.status !== 0) return false;
|
|
2893
|
+
const lsTree = (0, import_child_process3.spawnSync)("git", ["ls-tree", "-r", "--name-only", hash]);
|
|
2894
|
+
const snapshotFiles = new Set(lsTree.stdout.toString().trim().split("\n").filter(Boolean));
|
|
2895
|
+
const tracked = (0, import_child_process3.spawnSync)("git", ["ls-files"]).stdout.toString().trim().split("\n").filter(Boolean);
|
|
2896
|
+
const untracked = (0, import_child_process3.spawnSync)("git", ["ls-files", "--others", "--exclude-standard"]).stdout.toString().trim().split("\n").filter(Boolean);
|
|
2897
|
+
for (const file of [...tracked, ...untracked]) {
|
|
2898
|
+
if (!snapshotFiles.has(file) && import_fs4.default.existsSync(file)) {
|
|
2899
|
+
import_fs4.default.unlinkSync(file);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
return true;
|
|
2903
|
+
} catch {
|
|
2904
|
+
return false;
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
function getLatestSnapshotHash() {
|
|
2908
|
+
if (!import_fs4.default.existsSync(UNDO_LATEST_PATH)) return null;
|
|
2909
|
+
return import_fs4.default.readFileSync(UNDO_LATEST_PATH, "utf-8").trim();
|
|
2910
|
+
}
|
|
2911
|
+
|
|
2912
|
+
// src/cli.ts
|
|
2913
|
+
var import_prompts3 = require("@inquirer/prompts");
|
|
2198
2914
|
var { version } = JSON.parse(
|
|
2199
|
-
|
|
2915
|
+
import_fs5.default.readFileSync(import_path5.default.join(__dirname, "../package.json"), "utf-8")
|
|
2200
2916
|
);
|
|
2917
|
+
function parseDuration(str) {
|
|
2918
|
+
const m = str.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i);
|
|
2919
|
+
if (!m) return null;
|
|
2920
|
+
const n = parseFloat(m[1]);
|
|
2921
|
+
switch ((m[2] ?? "m").toLowerCase()) {
|
|
2922
|
+
case "s":
|
|
2923
|
+
return Math.round(n * 1e3);
|
|
2924
|
+
case "m":
|
|
2925
|
+
return Math.round(n * 6e4);
|
|
2926
|
+
case "h":
|
|
2927
|
+
return Math.round(n * 36e5);
|
|
2928
|
+
case "d":
|
|
2929
|
+
return Math.round(n * 864e5);
|
|
2930
|
+
default:
|
|
2931
|
+
return null;
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2201
2934
|
function sanitize(value) {
|
|
2202
2935
|
return value.replace(/[\x00-\x1F\x7F]/g, "");
|
|
2203
2936
|
}
|
|
@@ -2205,23 +2938,33 @@ function openBrowserLocal() {
|
|
|
2205
2938
|
const url = `http://${DAEMON_HOST2}:${DAEMON_PORT2}/`;
|
|
2206
2939
|
try {
|
|
2207
2940
|
const opts = { stdio: "ignore" };
|
|
2208
|
-
if (process.platform === "darwin") (0,
|
|
2209
|
-
else if (process.platform === "win32") (0,
|
|
2210
|
-
else (0,
|
|
2941
|
+
if (process.platform === "darwin") (0, import_child_process4.execSync)(`open "${url}"`, opts);
|
|
2942
|
+
else if (process.platform === "win32") (0, import_child_process4.execSync)(`cmd /c start "" "${url}"`, opts);
|
|
2943
|
+
else (0, import_child_process4.execSync)(`xdg-open "${url}"`, opts);
|
|
2211
2944
|
} catch {
|
|
2212
2945
|
}
|
|
2213
2946
|
}
|
|
2214
2947
|
async function autoStartDaemonAndWait() {
|
|
2215
2948
|
try {
|
|
2216
|
-
const child = (0,
|
|
2949
|
+
const child = (0, import_child_process4.spawn)("node9", ["daemon"], {
|
|
2217
2950
|
detached: true,
|
|
2218
2951
|
stdio: "ignore",
|
|
2219
2952
|
env: { ...process.env, NODE9_AUTO_STARTED: "1" }
|
|
2220
2953
|
});
|
|
2221
2954
|
child.unref();
|
|
2222
|
-
for (let i = 0; i <
|
|
2955
|
+
for (let i = 0; i < 20; i++) {
|
|
2223
2956
|
await new Promise((r) => setTimeout(r, 250));
|
|
2224
|
-
if (isDaemonRunning())
|
|
2957
|
+
if (!isDaemonRunning()) continue;
|
|
2958
|
+
try {
|
|
2959
|
+
const res = await fetch("http://127.0.0.1:7391/settings", {
|
|
2960
|
+
signal: AbortSignal.timeout(500)
|
|
2961
|
+
});
|
|
2962
|
+
if (res.ok) {
|
|
2963
|
+
openBrowserLocal();
|
|
2964
|
+
return true;
|
|
2965
|
+
}
|
|
2966
|
+
} catch {
|
|
2967
|
+
}
|
|
2225
2968
|
}
|
|
2226
2969
|
} catch {
|
|
2227
2970
|
}
|
|
@@ -2240,47 +2983,71 @@ async function runProxy(targetCommand) {
|
|
|
2240
2983
|
} catch {
|
|
2241
2984
|
}
|
|
2242
2985
|
console.log(import_chalk4.default.green(`\u{1F680} Node9 Proxy Active: Monitoring [${targetCommand}]`));
|
|
2243
|
-
const child = (0,
|
|
2986
|
+
const child = (0, import_child_process4.spawn)(executable, args, {
|
|
2244
2987
|
stdio: ["pipe", "pipe", "inherit"],
|
|
2988
|
+
// We control STDIN and STDOUT
|
|
2245
2989
|
shell: true,
|
|
2246
|
-
env: { ...process.env, FORCE_COLOR: "1"
|
|
2990
|
+
env: { ...process.env, FORCE_COLOR: "1" }
|
|
2247
2991
|
});
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2992
|
+
const agentIn = import_readline.default.createInterface({ input: process.stdin, terminal: false });
|
|
2993
|
+
agentIn.on("line", async (line) => {
|
|
2994
|
+
let message;
|
|
2251
2995
|
try {
|
|
2252
|
-
|
|
2253
|
-
|
|
2996
|
+
message = JSON.parse(line);
|
|
2997
|
+
} catch {
|
|
2998
|
+
child.stdin.write(line + "\n");
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
if (message.method === "call_tool" || message.method === "tools/call" || message.method === "use_tool") {
|
|
3002
|
+
agentIn.pause();
|
|
3003
|
+
try {
|
|
2254
3004
|
const name = message.params?.name || message.params?.tool_name || "unknown";
|
|
2255
3005
|
const toolArgs = message.params?.arguments || message.params?.tool_input || {};
|
|
2256
|
-
const
|
|
2257
|
-
|
|
3006
|
+
const result = await authorizeHeadless(sanitize(name), toolArgs, true, {
|
|
3007
|
+
agent: "Proxy/MCP"
|
|
3008
|
+
});
|
|
3009
|
+
if (!result.approved) {
|
|
2258
3010
|
const errorResponse = {
|
|
2259
3011
|
jsonrpc: "2.0",
|
|
2260
3012
|
id: message.id,
|
|
2261
|
-
error: {
|
|
3013
|
+
error: {
|
|
3014
|
+
code: -32e3,
|
|
3015
|
+
message: `Node9: Action denied. ${result.reason || ""}`
|
|
3016
|
+
}
|
|
2262
3017
|
};
|
|
2263
|
-
|
|
3018
|
+
process.stdout.write(JSON.stringify(errorResponse) + "\n");
|
|
2264
3019
|
return;
|
|
2265
3020
|
}
|
|
3021
|
+
} catch {
|
|
3022
|
+
const errorResponse = {
|
|
3023
|
+
jsonrpc: "2.0",
|
|
3024
|
+
id: message.id,
|
|
3025
|
+
error: {
|
|
3026
|
+
code: -32e3,
|
|
3027
|
+
message: `Node9: Security engine encountered an error. Action blocked for safety.`
|
|
3028
|
+
}
|
|
3029
|
+
};
|
|
3030
|
+
process.stdout.write(JSON.stringify(errorResponse) + "\n");
|
|
3031
|
+
return;
|
|
3032
|
+
} finally {
|
|
3033
|
+
agentIn.resume();
|
|
2266
3034
|
}
|
|
2267
|
-
process.stdout.write(line + "\n");
|
|
2268
|
-
} catch {
|
|
2269
|
-
process.stdout.write(line + "\n");
|
|
2270
3035
|
}
|
|
3036
|
+
child.stdin.write(line + "\n");
|
|
2271
3037
|
});
|
|
3038
|
+
child.stdout.pipe(process.stdout);
|
|
2272
3039
|
child.on("exit", (code) => process.exit(code || 0));
|
|
2273
3040
|
}
|
|
2274
3041
|
program.command("login").argument("<apiKey>").option("--local", "Save key for audit/logging only \u2014 local config still controls all decisions").option("--profile <name>", 'Save as a named profile (default: "default")').action((apiKey, options) => {
|
|
2275
3042
|
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
2276
|
-
const credPath =
|
|
2277
|
-
if (!
|
|
2278
|
-
|
|
3043
|
+
const credPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "credentials.json");
|
|
3044
|
+
if (!import_fs5.default.existsSync(import_path5.default.dirname(credPath)))
|
|
3045
|
+
import_fs5.default.mkdirSync(import_path5.default.dirname(credPath), { recursive: true });
|
|
2279
3046
|
const profileName = options.profile || "default";
|
|
2280
3047
|
let existingCreds = {};
|
|
2281
3048
|
try {
|
|
2282
|
-
if (
|
|
2283
|
-
const raw = JSON.parse(
|
|
3049
|
+
if (import_fs5.default.existsSync(credPath)) {
|
|
3050
|
+
const raw = JSON.parse(import_fs5.default.readFileSync(credPath, "utf-8"));
|
|
2284
3051
|
if (raw.apiKey) {
|
|
2285
3052
|
existingCreds = {
|
|
2286
3053
|
default: { apiKey: raw.apiKey, apiUrl: raw.apiUrl || DEFAULT_API_URL }
|
|
@@ -2292,40 +3059,38 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
|
|
|
2292
3059
|
} catch {
|
|
2293
3060
|
}
|
|
2294
3061
|
existingCreds[profileName] = { apiKey, apiUrl: DEFAULT_API_URL };
|
|
2295
|
-
|
|
3062
|
+
import_fs5.default.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
|
|
2296
3063
|
if (profileName === "default") {
|
|
2297
|
-
const configPath =
|
|
3064
|
+
const configPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
|
|
2298
3065
|
let config = {};
|
|
2299
3066
|
try {
|
|
2300
|
-
if (
|
|
2301
|
-
config = JSON.parse(
|
|
3067
|
+
if (import_fs5.default.existsSync(configPath))
|
|
3068
|
+
config = JSON.parse(import_fs5.default.readFileSync(configPath, "utf-8"));
|
|
2302
3069
|
} catch {
|
|
2303
3070
|
}
|
|
2304
3071
|
if (!config.settings || typeof config.settings !== "object") config.settings = {};
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
3072
|
+
const s = config.settings;
|
|
3073
|
+
const approvers = s.approvers || {
|
|
3074
|
+
native: true,
|
|
3075
|
+
browser: true,
|
|
3076
|
+
cloud: true,
|
|
3077
|
+
terminal: true
|
|
3078
|
+
};
|
|
3079
|
+
approvers.cloud = !options.local;
|
|
3080
|
+
s.approvers = approvers;
|
|
3081
|
+
if (!import_fs5.default.existsSync(import_path5.default.dirname(configPath)))
|
|
3082
|
+
import_fs5.default.mkdirSync(import_path5.default.dirname(configPath), { recursive: true });
|
|
3083
|
+
import_fs5.default.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
|
|
2309
3084
|
}
|
|
2310
3085
|
if (options.profile && profileName !== "default") {
|
|
2311
3086
|
console.log(import_chalk4.default.green(`\u2705 Profile "${profileName}" saved`));
|
|
2312
3087
|
console.log(import_chalk4.default.gray(` Switch to it per-session: NODE9_PROFILE=${profileName} claude`));
|
|
2313
|
-
console.log(
|
|
2314
|
-
import_chalk4.default.gray(
|
|
2315
|
-
` Or lock a project to it: add "apiKey": "<your-api-key>" to node9.config.json`
|
|
2316
|
-
)
|
|
2317
|
-
);
|
|
2318
3088
|
} else if (options.local) {
|
|
2319
3089
|
console.log(import_chalk4.default.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
|
|
2320
3090
|
console.log(import_chalk4.default.gray(` All decisions stay on this machine.`));
|
|
2321
|
-
console.log(
|
|
2322
|
-
import_chalk4.default.gray(` No data is sent to the cloud. Local config is the only authority.`)
|
|
2323
|
-
);
|
|
2324
|
-
console.log(import_chalk4.default.gray(` To enable cloud enforcement: node9 login <apiKey>`));
|
|
2325
3091
|
} else {
|
|
2326
3092
|
console.log(import_chalk4.default.green(`\u2705 Logged in \u2014 agent mode`));
|
|
2327
3093
|
console.log(import_chalk4.default.gray(` Team policy enforced for all calls via Node9 cloud.`));
|
|
2328
|
-
console.log(import_chalk4.default.gray(` To keep local control only: node9 login <apiKey> --local`));
|
|
2329
3094
|
}
|
|
2330
3095
|
});
|
|
2331
3096
|
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) => {
|
|
@@ -2336,16 +3101,23 @@ program.command("addto").description("Integrate Node9 with an AI agent").addHelp
|
|
|
2336
3101
|
process.exit(1);
|
|
2337
3102
|
});
|
|
2338
3103
|
program.command("init").description("Create ~/.node9/config.json with default policy (safe to run multiple times)").option("--force", "Overwrite existing config").action((options) => {
|
|
2339
|
-
const configPath =
|
|
2340
|
-
if (
|
|
3104
|
+
const configPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
|
|
3105
|
+
if (import_fs5.default.existsSync(configPath) && !options.force) {
|
|
2341
3106
|
console.log(import_chalk4.default.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
|
|
2342
3107
|
console.log(import_chalk4.default.gray(` Run with --force to overwrite.`));
|
|
2343
3108
|
return;
|
|
2344
3109
|
}
|
|
2345
3110
|
const defaultConfig = {
|
|
2346
3111
|
version: "1.0",
|
|
2347
|
-
settings: {
|
|
3112
|
+
settings: {
|
|
3113
|
+
mode: "standard",
|
|
3114
|
+
autoStartDaemon: true,
|
|
3115
|
+
enableUndo: true,
|
|
3116
|
+
enableHookLogDebug: false,
|
|
3117
|
+
approvers: { native: true, browser: true, cloud: true, terminal: true }
|
|
3118
|
+
},
|
|
2348
3119
|
policy: {
|
|
3120
|
+
sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
|
|
2349
3121
|
dangerousWords: DANGEROUS_WORDS,
|
|
2350
3122
|
ignoredTools: [
|
|
2351
3123
|
"list_*",
|
|
@@ -2355,60 +3127,65 @@ program.command("init").description("Create ~/.node9/config.json with default po
|
|
|
2355
3127
|
"read",
|
|
2356
3128
|
"write",
|
|
2357
3129
|
"edit",
|
|
2358
|
-
"multiedit",
|
|
2359
3130
|
"glob",
|
|
2360
3131
|
"grep",
|
|
2361
3132
|
"ls",
|
|
2362
3133
|
"notebookread",
|
|
2363
3134
|
"notebookedit",
|
|
2364
|
-
"todoread",
|
|
2365
|
-
"todowrite",
|
|
2366
3135
|
"webfetch",
|
|
2367
3136
|
"websearch",
|
|
2368
3137
|
"exitplanmode",
|
|
2369
|
-
"askuserquestion"
|
|
3138
|
+
"askuserquestion",
|
|
3139
|
+
"agent",
|
|
3140
|
+
"task*"
|
|
2370
3141
|
],
|
|
2371
3142
|
toolInspection: {
|
|
2372
3143
|
bash: "command",
|
|
2373
3144
|
shell: "command",
|
|
2374
3145
|
run_shell_command: "command",
|
|
2375
|
-
"terminal.execute": "command"
|
|
3146
|
+
"terminal.execute": "command",
|
|
3147
|
+
"postgres:query": "sql"
|
|
2376
3148
|
},
|
|
2377
3149
|
rules: [
|
|
2378
3150
|
{
|
|
2379
3151
|
action: "rm",
|
|
2380
|
-
allowPaths: [
|
|
3152
|
+
allowPaths: [
|
|
3153
|
+
"**/node_modules/**",
|
|
3154
|
+
"dist/**",
|
|
3155
|
+
"build/**",
|
|
3156
|
+
".next/**",
|
|
3157
|
+
"coverage/**",
|
|
3158
|
+
".cache/**",
|
|
3159
|
+
"tmp/**",
|
|
3160
|
+
"temp/**",
|
|
3161
|
+
".DS_Store"
|
|
3162
|
+
]
|
|
2381
3163
|
}
|
|
2382
3164
|
]
|
|
2383
3165
|
}
|
|
2384
3166
|
};
|
|
2385
|
-
if (!
|
|
2386
|
-
|
|
2387
|
-
|
|
3167
|
+
if (!import_fs5.default.existsSync(import_path5.default.dirname(configPath)))
|
|
3168
|
+
import_fs5.default.mkdirSync(import_path5.default.dirname(configPath), { recursive: true });
|
|
3169
|
+
import_fs5.default.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
|
|
2388
3170
|
console.log(import_chalk4.default.green(`\u2705 Global config created: ${configPath}`));
|
|
2389
3171
|
console.log(import_chalk4.default.gray(` Edit this file to add custom tool inspection or security rules.`));
|
|
2390
3172
|
});
|
|
2391
3173
|
program.command("status").description("Show current Node9 mode, policy source, and persistent decisions").action(() => {
|
|
2392
3174
|
const creds = getCredentials();
|
|
2393
3175
|
const daemonRunning = isDaemonRunning();
|
|
2394
|
-
const
|
|
3176
|
+
const mergedConfig = getConfig();
|
|
3177
|
+
const settings = mergedConfig.settings;
|
|
2395
3178
|
console.log("");
|
|
2396
|
-
if (creds && settings.
|
|
3179
|
+
if (creds && settings.approvers.cloud) {
|
|
2397
3180
|
console.log(import_chalk4.default.green(" \u25CF Agent mode") + import_chalk4.default.gray(" \u2014 cloud team policy enforced"));
|
|
2398
|
-
|
|
2399
|
-
console.log(import_chalk4.default.gray(" Switch to local control: node9 login <apiKey> --local"));
|
|
2400
|
-
} else if (creds && !settings.agentMode) {
|
|
3181
|
+
} else if (creds && !settings.approvers.cloud) {
|
|
2401
3182
|
console.log(
|
|
2402
3183
|
import_chalk4.default.blue(" \u25CF Privacy mode \u{1F6E1}\uFE0F") + import_chalk4.default.gray(" \u2014 all decisions stay on this machine")
|
|
2403
3184
|
);
|
|
3185
|
+
} else {
|
|
2404
3186
|
console.log(
|
|
2405
|
-
import_chalk4.default.
|
|
3187
|
+
import_chalk4.default.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + import_chalk4.default.gray(" \u2014 no API key (Local rules only)")
|
|
2406
3188
|
);
|
|
2407
|
-
console.log(import_chalk4.default.gray(" Enable cloud enforcement: node9 login <apiKey>"));
|
|
2408
|
-
} else {
|
|
2409
|
-
console.log(import_chalk4.default.yellow(" \u25CB Privacy mode \u{1F6E1}\uFE0F") + import_chalk4.default.gray(" \u2014 no API key"));
|
|
2410
|
-
console.log(import_chalk4.default.gray(" All decisions stay on this machine."));
|
|
2411
|
-
console.log(import_chalk4.default.gray(" Connect to your team: node9 login <apiKey>"));
|
|
2412
3189
|
}
|
|
2413
3190
|
console.log("");
|
|
2414
3191
|
if (daemonRunning) {
|
|
@@ -2417,64 +3194,39 @@ program.command("status").description("Show current Node9 mode, policy source, a
|
|
|
2417
3194
|
);
|
|
2418
3195
|
} else {
|
|
2419
3196
|
console.log(import_chalk4.default.gray(" \u25CB Daemon stopped"));
|
|
2420
|
-
console.log(import_chalk4.default.gray(" Start: node9 daemon --background"));
|
|
2421
3197
|
}
|
|
2422
|
-
|
|
2423
|
-
console.log(` Mode: ${import_chalk4.default.white(settings.mode)}`);
|
|
2424
|
-
const projectConfig = import_path4.default.join(process.cwd(), "node9.config.json");
|
|
2425
|
-
const globalConfig = import_path4.default.join(import_os4.default.homedir(), ".node9", "config.json");
|
|
2426
|
-
const configSource = import_fs4.default.existsSync(projectConfig) ? projectConfig : import_fs4.default.existsSync(globalConfig) ? globalConfig : import_chalk4.default.gray("none (built-in defaults)");
|
|
2427
|
-
console.log(` Config: ${import_chalk4.default.gray(configSource)}`);
|
|
2428
|
-
const profiles = listCredentialProfiles();
|
|
2429
|
-
if (profiles.length > 1) {
|
|
2430
|
-
const activeProfile = process.env.NODE9_PROFILE || "default";
|
|
2431
|
-
console.log("");
|
|
2432
|
-
console.log(` Active profile: ${import_chalk4.default.white(activeProfile)}`);
|
|
3198
|
+
if (settings.enableUndo) {
|
|
2433
3199
|
console.log(
|
|
2434
|
-
|
|
3200
|
+
import_chalk4.default.magenta(" \u25CF Undo Engine") + import_chalk4.default.gray(` \u2192 Auto-snapshotting Git repos on AI change`)
|
|
2435
3201
|
);
|
|
2436
|
-
console.log(import_chalk4.default.gray(` Switch: NODE9_PROFILE=<name> claude`));
|
|
2437
|
-
}
|
|
2438
|
-
const decisionsFile = import_path4.default.join(import_os4.default.homedir(), ".node9", "decisions.json");
|
|
2439
|
-
let decisions = {};
|
|
2440
|
-
try {
|
|
2441
|
-
if (import_fs4.default.existsSync(decisionsFile))
|
|
2442
|
-
decisions = JSON.parse(import_fs4.default.readFileSync(decisionsFile, "utf-8"));
|
|
2443
|
-
} catch {
|
|
2444
3202
|
}
|
|
2445
|
-
const keys = Object.keys(decisions);
|
|
2446
3203
|
console.log("");
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
3204
|
+
const modeLabel = settings.mode === "audit" ? import_chalk4.default.blue("audit") : settings.mode === "strict" ? import_chalk4.default.red("strict") : import_chalk4.default.white("standard");
|
|
3205
|
+
console.log(` Mode: ${modeLabel}`);
|
|
3206
|
+
const projectConfig = import_path5.default.join(process.cwd(), "node9.config.json");
|
|
3207
|
+
const globalConfig = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
|
|
3208
|
+
console.log(
|
|
3209
|
+
` Local: ${import_fs5.default.existsSync(projectConfig) ? import_chalk4.default.green("Active (node9.config.json)") : import_chalk4.default.gray("Not present")}`
|
|
3210
|
+
);
|
|
3211
|
+
console.log(
|
|
3212
|
+
` Global: ${import_fs5.default.existsSync(globalConfig) ? import_chalk4.default.green("Active (~/.node9/config.json)") : import_chalk4.default.gray("Not present")}`
|
|
3213
|
+
);
|
|
3214
|
+
if (mergedConfig.policy.sandboxPaths.length > 0) {
|
|
3215
|
+
console.log(
|
|
3216
|
+
` Sandbox: ${import_chalk4.default.green(`${mergedConfig.policy.sandboxPaths.length} safe zones active`)}`
|
|
3217
|
+
);
|
|
2457
3218
|
}
|
|
2458
|
-
const
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
);
|
|
2466
|
-
}
|
|
2467
|
-
} catch {
|
|
3219
|
+
const pauseState = checkPause();
|
|
3220
|
+
if (pauseState.paused) {
|
|
3221
|
+
const expiresAt = pauseState.expiresAt ? new Date(pauseState.expiresAt).toLocaleTimeString() : "indefinitely";
|
|
3222
|
+
console.log("");
|
|
3223
|
+
console.log(
|
|
3224
|
+
import_chalk4.default.yellow(` \u23F8 PAUSED until ${expiresAt}`) + import_chalk4.default.gray(" \u2014 all tool calls allowed")
|
|
3225
|
+
);
|
|
2468
3226
|
}
|
|
2469
3227
|
console.log("");
|
|
2470
3228
|
});
|
|
2471
|
-
program.command("daemon").description("Run the local approval server (
|
|
2472
|
-
"after",
|
|
2473
|
-
"\n Subcommands: start (default), stop, status\n Options:\n --background (-b) start detached, no second terminal needed\n --openui (-o) start in background and open the browser (or just open if already running)\n Example: node9 daemon --background"
|
|
2474
|
-
).argument("[action]", "start | stop | status (default: start)").option("-b, --background", "Start the daemon in the background (detached)").option(
|
|
2475
|
-
"-o, --openui",
|
|
2476
|
-
"Start in background and open browser (or just open browser if already running)"
|
|
2477
|
-
).action(
|
|
3229
|
+
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(
|
|
2478
3230
|
async (action, options) => {
|
|
2479
3231
|
const cmd = (action ?? "start").toLowerCase();
|
|
2480
3232
|
if (cmd === "stop") return stopDaemon();
|
|
@@ -2489,7 +3241,7 @@ program.command("daemon").description("Run the local approval server (browser HI
|
|
|
2489
3241
|
console.log(import_chalk4.default.green(`\u{1F310} Opened browser: http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
|
|
2490
3242
|
process.exit(0);
|
|
2491
3243
|
}
|
|
2492
|
-
const child = (0,
|
|
3244
|
+
const child = (0, import_child_process4.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
|
|
2493
3245
|
child.unref();
|
|
2494
3246
|
for (let i = 0; i < 12; i++) {
|
|
2495
3247
|
await new Promise((r) => setTimeout(r, 250));
|
|
@@ -2498,18 +3250,13 @@ program.command("daemon").description("Run the local approval server (browser HI
|
|
|
2498
3250
|
openBrowserLocal();
|
|
2499
3251
|
console.log(import_chalk4.default.green(`
|
|
2500
3252
|
\u{1F6E1}\uFE0F Node9 daemon started + browser opened`));
|
|
2501
|
-
console.log(import_chalk4.default.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
|
|
2502
3253
|
process.exit(0);
|
|
2503
3254
|
}
|
|
2504
3255
|
if (options.background) {
|
|
2505
|
-
const child = (0,
|
|
3256
|
+
const child = (0, import_child_process4.spawn)("node9", ["daemon"], { detached: true, stdio: "ignore" });
|
|
2506
3257
|
child.unref();
|
|
2507
3258
|
console.log(import_chalk4.default.green(`
|
|
2508
3259
|
\u{1F6E1}\uFE0F Node9 daemon started in background (PID ${child.pid})`));
|
|
2509
|
-
console.log(import_chalk4.default.gray(` http://${DAEMON_HOST2}:${DAEMON_PORT2}/`));
|
|
2510
|
-
console.log(import_chalk4.default.gray(` node9 daemon status \u2014 check if running`));
|
|
2511
|
-
console.log(import_chalk4.default.gray(` node9 daemon stop \u2014 stop it
|
|
2512
|
-
`));
|
|
2513
3260
|
process.exit(0);
|
|
2514
3261
|
}
|
|
2515
3262
|
startDaemon();
|
|
@@ -2519,53 +3266,81 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
|
|
|
2519
3266
|
const processPayload = async (raw) => {
|
|
2520
3267
|
try {
|
|
2521
3268
|
if (!raw || raw.trim() === "") process.exit(0);
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
3269
|
+
let payload = JSON.parse(raw);
|
|
3270
|
+
try {
|
|
3271
|
+
payload = JSON.parse(raw);
|
|
3272
|
+
} catch (err) {
|
|
3273
|
+
const tempConfig = getConfig();
|
|
3274
|
+
if (process.env.NODE9_DEBUG === "1" || tempConfig.settings.enableHookLogDebug) {
|
|
3275
|
+
const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
|
|
3276
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
3277
|
+
import_fs5.default.appendFileSync(
|
|
3278
|
+
logPath,
|
|
3279
|
+
`[${(/* @__PURE__ */ new Date()).toISOString()}] JSON_PARSE_ERROR: ${errMsg}
|
|
3280
|
+
RAW: ${raw}
|
|
2531
3281
|
`
|
|
2532
|
-
|
|
3282
|
+
);
|
|
3283
|
+
}
|
|
3284
|
+
process.exit(0);
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
if (payload.cwd) {
|
|
3288
|
+
try {
|
|
3289
|
+
process.chdir(payload.cwd);
|
|
3290
|
+
_resetConfigCache();
|
|
3291
|
+
} catch {
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
const config = getConfig();
|
|
3295
|
+
if (process.env.NODE9_DEBUG === "1" || config.settings.enableHookLogDebug) {
|
|
3296
|
+
const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
|
|
3297
|
+
if (!import_fs5.default.existsSync(import_path5.default.dirname(logPath)))
|
|
3298
|
+
import_fs5.default.mkdirSync(import_path5.default.dirname(logPath), { recursive: true });
|
|
3299
|
+
import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
|
|
3300
|
+
`);
|
|
2533
3301
|
}
|
|
2534
|
-
const payload = JSON.parse(raw);
|
|
2535
3302
|
const toolName = sanitize(payload.tool_name ?? payload.name ?? "");
|
|
2536
3303
|
const toolInput = payload.tool_input ?? payload.args ?? {};
|
|
2537
|
-
const agent = payload.
|
|
3304
|
+
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";
|
|
2538
3305
|
const mcpMatch = toolName.match(/^mcp__([^_](?:[^_]|_(?!_))*?)__/i);
|
|
2539
3306
|
const mcpServer = mcpMatch?.[1];
|
|
2540
3307
|
const sendBlock = (msg, result2) => {
|
|
2541
|
-
const
|
|
2542
|
-
|
|
2543
|
-
"persistent-deny": "you set this tool to always deny",
|
|
2544
|
-
"local-config": "your local config (dangerousWords / rules)",
|
|
2545
|
-
"local-decision": "you denied it in the browser",
|
|
2546
|
-
"no-approval-mechanism": "no approval method is configured"
|
|
2547
|
-
};
|
|
3308
|
+
const blockedByContext = result2?.blockedByLabel || result2?.blockedBy || "Local Security Policy";
|
|
3309
|
+
const isHumanDecision = blockedByContext.toLowerCase().includes("user") || blockedByContext.toLowerCase().includes("daemon") || blockedByContext.toLowerCase().includes("decision");
|
|
2548
3310
|
console.error(import_chalk4.default.red(`
|
|
2549
3311
|
\u{1F6D1} Node9 blocked "${toolName}"`));
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
import_chalk4.default.gray(
|
|
2553
|
-
` Blocked by: ${BLOCKED_BY_LABELS[result2.blockedBy] ?? result2.blockedBy}`
|
|
2554
|
-
)
|
|
2555
|
-
);
|
|
2556
|
-
}
|
|
2557
|
-
if (result2?.changeHint) {
|
|
2558
|
-
console.error(import_chalk4.default.cyan(` To change: ${result2.changeHint}`));
|
|
2559
|
-
}
|
|
3312
|
+
console.error(import_chalk4.default.gray(` Triggered by: ${blockedByContext}`));
|
|
3313
|
+
if (result2?.changeHint) console.error(import_chalk4.default.cyan(` To change: ${result2.changeHint}`));
|
|
2560
3314
|
console.error("");
|
|
3315
|
+
let aiFeedbackMessage = "";
|
|
3316
|
+
if (isHumanDecision) {
|
|
3317
|
+
aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: The human user specifically REJECTED this action.
|
|
3318
|
+
REASON: ${msg || "No specific reason provided by user."}
|
|
3319
|
+
|
|
3320
|
+
INSTRUCTIONS FOR AI AGENT:
|
|
3321
|
+
- Do NOT retry this exact command immediately.
|
|
3322
|
+
- Explain to the user that you understand they blocked the action.
|
|
3323
|
+
- Ask the user if there is an alternative approach they would prefer, or if they intended to block this action entirely.
|
|
3324
|
+
- 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.`;
|
|
3325
|
+
} else {
|
|
3326
|
+
aiFeedbackMessage = `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${blockedByContext}].
|
|
3327
|
+
REASON: ${msg}
|
|
3328
|
+
|
|
3329
|
+
INSTRUCTIONS FOR AI AGENT:
|
|
3330
|
+
- This command violates the current security configuration.
|
|
3331
|
+
- Do NOT attempt to bypass this rule with bash syntax tricks; it will be blocked again.
|
|
3332
|
+
- Pivot to a non-destructive or read-only alternative.
|
|
3333
|
+
- Inform the user which security rule was triggered.`;
|
|
3334
|
+
}
|
|
2561
3335
|
process.stdout.write(
|
|
2562
3336
|
JSON.stringify({
|
|
2563
3337
|
decision: "block",
|
|
2564
|
-
reason:
|
|
3338
|
+
reason: aiFeedbackMessage,
|
|
3339
|
+
// This is the core instruction
|
|
2565
3340
|
hookSpecificOutput: {
|
|
2566
3341
|
hookEventName: "PreToolUse",
|
|
2567
3342
|
permissionDecision: "deny",
|
|
2568
|
-
permissionDecisionReason:
|
|
3343
|
+
permissionDecisionReason: aiFeedbackMessage
|
|
2569
3344
|
}
|
|
2570
3345
|
}) + "\n"
|
|
2571
3346
|
);
|
|
@@ -2576,36 +3351,53 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
|
|
|
2576
3351
|
return;
|
|
2577
3352
|
}
|
|
2578
3353
|
const meta = { agent, mcpServer };
|
|
3354
|
+
const STATE_CHANGING_TOOLS_PRE = [
|
|
3355
|
+
"bash",
|
|
3356
|
+
"shell",
|
|
3357
|
+
"write_file",
|
|
3358
|
+
"edit_file",
|
|
3359
|
+
"replace",
|
|
3360
|
+
"terminal.execute",
|
|
3361
|
+
"str_replace_based_edit_tool",
|
|
3362
|
+
"create_file"
|
|
3363
|
+
];
|
|
3364
|
+
if (config.settings.enableUndo && STATE_CHANGING_TOOLS_PRE.includes(toolName.toLowerCase())) {
|
|
3365
|
+
await createShadowSnapshot();
|
|
3366
|
+
}
|
|
2579
3367
|
const result = await authorizeHeadless(toolName, toolInput, false, meta);
|
|
2580
3368
|
if (result.approved) {
|
|
2581
|
-
if (result.checkedBy)
|
|
3369
|
+
if (result.checkedBy)
|
|
2582
3370
|
process.stderr.write(`\u2713 node9 [${result.checkedBy}]: "${toolName}" allowed
|
|
2583
3371
|
`);
|
|
2584
|
-
}
|
|
2585
3372
|
process.exit(0);
|
|
2586
3373
|
}
|
|
2587
|
-
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY &&
|
|
3374
|
+
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && config.settings.autoStartDaemon) {
|
|
2588
3375
|
console.error(import_chalk4.default.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
|
|
2589
3376
|
const daemonReady = await autoStartDaemonAndWait();
|
|
2590
3377
|
if (daemonReady) {
|
|
2591
3378
|
const retry = await authorizeHeadless(toolName, toolInput, false, meta);
|
|
2592
3379
|
if (retry.approved) {
|
|
2593
|
-
if (retry.checkedBy)
|
|
3380
|
+
if (retry.checkedBy)
|
|
2594
3381
|
process.stderr.write(`\u2713 node9 [${retry.checkedBy}]: "${toolName}" allowed
|
|
2595
3382
|
`);
|
|
2596
|
-
}
|
|
2597
3383
|
process.exit(0);
|
|
2598
3384
|
}
|
|
2599
|
-
sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`,
|
|
3385
|
+
sendBlock(retry.reason ?? `Node9 blocked "${toolName}".`, {
|
|
3386
|
+
...retry,
|
|
3387
|
+
blockedByLabel: retry.blockedByLabel
|
|
3388
|
+
});
|
|
2600
3389
|
return;
|
|
2601
3390
|
}
|
|
2602
3391
|
}
|
|
2603
|
-
sendBlock(result.reason ?? `Node9 blocked "${toolName}".`,
|
|
3392
|
+
sendBlock(result.reason ?? `Node9 blocked "${toolName}".`, {
|
|
3393
|
+
...result,
|
|
3394
|
+
blockedByLabel: result.blockedByLabel
|
|
3395
|
+
});
|
|
2604
3396
|
} catch (err) {
|
|
2605
3397
|
if (process.env.NODE9_DEBUG === "1") {
|
|
2606
|
-
const logPath =
|
|
3398
|
+
const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
|
|
2607
3399
|
const errMsg = err instanceof Error ? err.message : String(err);
|
|
2608
|
-
|
|
3400
|
+
import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
|
|
2609
3401
|
`);
|
|
2610
3402
|
}
|
|
2611
3403
|
process.exit(0);
|
|
@@ -2616,48 +3408,101 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
|
|
|
2616
3408
|
} else {
|
|
2617
3409
|
let raw = "";
|
|
2618
3410
|
let processed = false;
|
|
3411
|
+
let inactivityTimer = null;
|
|
2619
3412
|
const done = async () => {
|
|
2620
3413
|
if (processed) return;
|
|
2621
3414
|
processed = true;
|
|
3415
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
2622
3416
|
if (!raw.trim()) return process.exit(0);
|
|
2623
3417
|
await processPayload(raw);
|
|
2624
3418
|
};
|
|
2625
3419
|
process.stdin.setEncoding("utf-8");
|
|
2626
|
-
process.stdin.on("data", (chunk) =>
|
|
2627
|
-
|
|
2628
|
-
|
|
3420
|
+
process.stdin.on("data", (chunk) => {
|
|
3421
|
+
raw += chunk;
|
|
3422
|
+
if (inactivityTimer) clearTimeout(inactivityTimer);
|
|
3423
|
+
inactivityTimer = setTimeout(() => void done(), 2e3);
|
|
3424
|
+
});
|
|
3425
|
+
process.stdin.on("end", () => {
|
|
3426
|
+
void done();
|
|
3427
|
+
});
|
|
3428
|
+
inactivityTimer = setTimeout(() => void done(), 5e3);
|
|
2629
3429
|
}
|
|
2630
3430
|
});
|
|
2631
3431
|
program.command("log").description("PostToolUse hook \u2014 records executed tool calls").argument("[data]", "JSON string of the tool call").action(async (data) => {
|
|
2632
|
-
const logPayload = (raw) => {
|
|
3432
|
+
const logPayload = async (raw) => {
|
|
2633
3433
|
try {
|
|
2634
3434
|
if (!raw || raw.trim() === "") process.exit(0);
|
|
2635
3435
|
const payload = JSON.parse(raw);
|
|
3436
|
+
const tool = sanitize(payload.tool_name ?? payload.name ?? "unknown");
|
|
3437
|
+
const rawInput = payload.tool_input ?? payload.args ?? {};
|
|
2636
3438
|
const entry = {
|
|
2637
3439
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2638
|
-
tool
|
|
2639
|
-
|
|
3440
|
+
tool,
|
|
3441
|
+
args: JSON.parse(redactSecrets(JSON.stringify(rawInput))),
|
|
3442
|
+
decision: "allowed",
|
|
3443
|
+
source: "post-hook"
|
|
2640
3444
|
};
|
|
2641
|
-
const logPath =
|
|
2642
|
-
if (!
|
|
2643
|
-
|
|
2644
|
-
|
|
3445
|
+
const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "audit.log");
|
|
3446
|
+
if (!import_fs5.default.existsSync(import_path5.default.dirname(logPath)))
|
|
3447
|
+
import_fs5.default.mkdirSync(import_path5.default.dirname(logPath), { recursive: true });
|
|
3448
|
+
import_fs5.default.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
3449
|
+
const config = getConfig();
|
|
3450
|
+
const STATE_CHANGING_TOOLS = [
|
|
3451
|
+
"bash",
|
|
3452
|
+
"shell",
|
|
3453
|
+
"write_file",
|
|
3454
|
+
"edit_file",
|
|
3455
|
+
"replace",
|
|
3456
|
+
"terminal.execute"
|
|
3457
|
+
];
|
|
3458
|
+
if (config.settings.enableUndo && STATE_CHANGING_TOOLS.includes(tool.toLowerCase())) {
|
|
3459
|
+
await createShadowSnapshot();
|
|
3460
|
+
}
|
|
2645
3461
|
} catch {
|
|
2646
3462
|
}
|
|
2647
3463
|
process.exit(0);
|
|
2648
3464
|
};
|
|
2649
3465
|
if (data) {
|
|
2650
|
-
logPayload(data);
|
|
3466
|
+
await logPayload(data);
|
|
2651
3467
|
} else {
|
|
2652
3468
|
let raw = "";
|
|
2653
3469
|
process.stdin.setEncoding("utf-8");
|
|
2654
3470
|
process.stdin.on("data", (chunk) => raw += chunk);
|
|
2655
|
-
process.stdin.on("end", () =>
|
|
3471
|
+
process.stdin.on("end", () => {
|
|
3472
|
+
void logPayload(raw);
|
|
3473
|
+
});
|
|
2656
3474
|
setTimeout(() => {
|
|
2657
3475
|
if (!raw) process.exit(0);
|
|
2658
3476
|
}, 500);
|
|
2659
3477
|
}
|
|
2660
3478
|
});
|
|
3479
|
+
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) => {
|
|
3480
|
+
const ms = parseDuration(options.duration);
|
|
3481
|
+
if (ms === null) {
|
|
3482
|
+
console.error(
|
|
3483
|
+
import_chalk4.default.red(`
|
|
3484
|
+
\u274C Invalid duration: "${options.duration}". Use format like 15m, 1h, 30s.
|
|
3485
|
+
`)
|
|
3486
|
+
);
|
|
3487
|
+
process.exit(1);
|
|
3488
|
+
}
|
|
3489
|
+
pauseNode9(ms, options.duration);
|
|
3490
|
+
const expiresAt = new Date(Date.now() + ms).toLocaleTimeString();
|
|
3491
|
+
console.log(import_chalk4.default.yellow(`
|
|
3492
|
+
\u23F8 Node9 paused until ${expiresAt}`));
|
|
3493
|
+
console.log(import_chalk4.default.gray(` All tool calls will be allowed without review.`));
|
|
3494
|
+
console.log(import_chalk4.default.gray(` Run "node9 resume" to re-enable early.
|
|
3495
|
+
`));
|
|
3496
|
+
});
|
|
3497
|
+
program.command("resume").description("Re-enable Node9 protection immediately").action(() => {
|
|
3498
|
+
const { paused } = checkPause();
|
|
3499
|
+
if (!paused) {
|
|
3500
|
+
console.log(import_chalk4.default.gray("\nNode9 is already active \u2014 nothing to resume.\n"));
|
|
3501
|
+
return;
|
|
3502
|
+
}
|
|
3503
|
+
resumeNode9();
|
|
3504
|
+
console.log(import_chalk4.default.green("\n\u25B6 Node9 resumed \u2014 protection is active.\n"));
|
|
3505
|
+
});
|
|
2661
3506
|
var HOOK_BASED_AGENTS = {
|
|
2662
3507
|
claude: "claude",
|
|
2663
3508
|
gemini: "gemini",
|
|
@@ -2672,35 +3517,17 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
|
|
|
2672
3517
|
import_chalk4.default.yellow(`
|
|
2673
3518
|
\u26A0\uFE0F Node9 proxy mode does not support "${target}" directly.`)
|
|
2674
3519
|
);
|
|
2675
|
-
console.error(
|
|
2676
|
-
import_chalk4.default.white(`
|
|
2677
|
-
"${target}" is an interactive terminal app \u2014 it needs a real`)
|
|
2678
|
-
);
|
|
2679
|
-
console.error(
|
|
2680
|
-
import_chalk4.default.white(` TTY and communicates via its own hook system, not JSON-RPC.
|
|
2681
|
-
`)
|
|
2682
|
-
);
|
|
2683
|
-
console.error(import_chalk4.default.bold(` Use the hook-based integration instead:
|
|
2684
|
-
`));
|
|
2685
|
-
console.error(
|
|
2686
|
-
import_chalk4.default.green(` node9 addto ${target} `) + import_chalk4.default.gray("# one-time setup")
|
|
2687
|
-
);
|
|
2688
|
-
console.error(
|
|
2689
|
-
import_chalk4.default.green(` ${target} `) + import_chalk4.default.gray("# run normally \u2014 Node9 hooks fire automatically")
|
|
2690
|
-
);
|
|
2691
3520
|
console.error(import_chalk4.default.white(`
|
|
2692
|
-
|
|
3521
|
+
"${target}" uses its own hook system. Use:`));
|
|
2693
3522
|
console.error(
|
|
2694
|
-
import_chalk4.default.green(` node9
|
|
2695
|
-
);
|
|
2696
|
-
console.error(
|
|
2697
|
-
import_chalk4.default.green(` ${target} `) + import_chalk4.default.gray("# Node9 will open browser on dangerous actions\n")
|
|
3523
|
+
import_chalk4.default.green(` node9 addto ${target} `) + import_chalk4.default.gray("# one-time setup")
|
|
2698
3524
|
);
|
|
3525
|
+
console.error(import_chalk4.default.green(` ${target} `) + import_chalk4.default.gray("# run normally"));
|
|
2699
3526
|
process.exit(1);
|
|
2700
3527
|
}
|
|
2701
3528
|
const fullCommand = commandArgs.join(" ");
|
|
2702
3529
|
let result = await authorizeHeadless("shell", { command: fullCommand });
|
|
2703
|
-
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON &&
|
|
3530
|
+
if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getConfig().settings.autoStartDaemon) {
|
|
2704
3531
|
console.error(import_chalk4.default.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
|
|
2705
3532
|
const daemonReady = await autoStartDaemonAndWait();
|
|
2706
3533
|
if (daemonReady) result = await authorizeHeadless("shell", { command: fullCommand });
|
|
@@ -2713,21 +3540,6 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
|
|
|
2713
3540
|
import_chalk4.default.red(`
|
|
2714
3541
|
\u274C Node9 Blocked: ${result.reason || "Dangerous command detected."}`)
|
|
2715
3542
|
);
|
|
2716
|
-
if (result.blockedBy) {
|
|
2717
|
-
const BLOCKED_BY_LABELS = {
|
|
2718
|
-
"team-policy": "Team policy (Node9 cloud)",
|
|
2719
|
-
"persistent-deny": "Persistent deny rule",
|
|
2720
|
-
"local-config": "Local config",
|
|
2721
|
-
"local-decision": "Browser UI decision",
|
|
2722
|
-
"no-approval-mechanism": "No approval mechanism available"
|
|
2723
|
-
};
|
|
2724
|
-
console.error(
|
|
2725
|
-
import_chalk4.default.gray(` Blocked by: ${BLOCKED_BY_LABELS[result.blockedBy] ?? result.blockedBy}`)
|
|
2726
|
-
);
|
|
2727
|
-
}
|
|
2728
|
-
if (result.changeHint) {
|
|
2729
|
-
console.error(import_chalk4.default.cyan(` To change: ${result.changeHint}`));
|
|
2730
|
-
}
|
|
2731
3543
|
process.exit(1);
|
|
2732
3544
|
}
|
|
2733
3545
|
console.error(import_chalk4.default.green("\n\u2705 Approved \u2014 running command...\n"));
|
|
@@ -2736,13 +3548,33 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
|
|
|
2736
3548
|
program.help();
|
|
2737
3549
|
}
|
|
2738
3550
|
});
|
|
3551
|
+
program.command("undo").description("Revert the project to the state before the last AI action").action(async () => {
|
|
3552
|
+
const hash = getLatestSnapshotHash();
|
|
3553
|
+
if (!hash) {
|
|
3554
|
+
console.log(import_chalk4.default.yellow("\n\u2139\uFE0F No Undo snapshot found for this machine.\n"));
|
|
3555
|
+
return;
|
|
3556
|
+
}
|
|
3557
|
+
console.log(import_chalk4.default.magenta.bold("\n\u23EA NODE9 UNDO ENGINE"));
|
|
3558
|
+
console.log(import_chalk4.default.white(`Target Snapshot: ${import_chalk4.default.gray(hash.slice(0, 7))}`));
|
|
3559
|
+
const proceed = await (0, import_prompts3.confirm)({
|
|
3560
|
+
message: "Revert all files to the state before the last AI action?",
|
|
3561
|
+
default: false
|
|
3562
|
+
});
|
|
3563
|
+
if (proceed) {
|
|
3564
|
+
if (applyUndo(hash)) {
|
|
3565
|
+
console.log(import_chalk4.default.green("\u2705 Project reverted successfully.\n"));
|
|
3566
|
+
} else {
|
|
3567
|
+
console.error(import_chalk4.default.red("\u274C Undo failed. Ensure you are in a Git repository.\n"));
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
});
|
|
2739
3571
|
process.on("unhandledRejection", (reason) => {
|
|
2740
3572
|
const isCheckHook = process.argv[2] === "check";
|
|
2741
3573
|
if (isCheckHook) {
|
|
2742
|
-
if (process.env.NODE9_DEBUG === "1") {
|
|
2743
|
-
const logPath =
|
|
3574
|
+
if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
|
|
3575
|
+
const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
|
|
2744
3576
|
const msg = reason instanceof Error ? reason.message : String(reason);
|
|
2745
|
-
|
|
3577
|
+
import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
|
|
2746
3578
|
`);
|
|
2747
3579
|
}
|
|
2748
3580
|
process.exit(0);
|