@node9/proxy 1.0.0 → 1.0.2
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 +28 -4
- package/dist/cli.js +391 -350
- package/dist/cli.mjs +391 -350
- package/dist/index.js +284 -188
- package/dist/index.mjs +284 -188
- package/package.json +30 -1
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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";
|
|
@@ -9,19 +9,69 @@ import { parse } from "sh-syntax";
|
|
|
9
9
|
|
|
10
10
|
// src/ui/native.ts
|
|
11
11
|
import { spawn } from "child_process";
|
|
12
|
+
import chalk from "chalk";
|
|
12
13
|
var isTestEnv = () => {
|
|
13
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";
|
|
14
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
|
+
}
|
|
15
67
|
function sendDesktopNotification(title, body) {
|
|
16
68
|
if (isTestEnv()) return;
|
|
17
69
|
try {
|
|
18
|
-
const safeTitle = title.replace(/"/g, '\\"');
|
|
19
|
-
const safeBody = body.replace(/"/g, '\\"');
|
|
20
70
|
if (process.platform === "darwin") {
|
|
21
|
-
const script = `display notification "${
|
|
71
|
+
const script = `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`;
|
|
22
72
|
spawn("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
|
|
23
73
|
} else if (process.platform === "linux") {
|
|
24
|
-
spawn("notify-send", [
|
|
74
|
+
spawn("notify-send", [title, body, "--icon=dialog-warning"], {
|
|
25
75
|
detached: true,
|
|
26
76
|
stdio: "ignore"
|
|
27
77
|
}).unref();
|
|
@@ -29,69 +79,54 @@ function sendDesktopNotification(title, body) {
|
|
|
29
79
|
} catch {
|
|
30
80
|
}
|
|
31
81
|
}
|
|
32
|
-
function
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const str = typeof val === "string" ? val : JSON.stringify(val);
|
|
46
|
-
const truncated = str.length > MAX_VALUE_LEN ? str.slice(0, MAX_VALUE_LEN) + "\u2026" : str;
|
|
47
|
-
return ` ${key}: ${truncated}`;
|
|
48
|
-
});
|
|
49
|
-
if (entries.length > MAX_FIELDS) {
|
|
50
|
-
lines.push(` \u2026 and ${entries.length - MAX_FIELDS} more field(s)`);
|
|
82
|
+
function escapePango(text) {
|
|
83
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
84
|
+
}
|
|
85
|
+
function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
|
|
86
|
+
const lines = [];
|
|
87
|
+
if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
|
|
88
|
+
lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
|
|
89
|
+
lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
|
|
90
|
+
lines.push("");
|
|
91
|
+
lines.push(formattedArgs);
|
|
92
|
+
if (!locked) {
|
|
93
|
+
lines.push("");
|
|
94
|
+
lines.push('\u21B5 Enter = Allow \u21B5 | \u238B Esc = Block \u238B | "Always Allow" = never ask again');
|
|
51
95
|
}
|
|
52
96
|
return lines.join("\n");
|
|
53
97
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (process.env.NODE9_DEBUG === "1" || process.env.VITEST) {
|
|
57
|
-
console.log(`[DEBUG Native] askNativePopup called for: ${toolName}`);
|
|
58
|
-
console.log(`[DEBUG Native] isTestEnv check:`, {
|
|
59
|
-
VITEST: process.env.VITEST,
|
|
60
|
-
NODE_ENV: process.env.NODE_ENV,
|
|
61
|
-
CI: process.env.CI,
|
|
62
|
-
isTest: isTestEnv()
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
const title = locked ? `\u26A1 Node9 \u2014 Locked by Admin Policy` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Requires Approval`;
|
|
66
|
-
let message = "";
|
|
98
|
+
function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
|
|
99
|
+
const lines = [];
|
|
67
100
|
if (locked) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
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
|
|
71
|
-
`;
|
|
72
|
-
}
|
|
73
|
-
message += `Tool: ${toolName}
|
|
74
|
-
`;
|
|
75
|
-
message += `Agent: ${agent || "AI Agent"}
|
|
76
|
-
`;
|
|
77
|
-
if (explainableLabel) {
|
|
78
|
-
message += `Reason: ${explainableLabel}
|
|
79
|
-
`;
|
|
101
|
+
lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
|
|
102
|
+
lines.push("");
|
|
80
103
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
104
|
+
lines.push(
|
|
105
|
+
`<b>\u{1F916} ${escapePango(agent || "AI Agent")}</b> | <b>\u{1F527} <tt>${escapePango(toolName)}</tt></b>`
|
|
106
|
+
);
|
|
107
|
+
lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
|
|
108
|
+
lines.push("");
|
|
109
|
+
lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
|
|
84
110
|
if (!locked) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
Enter = Allow
|
|
111
|
+
lines.push("");
|
|
112
|
+
lines.push(
|
|
113
|
+
'<small>\u21B5 Enter = <b>Allow \u21B5</b> | \u238B Esc = <b>Block \u238B</b> | "Always Allow" = never ask again</small>'
|
|
114
|
+
);
|
|
88
115
|
}
|
|
89
|
-
|
|
90
|
-
|
|
116
|
+
return lines.join("\n");
|
|
117
|
+
}
|
|
118
|
+
async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
|
|
119
|
+
if (isTestEnv()) return "deny";
|
|
120
|
+
const formattedArgs = formatArgs(args);
|
|
121
|
+
const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Approval`;
|
|
122
|
+
const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
|
|
123
|
+
process.stderr.write(chalk.yellow(`
|
|
124
|
+
\u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
|
|
125
|
+
`));
|
|
91
126
|
return new Promise((resolve) => {
|
|
92
127
|
let childProcess = null;
|
|
93
128
|
const onAbort = () => {
|
|
94
|
-
if (childProcess) {
|
|
129
|
+
if (childProcess && childProcess.pid) {
|
|
95
130
|
try {
|
|
96
131
|
process.kill(childProcess.pid, "SIGKILL");
|
|
97
132
|
} catch {
|
|
@@ -103,83 +138,58 @@ Enter = Allow | Click "Block" to deny`;
|
|
|
103
138
|
if (signal.aborted) return resolve("deny");
|
|
104
139
|
signal.addEventListener("abort", onAbort);
|
|
105
140
|
}
|
|
106
|
-
const cleanup = () => {
|
|
107
|
-
if (signal) signal.removeEventListener("abort", onAbort);
|
|
108
|
-
};
|
|
109
141
|
try {
|
|
110
142
|
if (process.platform === "darwin") {
|
|
111
|
-
const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block", "Always Allow", "Allow"} default button "Allow" cancel button "Block"`;
|
|
112
|
-
const script = `
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
childProcess.stdout?.on("data", (d) => output += d.toString());
|
|
120
|
-
childProcess.on("close", (code) => {
|
|
121
|
-
cleanup();
|
|
122
|
-
if (locked) return resolve("deny");
|
|
123
|
-
if (code === 0) {
|
|
124
|
-
if (output.includes("Always Allow")) return resolve("always_allow");
|
|
125
|
-
if (output.includes("Allow")) return resolve("allow");
|
|
126
|
-
}
|
|
127
|
-
resolve("deny");
|
|
128
|
-
});
|
|
143
|
+
const buttons = locked ? `buttons {"Waiting\u2026"} default button "Waiting\u2026"` : `buttons {"Block \u238B", "Always Allow", "Allow \u21B5"} default button "Allow \u21B5" cancel button "Block \u238B"`;
|
|
144
|
+
const script = `on run argv
|
|
145
|
+
tell application "System Events"
|
|
146
|
+
activate
|
|
147
|
+
display dialog (item 1 of argv) with title (item 2 of argv) ${buttons}
|
|
148
|
+
end tell
|
|
149
|
+
end run`;
|
|
150
|
+
childProcess = spawn("osascript", ["-e", script, "--", message, title]);
|
|
129
151
|
} else if (process.platform === "linux") {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
"--
|
|
139
|
-
"
|
|
140
|
-
|
|
141
|
-
"--question",
|
|
152
|
+
const pangoMessage = buildPangoMessage(
|
|
153
|
+
toolName,
|
|
154
|
+
formattedArgs,
|
|
155
|
+
agent,
|
|
156
|
+
explainableLabel,
|
|
157
|
+
locked
|
|
158
|
+
);
|
|
159
|
+
const argsList = [
|
|
160
|
+
locked ? "--info" : "--question",
|
|
161
|
+
"--modal",
|
|
162
|
+
"--width=480",
|
|
142
163
|
"--title",
|
|
143
164
|
title,
|
|
144
165
|
"--text",
|
|
145
|
-
|
|
166
|
+
pangoMessage,
|
|
146
167
|
"--ok-label",
|
|
147
|
-
"Allow",
|
|
148
|
-
"--cancel-label",
|
|
149
|
-
"Block",
|
|
150
|
-
"--extra-button",
|
|
151
|
-
"Always Allow",
|
|
168
|
+
locked ? "Waiting..." : "Allow \u21B5",
|
|
152
169
|
"--timeout",
|
|
153
170
|
"300"
|
|
154
171
|
];
|
|
172
|
+
if (!locked) {
|
|
173
|
+
argsList.push("--cancel-label", "Block \u238B");
|
|
174
|
+
argsList.push("--extra-button", "Always Allow");
|
|
175
|
+
}
|
|
155
176
|
childProcess = spawn("zenity", argsList);
|
|
156
|
-
let output = "";
|
|
157
|
-
childProcess.stdout?.on("data", (d) => output += d.toString());
|
|
158
|
-
childProcess.on("close", (code) => {
|
|
159
|
-
cleanup();
|
|
160
|
-
if (locked) return resolve("deny");
|
|
161
|
-
if (output.trim() === "Always Allow") return resolve("always_allow");
|
|
162
|
-
if (code === 0) return resolve("allow");
|
|
163
|
-
resolve("deny");
|
|
164
|
-
});
|
|
165
177
|
} else if (process.platform === "win32") {
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
$res = [System.Windows.MessageBox]::Show("${safeMessage}", "${safeTitle}", "${buttonType}", "Warning", "Button2", "DefaultDesktopOnly");
|
|
170
|
-
if ($res -eq "Yes") { exit 0 } else { exit 1 }`;
|
|
178
|
+
const b64Msg = Buffer.from(message).toString("base64");
|
|
179
|
+
const b64Title = Buffer.from(title).toString("base64");
|
|
180
|
+
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 }`;
|
|
171
181
|
childProcess = spawn("powershell", ["-Command", ps]);
|
|
172
|
-
childProcess.on("close", (code) => {
|
|
173
|
-
cleanup();
|
|
174
|
-
if (locked) return resolve("deny");
|
|
175
|
-
resolve(code === 0 ? "allow" : "deny");
|
|
176
|
-
});
|
|
177
|
-
} else {
|
|
178
|
-
cleanup();
|
|
179
|
-
resolve("deny");
|
|
180
182
|
}
|
|
183
|
+
let output = "";
|
|
184
|
+
childProcess?.stdout?.on("data", (d) => output += d.toString());
|
|
185
|
+
childProcess?.on("close", (code) => {
|
|
186
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
187
|
+
if (locked) return resolve("deny");
|
|
188
|
+
if (output.includes("Always Allow")) return resolve("always_allow");
|
|
189
|
+
if (code === 0) return resolve("allow");
|
|
190
|
+
resolve("deny");
|
|
191
|
+
});
|
|
181
192
|
} catch {
|
|
182
|
-
cleanup();
|
|
183
193
|
resolve("deny");
|
|
184
194
|
}
|
|
185
195
|
});
|
|
@@ -188,6 +198,8 @@ Enter = Allow | Click "Block" to deny`;
|
|
|
188
198
|
// src/core.ts
|
|
189
199
|
var PAUSED_FILE = path.join(os.homedir(), ".node9", "PAUSED");
|
|
190
200
|
var TRUST_FILE = path.join(os.homedir(), ".node9", "trust.json");
|
|
201
|
+
var LOCAL_AUDIT_LOG = path.join(os.homedir(), ".node9", "audit.log");
|
|
202
|
+
var HOOK_DEBUG_LOG = path.join(os.homedir(), ".node9", "hook-debug.log");
|
|
191
203
|
function checkPause() {
|
|
192
204
|
try {
|
|
193
205
|
if (!fs.existsSync(PAUSED_FILE)) return { paused: false };
|
|
@@ -244,36 +256,39 @@ function writeTrustSession(toolName, durationMs) {
|
|
|
244
256
|
}
|
|
245
257
|
}
|
|
246
258
|
}
|
|
247
|
-
function
|
|
259
|
+
function appendToLog(logPath, entry) {
|
|
248
260
|
try {
|
|
249
|
-
const entry = JSON.stringify({
|
|
250
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
251
|
-
tool: toolName,
|
|
252
|
-
args,
|
|
253
|
-
decision: "would-have-blocked",
|
|
254
|
-
source: "audit-mode"
|
|
255
|
-
});
|
|
256
|
-
const logPath = path.join(os.homedir(), ".node9", "audit.log");
|
|
257
261
|
const dir = path.dirname(logPath);
|
|
258
262
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
259
|
-
fs.appendFileSync(logPath, entry + "\n");
|
|
263
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
260
264
|
} catch {
|
|
261
265
|
}
|
|
262
266
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
267
|
+
function appendHookDebug(toolName, args, meta) {
|
|
268
|
+
const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
|
|
269
|
+
appendToLog(HOOK_DEBUG_LOG, {
|
|
270
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
271
|
+
tool: toolName,
|
|
272
|
+
args: safeArgs,
|
|
273
|
+
agent: meta?.agent,
|
|
274
|
+
mcpServer: meta?.mcpServer,
|
|
275
|
+
hostname: os.hostname(),
|
|
276
|
+
cwd: process.cwd()
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
function appendLocalAudit(toolName, args, decision, checkedBy, meta) {
|
|
280
|
+
const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
|
|
281
|
+
appendToLog(LOCAL_AUDIT_LOG, {
|
|
282
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
283
|
+
tool: toolName,
|
|
284
|
+
args: safeArgs,
|
|
285
|
+
decision,
|
|
286
|
+
checkedBy,
|
|
287
|
+
agent: meta?.agent,
|
|
288
|
+
mcpServer: meta?.mcpServer,
|
|
289
|
+
hostname: os.hostname()
|
|
290
|
+
});
|
|
291
|
+
}
|
|
277
292
|
function tokenize(toolName) {
|
|
278
293
|
return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
|
|
279
294
|
}
|
|
@@ -367,16 +382,41 @@ async function analyzeShellCommand(command) {
|
|
|
367
382
|
}
|
|
368
383
|
return { actions, paths, allTokens };
|
|
369
384
|
}
|
|
385
|
+
function redactSecrets(text) {
|
|
386
|
+
if (!text) return text;
|
|
387
|
+
let redacted = text;
|
|
388
|
+
redacted = redacted.replace(
|
|
389
|
+
/(authorization:\s*(?:bearer|basic)\s+)[a-zA-Z0-9._\-\/\\=]+/gi,
|
|
390
|
+
"$1********"
|
|
391
|
+
);
|
|
392
|
+
redacted = redacted.replace(
|
|
393
|
+
/(api[_-]?key|secret|password|token)([:=]\s*['"]?)[a-zA-Z0-9._\-]{8,}/gi,
|
|
394
|
+
"$1$2********"
|
|
395
|
+
);
|
|
396
|
+
return redacted;
|
|
397
|
+
}
|
|
398
|
+
var DANGEROUS_WORDS = [
|
|
399
|
+
"drop",
|
|
400
|
+
"truncate",
|
|
401
|
+
"purge",
|
|
402
|
+
"format",
|
|
403
|
+
"destroy",
|
|
404
|
+
"terminate",
|
|
405
|
+
"revoke",
|
|
406
|
+
"docker",
|
|
407
|
+
"psql"
|
|
408
|
+
];
|
|
370
409
|
var DEFAULT_CONFIG = {
|
|
371
410
|
settings: {
|
|
372
411
|
mode: "standard",
|
|
373
412
|
autoStartDaemon: true,
|
|
374
|
-
enableUndo:
|
|
413
|
+
enableUndo: true,
|
|
414
|
+
// 🔥 ALWAYS TRUE BY DEFAULT for the safety net
|
|
375
415
|
enableHookLogDebug: false,
|
|
376
416
|
approvers: { native: true, browser: true, cloud: true, terminal: true }
|
|
377
417
|
},
|
|
378
418
|
policy: {
|
|
379
|
-
sandboxPaths: [],
|
|
419
|
+
sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
|
|
380
420
|
dangerousWords: DANGEROUS_WORDS,
|
|
381
421
|
ignoredTools: [
|
|
382
422
|
"list_*",
|
|
@@ -384,12 +424,44 @@ var DEFAULT_CONFIG = {
|
|
|
384
424
|
"read_*",
|
|
385
425
|
"describe_*",
|
|
386
426
|
"read",
|
|
427
|
+
"glob",
|
|
387
428
|
"grep",
|
|
388
429
|
"ls",
|
|
389
|
-
"
|
|
430
|
+
"notebookread",
|
|
431
|
+
"notebookedit",
|
|
432
|
+
"webfetch",
|
|
433
|
+
"websearch",
|
|
434
|
+
"exitplanmode",
|
|
435
|
+
"askuserquestion",
|
|
436
|
+
"agent",
|
|
437
|
+
"task*",
|
|
438
|
+
"toolsearch",
|
|
439
|
+
"mcp__ide__*",
|
|
440
|
+
"getDiagnostics"
|
|
390
441
|
],
|
|
391
|
-
toolInspection: {
|
|
392
|
-
|
|
442
|
+
toolInspection: {
|
|
443
|
+
bash: "command",
|
|
444
|
+
shell: "command",
|
|
445
|
+
run_shell_command: "command",
|
|
446
|
+
"terminal.execute": "command",
|
|
447
|
+
"postgres:query": "sql"
|
|
448
|
+
},
|
|
449
|
+
rules: [
|
|
450
|
+
{
|
|
451
|
+
action: "rm",
|
|
452
|
+
allowPaths: [
|
|
453
|
+
"**/node_modules/**",
|
|
454
|
+
"dist/**",
|
|
455
|
+
"build/**",
|
|
456
|
+
".next/**",
|
|
457
|
+
"coverage/**",
|
|
458
|
+
".cache/**",
|
|
459
|
+
"tmp/**",
|
|
460
|
+
"temp/**",
|
|
461
|
+
".DS_Store"
|
|
462
|
+
]
|
|
463
|
+
}
|
|
464
|
+
]
|
|
393
465
|
},
|
|
394
466
|
environments: {}
|
|
395
467
|
};
|
|
@@ -427,20 +499,15 @@ async function evaluatePolicy(toolName, args, agent) {
|
|
|
427
499
|
}
|
|
428
500
|
const isManual = agent === "Terminal";
|
|
429
501
|
if (isManual) {
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
"
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
"revoke",
|
|
440
|
-
"docker"
|
|
441
|
-
];
|
|
442
|
-
const hasNuclear = allTokens.some((t) => NUCLEAR_COMMANDS.includes(t.toLowerCase()));
|
|
443
|
-
if (!hasNuclear) return { decision: "allow" };
|
|
502
|
+
const SYSTEM_DISASTER_COMMANDS = ["mkfs", "shred", "dd", "drop", "truncate", "purge"];
|
|
503
|
+
const hasSystemDisaster = allTokens.some(
|
|
504
|
+
(t) => SYSTEM_DISASTER_COMMANDS.includes(t.toLowerCase())
|
|
505
|
+
);
|
|
506
|
+
const isRootWipe = allTokens.includes("rm") && (allTokens.includes("/") || allTokens.includes("/*"));
|
|
507
|
+
if (hasSystemDisaster || isRootWipe) {
|
|
508
|
+
return { decision: "review", blockedByLabel: "Manual Nuclear Protection" };
|
|
509
|
+
}
|
|
510
|
+
return { decision: "allow" };
|
|
444
511
|
}
|
|
445
512
|
if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
|
|
446
513
|
const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
|
|
@@ -454,27 +521,39 @@ async function evaluatePolicy(toolName, args, agent) {
|
|
|
454
521
|
if (pathTokens.length > 0) {
|
|
455
522
|
const anyBlocked = pathTokens.some((p) => matchesPattern(p, rule.blockPaths || []));
|
|
456
523
|
if (anyBlocked)
|
|
457
|
-
return {
|
|
524
|
+
return {
|
|
525
|
+
decision: "review",
|
|
526
|
+
blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`
|
|
527
|
+
};
|
|
458
528
|
const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
|
|
459
529
|
if (allAllowed) return { decision: "allow" };
|
|
460
530
|
}
|
|
461
|
-
return {
|
|
531
|
+
return {
|
|
532
|
+
decision: "review",
|
|
533
|
+
blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`
|
|
534
|
+
};
|
|
462
535
|
}
|
|
463
536
|
}
|
|
537
|
+
let matchedDangerousWord;
|
|
464
538
|
const isDangerous = allTokens.some(
|
|
465
539
|
(token) => config.policy.dangerousWords.some((word) => {
|
|
466
540
|
const w = word.toLowerCase();
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
541
|
+
const hit = token === w || (() => {
|
|
542
|
+
try {
|
|
543
|
+
return new RegExp(`\\b${w}\\b`, "i").test(token);
|
|
544
|
+
} catch {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
})();
|
|
548
|
+
if (hit && !matchedDangerousWord) matchedDangerousWord = word;
|
|
549
|
+
return hit;
|
|
473
550
|
})
|
|
474
551
|
);
|
|
475
552
|
if (isDangerous) {
|
|
476
|
-
|
|
477
|
-
|
|
553
|
+
return {
|
|
554
|
+
decision: "review",
|
|
555
|
+
blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`
|
|
556
|
+
};
|
|
478
557
|
}
|
|
479
558
|
if (config.settings.mode === "strict") {
|
|
480
559
|
const envConfig = getActiveEnvironment(config);
|
|
@@ -589,13 +668,16 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
589
668
|
approvers.browser = false;
|
|
590
669
|
approvers.terminal = false;
|
|
591
670
|
}
|
|
671
|
+
if (config.settings.enableHookLogDebug && !isTestEnv2) {
|
|
672
|
+
appendHookDebug(toolName, args, meta);
|
|
673
|
+
}
|
|
592
674
|
const isManual = meta?.agent === "Terminal";
|
|
593
675
|
let explainableLabel = "Local Config";
|
|
594
676
|
if (config.settings.mode === "audit") {
|
|
595
677
|
if (!isIgnoredTool(toolName)) {
|
|
596
678
|
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
597
679
|
if (policyResult.decision === "review") {
|
|
598
|
-
|
|
680
|
+
appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
|
|
599
681
|
sendDesktopNotification(
|
|
600
682
|
"Node9 Audit Mode",
|
|
601
683
|
`Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
|
|
@@ -607,20 +689,24 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
607
689
|
if (!isIgnoredTool(toolName)) {
|
|
608
690
|
if (getActiveTrustSession(toolName)) {
|
|
609
691
|
if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
692
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
|
|
610
693
|
return { approved: true, checkedBy: "trust" };
|
|
611
694
|
}
|
|
612
695
|
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
613
696
|
if (policyResult.decision === "allow") {
|
|
614
697
|
if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
|
|
698
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
|
|
615
699
|
return { approved: true, checkedBy: "local-policy" };
|
|
616
700
|
}
|
|
617
701
|
explainableLabel = policyResult.blockedByLabel || "Local Config";
|
|
618
702
|
const persistent = getPersistentDecision(toolName);
|
|
619
703
|
if (persistent === "allow") {
|
|
620
704
|
if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
|
|
705
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
|
|
621
706
|
return { approved: true, checkedBy: "persistent" };
|
|
622
707
|
}
|
|
623
708
|
if (persistent === "deny") {
|
|
709
|
+
if (!isManual) appendLocalAudit(toolName, args, "deny", "persistent-deny", meta);
|
|
624
710
|
return {
|
|
625
711
|
approved: false,
|
|
626
712
|
reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
|
|
@@ -630,6 +716,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
630
716
|
}
|
|
631
717
|
} else {
|
|
632
718
|
if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
|
|
719
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
|
|
633
720
|
return { approved: true };
|
|
634
721
|
}
|
|
635
722
|
let cloudRequestId = null;
|
|
@@ -657,8 +744,8 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
657
744
|
const isNetworkError = error.message.includes("fetch") || error.name === "AbortError" || error.message.includes("ECONNREFUSED");
|
|
658
745
|
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;
|
|
659
746
|
console.error(
|
|
660
|
-
|
|
661
|
-
\u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) +
|
|
747
|
+
chalk2.yellow(`
|
|
748
|
+
\u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + chalk2.dim(`
|
|
662
749
|
Falling back to local rules...
|
|
663
750
|
`)
|
|
664
751
|
);
|
|
@@ -666,13 +753,13 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
666
753
|
}
|
|
667
754
|
if (cloudEnforced && cloudRequestId) {
|
|
668
755
|
console.error(
|
|
669
|
-
|
|
756
|
+
chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
|
|
670
757
|
);
|
|
671
|
-
console.error(
|
|
758
|
+
console.error(chalk2.cyan(" Dashboard \u2192 ") + chalk2.bold("Mission Control > Activity Feed\n"));
|
|
672
759
|
} else if (!cloudEnforced) {
|
|
673
760
|
const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
|
|
674
761
|
console.error(
|
|
675
|
-
|
|
762
|
+
chalk2.dim(`
|
|
676
763
|
\u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
|
|
677
764
|
`)
|
|
678
765
|
);
|
|
@@ -737,9 +824,9 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
737
824
|
try {
|
|
738
825
|
if (!approvers.native && !cloudEnforced) {
|
|
739
826
|
console.error(
|
|
740
|
-
|
|
827
|
+
chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
|
|
741
828
|
);
|
|
742
|
-
console.error(
|
|
829
|
+
console.error(chalk2.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
|
|
743
830
|
`));
|
|
744
831
|
}
|
|
745
832
|
const daemonDecision = await askDaemon(toolName, args, meta, signal);
|
|
@@ -762,11 +849,11 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
762
849
|
racePromises.push(
|
|
763
850
|
(async () => {
|
|
764
851
|
try {
|
|
765
|
-
console.log(
|
|
766
|
-
console.log(`${
|
|
767
|
-
console.log(`${
|
|
852
|
+
console.log(chalk2.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
|
|
853
|
+
console.log(`${chalk2.bold("Action:")} ${chalk2.red(toolName)}`);
|
|
854
|
+
console.log(`${chalk2.bold("Flagged By:")} ${chalk2.yellow(explainableLabel)}`);
|
|
768
855
|
if (isRemoteLocked) {
|
|
769
|
-
console.log(
|
|
856
|
+
console.log(chalk2.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
|
|
770
857
|
`));
|
|
771
858
|
await new Promise((_, reject) => {
|
|
772
859
|
signal.addEventListener("abort", () => reject(new Error("Aborted by SaaS")));
|
|
@@ -854,6 +941,15 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
|
|
|
854
941
|
if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
|
|
855
942
|
await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
|
|
856
943
|
}
|
|
944
|
+
if (!isManual) {
|
|
945
|
+
appendLocalAudit(
|
|
946
|
+
toolName,
|
|
947
|
+
args,
|
|
948
|
+
finalResult.approved ? "allow" : "deny",
|
|
949
|
+
finalResult.checkedBy || finalResult.blockedBy || "unknown",
|
|
950
|
+
meta
|
|
951
|
+
);
|
|
952
|
+
}
|
|
857
953
|
return finalResult;
|
|
858
954
|
}
|
|
859
955
|
function getConfig() {
|
|
@@ -884,8 +980,8 @@ function getConfig() {
|
|
|
884
980
|
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
885
981
|
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
886
982
|
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
887
|
-
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
888
983
|
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
984
|
+
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
889
985
|
if (p.toolInspection)
|
|
890
986
|
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
891
987
|
if (p.rules) mergedPolicy.rules.push(...p.rules);
|
|
@@ -1017,11 +1113,11 @@ async function pollNode9SaaS(requestId, creds, signal) {
|
|
|
1017
1113
|
if (!statusRes.ok) continue;
|
|
1018
1114
|
const { status, reason } = await statusRes.json();
|
|
1019
1115
|
if (status === "APPROVED") {
|
|
1020
|
-
console.error(
|
|
1116
|
+
console.error(chalk2.green("\u2705 Approved via Cloud.\n"));
|
|
1021
1117
|
return { approved: true, reason };
|
|
1022
1118
|
}
|
|
1023
1119
|
if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
|
|
1024
|
-
console.error(
|
|
1120
|
+
console.error(chalk2.red("\u274C Denied via Cloud.\n"));
|
|
1025
1121
|
return { approved: false, reason };
|
|
1026
1122
|
}
|
|
1027
1123
|
} catch {
|