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