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