@node9/proxy 0.2.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/LICENSE +21 -0
- package/README.md +389 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2754 -0
- package/dist/cli.mjs +2731 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +613 -0
- package/dist/index.mjs +576 -0
- package/package.json +87 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
// src/core.ts
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { confirm } from "@inquirer/prompts";
|
|
4
|
+
import fs from "fs";
|
|
5
|
+
import path from "path";
|
|
6
|
+
import os from "os";
|
|
7
|
+
import pm from "picomatch";
|
|
8
|
+
import { parse } from "sh-syntax";
|
|
9
|
+
var DANGEROUS_WORDS = [
|
|
10
|
+
"delete",
|
|
11
|
+
"drop",
|
|
12
|
+
"remove",
|
|
13
|
+
"terminate",
|
|
14
|
+
"refund",
|
|
15
|
+
"write",
|
|
16
|
+
"update",
|
|
17
|
+
"destroy",
|
|
18
|
+
"rm",
|
|
19
|
+
"rmdir",
|
|
20
|
+
"purge",
|
|
21
|
+
"format"
|
|
22
|
+
];
|
|
23
|
+
function tokenize(toolName) {
|
|
24
|
+
return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
function containsDangerousWord(toolName, dangerousWords) {
|
|
27
|
+
const tokens = tokenize(toolName);
|
|
28
|
+
return dangerousWords.some((word) => tokens.includes(word.toLowerCase()));
|
|
29
|
+
}
|
|
30
|
+
function matchesPattern(text, patterns) {
|
|
31
|
+
const p = Array.isArray(patterns) ? patterns : [patterns];
|
|
32
|
+
if (p.length === 0) return false;
|
|
33
|
+
const isMatch = pm(p, { nocase: true, dot: true });
|
|
34
|
+
const target = text.toLowerCase();
|
|
35
|
+
const directMatch = isMatch(target);
|
|
36
|
+
if (directMatch) return true;
|
|
37
|
+
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
38
|
+
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
39
|
+
}
|
|
40
|
+
function getNestedValue(obj, path2) {
|
|
41
|
+
if (!obj || typeof obj !== "object") return null;
|
|
42
|
+
return path2.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
43
|
+
}
|
|
44
|
+
function extractShellCommand(toolName, args, toolInspection) {
|
|
45
|
+
const patterns = Object.keys(toolInspection);
|
|
46
|
+
const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
|
|
47
|
+
if (!matchingPattern) return null;
|
|
48
|
+
const fieldPath = toolInspection[matchingPattern];
|
|
49
|
+
const value = getNestedValue(args, fieldPath);
|
|
50
|
+
return typeof value === "string" ? value : null;
|
|
51
|
+
}
|
|
52
|
+
async function analyzeShellCommand(command) {
|
|
53
|
+
const actions = [];
|
|
54
|
+
const paths = [];
|
|
55
|
+
const allTokens = [];
|
|
56
|
+
const addToken = (token) => {
|
|
57
|
+
const lower = token.toLowerCase();
|
|
58
|
+
allTokens.push(lower);
|
|
59
|
+
if (lower.includes("/")) {
|
|
60
|
+
const segments = lower.split("/").filter(Boolean);
|
|
61
|
+
allTokens.push(...segments);
|
|
62
|
+
}
|
|
63
|
+
if (lower.startsWith("-")) {
|
|
64
|
+
allTokens.push(lower.replace(/^-+/, ""));
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
const ast = await parse(command);
|
|
69
|
+
const walk = (node) => {
|
|
70
|
+
if (!node) return;
|
|
71
|
+
if (node.type === "CallExpr") {
|
|
72
|
+
const parts = (node.Args || []).map((arg) => {
|
|
73
|
+
return (arg.Parts || []).map((p) => p.Value || "").join("");
|
|
74
|
+
}).filter((s) => s.length > 0);
|
|
75
|
+
if (parts.length > 0) {
|
|
76
|
+
actions.push(parts[0].toLowerCase());
|
|
77
|
+
parts.forEach((p) => addToken(p));
|
|
78
|
+
parts.slice(1).forEach((p) => {
|
|
79
|
+
if (!p.startsWith("-")) paths.push(p);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const key in node) {
|
|
84
|
+
if (key === "Parent") continue;
|
|
85
|
+
const val = node[key];
|
|
86
|
+
if (Array.isArray(val)) {
|
|
87
|
+
val.forEach((child) => {
|
|
88
|
+
if (child && typeof child === "object" && "type" in child) {
|
|
89
|
+
walk(child);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
} else if (val && typeof val === "object" && "type" in val) {
|
|
93
|
+
walk(val);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
walk(ast);
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
if (allTokens.length === 0) {
|
|
101
|
+
const normalized = command.replace(/\\(.)/g, "$1");
|
|
102
|
+
const sanitized = normalized.replace(/["'<>]/g, " ");
|
|
103
|
+
const segments = sanitized.split(/[|;&]|\$\(|\)|`/);
|
|
104
|
+
segments.forEach((segment) => {
|
|
105
|
+
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
106
|
+
if (tokens.length > 0) {
|
|
107
|
+
const action = tokens[0].toLowerCase();
|
|
108
|
+
if (!actions.includes(action)) actions.push(action);
|
|
109
|
+
tokens.forEach((t) => {
|
|
110
|
+
addToken(t);
|
|
111
|
+
if (t !== tokens[0] && !t.startsWith("-")) {
|
|
112
|
+
if (!paths.includes(t)) paths.push(t);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return { actions, paths, allTokens };
|
|
119
|
+
}
|
|
120
|
+
var DEFAULT_CONFIG = {
|
|
121
|
+
settings: { mode: "standard" },
|
|
122
|
+
policy: {
|
|
123
|
+
dangerousWords: DANGEROUS_WORDS,
|
|
124
|
+
ignoredTools: [
|
|
125
|
+
"list_*",
|
|
126
|
+
"get_*",
|
|
127
|
+
"read_*",
|
|
128
|
+
"describe_*",
|
|
129
|
+
"read",
|
|
130
|
+
"write",
|
|
131
|
+
"edit",
|
|
132
|
+
"multiedit",
|
|
133
|
+
"glob",
|
|
134
|
+
"grep",
|
|
135
|
+
"ls",
|
|
136
|
+
"notebookread",
|
|
137
|
+
"notebookedit",
|
|
138
|
+
"todoread",
|
|
139
|
+
"todowrite",
|
|
140
|
+
"webfetch",
|
|
141
|
+
"websearch",
|
|
142
|
+
"exitplanmode",
|
|
143
|
+
"askuserquestion"
|
|
144
|
+
],
|
|
145
|
+
toolInspection: {
|
|
146
|
+
bash: "command",
|
|
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
|
+
]
|
|
154
|
+
},
|
|
155
|
+
environments: {}
|
|
156
|
+
};
|
|
157
|
+
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
|
+
function getInternalToken() {
|
|
182
|
+
try {
|
|
183
|
+
const pidFile = path.join(os.homedir(), ".node9", "daemon.pid");
|
|
184
|
+
if (!fs.existsSync(pidFile)) return null;
|
|
185
|
+
const data = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
|
|
186
|
+
process.kill(data.pid, 0);
|
|
187
|
+
return data.internalToken ?? null;
|
|
188
|
+
} catch {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async function evaluatePolicy(toolName, args) {
|
|
193
|
+
const config = getConfig();
|
|
194
|
+
if (matchesPattern(toolName, config.policy.ignoredTools)) return "allow";
|
|
195
|
+
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
196
|
+
if (shellCommand) {
|
|
197
|
+
const { actions, paths, allTokens } = await analyzeShellCommand(shellCommand);
|
|
198
|
+
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())) return "review";
|
|
200
|
+
for (const action of actions) {
|
|
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
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const isDangerous2 = allTokens.some(
|
|
216
|
+
(token) => config.policy.dangerousWords.some((word) => {
|
|
217
|
+
const w = word.toLowerCase();
|
|
218
|
+
if (token === w) return true;
|
|
219
|
+
try {
|
|
220
|
+
return new RegExp(`\\b${w}\\b`, "i").test(token);
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
);
|
|
226
|
+
if (isDangerous2) return "review";
|
|
227
|
+
if (config.settings.mode === "strict") return "review";
|
|
228
|
+
return "allow";
|
|
229
|
+
}
|
|
230
|
+
const isDangerous = containsDangerousWord(toolName, config.policy.dangerousWords);
|
|
231
|
+
if (isDangerous || config.settings.mode === "strict") {
|
|
232
|
+
const envConfig = getActiveEnvironment(config);
|
|
233
|
+
if (envConfig?.requireApproval === false) return "allow";
|
|
234
|
+
return "review";
|
|
235
|
+
}
|
|
236
|
+
return "allow";
|
|
237
|
+
}
|
|
238
|
+
function isIgnoredTool(toolName) {
|
|
239
|
+
const config = getConfig();
|
|
240
|
+
return matchesPattern(toolName, config.policy.ignoredTools);
|
|
241
|
+
}
|
|
242
|
+
var DAEMON_PORT = 7391;
|
|
243
|
+
var DAEMON_HOST = "127.0.0.1";
|
|
244
|
+
function isDaemonRunning() {
|
|
245
|
+
try {
|
|
246
|
+
const pidFile = path.join(os.homedir(), ".node9", "daemon.pid");
|
|
247
|
+
if (!fs.existsSync(pidFile)) return false;
|
|
248
|
+
const { pid, port } = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
|
|
249
|
+
if (port !== DAEMON_PORT) return false;
|
|
250
|
+
process.kill(pid, 0);
|
|
251
|
+
return true;
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function getPersistentDecision(toolName) {
|
|
257
|
+
try {
|
|
258
|
+
const file = path.join(os.homedir(), ".node9", "decisions.json");
|
|
259
|
+
if (!fs.existsSync(file)) return null;
|
|
260
|
+
const decisions = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
261
|
+
const d = decisions[toolName];
|
|
262
|
+
if (d === "allow" || d === "deny") return d;
|
|
263
|
+
} catch {
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
async function askDaemon(toolName, args, meta) {
|
|
268
|
+
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
269
|
+
const checkRes = await fetch(`${base}/check`, {
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers: { "Content-Type": "application/json" },
|
|
272
|
+
body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
|
|
273
|
+
signal: AbortSignal.timeout(5e3)
|
|
274
|
+
});
|
|
275
|
+
if (!checkRes.ok) throw new Error("Daemon fail");
|
|
276
|
+
const { id } = await checkRes.json();
|
|
277
|
+
const waitRes = await fetch(`${base}/wait/${id}`, { signal: AbortSignal.timeout(12e4) });
|
|
278
|
+
if (!waitRes.ok) return "deny";
|
|
279
|
+
const { decision } = await waitRes.json();
|
|
280
|
+
if (decision === "allow") return "allow";
|
|
281
|
+
if (decision === "abandoned") return "abandoned";
|
|
282
|
+
return "deny";
|
|
283
|
+
}
|
|
284
|
+
async function notifyDaemonViewer(toolName, args, meta) {
|
|
285
|
+
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
286
|
+
const res = await fetch(`${base}/check`, {
|
|
287
|
+
method: "POST",
|
|
288
|
+
headers: { "Content-Type": "application/json" },
|
|
289
|
+
body: JSON.stringify({
|
|
290
|
+
toolName,
|
|
291
|
+
args,
|
|
292
|
+
slackDelegated: true,
|
|
293
|
+
agent: meta?.agent,
|
|
294
|
+
mcpServer: meta?.mcpServer
|
|
295
|
+
}),
|
|
296
|
+
signal: AbortSignal.timeout(3e3)
|
|
297
|
+
});
|
|
298
|
+
if (!res.ok) throw new Error("Daemon unreachable");
|
|
299
|
+
const { id } = await res.json();
|
|
300
|
+
return id;
|
|
301
|
+
}
|
|
302
|
+
async function resolveViaDaemon(id, decision, internalToken) {
|
|
303
|
+
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
304
|
+
await fetch(`${base}/resolve/${id}`, {
|
|
305
|
+
method: "POST",
|
|
306
|
+
headers: { "Content-Type": "application/json", "X-Node9-Internal": internalToken },
|
|
307
|
+
body: JSON.stringify({ decision }),
|
|
308
|
+
signal: AbortSignal.timeout(3e3)
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
|
|
312
|
+
const { agentMode } = getGlobalSettings();
|
|
313
|
+
const cloudEnforced = agentMode && hasSlack();
|
|
314
|
+
if (!cloudEnforced) {
|
|
315
|
+
if (isIgnoredTool(toolName)) return { approved: true };
|
|
316
|
+
const policyDecision = await evaluatePolicy(toolName, args);
|
|
317
|
+
if (policyDecision === "allow") return { approved: true, checkedBy: "local-policy" };
|
|
318
|
+
const persistent = getPersistentDecision(toolName);
|
|
319
|
+
if (persistent === "allow") return { approved: true, checkedBy: "persistent" };
|
|
320
|
+
if (persistent === "deny")
|
|
321
|
+
return {
|
|
322
|
+
approved: false,
|
|
323
|
+
reason: `Node9: "${toolName}" is set to always deny.`,
|
|
324
|
+
blockedBy: "persistent-deny",
|
|
325
|
+
changeHint: `Open the daemon UI to manage decisions: node9 daemon --openui`
|
|
326
|
+
};
|
|
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
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
approved,
|
|
342
|
+
checkedBy: approved ? "cloud" : void 0,
|
|
343
|
+
blockedBy: approved ? void 0 : "team-policy",
|
|
344
|
+
changeHint: approved ? void 0 : `Visit your Node9 dashboard \u2192 Policy Studio to change this rule`
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (isDaemonRunning()) {
|
|
348
|
+
console.error(chalk.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for your approval."));
|
|
349
|
+
console.error(chalk.cyan(` Browser UI \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
|
|
350
|
+
`));
|
|
351
|
+
try {
|
|
352
|
+
const daemonDecision = await askDaemon(toolName, args, meta);
|
|
353
|
+
if (daemonDecision === "abandoned") {
|
|
354
|
+
console.error(chalk.yellow("\n\u26A0\uFE0F Browser closed without a decision. Falling back..."));
|
|
355
|
+
} else {
|
|
356
|
+
return {
|
|
357
|
+
approved: daemonDecision === "allow",
|
|
358
|
+
reason: daemonDecision === "deny" ? `Node9 blocked "${toolName}" \u2014 denied in browser.` : void 0,
|
|
359
|
+
checkedBy: daemonDecision === "allow" ? "daemon" : void 0,
|
|
360
|
+
blockedBy: daemonDecision === "deny" ? "local-decision" : void 0,
|
|
361
|
+
changeHint: daemonDecision === "deny" ? `Open the daemon UI to change: node9 daemon --openui` : void 0
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
} catch {
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (allowTerminalFallback && process.stdout.isTTY) {
|
|
368
|
+
console.log(chalk.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
|
|
369
|
+
console.log(`${chalk.bold("Action:")} ${chalk.red(toolName)}`);
|
|
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)}`
|
|
374
|
+
);
|
|
375
|
+
const controller = new AbortController();
|
|
376
|
+
const TIMEOUT_MS = 3e4;
|
|
377
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
378
|
+
try {
|
|
379
|
+
const approved = await confirm(
|
|
380
|
+
{ message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
|
|
381
|
+
{ signal: controller.signal }
|
|
382
|
+
);
|
|
383
|
+
clearTimeout(timer);
|
|
384
|
+
return { approved };
|
|
385
|
+
} catch {
|
|
386
|
+
clearTimeout(timer);
|
|
387
|
+
console.error(chalk.yellow("\n\u23F1 Prompt timed out \u2014 action denied by default."));
|
|
388
|
+
return { approved: false };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
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
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function getConfig() {
|
|
401
|
+
if (cachedConfig) return cachedConfig;
|
|
402
|
+
const projectConfig = tryLoadConfig(path.join(process.cwd(), "node9.config.json"));
|
|
403
|
+
if (projectConfig) {
|
|
404
|
+
cachedConfig = buildConfig(projectConfig);
|
|
405
|
+
return cachedConfig;
|
|
406
|
+
}
|
|
407
|
+
const globalConfig = tryLoadConfig(path.join(os.homedir(), ".node9", "config.json"));
|
|
408
|
+
if (globalConfig) {
|
|
409
|
+
cachedConfig = buildConfig(globalConfig);
|
|
410
|
+
return cachedConfig;
|
|
411
|
+
}
|
|
412
|
+
cachedConfig = DEFAULT_CONFIG;
|
|
413
|
+
return cachedConfig;
|
|
414
|
+
}
|
|
415
|
+
function tryLoadConfig(filePath) {
|
|
416
|
+
if (!fs.existsSync(filePath)) return null;
|
|
417
|
+
try {
|
|
418
|
+
const config = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
419
|
+
validateConfig(config, filePath);
|
|
420
|
+
return config;
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
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
|
+
function getActiveEnvironment(config) {
|
|
450
|
+
const env = process.env.NODE_ENV || "development";
|
|
451
|
+
return config.environments[env] ?? null;
|
|
452
|
+
}
|
|
453
|
+
function getCredentials() {
|
|
454
|
+
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
455
|
+
if (process.env.NODE9_API_KEY)
|
|
456
|
+
return {
|
|
457
|
+
apiKey: process.env.NODE9_API_KEY,
|
|
458
|
+
apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
|
|
459
|
+
};
|
|
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
|
+
}
|
|
473
|
+
try {
|
|
474
|
+
const credPath = path.join(os.homedir(), ".node9", "credentials.json");
|
|
475
|
+
if (fs.existsSync(credPath)) {
|
|
476
|
+
const creds = JSON.parse(fs.readFileSync(credPath, "utf-8"));
|
|
477
|
+
const profileName = process.env.NODE9_PROFILE || "default";
|
|
478
|
+
const profile = creds[profileName];
|
|
479
|
+
if (profile?.apiKey) {
|
|
480
|
+
return {
|
|
481
|
+
apiKey: profile.apiKey,
|
|
482
|
+
apiUrl: profile.apiUrl || DEFAULT_API_URL
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
if (creds.apiKey) {
|
|
486
|
+
return {
|
|
487
|
+
apiKey: creds.apiKey,
|
|
488
|
+
apiUrl: creds.apiUrl || DEFAULT_API_URL
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
} catch {
|
|
493
|
+
}
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
async function authorizeAction(toolName, args) {
|
|
497
|
+
const result = await authorizeHeadless(toolName, args, true);
|
|
498
|
+
return result.approved;
|
|
499
|
+
}
|
|
500
|
+
async function callNode9SaaS(toolName, args, creds, slackChannel, meta) {
|
|
501
|
+
try {
|
|
502
|
+
const controller = new AbortController();
|
|
503
|
+
const timeout = setTimeout(() => controller.abort(), 35e3);
|
|
504
|
+
const response = await fetch(creds.apiUrl, {
|
|
505
|
+
method: "POST",
|
|
506
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
507
|
+
body: JSON.stringify({
|
|
508
|
+
toolName,
|
|
509
|
+
args,
|
|
510
|
+
slackChannel,
|
|
511
|
+
context: {
|
|
512
|
+
agent: meta?.agent,
|
|
513
|
+
mcpServer: meta?.mcpServer,
|
|
514
|
+
hostname: os.hostname(),
|
|
515
|
+
cwd: process.cwd(),
|
|
516
|
+
platform: os.platform()
|
|
517
|
+
}
|
|
518
|
+
}),
|
|
519
|
+
signal: controller.signal
|
|
520
|
+
});
|
|
521
|
+
clearTimeout(timeout);
|
|
522
|
+
if (!response.ok) throw new Error("API fail");
|
|
523
|
+
const data = await response.json();
|
|
524
|
+
if (!data.pending) return data.approved;
|
|
525
|
+
if (!data.requestId) return false;
|
|
526
|
+
const statusUrl = `${creds.apiUrl}/status/${data.requestId}`;
|
|
527
|
+
console.error(chalk.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for your approval."));
|
|
528
|
+
if (isDaemonRunning()) {
|
|
529
|
+
console.error(
|
|
530
|
+
chalk.cyan(" Browser UI \u2192 ") + chalk.bold(`http://${DAEMON_HOST}:${DAEMON_PORT}/`)
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
console.error(chalk.cyan(" Dashboard \u2192 ") + chalk.bold("Mission Control > Flows"));
|
|
534
|
+
console.error(chalk.gray(" Agent is paused. Approve or deny to continue.\n"));
|
|
535
|
+
const POLL_INTERVAL_MS = 3e3;
|
|
536
|
+
const POLL_DEADLINE = Date.now() + 5 * 60 * 1e3;
|
|
537
|
+
while (Date.now() < POLL_DEADLINE) {
|
|
538
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
539
|
+
try {
|
|
540
|
+
const statusRes = await fetch(statusUrl, {
|
|
541
|
+
headers: { Authorization: `Bearer ${creds.apiKey}` },
|
|
542
|
+
signal: AbortSignal.timeout(5e3)
|
|
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 {
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
console.error(chalk.yellow("\u23F1 Timed out waiting for approval \u2014 action blocked.\n"));
|
|
558
|
+
return false;
|
|
559
|
+
} catch {
|
|
560
|
+
return false;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/index.ts
|
|
565
|
+
function protect(toolName, fn) {
|
|
566
|
+
return async (...args) => {
|
|
567
|
+
const isAuthorized = await authorizeAction(toolName, args);
|
|
568
|
+
if (!isAuthorized) {
|
|
569
|
+
throw new Error(`Node9: Execution of ${toolName} was denied by the user.`);
|
|
570
|
+
}
|
|
571
|
+
return await fn(...args);
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
export {
|
|
575
|
+
protect
|
|
576
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@node9/proxy",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "The Sudo Command for AI Agents. Execution Security for Claude Code & MCP.",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.mjs",
|
|
12
|
+
"require": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"node9": "./dist/cli.js"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/nadav-node9/node9-proxy.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/nadav-node9/node9-proxy/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/nadav-node9/node9-proxy#readme",
|
|
29
|
+
"keywords": [
|
|
30
|
+
"ai-security",
|
|
31
|
+
"mcp",
|
|
32
|
+
"mcp-proxy",
|
|
33
|
+
"claude-code",
|
|
34
|
+
"gemini-cli",
|
|
35
|
+
"cursor",
|
|
36
|
+
"agentic-ai",
|
|
37
|
+
"agent-security",
|
|
38
|
+
"sudo",
|
|
39
|
+
"security-proxy",
|
|
40
|
+
"human-in-the-loop",
|
|
41
|
+
"hitl"
|
|
42
|
+
],
|
|
43
|
+
"author": "Nadav <nadav@node9.ai>",
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"files": [
|
|
46
|
+
"dist",
|
|
47
|
+
"README.md",
|
|
48
|
+
"LICENSE"
|
|
49
|
+
],
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "tsup",
|
|
52
|
+
"dev": "tsup --watch",
|
|
53
|
+
"demo": "tsx examples/demo.ts",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"test:watch": "vitest",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"lint": "eslint .",
|
|
58
|
+
"lint:fix": "eslint . --fix",
|
|
59
|
+
"format": "prettier --write .",
|
|
60
|
+
"format:check": "prettier --check .",
|
|
61
|
+
"fix": "npm run format && npm run lint:fix",
|
|
62
|
+
"validate": "npm run format && npm run lint && npm run typecheck && npm run test && npm run test:e2e && npm run build",
|
|
63
|
+
"test:e2e": "bash scripts/e2e.sh",
|
|
64
|
+
"prepublishOnly": "npm run validate"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@inquirer/prompts": "^8.3.0",
|
|
68
|
+
"chalk": "^4.1.2",
|
|
69
|
+
"commander": "^14.0.3",
|
|
70
|
+
"execa": "^9.6.1",
|
|
71
|
+
"picomatch": "^4.0.3",
|
|
72
|
+
"sh-syntax": "^0.5.8"
|
|
73
|
+
},
|
|
74
|
+
"devDependencies": {
|
|
75
|
+
"@types/node": "^25.3.1",
|
|
76
|
+
"@types/picomatch": "^4.0.2",
|
|
77
|
+
"prettier": "^3.4.2",
|
|
78
|
+
"tsup": "^8.5.1",
|
|
79
|
+
"tsx": "^4.21.0",
|
|
80
|
+
"typescript": "^5.9.3",
|
|
81
|
+
"typescript-eslint": "^8.20.0",
|
|
82
|
+
"vitest": "^4.0.18"
|
|
83
|
+
},
|
|
84
|
+
"publishConfig": {
|
|
85
|
+
"access": "public"
|
|
86
|
+
}
|
|
87
|
+
}
|