@node9/proxy 1.1.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/cli.js +4200 -3668
- package/dist/cli.mjs +3937 -3408
- package/dist/index.js +1689 -1752
- package/dist/index.mjs +1805 -1868
- package/package.json +9 -3
package/dist/index.js
CHANGED
|
@@ -34,452 +34,177 @@ __export(src_exports, {
|
|
|
34
34
|
});
|
|
35
35
|
module.exports = __toCommonJS(src_exports);
|
|
36
36
|
|
|
37
|
-
// src/
|
|
38
|
-
var
|
|
39
|
-
var import_prompts = require("@inquirer/prompts");
|
|
40
|
-
var import_fs3 = __toESM(require("fs"));
|
|
41
|
-
var import_path5 = __toESM(require("path"));
|
|
42
|
-
var import_os2 = __toESM(require("os"));
|
|
43
|
-
var import_net = __toESM(require("net"));
|
|
44
|
-
var import_crypto = require("crypto");
|
|
45
|
-
var import_child_process2 = require("child_process");
|
|
46
|
-
var import_picomatch = __toESM(require("picomatch"));
|
|
47
|
-
var import_safe_regex2 = __toESM(require("safe-regex2"));
|
|
48
|
-
var import_sh_syntax = require("sh-syntax");
|
|
49
|
-
|
|
50
|
-
// src/ui/native.ts
|
|
51
|
-
var import_child_process = require("child_process");
|
|
52
|
-
var import_path2 = __toESM(require("path"));
|
|
53
|
-
var import_chalk = __toESM(require("chalk"));
|
|
54
|
-
|
|
55
|
-
// src/context-sniper.ts
|
|
37
|
+
// src/audit/index.ts
|
|
38
|
+
var import_fs = __toESM(require("fs"));
|
|
56
39
|
var import_path = __toESM(require("path"));
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
40
|
+
var import_os = __toESM(require("os"));
|
|
41
|
+
var LOCAL_AUDIT_LOG = import_path.default.join(import_os.default.homedir(), ".node9", "audit.log");
|
|
42
|
+
var HOOK_DEBUG_LOG = import_path.default.join(import_os.default.homedir(), ".node9", "hook-debug.log");
|
|
43
|
+
function redactSecrets(text) {
|
|
44
|
+
if (!text) return text;
|
|
45
|
+
let redacted = text;
|
|
46
|
+
redacted = redacted.replace(
|
|
47
|
+
/(authorization:\s*(?:bearer|basic)\s+)[a-zA-Z0-9._\-\/\\=]+/gi,
|
|
48
|
+
"$1********"
|
|
49
|
+
);
|
|
50
|
+
redacted = redacted.replace(
|
|
51
|
+
/(api[_-]?key|secret|password|token)([:=]\s*['"]?)[a-zA-Z0-9._\-]{8,}/gi,
|
|
52
|
+
"$1$2********"
|
|
53
|
+
);
|
|
54
|
+
return redacted;
|
|
61
55
|
}
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
function appendToLog(logPath, entry) {
|
|
57
|
+
try {
|
|
58
|
+
const dir = import_path.default.dirname(logPath);
|
|
59
|
+
if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
|
|
60
|
+
import_fs.default.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
61
|
+
} catch {
|
|
66
62
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
63
|
+
}
|
|
64
|
+
function appendHookDebug(toolName, args, meta) {
|
|
65
|
+
const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
|
|
66
|
+
appendToLog(HOOK_DEBUG_LOG, {
|
|
67
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
68
|
+
tool: toolName,
|
|
69
|
+
args: safeArgs,
|
|
70
|
+
agent: meta?.agent,
|
|
71
|
+
mcpServer: meta?.mcpServer,
|
|
72
|
+
hostname: import_os.default.hostname(),
|
|
73
|
+
cwd: process.cwd()
|
|
74
74
|
});
|
|
75
|
-
const hitIndex = (nonComment ?? allHits[0]).i;
|
|
76
|
-
const start = Math.max(0, hitIndex - 3);
|
|
77
|
-
const end = Math.min(lines.length, hitIndex + 4);
|
|
78
|
-
const lineIndex = hitIndex - start;
|
|
79
|
-
const snippet = lines.slice(start, end).map((line, i) => `${start + i === hitIndex ? "\u{1F6D1} " : " "}${line}`).join("\n");
|
|
80
|
-
const head = start > 0 ? `... [${start} lines hidden] ...
|
|
81
|
-
` : "";
|
|
82
|
-
const tail = end < lines.length ? `
|
|
83
|
-
... [${lines.length - end} lines hidden] ...` : "";
|
|
84
|
-
return { snippet: `${head}${snippet}${tail}`, lineIndex };
|
|
85
75
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
76
|
+
function appendLocalAudit(toolName, args, decision, checkedBy, meta) {
|
|
77
|
+
const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
|
|
78
|
+
appendToLog(LOCAL_AUDIT_LOG, {
|
|
79
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
80
|
+
tool: toolName,
|
|
81
|
+
args: safeArgs,
|
|
82
|
+
decision,
|
|
83
|
+
checkedBy,
|
|
84
|
+
agent: meta?.agent,
|
|
85
|
+
mcpServer: meta?.mcpServer,
|
|
86
|
+
hostname: import_os.default.hostname()
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/config/index.ts
|
|
91
|
+
var import_fs3 = __toESM(require("fs"));
|
|
92
|
+
var import_path3 = __toESM(require("path"));
|
|
93
|
+
var import_os3 = __toESM(require("os"));
|
|
94
|
+
|
|
95
|
+
// src/config-schema.ts
|
|
96
|
+
var import_zod = require("zod");
|
|
97
|
+
var noNewlines = import_zod.z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
|
|
98
|
+
message: "Value must not contain literal newline characters (use \\n instead)"
|
|
99
|
+
});
|
|
100
|
+
var SmartConditionSchema = import_zod.z.object({
|
|
101
|
+
field: import_zod.z.string().min(1, "Condition field must not be empty"),
|
|
102
|
+
op: import_zod.z.enum(
|
|
103
|
+
[
|
|
104
|
+
"matches",
|
|
105
|
+
"notMatches",
|
|
106
|
+
"contains",
|
|
107
|
+
"notContains",
|
|
108
|
+
"exists",
|
|
109
|
+
"notExists",
|
|
110
|
+
"matchesGlob",
|
|
111
|
+
"notMatchesGlob"
|
|
112
|
+
],
|
|
113
|
+
{
|
|
114
|
+
errorMap: () => ({
|
|
115
|
+
message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists, matchesGlob, notMatchesGlob"
|
|
116
|
+
})
|
|
116
117
|
}
|
|
118
|
+
),
|
|
119
|
+
value: import_zod.z.string().optional(),
|
|
120
|
+
flags: import_zod.z.string().optional()
|
|
121
|
+
}).refine(
|
|
122
|
+
(c) => {
|
|
123
|
+
if (c.op === "matchesGlob" || c.op === "notMatchesGlob") return c.value !== void 0;
|
|
124
|
+
return true;
|
|
125
|
+
},
|
|
126
|
+
{ message: "matchesGlob and notMatchesGlob conditions require a value field" }
|
|
127
|
+
);
|
|
128
|
+
var SmartRuleSchema = import_zod.z.object({
|
|
129
|
+
name: import_zod.z.string().optional(),
|
|
130
|
+
tool: import_zod.z.string().min(1, "Smart rule tool must not be empty"),
|
|
131
|
+
conditions: import_zod.z.array(SmartConditionSchema).min(1, "Smart rule must have at least one condition"),
|
|
132
|
+
conditionMode: import_zod.z.enum(["all", "any"]).optional(),
|
|
133
|
+
verdict: import_zod.z.enum(["allow", "review", "block"], {
|
|
134
|
+
errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
|
|
135
|
+
}),
|
|
136
|
+
reason: import_zod.z.string().optional()
|
|
137
|
+
});
|
|
138
|
+
var ConfigFileSchema = import_zod.z.object({
|
|
139
|
+
version: import_zod.z.string().optional(),
|
|
140
|
+
settings: import_zod.z.object({
|
|
141
|
+
mode: import_zod.z.enum(["standard", "strict", "audit"]).optional(),
|
|
142
|
+
autoStartDaemon: import_zod.z.boolean().optional(),
|
|
143
|
+
enableUndo: import_zod.z.boolean().optional(),
|
|
144
|
+
enableHookLogDebug: import_zod.z.boolean().optional(),
|
|
145
|
+
approvalTimeoutMs: import_zod.z.number().nonnegative().optional(),
|
|
146
|
+
approvalTimeoutSeconds: import_zod.z.number().nonnegative().optional(),
|
|
147
|
+
flightRecorder: import_zod.z.boolean().optional(),
|
|
148
|
+
approvers: import_zod.z.object({
|
|
149
|
+
native: import_zod.z.boolean().optional(),
|
|
150
|
+
browser: import_zod.z.boolean().optional(),
|
|
151
|
+
cloud: import_zod.z.boolean().optional(),
|
|
152
|
+
terminal: import_zod.z.boolean().optional()
|
|
153
|
+
}).optional(),
|
|
154
|
+
environment: import_zod.z.string().optional(),
|
|
155
|
+
slackEnabled: import_zod.z.boolean().optional(),
|
|
156
|
+
enableTrustSessions: import_zod.z.boolean().optional(),
|
|
157
|
+
allowGlobalPause: import_zod.z.boolean().optional()
|
|
158
|
+
}).optional(),
|
|
159
|
+
policy: import_zod.z.object({
|
|
160
|
+
sandboxPaths: import_zod.z.array(import_zod.z.string()).optional(),
|
|
161
|
+
dangerousWords: import_zod.z.array(noNewlines).optional(),
|
|
162
|
+
ignoredTools: import_zod.z.array(import_zod.z.string()).optional(),
|
|
163
|
+
toolInspection: import_zod.z.record(import_zod.z.string()).optional(),
|
|
164
|
+
smartRules: import_zod.z.array(SmartRuleSchema).optional(),
|
|
165
|
+
snapshot: import_zod.z.object({
|
|
166
|
+
tools: import_zod.z.array(import_zod.z.string()).optional(),
|
|
167
|
+
onlyPaths: import_zod.z.array(import_zod.z.string()).optional(),
|
|
168
|
+
ignorePaths: import_zod.z.array(import_zod.z.string()).optional()
|
|
169
|
+
}).optional(),
|
|
170
|
+
dlp: import_zod.z.object({
|
|
171
|
+
enabled: import_zod.z.boolean().optional(),
|
|
172
|
+
scanIgnoredTools: import_zod.z.boolean().optional()
|
|
173
|
+
}).optional()
|
|
174
|
+
}).optional(),
|
|
175
|
+
environments: import_zod.z.record(import_zod.z.object({ requireApproval: import_zod.z.boolean().optional() })).optional()
|
|
176
|
+
}).strict({ message: "Config contains unknown top-level keys" });
|
|
177
|
+
function sanitizeConfig(raw) {
|
|
178
|
+
const result = ConfigFileSchema.safeParse(raw);
|
|
179
|
+
if (result.success) {
|
|
180
|
+
return { sanitized: result.data, error: null };
|
|
117
181
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const result = extractContext(String(obj.new_string), matchedWord);
|
|
127
|
-
contextSnippet = result.snippet;
|
|
128
|
-
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
129
|
-
} else if (matchedField && obj[matchedField] !== void 0) {
|
|
130
|
-
const result = extractContext(String(obj[matchedField]), matchedWord);
|
|
131
|
-
contextSnippet = result.snippet;
|
|
132
|
-
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
133
|
-
} else {
|
|
134
|
-
const foundKey = Object.keys(obj).find((k) => CODE_KEYS.includes(k.toLowerCase()));
|
|
135
|
-
if (foundKey) {
|
|
136
|
-
const val = obj[foundKey];
|
|
137
|
-
contextSnippet = smartTruncate(typeof val === "string" ? val : JSON.stringify(val), 500);
|
|
182
|
+
const invalidTopLevelKeys = new Set(
|
|
183
|
+
result.error.issues.filter((issue) => issue.path.length > 0).map((issue) => String(issue.path[0]))
|
|
184
|
+
);
|
|
185
|
+
const sanitized = {};
|
|
186
|
+
if (typeof raw === "object" && raw !== null) {
|
|
187
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
188
|
+
if (!invalidTopLevelKeys.has(key)) {
|
|
189
|
+
sanitized[key] = value;
|
|
138
190
|
}
|
|
139
191
|
}
|
|
140
|
-
} else if (typeof parsed === "string") {
|
|
141
|
-
contextSnippet = smartTruncate(parsed, 500);
|
|
142
192
|
}
|
|
193
|
+
const lines = result.error.issues.map((issue) => {
|
|
194
|
+
const path10 = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
195
|
+
return ` \u2022 ${path10}: ${issue.message}`;
|
|
196
|
+
});
|
|
143
197
|
return {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
...matchedWord && { matchedWord },
|
|
148
|
-
...matchedField && { matchedField },
|
|
149
|
-
...contextSnippet !== void 0 && { contextSnippet },
|
|
150
|
-
...contextLineIndex !== void 0 && { contextLineIndex },
|
|
151
|
-
...editFileName && { editFileName },
|
|
152
|
-
...editFilePath && { editFilePath },
|
|
153
|
-
...ruleName && { ruleName }
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// src/ui/native.ts
|
|
158
|
-
var isTestEnv = () => {
|
|
159
|
-
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";
|
|
160
|
-
};
|
|
161
|
-
function formatArgs(args, matchedField, matchedWord) {
|
|
162
|
-
if (args === null || args === void 0) return { message: "(none)", intent: "EXEC" };
|
|
163
|
-
let parsed = args;
|
|
164
|
-
if (typeof args === "string") {
|
|
165
|
-
const trimmed = args.trim();
|
|
166
|
-
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
167
|
-
try {
|
|
168
|
-
parsed = JSON.parse(trimmed);
|
|
169
|
-
} catch {
|
|
170
|
-
parsed = args;
|
|
171
|
-
}
|
|
172
|
-
} else {
|
|
173
|
-
return { message: smartTruncate(args, 600), intent: "EXEC" };
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
if (typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
177
|
-
const obj = parsed;
|
|
178
|
-
if (obj.old_string !== void 0 && obj.new_string !== void 0) {
|
|
179
|
-
const file = obj.file_path ? import_path2.default.basename(String(obj.file_path)) : "file";
|
|
180
|
-
const oldPreview = smartTruncate(String(obj.old_string), 120);
|
|
181
|
-
const newPreview = extractContext(String(obj.new_string), matchedWord).snippet;
|
|
182
|
-
return {
|
|
183
|
-
intent: "EDIT",
|
|
184
|
-
message: `\u{1F4DD} EDITING: ${file}
|
|
185
|
-
\u{1F4C2} PATH: ${obj.file_path}
|
|
186
|
-
|
|
187
|
-
--- REPLACING ---
|
|
188
|
-
${oldPreview}
|
|
189
|
-
|
|
190
|
-
+++ NEW CODE +++
|
|
191
|
-
${newPreview}`
|
|
192
|
-
};
|
|
193
|
-
}
|
|
194
|
-
if (matchedField && obj[matchedField] !== void 0) {
|
|
195
|
-
const otherKeys = Object.keys(obj).filter((k) => k !== matchedField);
|
|
196
|
-
const context = otherKeys.length > 0 ? `\u2699\uFE0F Context: ${otherKeys.map((k) => `${k}=${smartTruncate(typeof obj[k] === "object" ? JSON.stringify(obj[k]) : String(obj[k]), 30)}`).join(", ")}
|
|
197
|
-
|
|
198
|
-
` : "";
|
|
199
|
-
const content = extractContext(String(obj[matchedField]), matchedWord).snippet;
|
|
200
|
-
return {
|
|
201
|
-
intent: "EXEC",
|
|
202
|
-
message: `${context}\u{1F6D1} [${matchedField.toUpperCase()}]:
|
|
203
|
-
${content}`
|
|
204
|
-
};
|
|
205
|
-
}
|
|
206
|
-
const codeKeys = [
|
|
207
|
-
"command",
|
|
208
|
-
"cmd",
|
|
209
|
-
"shell_command",
|
|
210
|
-
"bash_command",
|
|
211
|
-
"script",
|
|
212
|
-
"code",
|
|
213
|
-
"input",
|
|
214
|
-
"sql",
|
|
215
|
-
"query",
|
|
216
|
-
"arguments",
|
|
217
|
-
"args",
|
|
218
|
-
"param",
|
|
219
|
-
"params",
|
|
220
|
-
"text"
|
|
221
|
-
];
|
|
222
|
-
const foundKey = Object.keys(obj).find((k) => codeKeys.includes(k.toLowerCase()));
|
|
223
|
-
if (foundKey) {
|
|
224
|
-
const val = obj[foundKey];
|
|
225
|
-
const str = typeof val === "string" ? val : JSON.stringify(val);
|
|
226
|
-
return {
|
|
227
|
-
intent: "EXEC",
|
|
228
|
-
message: `[${foundKey.toUpperCase()}]:
|
|
229
|
-
${smartTruncate(str, 500)}`
|
|
230
|
-
};
|
|
231
|
-
}
|
|
232
|
-
const msg = Object.entries(obj).slice(0, 5).map(
|
|
233
|
-
([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
|
|
234
|
-
).join("\n");
|
|
235
|
-
return { intent: "EXEC", message: msg };
|
|
236
|
-
}
|
|
237
|
-
return { intent: "EXEC", message: smartTruncate(JSON.stringify(parsed), 200) };
|
|
238
|
-
}
|
|
239
|
-
function sendDesktopNotification(title, body) {
|
|
240
|
-
if (isTestEnv()) return;
|
|
241
|
-
try {
|
|
242
|
-
if (process.platform === "darwin") {
|
|
243
|
-
const script = `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`;
|
|
244
|
-
(0, import_child_process.spawn)("osascript", ["-e", script], { detached: true, stdio: "ignore" }).unref();
|
|
245
|
-
} else if (process.platform === "linux") {
|
|
246
|
-
(0, import_child_process.spawn)("notify-send", [title, body, "--icon=dialog-warning"], {
|
|
247
|
-
detached: true,
|
|
248
|
-
stdio: "ignore"
|
|
249
|
-
}).unref();
|
|
250
|
-
}
|
|
251
|
-
} catch {
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
function escapePango(text) {
|
|
255
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
256
|
-
}
|
|
257
|
-
function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
|
|
258
|
-
const lines = [];
|
|
259
|
-
if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
|
|
260
|
-
lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
|
|
261
|
-
lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
|
|
262
|
-
lines.push("");
|
|
263
|
-
lines.push(formattedArgs);
|
|
264
|
-
if (!locked) {
|
|
265
|
-
lines.push("");
|
|
266
|
-
lines.push('\u21B5 Enter = Allow \u21B5 | \u238B Esc = Block \u238B | "Always Allow" = never ask again');
|
|
267
|
-
}
|
|
268
|
-
return lines.join("\n");
|
|
269
|
-
}
|
|
270
|
-
function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
|
|
271
|
-
const lines = [];
|
|
272
|
-
if (locked) {
|
|
273
|
-
lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
|
|
274
|
-
lines.push("");
|
|
275
|
-
}
|
|
276
|
-
lines.push(
|
|
277
|
-
`<b>\u{1F916} ${escapePango(agent || "AI Agent")}</b> | <b>\u{1F527} <tt>${escapePango(toolName)}</tt></b>`
|
|
278
|
-
);
|
|
279
|
-
lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
|
|
280
|
-
lines.push("");
|
|
281
|
-
lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
|
|
282
|
-
if (!locked) {
|
|
283
|
-
lines.push("");
|
|
284
|
-
lines.push(
|
|
285
|
-
'<small>\u21B5 Enter = <b>Allow \u21B5</b> | \u238B Esc = <b>Block \u238B</b> | "Always Allow" = never ask again</small>'
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
return lines.join("\n");
|
|
289
|
-
}
|
|
290
|
-
async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord) {
|
|
291
|
-
if (isTestEnv()) return "deny";
|
|
292
|
-
const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
|
|
293
|
-
const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
|
|
294
|
-
const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
|
|
295
|
-
const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
|
|
296
|
-
process.stderr.write(import_chalk.default.yellow(`
|
|
297
|
-
\u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
|
|
298
|
-
`));
|
|
299
|
-
return new Promise((resolve) => {
|
|
300
|
-
let childProcess = null;
|
|
301
|
-
const onAbort = () => {
|
|
302
|
-
if (childProcess && childProcess.pid) {
|
|
303
|
-
try {
|
|
304
|
-
process.kill(childProcess.pid, "SIGKILL");
|
|
305
|
-
} catch {
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
resolve("deny");
|
|
309
|
-
};
|
|
310
|
-
if (signal) {
|
|
311
|
-
if (signal.aborted) return resolve("deny");
|
|
312
|
-
signal.addEventListener("abort", onAbort);
|
|
313
|
-
}
|
|
314
|
-
try {
|
|
315
|
-
if (process.platform === "darwin") {
|
|
316
|
-
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"`;
|
|
317
|
-
const script = `on run argv
|
|
318
|
-
tell application "System Events"
|
|
319
|
-
activate
|
|
320
|
-
display dialog (item 1 of argv) with title (item 2 of argv) ${buttons}
|
|
321
|
-
end tell
|
|
322
|
-
end run`;
|
|
323
|
-
childProcess = (0, import_child_process.spawn)("osascript", ["-e", script, "--", message, title]);
|
|
324
|
-
} else if (process.platform === "linux") {
|
|
325
|
-
const pangoMessage = buildPangoMessage(
|
|
326
|
-
toolName,
|
|
327
|
-
formattedArgs,
|
|
328
|
-
agent,
|
|
329
|
-
explainableLabel,
|
|
330
|
-
locked
|
|
331
|
-
);
|
|
332
|
-
const argsList = [
|
|
333
|
-
locked ? "--info" : "--question",
|
|
334
|
-
"--modal",
|
|
335
|
-
"--width=480",
|
|
336
|
-
"--title",
|
|
337
|
-
title,
|
|
338
|
-
"--text",
|
|
339
|
-
pangoMessage,
|
|
340
|
-
"--ok-label",
|
|
341
|
-
locked ? "Waiting..." : "Allow \u21B5",
|
|
342
|
-
"--timeout",
|
|
343
|
-
"300"
|
|
344
|
-
];
|
|
345
|
-
if (!locked) {
|
|
346
|
-
argsList.push("--cancel-label", "Block \u238B");
|
|
347
|
-
argsList.push("--extra-button", "Always Allow");
|
|
348
|
-
}
|
|
349
|
-
childProcess = (0, import_child_process.spawn)("zenity", argsList);
|
|
350
|
-
} else if (process.platform === "win32") {
|
|
351
|
-
const b64Msg = Buffer.from(message).toString("base64");
|
|
352
|
-
const b64Title = Buffer.from(title).toString("base64");
|
|
353
|
-
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 }`;
|
|
354
|
-
childProcess = (0, import_child_process.spawn)("powershell", ["-Command", ps]);
|
|
355
|
-
}
|
|
356
|
-
let output = "";
|
|
357
|
-
childProcess?.stdout?.on("data", (d) => output += d.toString());
|
|
358
|
-
childProcess?.on("close", (code) => {
|
|
359
|
-
if (signal) signal.removeEventListener("abort", onAbort);
|
|
360
|
-
if (locked) return resolve("deny");
|
|
361
|
-
if (output.includes("Always Allow")) return resolve("always_allow");
|
|
362
|
-
if (code === 0) return resolve("allow");
|
|
363
|
-
resolve("deny");
|
|
364
|
-
});
|
|
365
|
-
} catch {
|
|
366
|
-
resolve("deny");
|
|
367
|
-
}
|
|
368
|
-
});
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
// src/config-schema.ts
|
|
372
|
-
var import_zod = require("zod");
|
|
373
|
-
var noNewlines = import_zod.z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
|
|
374
|
-
message: "Value must not contain literal newline characters (use \\n instead)"
|
|
375
|
-
});
|
|
376
|
-
var SmartConditionSchema = import_zod.z.object({
|
|
377
|
-
field: import_zod.z.string().min(1, "Condition field must not be empty"),
|
|
378
|
-
op: import_zod.z.enum(
|
|
379
|
-
[
|
|
380
|
-
"matches",
|
|
381
|
-
"notMatches",
|
|
382
|
-
"contains",
|
|
383
|
-
"notContains",
|
|
384
|
-
"exists",
|
|
385
|
-
"notExists",
|
|
386
|
-
"matchesGlob",
|
|
387
|
-
"notMatchesGlob"
|
|
388
|
-
],
|
|
389
|
-
{
|
|
390
|
-
errorMap: () => ({
|
|
391
|
-
message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists, matchesGlob, notMatchesGlob"
|
|
392
|
-
})
|
|
393
|
-
}
|
|
394
|
-
),
|
|
395
|
-
value: import_zod.z.string().optional(),
|
|
396
|
-
flags: import_zod.z.string().optional()
|
|
397
|
-
}).refine(
|
|
398
|
-
(c) => {
|
|
399
|
-
if (c.op === "matchesGlob" || c.op === "notMatchesGlob") return c.value !== void 0;
|
|
400
|
-
return true;
|
|
401
|
-
},
|
|
402
|
-
{ message: "matchesGlob and notMatchesGlob conditions require a value field" }
|
|
403
|
-
);
|
|
404
|
-
var SmartRuleSchema = import_zod.z.object({
|
|
405
|
-
name: import_zod.z.string().optional(),
|
|
406
|
-
tool: import_zod.z.string().min(1, "Smart rule tool must not be empty"),
|
|
407
|
-
conditions: import_zod.z.array(SmartConditionSchema).min(1, "Smart rule must have at least one condition"),
|
|
408
|
-
conditionMode: import_zod.z.enum(["all", "any"]).optional(),
|
|
409
|
-
verdict: import_zod.z.enum(["allow", "review", "block"], {
|
|
410
|
-
errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
|
|
411
|
-
}),
|
|
412
|
-
reason: import_zod.z.string().optional()
|
|
413
|
-
});
|
|
414
|
-
var ConfigFileSchema = import_zod.z.object({
|
|
415
|
-
version: import_zod.z.string().optional(),
|
|
416
|
-
settings: import_zod.z.object({
|
|
417
|
-
mode: import_zod.z.enum(["standard", "strict", "audit"]).optional(),
|
|
418
|
-
autoStartDaemon: import_zod.z.boolean().optional(),
|
|
419
|
-
enableUndo: import_zod.z.boolean().optional(),
|
|
420
|
-
enableHookLogDebug: import_zod.z.boolean().optional(),
|
|
421
|
-
approvalTimeoutMs: import_zod.z.number().nonnegative().optional(),
|
|
422
|
-
flightRecorder: import_zod.z.boolean().optional(),
|
|
423
|
-
approvers: import_zod.z.object({
|
|
424
|
-
native: import_zod.z.boolean().optional(),
|
|
425
|
-
browser: import_zod.z.boolean().optional(),
|
|
426
|
-
cloud: import_zod.z.boolean().optional(),
|
|
427
|
-
terminal: import_zod.z.boolean().optional()
|
|
428
|
-
}).optional(),
|
|
429
|
-
environment: import_zod.z.string().optional(),
|
|
430
|
-
slackEnabled: import_zod.z.boolean().optional(),
|
|
431
|
-
enableTrustSessions: import_zod.z.boolean().optional(),
|
|
432
|
-
allowGlobalPause: import_zod.z.boolean().optional()
|
|
433
|
-
}).optional(),
|
|
434
|
-
policy: import_zod.z.object({
|
|
435
|
-
sandboxPaths: import_zod.z.array(import_zod.z.string()).optional(),
|
|
436
|
-
dangerousWords: import_zod.z.array(noNewlines).optional(),
|
|
437
|
-
ignoredTools: import_zod.z.array(import_zod.z.string()).optional(),
|
|
438
|
-
toolInspection: import_zod.z.record(import_zod.z.string()).optional(),
|
|
439
|
-
smartRules: import_zod.z.array(SmartRuleSchema).optional(),
|
|
440
|
-
snapshot: import_zod.z.object({
|
|
441
|
-
tools: import_zod.z.array(import_zod.z.string()).optional(),
|
|
442
|
-
onlyPaths: import_zod.z.array(import_zod.z.string()).optional(),
|
|
443
|
-
ignorePaths: import_zod.z.array(import_zod.z.string()).optional()
|
|
444
|
-
}).optional(),
|
|
445
|
-
dlp: import_zod.z.object({
|
|
446
|
-
enabled: import_zod.z.boolean().optional(),
|
|
447
|
-
scanIgnoredTools: import_zod.z.boolean().optional()
|
|
448
|
-
}).optional()
|
|
449
|
-
}).optional(),
|
|
450
|
-
environments: import_zod.z.record(import_zod.z.object({ requireApproval: import_zod.z.boolean().optional() })).optional()
|
|
451
|
-
}).strict({ message: "Config contains unknown top-level keys" });
|
|
452
|
-
function sanitizeConfig(raw) {
|
|
453
|
-
const result = ConfigFileSchema.safeParse(raw);
|
|
454
|
-
if (result.success) {
|
|
455
|
-
return { sanitized: result.data, error: null };
|
|
456
|
-
}
|
|
457
|
-
const invalidTopLevelKeys = new Set(
|
|
458
|
-
result.error.issues.filter((issue) => issue.path.length > 0).map((issue) => String(issue.path[0]))
|
|
459
|
-
);
|
|
460
|
-
const sanitized = {};
|
|
461
|
-
if (typeof raw === "object" && raw !== null) {
|
|
462
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
463
|
-
if (!invalidTopLevelKeys.has(key)) {
|
|
464
|
-
sanitized[key] = value;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
const lines = result.error.issues.map((issue) => {
|
|
469
|
-
const path6 = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
470
|
-
return ` \u2022 ${path6}: ${issue.message}`;
|
|
471
|
-
});
|
|
472
|
-
return {
|
|
473
|
-
sanitized,
|
|
474
|
-
error: `Invalid config:
|
|
475
|
-
${lines.join("\n")}`
|
|
198
|
+
sanitized,
|
|
199
|
+
error: `Invalid config:
|
|
200
|
+
${lines.join("\n")}`
|
|
476
201
|
};
|
|
477
202
|
}
|
|
478
203
|
|
|
479
204
|
// src/shields.ts
|
|
480
|
-
var
|
|
481
|
-
var
|
|
482
|
-
var
|
|
205
|
+
var import_fs2 = __toESM(require("fs"));
|
|
206
|
+
var import_path2 = __toESM(require("path"));
|
|
207
|
+
var import_os2 = __toESM(require("os"));
|
|
483
208
|
var SHIELDS = {
|
|
484
209
|
postgres: {
|
|
485
210
|
name: "postgres",
|
|
@@ -558,157 +283,650 @@ var SHIELDS = {
|
|
|
558
283
|
aliases: ["amazon"],
|
|
559
284
|
smartRules: [
|
|
560
285
|
{
|
|
561
|
-
name: "shield:aws:block-delete-s3-bucket",
|
|
562
|
-
tool: "*",
|
|
286
|
+
name: "shield:aws:block-delete-s3-bucket",
|
|
287
|
+
tool: "*",
|
|
288
|
+
conditions: [
|
|
289
|
+
{
|
|
290
|
+
field: "command",
|
|
291
|
+
op: "matches",
|
|
292
|
+
value: "aws\\s+s3.*rb\\s|aws\\s+s3api\\s+delete-bucket",
|
|
293
|
+
flags: "i"
|
|
294
|
+
}
|
|
295
|
+
],
|
|
296
|
+
verdict: "block",
|
|
297
|
+
reason: "S3 bucket deletion is irreversible \u2014 blocked by AWS shield"
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
name: "shield:aws:review-iam-changes",
|
|
301
|
+
tool: "*",
|
|
302
|
+
conditions: [
|
|
303
|
+
{
|
|
304
|
+
field: "command",
|
|
305
|
+
op: "matches",
|
|
306
|
+
value: "aws\\s+iam\\s+(create|delete|attach|detach|put|remove)",
|
|
307
|
+
flags: "i"
|
|
308
|
+
}
|
|
309
|
+
],
|
|
310
|
+
verdict: "review",
|
|
311
|
+
reason: "IAM changes require human approval (AWS shield)"
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
name: "shield:aws:block-ec2-terminate",
|
|
315
|
+
tool: "*",
|
|
316
|
+
conditions: [
|
|
317
|
+
{
|
|
318
|
+
field: "command",
|
|
319
|
+
op: "matches",
|
|
320
|
+
value: "aws\\s+ec2\\s+terminate-instances",
|
|
321
|
+
flags: "i"
|
|
322
|
+
}
|
|
323
|
+
],
|
|
324
|
+
verdict: "block",
|
|
325
|
+
reason: "EC2 instance termination is irreversible \u2014 blocked by AWS shield"
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: "shield:aws:review-rds-delete",
|
|
329
|
+
tool: "*",
|
|
330
|
+
conditions: [
|
|
331
|
+
{ field: "command", op: "matches", value: "aws\\s+rds\\s+delete-", flags: "i" }
|
|
332
|
+
],
|
|
333
|
+
verdict: "review",
|
|
334
|
+
reason: "RDS deletion requires human approval (AWS shield)"
|
|
335
|
+
}
|
|
336
|
+
],
|
|
337
|
+
dangerousWords: []
|
|
338
|
+
},
|
|
339
|
+
filesystem: {
|
|
340
|
+
name: "filesystem",
|
|
341
|
+
description: "Protects the local filesystem from dangerous AI operations",
|
|
342
|
+
aliases: ["fs"],
|
|
343
|
+
smartRules: [
|
|
344
|
+
{
|
|
345
|
+
name: "shield:filesystem:review-chmod-777",
|
|
346
|
+
tool: "bash",
|
|
347
|
+
conditions: [
|
|
348
|
+
{ field: "command", op: "matches", value: "chmod\\s+(777|a\\+rwx)", flags: "i" }
|
|
349
|
+
],
|
|
350
|
+
verdict: "review",
|
|
351
|
+
reason: "chmod 777 requires human approval (filesystem shield)"
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
name: "shield:filesystem:review-write-etc",
|
|
355
|
+
tool: "bash",
|
|
356
|
+
conditions: [
|
|
357
|
+
{
|
|
358
|
+
field: "command",
|
|
359
|
+
// Narrow to write-indicative operations to avoid approval fatigue on reads.
|
|
360
|
+
// Matches: tee /etc/*, cp .../etc/*, mv .../etc/*, > /etc/*, install .../etc/*
|
|
361
|
+
op: "matches",
|
|
362
|
+
value: "(tee|\\bcp\\b|\\bmv\\b|install|>+)\\s+.*\\/etc\\/"
|
|
363
|
+
}
|
|
364
|
+
],
|
|
365
|
+
verdict: "review",
|
|
366
|
+
reason: "Writing to /etc requires human approval (filesystem shield)"
|
|
367
|
+
}
|
|
368
|
+
],
|
|
369
|
+
// dd removed: too common as a legitimate tool (disk imaging, file ops).
|
|
370
|
+
// mkfs removed: already in the built-in DANGEROUS_WORDS baseline.
|
|
371
|
+
// wipefs retained: rarely legitimate in an agent context and not in built-ins.
|
|
372
|
+
dangerousWords: ["wipefs"]
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
function resolveShieldName(input) {
|
|
376
|
+
const lower = input.toLowerCase();
|
|
377
|
+
if (SHIELDS[lower]) return lower;
|
|
378
|
+
for (const [name, def] of Object.entries(SHIELDS)) {
|
|
379
|
+
if (def.aliases.includes(lower)) return name;
|
|
380
|
+
}
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
function getShield(name) {
|
|
384
|
+
const resolved = resolveShieldName(name);
|
|
385
|
+
return resolved ? SHIELDS[resolved] : null;
|
|
386
|
+
}
|
|
387
|
+
var SHIELDS_STATE_FILE = import_path2.default.join(import_os2.default.homedir(), ".node9", "shields.json");
|
|
388
|
+
function isShieldVerdict(v) {
|
|
389
|
+
return v === "allow" || v === "review" || v === "block";
|
|
390
|
+
}
|
|
391
|
+
function validateOverrides(raw) {
|
|
392
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
393
|
+
const result = {};
|
|
394
|
+
for (const [shieldName, rules] of Object.entries(raw)) {
|
|
395
|
+
if (!rules || typeof rules !== "object" || Array.isArray(rules)) continue;
|
|
396
|
+
const validRules = {};
|
|
397
|
+
for (const [ruleName, verdict] of Object.entries(rules)) {
|
|
398
|
+
if (isShieldVerdict(verdict)) {
|
|
399
|
+
validRules[ruleName] = verdict;
|
|
400
|
+
} else {
|
|
401
|
+
process.stderr.write(
|
|
402
|
+
`[node9] Warning: shields.json contains invalid verdict "${String(verdict)}" for ${shieldName}/${ruleName} \u2014 entry ignored. File may be corrupted or tampered with.
|
|
403
|
+
`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (Object.keys(validRules).length > 0) result[shieldName] = validRules;
|
|
408
|
+
}
|
|
409
|
+
return result;
|
|
410
|
+
}
|
|
411
|
+
function readShieldsFile() {
|
|
412
|
+
try {
|
|
413
|
+
const raw = import_fs2.default.readFileSync(SHIELDS_STATE_FILE, "utf-8");
|
|
414
|
+
if (!raw.trim()) return { active: [] };
|
|
415
|
+
const parsed = JSON.parse(raw);
|
|
416
|
+
const active = Array.isArray(parsed.active) ? parsed.active.filter(
|
|
417
|
+
(e) => typeof e === "string" && e.length > 0 && e in SHIELDS
|
|
418
|
+
) : [];
|
|
419
|
+
return { active, overrides: validateOverrides(parsed.overrides) };
|
|
420
|
+
} catch (err) {
|
|
421
|
+
if (err.code !== "ENOENT") {
|
|
422
|
+
process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
|
|
423
|
+
`);
|
|
424
|
+
}
|
|
425
|
+
return { active: [] };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
function readActiveShields() {
|
|
429
|
+
return readShieldsFile().active;
|
|
430
|
+
}
|
|
431
|
+
function readShieldOverrides() {
|
|
432
|
+
return readShieldsFile().overrides ?? {};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// src/config/index.ts
|
|
436
|
+
var DANGEROUS_WORDS = [
|
|
437
|
+
"mkfs",
|
|
438
|
+
// formats/wipes a filesystem partition
|
|
439
|
+
"shred"
|
|
440
|
+
// permanently overwrites file contents (unrecoverable)
|
|
441
|
+
];
|
|
442
|
+
var DEFAULT_CONFIG = {
|
|
443
|
+
version: "1.0",
|
|
444
|
+
settings: {
|
|
445
|
+
mode: "audit",
|
|
446
|
+
autoStartDaemon: true,
|
|
447
|
+
enableUndo: true,
|
|
448
|
+
// 🔥 ALWAYS TRUE BY DEFAULT for the safety net
|
|
449
|
+
enableHookLogDebug: true,
|
|
450
|
+
approvalTimeoutMs: 12e4,
|
|
451
|
+
// 120-second auto-deny timeout
|
|
452
|
+
flightRecorder: true,
|
|
453
|
+
approvers: { native: true, browser: true, cloud: false, terminal: true }
|
|
454
|
+
},
|
|
455
|
+
policy: {
|
|
456
|
+
sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
|
|
457
|
+
dangerousWords: DANGEROUS_WORDS,
|
|
458
|
+
ignoredTools: [
|
|
459
|
+
"list_*",
|
|
460
|
+
"get_*",
|
|
461
|
+
"read_*",
|
|
462
|
+
"describe_*",
|
|
463
|
+
"read",
|
|
464
|
+
"glob",
|
|
465
|
+
"grep",
|
|
466
|
+
"ls",
|
|
467
|
+
"notebookread",
|
|
468
|
+
"notebookedit",
|
|
469
|
+
"webfetch",
|
|
470
|
+
"websearch",
|
|
471
|
+
"exitplanmode",
|
|
472
|
+
"askuserquestion",
|
|
473
|
+
"agent",
|
|
474
|
+
"task*",
|
|
475
|
+
"toolsearch",
|
|
476
|
+
"mcp__ide__*",
|
|
477
|
+
"getDiagnostics"
|
|
478
|
+
],
|
|
479
|
+
toolInspection: {
|
|
480
|
+
bash: "command",
|
|
481
|
+
shell: "command",
|
|
482
|
+
run_shell_command: "command",
|
|
483
|
+
"terminal.execute": "command",
|
|
484
|
+
"postgres:query": "sql"
|
|
485
|
+
},
|
|
486
|
+
snapshot: {
|
|
487
|
+
tools: [
|
|
488
|
+
"str_replace_based_edit_tool",
|
|
489
|
+
"write_file",
|
|
490
|
+
"edit_file",
|
|
491
|
+
"create_file",
|
|
492
|
+
"edit",
|
|
493
|
+
"replace"
|
|
494
|
+
],
|
|
495
|
+
onlyPaths: [],
|
|
496
|
+
ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
|
|
497
|
+
},
|
|
498
|
+
smartRules: [
|
|
499
|
+
// ── rm safety (critical — always evaluated first) ──────────────────────
|
|
500
|
+
{
|
|
501
|
+
name: "block-rm-rf-home",
|
|
502
|
+
tool: "bash",
|
|
503
|
+
conditionMode: "all",
|
|
504
|
+
conditions: [
|
|
505
|
+
{
|
|
506
|
+
field: "command",
|
|
507
|
+
op: "matches",
|
|
508
|
+
value: "rm\\b.*(-[rRfF]*[rR][rRfF]*|--recursive)"
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
field: "command",
|
|
512
|
+
op: "matches",
|
|
513
|
+
value: "(~|\\/root(\\/|$)|\\$HOME|\\/home\\/)"
|
|
514
|
+
}
|
|
515
|
+
],
|
|
516
|
+
verdict: "block",
|
|
517
|
+
reason: "Recursive delete of home directory is irreversible"
|
|
518
|
+
},
|
|
519
|
+
// ── SQL safety ────────────────────────────────────────────────────────
|
|
520
|
+
{
|
|
521
|
+
name: "no-delete-without-where",
|
|
522
|
+
tool: "*",
|
|
523
|
+
conditions: [
|
|
524
|
+
{ field: "sql", op: "matches", value: "^(DELETE|UPDATE)\\s", flags: "i" },
|
|
525
|
+
{ field: "sql", op: "notMatches", value: "\\bWHERE\\b", flags: "i" }
|
|
526
|
+
],
|
|
527
|
+
conditionMode: "all",
|
|
528
|
+
verdict: "review",
|
|
529
|
+
reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
name: "review-drop-truncate-shell",
|
|
533
|
+
tool: "bash",
|
|
563
534
|
conditions: [
|
|
564
535
|
{
|
|
565
536
|
field: "command",
|
|
566
537
|
op: "matches",
|
|
567
|
-
value: "
|
|
538
|
+
value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
|
|
568
539
|
flags: "i"
|
|
569
540
|
}
|
|
570
541
|
],
|
|
571
|
-
|
|
572
|
-
|
|
542
|
+
conditionMode: "all",
|
|
543
|
+
verdict: "review",
|
|
544
|
+
reason: "SQL DDL destructive statement inside a shell command"
|
|
573
545
|
},
|
|
546
|
+
// ── Git safety ────────────────────────────────────────────────────────
|
|
574
547
|
{
|
|
575
|
-
name: "
|
|
576
|
-
tool: "
|
|
548
|
+
name: "block-force-push",
|
|
549
|
+
tool: "bash",
|
|
577
550
|
conditions: [
|
|
578
551
|
{
|
|
579
552
|
field: "command",
|
|
580
553
|
op: "matches",
|
|
581
|
-
value: "
|
|
554
|
+
value: "^\\s*git\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
|
|
582
555
|
flags: "i"
|
|
583
556
|
}
|
|
584
557
|
],
|
|
585
|
-
|
|
586
|
-
|
|
558
|
+
conditionMode: "all",
|
|
559
|
+
verdict: "block",
|
|
560
|
+
reason: "Force push overwrites remote history and cannot be undone"
|
|
587
561
|
},
|
|
588
562
|
{
|
|
589
|
-
name: "
|
|
590
|
-
tool: "
|
|
563
|
+
name: "review-git-push",
|
|
564
|
+
tool: "bash",
|
|
591
565
|
conditions: [
|
|
592
566
|
{
|
|
593
567
|
field: "command",
|
|
594
568
|
op: "matches",
|
|
595
|
-
value: "
|
|
569
|
+
value: "^\\s*git\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
|
|
596
570
|
flags: "i"
|
|
597
571
|
}
|
|
598
572
|
],
|
|
599
|
-
|
|
600
|
-
|
|
573
|
+
conditionMode: "all",
|
|
574
|
+
verdict: "review",
|
|
575
|
+
reason: "git push sends changes to a shared remote"
|
|
601
576
|
},
|
|
602
577
|
{
|
|
603
|
-
name: "
|
|
604
|
-
tool: "
|
|
578
|
+
name: "review-git-destructive",
|
|
579
|
+
tool: "bash",
|
|
605
580
|
conditions: [
|
|
606
|
-
{
|
|
581
|
+
{
|
|
582
|
+
field: "command",
|
|
583
|
+
op: "matches",
|
|
584
|
+
value: "^\\s*git\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
|
|
585
|
+
flags: "i"
|
|
586
|
+
}
|
|
607
587
|
],
|
|
588
|
+
conditionMode: "all",
|
|
608
589
|
verdict: "review",
|
|
609
|
-
reason: "
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
dangerousWords: []
|
|
613
|
-
},
|
|
614
|
-
filesystem: {
|
|
615
|
-
name: "filesystem",
|
|
616
|
-
description: "Protects the local filesystem from dangerous AI operations",
|
|
617
|
-
aliases: ["fs"],
|
|
618
|
-
smartRules: [
|
|
590
|
+
reason: "Destructive git operation \u2014 discards history or working-tree changes"
|
|
591
|
+
},
|
|
592
|
+
// ── Shell safety ──────────────────────────────────────────────────────
|
|
619
593
|
{
|
|
620
|
-
name: "
|
|
594
|
+
name: "review-sudo",
|
|
621
595
|
tool: "bash",
|
|
622
|
-
conditions: [
|
|
623
|
-
|
|
624
|
-
],
|
|
596
|
+
conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
|
|
597
|
+
conditionMode: "all",
|
|
625
598
|
verdict: "review",
|
|
626
|
-
reason: "
|
|
599
|
+
reason: "Command requires elevated privileges"
|
|
627
600
|
},
|
|
628
601
|
{
|
|
629
|
-
name: "
|
|
602
|
+
name: "review-curl-pipe-shell",
|
|
630
603
|
tool: "bash",
|
|
631
604
|
conditions: [
|
|
632
605
|
{
|
|
633
606
|
field: "command",
|
|
634
|
-
// Narrow to write-indicative operations to avoid approval fatigue on reads.
|
|
635
|
-
// Matches: tee /etc/*, cp .../etc/*, mv .../etc/*, > /etc/*, install .../etc/*
|
|
636
607
|
op: "matches",
|
|
637
|
-
value: "(
|
|
608
|
+
value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
|
|
609
|
+
flags: "i"
|
|
638
610
|
}
|
|
639
611
|
],
|
|
640
|
-
|
|
641
|
-
|
|
612
|
+
conditionMode: "all",
|
|
613
|
+
verdict: "block",
|
|
614
|
+
reason: "Piping remote script into a shell is a supply-chain attack vector"
|
|
642
615
|
}
|
|
643
616
|
],
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
dangerousWords: ["wipefs"]
|
|
648
|
-
}
|
|
617
|
+
dlp: { enabled: true, scanIgnoredTools: true }
|
|
618
|
+
},
|
|
619
|
+
environments: {}
|
|
649
620
|
};
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
621
|
+
var ADVISORY_SMART_RULES = [
|
|
622
|
+
// ── rm safety ─────────────────────────────────────────────────────────────
|
|
623
|
+
// tool: '*' so they cover bash, shell, run_shell_command, and Gemini's Shell.
|
|
624
|
+
// Pattern '(^|&&|\|\||;)\s*rm\b' matches rm as a shell command (including in
|
|
625
|
+
// chained commands like 'cat foo && rm bar') but avoids false-positives on 'docker rm'.
|
|
626
|
+
{
|
|
627
|
+
name: "allow-rm-safe-paths",
|
|
628
|
+
tool: "*",
|
|
629
|
+
conditionMode: "all",
|
|
630
|
+
conditions: [
|
|
631
|
+
{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" },
|
|
632
|
+
{
|
|
633
|
+
field: "command",
|
|
634
|
+
op: "matches",
|
|
635
|
+
// Matches known-safe build artifact paths in the command.
|
|
636
|
+
value: "(node_modules|\\bdist\\b|\\.next|\\bcoverage\\b|\\.cache|\\btmp\\b|\\btemp\\b|\\.DS_Store)(\\/|\\s|$)"
|
|
637
|
+
}
|
|
638
|
+
],
|
|
639
|
+
verdict: "allow",
|
|
640
|
+
reason: "Deleting a known-safe build artifact path"
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
name: "review-rm",
|
|
644
|
+
tool: "*",
|
|
645
|
+
conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
|
|
646
|
+
verdict: "review",
|
|
647
|
+
reason: "rm can permanently delete files \u2014 confirm the target path"
|
|
648
|
+
},
|
|
649
|
+
// ── SQL safety (Safe by Default) ──────────────────────────────────────────
|
|
650
|
+
// These rules fire when an AI calls a database tool directly (e.g. MCP postgres,
|
|
651
|
+
// mcp__postgres__query) with a destructive SQL statement in the 'sql' field.
|
|
652
|
+
// The postgres shield upgrades these from 'review' → 'block' for stricter teams;
|
|
653
|
+
// without a shield, users still get a human-approval gate on every destructive op.
|
|
654
|
+
{
|
|
655
|
+
name: "review-drop-table-sql",
|
|
656
|
+
tool: "*",
|
|
657
|
+
conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
|
|
658
|
+
verdict: "review",
|
|
659
|
+
reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead"
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: "review-truncate-sql",
|
|
663
|
+
tool: "*",
|
|
664
|
+
conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
|
|
665
|
+
verdict: "review",
|
|
666
|
+
reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead"
|
|
667
|
+
},
|
|
668
|
+
{
|
|
669
|
+
name: "review-drop-column-sql",
|
|
670
|
+
tool: "*",
|
|
671
|
+
conditions: [
|
|
672
|
+
{ field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
|
|
673
|
+
],
|
|
674
|
+
verdict: "review",
|
|
675
|
+
reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead"
|
|
676
|
+
}
|
|
677
|
+
];
|
|
678
|
+
var cachedConfig = null;
|
|
679
|
+
function getCredentials() {
|
|
680
|
+
const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
|
|
681
|
+
if (process.env.NODE9_API_KEY) {
|
|
682
|
+
return {
|
|
683
|
+
apiKey: process.env.NODE9_API_KEY,
|
|
684
|
+
apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
const credPath = import_path3.default.join(import_os3.default.homedir(), ".node9", "credentials.json");
|
|
689
|
+
if (import_fs3.default.existsSync(credPath)) {
|
|
690
|
+
const creds = JSON.parse(import_fs3.default.readFileSync(credPath, "utf-8"));
|
|
691
|
+
const profileName = process.env.NODE9_PROFILE || "default";
|
|
692
|
+
const profile = creds[profileName];
|
|
693
|
+
if (profile?.apiKey) {
|
|
694
|
+
return {
|
|
695
|
+
apiKey: profile.apiKey,
|
|
696
|
+
apiUrl: profile.apiUrl || DEFAULT_API_URL
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
if (creds.apiKey) {
|
|
700
|
+
return {
|
|
701
|
+
apiKey: creds.apiKey,
|
|
702
|
+
apiUrl: creds.apiUrl || DEFAULT_API_URL
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
} catch {
|
|
655
707
|
}
|
|
656
708
|
return null;
|
|
657
709
|
}
|
|
658
|
-
function
|
|
659
|
-
const
|
|
660
|
-
return
|
|
661
|
-
}
|
|
662
|
-
var SHIELDS_STATE_FILE = import_path3.default.join(import_os.default.homedir(), ".node9", "shields.json");
|
|
663
|
-
function isShieldVerdict(v) {
|
|
664
|
-
return v === "allow" || v === "review" || v === "block";
|
|
710
|
+
function getActiveEnvironment(config) {
|
|
711
|
+
const env = config.settings.environment || process.env.NODE_ENV || "development";
|
|
712
|
+
return config.environments[env] ?? null;
|
|
665
713
|
}
|
|
666
|
-
function
|
|
667
|
-
if (!
|
|
668
|
-
const
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
714
|
+
function getConfig(cwd) {
|
|
715
|
+
if (!cwd && cachedConfig) return cachedConfig;
|
|
716
|
+
const globalPath = import_path3.default.join(import_os3.default.homedir(), ".node9", "config.json");
|
|
717
|
+
const projectPath = import_path3.default.join(cwd ?? process.cwd(), "node9.config.json");
|
|
718
|
+
const globalConfig = tryLoadConfig(globalPath);
|
|
719
|
+
const projectConfig = tryLoadConfig(projectPath);
|
|
720
|
+
const mergedSettings = {
|
|
721
|
+
...DEFAULT_CONFIG.settings,
|
|
722
|
+
approvers: { ...DEFAULT_CONFIG.settings.approvers }
|
|
723
|
+
};
|
|
724
|
+
const mergedPolicy = {
|
|
725
|
+
sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
|
|
726
|
+
dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
|
|
727
|
+
ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
|
|
728
|
+
toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
|
|
729
|
+
smartRules: [...DEFAULT_CONFIG.policy.smartRules],
|
|
730
|
+
snapshot: {
|
|
731
|
+
tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
|
|
732
|
+
onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
|
|
733
|
+
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
734
|
+
},
|
|
735
|
+
dlp: { ...DEFAULT_CONFIG.policy.dlp }
|
|
736
|
+
};
|
|
737
|
+
const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
|
|
738
|
+
const applyLayer = (source) => {
|
|
739
|
+
if (!source) return;
|
|
740
|
+
const s = source.settings || {};
|
|
741
|
+
const p = source.policy || {};
|
|
742
|
+
if (s.mode !== void 0) mergedSettings.mode = s.mode;
|
|
743
|
+
if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
|
|
744
|
+
if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
|
|
745
|
+
if (s.enableHookLogDebug !== void 0)
|
|
746
|
+
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
747
|
+
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
748
|
+
if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
|
|
749
|
+
if (s.approvalTimeoutSeconds !== void 0 && s.approvalTimeoutMs === void 0)
|
|
750
|
+
mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
|
|
751
|
+
if (s.environment !== void 0) mergedSettings.environment = s.environment;
|
|
752
|
+
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
753
|
+
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
754
|
+
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
755
|
+
if (p.toolInspection)
|
|
756
|
+
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
757
|
+
if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
|
|
758
|
+
if (p.snapshot) {
|
|
759
|
+
const s2 = p.snapshot;
|
|
760
|
+
if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
|
|
761
|
+
if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
|
|
762
|
+
if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
|
|
763
|
+
}
|
|
764
|
+
if (p.dlp) {
|
|
765
|
+
const d = p.dlp;
|
|
766
|
+
if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
|
|
767
|
+
if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
|
|
768
|
+
}
|
|
769
|
+
const envs = source.environments || {};
|
|
770
|
+
for (const [envName, envConfig] of Object.entries(envs)) {
|
|
771
|
+
if (envConfig && typeof envConfig === "object") {
|
|
772
|
+
const ec = envConfig;
|
|
773
|
+
mergedEnvironments[envName] = {
|
|
774
|
+
...mergedEnvironments[envName],
|
|
775
|
+
// Validate field types before merging — do not blindly spread user input
|
|
776
|
+
...typeof ec.requireApproval === "boolean" ? { requireApproval: ec.requireApproval } : {}
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
applyLayer(globalConfig);
|
|
782
|
+
applyLayer(projectConfig);
|
|
783
|
+
const shieldOverrides = readShieldOverrides();
|
|
784
|
+
for (const shieldName of readActiveShields()) {
|
|
785
|
+
const shield = getShield(shieldName);
|
|
786
|
+
if (!shield) continue;
|
|
787
|
+
const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
788
|
+
const ruleOverrides = shieldOverrides[shieldName] ?? {};
|
|
789
|
+
for (const rule of shield.smartRules) {
|
|
790
|
+
if (!existingRuleNames.has(rule.name)) {
|
|
791
|
+
const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
|
|
792
|
+
mergedPolicy.smartRules.push(
|
|
793
|
+
overrideVerdict !== void 0 ? { ...rule, verdict: overrideVerdict } : rule
|
|
679
794
|
);
|
|
680
795
|
}
|
|
681
796
|
}
|
|
682
|
-
|
|
797
|
+
const existingWords = new Set(mergedPolicy.dangerousWords);
|
|
798
|
+
for (const word of shield.dangerousWords) {
|
|
799
|
+
if (!existingWords.has(word)) mergedPolicy.dangerousWords.push(word);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
const existingAdvisoryNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
803
|
+
for (const rule of ADVISORY_SMART_RULES) {
|
|
804
|
+
if (!existingAdvisoryNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
|
|
683
805
|
}
|
|
806
|
+
if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
|
|
807
|
+
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
808
|
+
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
809
|
+
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
810
|
+
mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
|
|
811
|
+
mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
|
|
812
|
+
mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
|
|
813
|
+
const result = {
|
|
814
|
+
settings: mergedSettings,
|
|
815
|
+
policy: mergedPolicy,
|
|
816
|
+
environments: mergedEnvironments
|
|
817
|
+
};
|
|
818
|
+
if (!cwd) cachedConfig = result;
|
|
684
819
|
return result;
|
|
685
820
|
}
|
|
686
|
-
function
|
|
821
|
+
function tryLoadConfig(filePath) {
|
|
822
|
+
if (!import_fs3.default.existsSync(filePath)) return null;
|
|
823
|
+
let raw;
|
|
687
824
|
try {
|
|
688
|
-
|
|
689
|
-
if (!raw.trim()) return { active: [] };
|
|
690
|
-
const parsed = JSON.parse(raw);
|
|
691
|
-
const active = Array.isArray(parsed.active) ? parsed.active.filter(
|
|
692
|
-
(e) => typeof e === "string" && e.length > 0 && e in SHIELDS
|
|
693
|
-
) : [];
|
|
694
|
-
return { active, overrides: validateOverrides(parsed.overrides) };
|
|
825
|
+
raw = JSON.parse(import_fs3.default.readFileSync(filePath, "utf-8"));
|
|
695
826
|
} catch (err) {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
`
|
|
827
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
828
|
+
process.stderr.write(
|
|
829
|
+
`
|
|
830
|
+
\u26A0\uFE0F Node9: Failed to parse ${filePath}
|
|
831
|
+
${msg}
|
|
832
|
+
\u2192 Using default config
|
|
833
|
+
|
|
834
|
+
`
|
|
835
|
+
);
|
|
836
|
+
return null;
|
|
837
|
+
}
|
|
838
|
+
const SUPPORTED_VERSION = "1.0";
|
|
839
|
+
const SUPPORTED_MAJOR = SUPPORTED_VERSION.split(".")[0];
|
|
840
|
+
const fileVersion = raw?.version;
|
|
841
|
+
if (fileVersion !== void 0) {
|
|
842
|
+
const vStr = String(fileVersion);
|
|
843
|
+
const fileMajor = vStr.split(".")[0];
|
|
844
|
+
if (fileMajor !== SUPPORTED_MAJOR) {
|
|
845
|
+
process.stderr.write(
|
|
846
|
+
`
|
|
847
|
+
\u274C Node9: Config at ${filePath} has version "${vStr}" \u2014 major version is incompatible with this release (expected "${SUPPORTED_VERSION}"). Config will not be loaded.
|
|
848
|
+
|
|
849
|
+
`
|
|
850
|
+
);
|
|
851
|
+
return null;
|
|
852
|
+
} else if (vStr !== SUPPORTED_VERSION) {
|
|
853
|
+
process.stderr.write(
|
|
854
|
+
`
|
|
855
|
+
\u26A0\uFE0F Node9: Config at ${filePath} declares version "${vStr}" \u2014 expected "${SUPPORTED_VERSION}". Continuing with best-effort parsing.
|
|
856
|
+
|
|
857
|
+
`
|
|
858
|
+
);
|
|
699
859
|
}
|
|
700
|
-
return { active: [] };
|
|
701
860
|
}
|
|
861
|
+
const { sanitized, error } = sanitizeConfig(raw);
|
|
862
|
+
if (error) {
|
|
863
|
+
process.stderr.write(
|
|
864
|
+
`
|
|
865
|
+
\u26A0\uFE0F Node9: Invalid config at ${filePath}:
|
|
866
|
+
${error.replace("Invalid config:\n", "")}
|
|
867
|
+
\u2192 Invalid fields ignored, using defaults for those keys
|
|
868
|
+
|
|
869
|
+
`
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
return sanitized;
|
|
702
873
|
}
|
|
703
|
-
|
|
704
|
-
|
|
874
|
+
|
|
875
|
+
// src/utils/regex.ts
|
|
876
|
+
var import_safe_regex2 = __toESM(require("safe-regex2"));
|
|
877
|
+
var MAX_REGEX_LENGTH = 100;
|
|
878
|
+
var REGEX_CACHE_MAX = 500;
|
|
879
|
+
var regexCache = /* @__PURE__ */ new Map();
|
|
880
|
+
function validateRegex(pattern) {
|
|
881
|
+
if (!pattern) return "Pattern is required";
|
|
882
|
+
if (pattern.length > MAX_REGEX_LENGTH) return `Pattern exceeds max length of ${MAX_REGEX_LENGTH}`;
|
|
883
|
+
try {
|
|
884
|
+
new RegExp(pattern);
|
|
885
|
+
} catch (e) {
|
|
886
|
+
return `Invalid regex syntax: ${e.message}`;
|
|
887
|
+
}
|
|
888
|
+
if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
|
|
889
|
+
if (!(0, import_safe_regex2.default)(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
|
|
890
|
+
return null;
|
|
705
891
|
}
|
|
706
|
-
function
|
|
707
|
-
|
|
892
|
+
function getCompiledRegex(pattern, flags = "") {
|
|
893
|
+
if (flags && !/^[gimsuy]+$/.test(flags)) {
|
|
894
|
+
if (process.env.NODE9_DEBUG === "1") console.error(`[Node9] Invalid regex flags: "${flags}"`);
|
|
895
|
+
return null;
|
|
896
|
+
}
|
|
897
|
+
const key = `${pattern}\0${flags}`;
|
|
898
|
+
if (regexCache.has(key)) {
|
|
899
|
+
const cached = regexCache.get(key);
|
|
900
|
+
regexCache.delete(key);
|
|
901
|
+
regexCache.set(key, cached);
|
|
902
|
+
return cached;
|
|
903
|
+
}
|
|
904
|
+
const err = validateRegex(pattern);
|
|
905
|
+
if (err) {
|
|
906
|
+
if (process.env.NODE9_DEBUG === "1")
|
|
907
|
+
console.error(`[Node9] Regex blocked: ${err} \u2014 pattern: "${pattern}"`);
|
|
908
|
+
return null;
|
|
909
|
+
}
|
|
910
|
+
try {
|
|
911
|
+
const re = new RegExp(pattern, flags);
|
|
912
|
+
if (regexCache.size >= REGEX_CACHE_MAX) {
|
|
913
|
+
const oldest = regexCache.keys().next().value;
|
|
914
|
+
if (oldest) regexCache.delete(oldest);
|
|
915
|
+
}
|
|
916
|
+
regexCache.set(key, re);
|
|
917
|
+
return re;
|
|
918
|
+
} catch (e) {
|
|
919
|
+
if (process.env.NODE9_DEBUG === "1") console.error(`[Node9] Regex compile failed:`, e);
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
708
922
|
}
|
|
709
923
|
|
|
924
|
+
// src/policy/index.ts
|
|
925
|
+
var import_picomatch = __toESM(require("picomatch"));
|
|
926
|
+
var import_sh_syntax = require("sh-syntax");
|
|
927
|
+
|
|
710
928
|
// src/dlp.ts
|
|
711
|
-
var
|
|
929
|
+
var import_fs4 = __toESM(require("fs"));
|
|
712
930
|
var import_path4 = __toESM(require("path"));
|
|
713
931
|
var DLP_PATTERNS = [
|
|
714
932
|
{ name: "AWS Access Key ID", regex: /\bAKIA[0-9A-Z]{16}\b/, severity: "block" },
|
|
@@ -765,7 +983,7 @@ function scanFilePath(filePath, cwd = process.cwd()) {
|
|
|
765
983
|
let resolved;
|
|
766
984
|
try {
|
|
767
985
|
const absolute = import_path4.default.resolve(cwd, filePath);
|
|
768
|
-
resolved =
|
|
986
|
+
resolved = import_fs4.default.realpathSync.native(absolute);
|
|
769
987
|
} catch (err) {
|
|
770
988
|
const code = err.code;
|
|
771
989
|
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
@@ -827,168 +1045,29 @@ function scanArgs(args, depth = 0, fieldPath = "args") {
|
|
|
827
1045
|
for (const pattern of DLP_PATTERNS) {
|
|
828
1046
|
if (pattern.regex.test(text)) {
|
|
829
1047
|
return {
|
|
830
|
-
patternName: pattern.name,
|
|
831
|
-
fieldPath,
|
|
832
|
-
redactedSample: maskSecret(text, pattern.regex),
|
|
833
|
-
severity: pattern.severity
|
|
834
|
-
};
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
if (text.length < MAX_JSON_PARSE_BYTES) {
|
|
838
|
-
const trimmed = text.trim();
|
|
839
|
-
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
840
|
-
try {
|
|
841
|
-
const parsed = JSON.parse(text);
|
|
842
|
-
const inner = scanArgs(parsed, depth + 1, fieldPath);
|
|
843
|
-
if (inner) return inner;
|
|
844
|
-
} catch {
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
return null;
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
// src/core.ts
|
|
853
|
-
var PAUSED_FILE = import_path5.default.join(import_os2.default.homedir(), ".node9", "PAUSED");
|
|
854
|
-
var TRUST_FILE = import_path5.default.join(import_os2.default.homedir(), ".node9", "trust.json");
|
|
855
|
-
var LOCAL_AUDIT_LOG = import_path5.default.join(import_os2.default.homedir(), ".node9", "audit.log");
|
|
856
|
-
var HOOK_DEBUG_LOG = import_path5.default.join(import_os2.default.homedir(), ".node9", "hook-debug.log");
|
|
857
|
-
function checkPause() {
|
|
858
|
-
try {
|
|
859
|
-
if (!import_fs3.default.existsSync(PAUSED_FILE)) return { paused: false };
|
|
860
|
-
const state = JSON.parse(import_fs3.default.readFileSync(PAUSED_FILE, "utf-8"));
|
|
861
|
-
if (state.expiry > 0 && Date.now() >= state.expiry) {
|
|
862
|
-
try {
|
|
863
|
-
import_fs3.default.unlinkSync(PAUSED_FILE);
|
|
864
|
-
} catch {
|
|
865
|
-
}
|
|
866
|
-
return { paused: false };
|
|
867
|
-
}
|
|
868
|
-
return { paused: true, expiresAt: state.expiry, duration: state.duration };
|
|
869
|
-
} catch {
|
|
870
|
-
return { paused: false };
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
function atomicWriteSync(filePath, data, options) {
|
|
874
|
-
const dir = import_path5.default.dirname(filePath);
|
|
875
|
-
if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
|
|
876
|
-
const tmpPath = `${filePath}.${import_os2.default.hostname()}.${process.pid}.tmp`;
|
|
877
|
-
import_fs3.default.writeFileSync(tmpPath, data, options);
|
|
878
|
-
import_fs3.default.renameSync(tmpPath, filePath);
|
|
879
|
-
}
|
|
880
|
-
var MAX_REGEX_LENGTH = 100;
|
|
881
|
-
var REGEX_CACHE_MAX = 500;
|
|
882
|
-
var regexCache = /* @__PURE__ */ new Map();
|
|
883
|
-
function validateRegex(pattern) {
|
|
884
|
-
if (!pattern) return "Pattern is required";
|
|
885
|
-
if (pattern.length > MAX_REGEX_LENGTH) return `Pattern exceeds max length of ${MAX_REGEX_LENGTH}`;
|
|
886
|
-
try {
|
|
887
|
-
new RegExp(pattern);
|
|
888
|
-
} catch (e) {
|
|
889
|
-
return `Invalid regex syntax: ${e.message}`;
|
|
890
|
-
}
|
|
891
|
-
if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
|
|
892
|
-
if (!(0, import_safe_regex2.default)(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
|
|
893
|
-
return null;
|
|
894
|
-
}
|
|
895
|
-
function getCompiledRegex(pattern, flags = "") {
|
|
896
|
-
if (flags && !/^[gimsuy]+$/.test(flags)) {
|
|
897
|
-
if (process.env.NODE9_DEBUG === "1") console.error(`[Node9] Invalid regex flags: "${flags}"`);
|
|
898
|
-
return null;
|
|
899
|
-
}
|
|
900
|
-
const key = `${pattern}\0${flags}`;
|
|
901
|
-
if (regexCache.has(key)) {
|
|
902
|
-
const cached = regexCache.get(key);
|
|
903
|
-
regexCache.delete(key);
|
|
904
|
-
regexCache.set(key, cached);
|
|
905
|
-
return cached;
|
|
906
|
-
}
|
|
907
|
-
const err = validateRegex(pattern);
|
|
908
|
-
if (err) {
|
|
909
|
-
if (process.env.NODE9_DEBUG === "1")
|
|
910
|
-
console.error(`[Node9] Regex blocked: ${err} \u2014 pattern: "${pattern}"`);
|
|
911
|
-
return null;
|
|
912
|
-
}
|
|
913
|
-
try {
|
|
914
|
-
const re = new RegExp(pattern, flags);
|
|
915
|
-
if (regexCache.size >= REGEX_CACHE_MAX) {
|
|
916
|
-
const oldest = regexCache.keys().next().value;
|
|
917
|
-
if (oldest) regexCache.delete(oldest);
|
|
918
|
-
}
|
|
919
|
-
regexCache.set(key, re);
|
|
920
|
-
return re;
|
|
921
|
-
} catch (e) {
|
|
922
|
-
if (process.env.NODE9_DEBUG === "1") console.error(`[Node9] Regex compile failed:`, e);
|
|
923
|
-
return null;
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
function getActiveTrustSession(toolName) {
|
|
927
|
-
try {
|
|
928
|
-
if (!import_fs3.default.existsSync(TRUST_FILE)) return false;
|
|
929
|
-
const trust = JSON.parse(import_fs3.default.readFileSync(TRUST_FILE, "utf-8"));
|
|
930
|
-
const now = Date.now();
|
|
931
|
-
const active = trust.entries.filter((e) => e.expiry > now);
|
|
932
|
-
if (active.length !== trust.entries.length) {
|
|
933
|
-
import_fs3.default.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
|
|
934
|
-
}
|
|
935
|
-
return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
|
|
936
|
-
} catch {
|
|
937
|
-
return false;
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
function writeTrustSession(toolName, durationMs) {
|
|
941
|
-
try {
|
|
942
|
-
let trust = { entries: [] };
|
|
943
|
-
try {
|
|
944
|
-
if (import_fs3.default.existsSync(TRUST_FILE)) {
|
|
945
|
-
trust = JSON.parse(import_fs3.default.readFileSync(TRUST_FILE, "utf-8"));
|
|
1048
|
+
patternName: pattern.name,
|
|
1049
|
+
fieldPath,
|
|
1050
|
+
redactedSample: maskSecret(text, pattern.regex),
|
|
1051
|
+
severity: pattern.severity
|
|
1052
|
+
};
|
|
946
1053
|
}
|
|
947
|
-
} catch {
|
|
948
1054
|
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1055
|
+
if (text.length < MAX_JSON_PARSE_BYTES) {
|
|
1056
|
+
const trimmed = text.trim();
|
|
1057
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
1058
|
+
try {
|
|
1059
|
+
const parsed = JSON.parse(text);
|
|
1060
|
+
const inner = scanArgs(parsed, depth + 1, fieldPath);
|
|
1061
|
+
if (inner) return inner;
|
|
1062
|
+
} catch {
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
956
1065
|
}
|
|
957
1066
|
}
|
|
1067
|
+
return null;
|
|
958
1068
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
const dir = import_path5.default.dirname(logPath);
|
|
962
|
-
if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
|
|
963
|
-
import_fs3.default.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
964
|
-
} catch {
|
|
965
|
-
}
|
|
966
|
-
}
|
|
967
|
-
function appendHookDebug(toolName, args, meta) {
|
|
968
|
-
const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
|
|
969
|
-
appendToLog(HOOK_DEBUG_LOG, {
|
|
970
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
971
|
-
tool: toolName,
|
|
972
|
-
args: safeArgs,
|
|
973
|
-
agent: meta?.agent,
|
|
974
|
-
mcpServer: meta?.mcpServer,
|
|
975
|
-
hostname: import_os2.default.hostname(),
|
|
976
|
-
cwd: process.cwd()
|
|
977
|
-
});
|
|
978
|
-
}
|
|
979
|
-
function appendLocalAudit(toolName, args, decision, checkedBy, meta) {
|
|
980
|
-
const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
|
|
981
|
-
appendToLog(LOCAL_AUDIT_LOG, {
|
|
982
|
-
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
983
|
-
tool: toolName,
|
|
984
|
-
args: safeArgs,
|
|
985
|
-
decision,
|
|
986
|
-
checkedBy,
|
|
987
|
-
agent: meta?.agent,
|
|
988
|
-
mcpServer: meta?.mcpServer,
|
|
989
|
-
hostname: import_os2.default.hostname()
|
|
990
|
-
});
|
|
991
|
-
}
|
|
1069
|
+
|
|
1070
|
+
// src/policy/index.ts
|
|
992
1071
|
function tokenize(toolName) {
|
|
993
1072
|
return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
|
|
994
1073
|
}
|
|
@@ -1002,9 +1081,9 @@ function matchesPattern(text, patterns) {
|
|
|
1002
1081
|
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
1003
1082
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
1004
1083
|
}
|
|
1005
|
-
function getNestedValue(obj,
|
|
1084
|
+
function getNestedValue(obj, path10) {
|
|
1006
1085
|
if (!obj || typeof obj !== "object") return null;
|
|
1007
|
-
return
|
|
1086
|
+
return path10.split(".").reduce((prev, curr) => prev?.[curr], obj);
|
|
1008
1087
|
}
|
|
1009
1088
|
function evaluateSmartConditions(args, rule) {
|
|
1010
1089
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
@@ -1128,494 +1207,727 @@ async function analyzeShellCommand(command) {
|
|
|
1128
1207
|
}
|
|
1129
1208
|
return { actions, paths, allTokens };
|
|
1130
1209
|
}
|
|
1131
|
-
function
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
"
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
}
|
|
1218
|
-
{
|
|
1219
|
-
field: "command",
|
|
1220
|
-
op: "matches",
|
|
1221
|
-
value: "(~|\\/root(\\/|$)|\\$HOME|\\/home\\/)"
|
|
1222
|
-
}
|
|
1223
|
-
],
|
|
1224
|
-
verdict: "block",
|
|
1225
|
-
reason: "Recursive delete of home directory is irreversible"
|
|
1226
|
-
},
|
|
1227
|
-
// ── SQL safety ────────────────────────────────────────────────────────
|
|
1228
|
-
{
|
|
1229
|
-
name: "no-delete-without-where",
|
|
1230
|
-
tool: "*",
|
|
1231
|
-
conditions: [
|
|
1232
|
-
{ field: "sql", op: "matches", value: "^(DELETE|UPDATE)\\s", flags: "i" },
|
|
1233
|
-
{ field: "sql", op: "notMatches", value: "\\bWHERE\\b", flags: "i" }
|
|
1234
|
-
],
|
|
1235
|
-
conditionMode: "all",
|
|
1236
|
-
verdict: "review",
|
|
1237
|
-
reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
|
|
1238
|
-
},
|
|
1239
|
-
{
|
|
1240
|
-
name: "review-drop-truncate-shell",
|
|
1241
|
-
tool: "bash",
|
|
1242
|
-
conditions: [
|
|
1243
|
-
{
|
|
1244
|
-
field: "command",
|
|
1245
|
-
op: "matches",
|
|
1246
|
-
value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
|
|
1247
|
-
flags: "i"
|
|
1248
|
-
}
|
|
1249
|
-
],
|
|
1250
|
-
conditionMode: "all",
|
|
1251
|
-
verdict: "review",
|
|
1252
|
-
reason: "SQL DDL destructive statement inside a shell command"
|
|
1253
|
-
},
|
|
1254
|
-
// ── Git safety ────────────────────────────────────────────────────────
|
|
1255
|
-
{
|
|
1256
|
-
name: "block-force-push",
|
|
1257
|
-
tool: "bash",
|
|
1258
|
-
conditions: [
|
|
1259
|
-
{
|
|
1260
|
-
field: "command",
|
|
1261
|
-
op: "matches",
|
|
1262
|
-
value: "^\\s*git\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
|
|
1263
|
-
flags: "i"
|
|
1264
|
-
}
|
|
1265
|
-
],
|
|
1266
|
-
conditionMode: "all",
|
|
1267
|
-
verdict: "block",
|
|
1268
|
-
reason: "Force push overwrites remote history and cannot be undone"
|
|
1269
|
-
},
|
|
1270
|
-
{
|
|
1271
|
-
name: "review-git-push",
|
|
1272
|
-
tool: "bash",
|
|
1273
|
-
conditions: [
|
|
1274
|
-
{
|
|
1275
|
-
field: "command",
|
|
1276
|
-
op: "matches",
|
|
1277
|
-
value: "^\\s*git\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
|
|
1278
|
-
flags: "i"
|
|
1279
|
-
}
|
|
1280
|
-
],
|
|
1281
|
-
conditionMode: "all",
|
|
1282
|
-
verdict: "review",
|
|
1283
|
-
reason: "git push sends changes to a shared remote"
|
|
1284
|
-
},
|
|
1285
|
-
{
|
|
1286
|
-
name: "review-git-destructive",
|
|
1287
|
-
tool: "bash",
|
|
1288
|
-
conditions: [
|
|
1289
|
-
{
|
|
1290
|
-
field: "command",
|
|
1291
|
-
op: "matches",
|
|
1292
|
-
value: "^\\s*git\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
|
|
1293
|
-
flags: "i"
|
|
1294
|
-
}
|
|
1295
|
-
],
|
|
1296
|
-
conditionMode: "all",
|
|
1297
|
-
verdict: "review",
|
|
1298
|
-
reason: "Destructive git operation \u2014 discards history or working-tree changes"
|
|
1299
|
-
},
|
|
1300
|
-
// ── Shell safety ──────────────────────────────────────────────────────
|
|
1301
|
-
{
|
|
1302
|
-
name: "review-sudo",
|
|
1303
|
-
tool: "bash",
|
|
1304
|
-
conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
|
|
1305
|
-
conditionMode: "all",
|
|
1306
|
-
verdict: "review",
|
|
1307
|
-
reason: "Command requires elevated privileges"
|
|
1308
|
-
},
|
|
1309
|
-
{
|
|
1310
|
-
name: "review-curl-pipe-shell",
|
|
1311
|
-
tool: "bash",
|
|
1312
|
-
conditions: [
|
|
1313
|
-
{
|
|
1314
|
-
field: "command",
|
|
1315
|
-
op: "matches",
|
|
1316
|
-
value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
|
|
1317
|
-
flags: "i"
|
|
1210
|
+
async function evaluatePolicy(toolName, args, agent) {
|
|
1211
|
+
const config = getConfig();
|
|
1212
|
+
if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
|
|
1213
|
+
if (config.policy.smartRules.length > 0) {
|
|
1214
|
+
const matchedRule = config.policy.smartRules.find(
|
|
1215
|
+
(rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
|
|
1216
|
+
);
|
|
1217
|
+
if (matchedRule) {
|
|
1218
|
+
if (matchedRule.verdict === "allow")
|
|
1219
|
+
return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
|
|
1220
|
+
return {
|
|
1221
|
+
decision: matchedRule.verdict,
|
|
1222
|
+
blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
|
|
1223
|
+
reason: matchedRule.reason,
|
|
1224
|
+
tier: 2,
|
|
1225
|
+
ruleName: matchedRule.name ?? matchedRule.tool
|
|
1226
|
+
};
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
let allTokens = [];
|
|
1230
|
+
let pathTokens = [];
|
|
1231
|
+
const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
|
|
1232
|
+
if (shellCommand) {
|
|
1233
|
+
const analyzed = await analyzeShellCommand(shellCommand);
|
|
1234
|
+
allTokens = analyzed.allTokens;
|
|
1235
|
+
pathTokens = analyzed.paths;
|
|
1236
|
+
const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
|
|
1237
|
+
if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
|
|
1238
|
+
return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)", tier: 3 };
|
|
1239
|
+
}
|
|
1240
|
+
if (isSqlTool(toolName, config.policy.toolInspection)) {
|
|
1241
|
+
allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
|
|
1242
|
+
}
|
|
1243
|
+
} else {
|
|
1244
|
+
allTokens = tokenize(toolName);
|
|
1245
|
+
if (args && typeof args === "object") {
|
|
1246
|
+
const flattenedArgs = JSON.stringify(args).toLowerCase();
|
|
1247
|
+
const extraTokens = flattenedArgs.split(/[^a-zA-Z0-9]+/).filter((t) => t.length > 1);
|
|
1248
|
+
allTokens.push(...extraTokens);
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
const isManual = agent === "Terminal";
|
|
1252
|
+
if (isManual) {
|
|
1253
|
+
const SYSTEM_DISASTER_COMMANDS = ["mkfs", "shred", "dd", "drop", "truncate", "purge"];
|
|
1254
|
+
const hasSystemDisaster = allTokens.some(
|
|
1255
|
+
(t) => SYSTEM_DISASTER_COMMANDS.includes(t.toLowerCase())
|
|
1256
|
+
);
|
|
1257
|
+
const isRootWipe = allTokens.includes("rm") && (allTokens.includes("/") || allTokens.includes("/*"));
|
|
1258
|
+
if (hasSystemDisaster || isRootWipe) {
|
|
1259
|
+
return { decision: "review", blockedByLabel: "Manual Nuclear Protection", tier: 3 };
|
|
1260
|
+
}
|
|
1261
|
+
return { decision: "allow" };
|
|
1262
|
+
}
|
|
1263
|
+
if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
|
|
1264
|
+
const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
|
|
1265
|
+
if (allInSandbox) return { decision: "allow" };
|
|
1266
|
+
}
|
|
1267
|
+
let matchedDangerousWord;
|
|
1268
|
+
const isDangerous = allTokens.some(
|
|
1269
|
+
(token) => config.policy.dangerousWords.some((word) => {
|
|
1270
|
+
const w = word.toLowerCase();
|
|
1271
|
+
const hit = token === w || (() => {
|
|
1272
|
+
try {
|
|
1273
|
+
return new RegExp(`\\b${w}\\b`, "i").test(token);
|
|
1274
|
+
} catch {
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
})();
|
|
1278
|
+
if (hit && !matchedDangerousWord) matchedDangerousWord = word;
|
|
1279
|
+
return hit;
|
|
1280
|
+
})
|
|
1281
|
+
);
|
|
1282
|
+
if (isDangerous) {
|
|
1283
|
+
let matchedField;
|
|
1284
|
+
if (matchedDangerousWord && args && typeof args === "object" && !Array.isArray(args)) {
|
|
1285
|
+
const obj = args;
|
|
1286
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1287
|
+
if (typeof value === "string") {
|
|
1288
|
+
try {
|
|
1289
|
+
if (new RegExp(
|
|
1290
|
+
`\\b${matchedDangerousWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
|
1291
|
+
"i"
|
|
1292
|
+
).test(value)) {
|
|
1293
|
+
matchedField = key;
|
|
1294
|
+
break;
|
|
1295
|
+
}
|
|
1296
|
+
} catch {
|
|
1318
1297
|
}
|
|
1319
|
-
|
|
1320
|
-
conditionMode: "all",
|
|
1321
|
-
verdict: "block",
|
|
1322
|
-
reason: "Piping remote script into a shell is a supply-chain attack vector"
|
|
1298
|
+
}
|
|
1323
1299
|
}
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1300
|
+
}
|
|
1301
|
+
return {
|
|
1302
|
+
decision: "review",
|
|
1303
|
+
blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
|
|
1304
|
+
matchedWord: matchedDangerousWord,
|
|
1305
|
+
matchedField,
|
|
1306
|
+
tier: 6
|
|
1307
|
+
};
|
|
1308
|
+
}
|
|
1309
|
+
if (config.settings.mode === "strict") {
|
|
1310
|
+
const envConfig = getActiveEnvironment(config);
|
|
1311
|
+
if (envConfig?.requireApproval === false) return { decision: "allow" };
|
|
1312
|
+
return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)", tier: 7 };
|
|
1313
|
+
}
|
|
1314
|
+
return { decision: "allow" };
|
|
1315
|
+
}
|
|
1316
|
+
function isIgnoredTool(toolName) {
|
|
1317
|
+
const config = getConfig();
|
|
1318
|
+
return matchesPattern(toolName, config.policy.ignoredTools);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// src/auth/state.ts
|
|
1322
|
+
var import_fs5 = __toESM(require("fs"));
|
|
1323
|
+
var import_path5 = __toESM(require("path"));
|
|
1324
|
+
var import_os4 = __toESM(require("os"));
|
|
1325
|
+
var PAUSED_FILE = import_path5.default.join(import_os4.default.homedir(), ".node9", "PAUSED");
|
|
1326
|
+
var TRUST_FILE = import_path5.default.join(import_os4.default.homedir(), ".node9", "trust.json");
|
|
1327
|
+
function checkPause() {
|
|
1328
|
+
try {
|
|
1329
|
+
if (!import_fs5.default.existsSync(PAUSED_FILE)) return { paused: false };
|
|
1330
|
+
const state = JSON.parse(import_fs5.default.readFileSync(PAUSED_FILE, "utf-8"));
|
|
1331
|
+
if (state.expiry > 0 && Date.now() >= state.expiry) {
|
|
1332
|
+
try {
|
|
1333
|
+
import_fs5.default.unlinkSync(PAUSED_FILE);
|
|
1334
|
+
} catch {
|
|
1345
1335
|
}
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
}
|
|
1350
|
-
|
|
1351
|
-
name: "review-rm",
|
|
1352
|
-
tool: "*",
|
|
1353
|
-
conditions: [{ field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" }],
|
|
1354
|
-
verdict: "review",
|
|
1355
|
-
reason: "rm can permanently delete files \u2014 confirm the target path"
|
|
1356
|
-
},
|
|
1357
|
-
// ── SQL safety (Safe by Default) ──────────────────────────────────────────
|
|
1358
|
-
// These rules fire when an AI calls a database tool directly (e.g. MCP postgres,
|
|
1359
|
-
// mcp__postgres__query) with a destructive SQL statement in the 'sql' field.
|
|
1360
|
-
// The postgres shield upgrades these from 'review' → 'block' for stricter teams;
|
|
1361
|
-
// without a shield, users still get a human-approval gate on every destructive op.
|
|
1362
|
-
{
|
|
1363
|
-
name: "review-drop-table-sql",
|
|
1364
|
-
tool: "*",
|
|
1365
|
-
conditions: [{ field: "sql", op: "matches", value: "DROP\\s+TABLE", flags: "i" }],
|
|
1366
|
-
verdict: "review",
|
|
1367
|
-
reason: "DROP TABLE is irreversible \u2014 enable the postgres shield to block instead"
|
|
1368
|
-
},
|
|
1369
|
-
{
|
|
1370
|
-
name: "review-truncate-sql",
|
|
1371
|
-
tool: "*",
|
|
1372
|
-
conditions: [{ field: "sql", op: "matches", value: "TRUNCATE\\s+TABLE", flags: "i" }],
|
|
1373
|
-
verdict: "review",
|
|
1374
|
-
reason: "TRUNCATE removes all rows \u2014 enable the postgres shield to block instead"
|
|
1375
|
-
},
|
|
1376
|
-
{
|
|
1377
|
-
name: "review-drop-column-sql",
|
|
1378
|
-
tool: "*",
|
|
1379
|
-
conditions: [
|
|
1380
|
-
{ field: "sql", op: "matches", value: "ALTER\\s+TABLE.*DROP\\s+COLUMN", flags: "i" }
|
|
1381
|
-
],
|
|
1382
|
-
verdict: "review",
|
|
1383
|
-
reason: "DROP COLUMN is irreversible \u2014 enable the postgres shield to block instead"
|
|
1336
|
+
return { paused: false };
|
|
1337
|
+
}
|
|
1338
|
+
return { paused: true, expiresAt: state.expiry, duration: state.duration };
|
|
1339
|
+
} catch {
|
|
1340
|
+
return { paused: false };
|
|
1384
1341
|
}
|
|
1385
|
-
|
|
1386
|
-
|
|
1342
|
+
}
|
|
1343
|
+
function atomicWriteSync(filePath, data, options) {
|
|
1344
|
+
const dir = import_path5.default.dirname(filePath);
|
|
1345
|
+
if (!import_fs5.default.existsSync(dir)) import_fs5.default.mkdirSync(dir, { recursive: true });
|
|
1346
|
+
const tmpPath = `${filePath}.${import_os4.default.hostname()}.${process.pid}.tmp`;
|
|
1347
|
+
import_fs5.default.writeFileSync(tmpPath, data, options);
|
|
1348
|
+
import_fs5.default.renameSync(tmpPath, filePath);
|
|
1349
|
+
}
|
|
1350
|
+
function getActiveTrustSession(toolName) {
|
|
1351
|
+
try {
|
|
1352
|
+
if (!import_fs5.default.existsSync(TRUST_FILE)) return false;
|
|
1353
|
+
const trust = JSON.parse(import_fs5.default.readFileSync(TRUST_FILE, "utf-8"));
|
|
1354
|
+
const now = Date.now();
|
|
1355
|
+
const active = trust.entries.filter((e) => e.expiry > now);
|
|
1356
|
+
if (active.length !== trust.entries.length) {
|
|
1357
|
+
import_fs5.default.writeFileSync(TRUST_FILE, JSON.stringify({ entries: active }, null, 2));
|
|
1358
|
+
}
|
|
1359
|
+
return active.some((e) => e.tool === toolName || matchesPattern(toolName, e.tool));
|
|
1360
|
+
} catch {
|
|
1361
|
+
return false;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
function writeTrustSession(toolName, durationMs) {
|
|
1365
|
+
try {
|
|
1366
|
+
let trust = { entries: [] };
|
|
1367
|
+
try {
|
|
1368
|
+
if (import_fs5.default.existsSync(TRUST_FILE)) {
|
|
1369
|
+
trust = JSON.parse(import_fs5.default.readFileSync(TRUST_FILE, "utf-8"));
|
|
1370
|
+
}
|
|
1371
|
+
} catch {
|
|
1372
|
+
}
|
|
1373
|
+
const now = Date.now();
|
|
1374
|
+
trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
|
|
1375
|
+
trust.entries.push({ tool: toolName, expiry: now + durationMs });
|
|
1376
|
+
atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
if (process.env.NODE9_DEBUG === "1") {
|
|
1379
|
+
console.error("[Node9 Trust Error]:", err);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
function getPersistentDecision(toolName) {
|
|
1384
|
+
try {
|
|
1385
|
+
const file = import_path5.default.join(import_os4.default.homedir(), ".node9", "decisions.json");
|
|
1386
|
+
if (!import_fs5.default.existsSync(file)) return null;
|
|
1387
|
+
const decisions = JSON.parse(import_fs5.default.readFileSync(file, "utf-8"));
|
|
1388
|
+
const d = decisions[toolName];
|
|
1389
|
+
if (d === "allow" || d === "deny") return d;
|
|
1390
|
+
} catch {
|
|
1391
|
+
}
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// src/auth/daemon.ts
|
|
1396
|
+
var import_fs6 = __toESM(require("fs"));
|
|
1397
|
+
var import_path6 = __toESM(require("path"));
|
|
1398
|
+
var import_os5 = __toESM(require("os"));
|
|
1399
|
+
var import_child_process = require("child_process");
|
|
1400
|
+
var DAEMON_PORT = 7391;
|
|
1401
|
+
var DAEMON_HOST = "127.0.0.1";
|
|
1387
1402
|
function getInternalToken() {
|
|
1388
1403
|
try {
|
|
1389
|
-
const pidFile =
|
|
1390
|
-
if (!
|
|
1391
|
-
const data = JSON.parse(
|
|
1392
|
-
process.kill(data.pid, 0);
|
|
1393
|
-
return data.internalToken ?? null;
|
|
1394
|
-
} catch {
|
|
1395
|
-
return null;
|
|
1404
|
+
const pidFile = import_path6.default.join(import_os5.default.homedir(), ".node9", "daemon.pid");
|
|
1405
|
+
if (!import_fs6.default.existsSync(pidFile)) return null;
|
|
1406
|
+
const data = JSON.parse(import_fs6.default.readFileSync(pidFile, "utf-8"));
|
|
1407
|
+
process.kill(data.pid, 0);
|
|
1408
|
+
return data.internalToken ?? null;
|
|
1409
|
+
} catch {
|
|
1410
|
+
return null;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
function isDaemonRunning() {
|
|
1414
|
+
const pidFile = import_path6.default.join(import_os5.default.homedir(), ".node9", "daemon.pid");
|
|
1415
|
+
if (import_fs6.default.existsSync(pidFile)) {
|
|
1416
|
+
try {
|
|
1417
|
+
const { pid, port } = JSON.parse(import_fs6.default.readFileSync(pidFile, "utf-8"));
|
|
1418
|
+
if (port !== DAEMON_PORT) return false;
|
|
1419
|
+
process.kill(pid, 0);
|
|
1420
|
+
return true;
|
|
1421
|
+
} catch {
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
try {
|
|
1426
|
+
const r = (0, import_child_process.spawnSync)("ss", ["-Htnp", `sport = :${DAEMON_PORT}`], {
|
|
1427
|
+
encoding: "utf8",
|
|
1428
|
+
timeout: 500
|
|
1429
|
+
});
|
|
1430
|
+
return r.status === 0 && (r.stdout ?? "").includes(`:${DAEMON_PORT}`);
|
|
1431
|
+
} catch {
|
|
1432
|
+
return false;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd) {
|
|
1436
|
+
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
1437
|
+
const ctrl = new AbortController();
|
|
1438
|
+
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
1439
|
+
try {
|
|
1440
|
+
const res = await fetch(`${base}/check`, {
|
|
1441
|
+
method: "POST",
|
|
1442
|
+
headers: { "Content-Type": "application/json" },
|
|
1443
|
+
body: JSON.stringify({
|
|
1444
|
+
toolName,
|
|
1445
|
+
args,
|
|
1446
|
+
agent: meta?.agent,
|
|
1447
|
+
mcpServer: meta?.mcpServer,
|
|
1448
|
+
fromCLI: true,
|
|
1449
|
+
// Pass the flight-recorder ID so the daemon uses the same UUID for
|
|
1450
|
+
// activity-result as the CLI used for the pending activity event.
|
|
1451
|
+
activityId,
|
|
1452
|
+
...riskMetadata && { riskMetadata },
|
|
1453
|
+
...cwd && { cwd }
|
|
1454
|
+
}),
|
|
1455
|
+
signal: ctrl.signal
|
|
1456
|
+
});
|
|
1457
|
+
if (!res.ok) throw new Error("Daemon fail");
|
|
1458
|
+
const { id } = await res.json();
|
|
1459
|
+
return id;
|
|
1460
|
+
} finally {
|
|
1461
|
+
clearTimeout(timer);
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
async function waitForDaemonDecision(id, signal) {
|
|
1465
|
+
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
1466
|
+
const waitCtrl = new AbortController();
|
|
1467
|
+
const waitTimer = setTimeout(() => waitCtrl.abort(), 12e4);
|
|
1468
|
+
const onAbort = () => waitCtrl.abort();
|
|
1469
|
+
if (signal) signal.addEventListener("abort", onAbort);
|
|
1470
|
+
try {
|
|
1471
|
+
const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
|
|
1472
|
+
if (!waitRes.ok) return { decision: "deny" };
|
|
1473
|
+
const { decision, source } = await waitRes.json();
|
|
1474
|
+
if (decision === "allow") return { decision: "allow", source };
|
|
1475
|
+
if (decision === "abandoned") return { decision: "abandoned", source };
|
|
1476
|
+
return { decision: "deny", source };
|
|
1477
|
+
} finally {
|
|
1478
|
+
clearTimeout(waitTimer);
|
|
1479
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
1396
1480
|
}
|
|
1397
1481
|
}
|
|
1398
|
-
async function
|
|
1399
|
-
const
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1482
|
+
async function notifyDaemonViewer(toolName, args, meta, riskMetadata) {
|
|
1483
|
+
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
1484
|
+
const res = await fetch(`${base}/check`, {
|
|
1485
|
+
method: "POST",
|
|
1486
|
+
headers: { "Content-Type": "application/json" },
|
|
1487
|
+
body: JSON.stringify({
|
|
1488
|
+
toolName,
|
|
1489
|
+
args,
|
|
1490
|
+
slackDelegated: true,
|
|
1491
|
+
agent: meta?.agent,
|
|
1492
|
+
mcpServer: meta?.mcpServer,
|
|
1493
|
+
...riskMetadata && { riskMetadata }
|
|
1494
|
+
}),
|
|
1495
|
+
signal: AbortSignal.timeout(3e3)
|
|
1496
|
+
});
|
|
1497
|
+
if (!res.ok) throw new Error("Daemon unreachable");
|
|
1498
|
+
const { id } = await res.json();
|
|
1499
|
+
return id;
|
|
1500
|
+
}
|
|
1501
|
+
async function resolveViaDaemon(id, decision, internalToken) {
|
|
1502
|
+
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
1503
|
+
await fetch(`${base}/resolve/${id}`, {
|
|
1504
|
+
method: "POST",
|
|
1505
|
+
headers: { "Content-Type": "application/json", "X-Node9-Internal": internalToken },
|
|
1506
|
+
body: JSON.stringify({ decision }),
|
|
1507
|
+
signal: AbortSignal.timeout(3e3)
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
// src/auth/orchestrator.ts
|
|
1512
|
+
var import_net = __toESM(require("net"));
|
|
1513
|
+
var import_path9 = __toESM(require("path"));
|
|
1514
|
+
var import_os7 = __toESM(require("os"));
|
|
1515
|
+
var import_crypto = require("crypto");
|
|
1516
|
+
|
|
1517
|
+
// src/ui/native.ts
|
|
1518
|
+
var import_child_process2 = require("child_process");
|
|
1519
|
+
var import_path8 = __toESM(require("path"));
|
|
1520
|
+
|
|
1521
|
+
// src/context-sniper.ts
|
|
1522
|
+
var import_path7 = __toESM(require("path"));
|
|
1523
|
+
function smartTruncate(str, maxLen = 500) {
|
|
1524
|
+
if (str.length <= maxLen) return str;
|
|
1525
|
+
const edge = Math.floor(maxLen / 2) - 3;
|
|
1526
|
+
return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
|
|
1527
|
+
}
|
|
1528
|
+
function extractContext(text, matchedWord) {
|
|
1529
|
+
const lines = text.split("\n");
|
|
1530
|
+
if (lines.length <= 7 || !matchedWord) {
|
|
1531
|
+
return { snippet: smartTruncate(text, 500), lineIndex: -1 };
|
|
1416
1532
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
const
|
|
1420
|
-
if (
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1533
|
+
const escaped = matchedWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1534
|
+
const pattern = new RegExp(`\\b${escaped}\\b`, "i");
|
|
1535
|
+
const allHits = lines.map((line, i) => ({ i, line })).filter(({ line }) => pattern.test(line));
|
|
1536
|
+
if (allHits.length === 0) return { snippet: smartTruncate(text, 500), lineIndex: -1 };
|
|
1537
|
+
const nonComment = allHits.find(({ line }) => {
|
|
1538
|
+
const trimmed = line.trim();
|
|
1539
|
+
return !trimmed.startsWith("//") && !trimmed.startsWith("#");
|
|
1540
|
+
});
|
|
1541
|
+
const hitIndex = (nonComment ?? allHits[0]).i;
|
|
1542
|
+
const start = Math.max(0, hitIndex - 3);
|
|
1543
|
+
const end = Math.min(lines.length, hitIndex + 4);
|
|
1544
|
+
const lineIndex = hitIndex - start;
|
|
1545
|
+
const snippet = lines.slice(start, end).map((line, i) => `${start + i === hitIndex ? "\u{1F6D1} " : " "}${line}`).join("\n");
|
|
1546
|
+
const head = start > 0 ? `... [${start} lines hidden] ...
|
|
1547
|
+
` : "";
|
|
1548
|
+
const tail = end < lines.length ? `
|
|
1549
|
+
... [${lines.length - end} lines hidden] ...` : "";
|
|
1550
|
+
return { snippet: `${head}${snippet}${tail}`, lineIndex };
|
|
1551
|
+
}
|
|
1552
|
+
var CODE_KEYS = [
|
|
1553
|
+
"command",
|
|
1554
|
+
"cmd",
|
|
1555
|
+
"shell_command",
|
|
1556
|
+
"bash_command",
|
|
1557
|
+
"script",
|
|
1558
|
+
"code",
|
|
1559
|
+
"input",
|
|
1560
|
+
"sql",
|
|
1561
|
+
"query",
|
|
1562
|
+
"arguments",
|
|
1563
|
+
"args",
|
|
1564
|
+
"param",
|
|
1565
|
+
"params",
|
|
1566
|
+
"text"
|
|
1567
|
+
];
|
|
1568
|
+
function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWord, ruleName) {
|
|
1569
|
+
let intent = "EXEC";
|
|
1570
|
+
let contextSnippet;
|
|
1571
|
+
let contextLineIndex;
|
|
1572
|
+
let editFileName;
|
|
1573
|
+
let editFilePath;
|
|
1574
|
+
let parsed = args;
|
|
1575
|
+
if (typeof args === "string") {
|
|
1576
|
+
const trimmed = args.trim();
|
|
1577
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
1578
|
+
try {
|
|
1579
|
+
parsed = JSON.parse(trimmed);
|
|
1580
|
+
} catch {
|
|
1581
|
+
}
|
|
1427
1582
|
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1583
|
+
}
|
|
1584
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1585
|
+
const obj = parsed;
|
|
1586
|
+
if (obj.old_string !== void 0 && obj.new_string !== void 0) {
|
|
1587
|
+
intent = "EDIT";
|
|
1588
|
+
if (obj.file_path) {
|
|
1589
|
+
editFilePath = String(obj.file_path);
|
|
1590
|
+
editFileName = import_path7.default.basename(editFilePath);
|
|
1591
|
+
}
|
|
1592
|
+
const result = extractContext(String(obj.new_string), matchedWord);
|
|
1593
|
+
contextSnippet = result.snippet;
|
|
1594
|
+
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
1595
|
+
} else if (matchedField && obj[matchedField] !== void 0) {
|
|
1596
|
+
const result = extractContext(String(obj[matchedField]), matchedWord);
|
|
1597
|
+
contextSnippet = result.snippet;
|
|
1598
|
+
if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
|
|
1599
|
+
} else {
|
|
1600
|
+
const foundKey = Object.keys(obj).find((k) => CODE_KEYS.includes(k.toLowerCase()));
|
|
1601
|
+
if (foundKey) {
|
|
1602
|
+
const val = obj[foundKey];
|
|
1603
|
+
contextSnippet = smartTruncate(typeof val === "string" ? val : JSON.stringify(val), 500);
|
|
1604
|
+
}
|
|
1430
1605
|
}
|
|
1431
|
-
} else {
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1606
|
+
} else if (typeof parsed === "string") {
|
|
1607
|
+
contextSnippet = smartTruncate(parsed, 500);
|
|
1608
|
+
}
|
|
1609
|
+
return {
|
|
1610
|
+
intent,
|
|
1611
|
+
tier,
|
|
1612
|
+
blockedByLabel,
|
|
1613
|
+
...matchedWord && { matchedWord },
|
|
1614
|
+
...matchedField && { matchedField },
|
|
1615
|
+
...contextSnippet !== void 0 && { contextSnippet },
|
|
1616
|
+
...contextLineIndex !== void 0 && { contextLineIndex },
|
|
1617
|
+
...editFileName && { editFileName },
|
|
1618
|
+
...editFilePath && { editFilePath },
|
|
1619
|
+
...ruleName && { ruleName }
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
// src/ui/native.ts
|
|
1624
|
+
var isTestEnv = () => {
|
|
1625
|
+
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";
|
|
1626
|
+
};
|
|
1627
|
+
function formatArgs(args, matchedField, matchedWord) {
|
|
1628
|
+
if (args === null || args === void 0) return { message: "(none)", intent: "EXEC" };
|
|
1629
|
+
let parsed = args;
|
|
1630
|
+
if (typeof args === "string") {
|
|
1631
|
+
const trimmed = args.trim();
|
|
1632
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
1633
|
+
try {
|
|
1634
|
+
parsed = JSON.parse(trimmed);
|
|
1635
|
+
} catch {
|
|
1636
|
+
parsed = args;
|
|
1637
|
+
}
|
|
1638
|
+
} else {
|
|
1639
|
+
return { message: smartTruncate(args, 600), intent: "EXEC" };
|
|
1437
1640
|
}
|
|
1438
1641
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1642
|
+
if (typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
1643
|
+
const obj = parsed;
|
|
1644
|
+
if (obj.old_string !== void 0 && obj.new_string !== void 0) {
|
|
1645
|
+
const file = obj.file_path ? import_path8.default.basename(String(obj.file_path)) : "file";
|
|
1646
|
+
const oldPreview = smartTruncate(String(obj.old_string), 120);
|
|
1647
|
+
const newPreview = extractContext(String(obj.new_string), matchedWord).snippet;
|
|
1648
|
+
return {
|
|
1649
|
+
intent: "EDIT",
|
|
1650
|
+
message: `\u{1F4DD} EDITING: ${file}
|
|
1651
|
+
\u{1F4C2} PATH: ${obj.file_path}
|
|
1652
|
+
|
|
1653
|
+
--- REPLACING ---
|
|
1654
|
+
${oldPreview}
|
|
1655
|
+
|
|
1656
|
+
+++ NEW CODE +++
|
|
1657
|
+
${newPreview}`
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
if (matchedField && obj[matchedField] !== void 0) {
|
|
1661
|
+
const otherKeys = Object.keys(obj).filter((k) => k !== matchedField);
|
|
1662
|
+
const context = otherKeys.length > 0 ? `\u2699\uFE0F Context: ${otherKeys.map((k) => `${k}=${smartTruncate(typeof obj[k] === "object" ? JSON.stringify(obj[k]) : String(obj[k]), 30)}`).join(", ")}
|
|
1663
|
+
|
|
1664
|
+
` : "";
|
|
1665
|
+
const content = extractContext(String(obj[matchedField]), matchedWord).snippet;
|
|
1666
|
+
return {
|
|
1667
|
+
intent: "EXEC",
|
|
1668
|
+
message: `${context}\u{1F6D1} [${matchedField.toUpperCase()}]:
|
|
1669
|
+
${content}`
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
const codeKeys = [
|
|
1673
|
+
"command",
|
|
1674
|
+
"cmd",
|
|
1675
|
+
"shell_command",
|
|
1676
|
+
"bash_command",
|
|
1677
|
+
"script",
|
|
1678
|
+
"code",
|
|
1679
|
+
"input",
|
|
1680
|
+
"sql",
|
|
1681
|
+
"query",
|
|
1682
|
+
"arguments",
|
|
1683
|
+
"args",
|
|
1684
|
+
"param",
|
|
1685
|
+
"params",
|
|
1686
|
+
"text"
|
|
1687
|
+
];
|
|
1688
|
+
const foundKey = Object.keys(obj).find((k) => codeKeys.includes(k.toLowerCase()));
|
|
1689
|
+
if (foundKey) {
|
|
1690
|
+
const val = obj[foundKey];
|
|
1691
|
+
const str = typeof val === "string" ? val : JSON.stringify(val);
|
|
1692
|
+
return {
|
|
1693
|
+
intent: "EXEC",
|
|
1694
|
+
message: `[${foundKey.toUpperCase()}]:
|
|
1695
|
+
${smartTruncate(str, 500)}`
|
|
1696
|
+
};
|
|
1448
1697
|
}
|
|
1449
|
-
|
|
1698
|
+
const msg = Object.entries(obj).slice(0, 5).map(
|
|
1699
|
+
([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
|
|
1700
|
+
).join("\n");
|
|
1701
|
+
return { intent: "EXEC", message: msg };
|
|
1450
1702
|
}
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1703
|
+
return { intent: "EXEC", message: smartTruncate(JSON.stringify(parsed), 200) };
|
|
1704
|
+
}
|
|
1705
|
+
function escapePango(text) {
|
|
1706
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1707
|
+
}
|
|
1708
|
+
function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
|
|
1709
|
+
const lines = [];
|
|
1710
|
+
if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
|
|
1711
|
+
lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
|
|
1712
|
+
lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
|
|
1713
|
+
lines.push("");
|
|
1714
|
+
lines.push(formattedArgs);
|
|
1715
|
+
if (!locked) {
|
|
1716
|
+
lines.push("");
|
|
1717
|
+
lines.push('\u21B5 Enter = Allow \u21B5 | \u238B Esc = Block \u238B | "Always Allow" = never ask again');
|
|
1454
1718
|
}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1719
|
+
return lines.join("\n");
|
|
1720
|
+
}
|
|
1721
|
+
function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
|
|
1722
|
+
const lines = [];
|
|
1723
|
+
if (locked) {
|
|
1724
|
+
lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
|
|
1725
|
+
lines.push("");
|
|
1726
|
+
}
|
|
1727
|
+
lines.push(
|
|
1728
|
+
`<b>\u{1F916} ${escapePango(agent || "AI Agent")}</b> | <b>\u{1F527} <tt>${escapePango(toolName)}</tt></b>`
|
|
1729
|
+
);
|
|
1730
|
+
lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
|
|
1731
|
+
lines.push("");
|
|
1732
|
+
lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
|
|
1733
|
+
if (!locked) {
|
|
1734
|
+
lines.push("");
|
|
1735
|
+
lines.push(
|
|
1736
|
+
'<small>\u21B5 Enter = <b>Allow \u21B5</b> | \u238B Esc = <b>Block \u238B</b> | "Always Allow" = never ask again</small>'
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
return lines.join("\n");
|
|
1740
|
+
}
|
|
1741
|
+
async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord) {
|
|
1742
|
+
if (isTestEnv()) return "deny";
|
|
1743
|
+
const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
|
|
1744
|
+
const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
|
|
1745
|
+
const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
|
|
1746
|
+
const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
|
|
1747
|
+
return new Promise((resolve) => {
|
|
1748
|
+
let childProcess = null;
|
|
1749
|
+
const onAbort = () => {
|
|
1750
|
+
if (childProcess && childProcess.pid) {
|
|
1460
1751
|
try {
|
|
1461
|
-
|
|
1752
|
+
process.kill(childProcess.pid, "SIGKILL");
|
|
1462
1753
|
} catch {
|
|
1463
|
-
return false;
|
|
1464
|
-
}
|
|
1465
|
-
})();
|
|
1466
|
-
if (hit && !matchedDangerousWord) matchedDangerousWord = word;
|
|
1467
|
-
return hit;
|
|
1468
|
-
})
|
|
1469
|
-
);
|
|
1470
|
-
if (isDangerous) {
|
|
1471
|
-
let matchedField;
|
|
1472
|
-
if (matchedDangerousWord && args && typeof args === "object" && !Array.isArray(args)) {
|
|
1473
|
-
const obj = args;
|
|
1474
|
-
for (const [key, value] of Object.entries(obj)) {
|
|
1475
|
-
if (typeof value === "string") {
|
|
1476
|
-
try {
|
|
1477
|
-
if (new RegExp(
|
|
1478
|
-
`\\b${matchedDangerousWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
|
|
1479
|
-
"i"
|
|
1480
|
-
).test(value)) {
|
|
1481
|
-
matchedField = key;
|
|
1482
|
-
break;
|
|
1483
|
-
}
|
|
1484
|
-
} catch {
|
|
1485
|
-
}
|
|
1486
1754
|
}
|
|
1487
1755
|
}
|
|
1488
|
-
|
|
1489
|
-
return {
|
|
1490
|
-
decision: "review",
|
|
1491
|
-
blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
|
|
1492
|
-
matchedWord: matchedDangerousWord,
|
|
1493
|
-
matchedField,
|
|
1494
|
-
tier: 6
|
|
1756
|
+
resolve("deny");
|
|
1495
1757
|
};
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)", tier: 7 };
|
|
1501
|
-
}
|
|
1502
|
-
return { decision: "allow" };
|
|
1503
|
-
}
|
|
1504
|
-
function isIgnoredTool(toolName) {
|
|
1505
|
-
const config = getConfig();
|
|
1506
|
-
return matchesPattern(toolName, config.policy.ignoredTools);
|
|
1507
|
-
}
|
|
1508
|
-
var DAEMON_PORT = 7391;
|
|
1509
|
-
var DAEMON_HOST = "127.0.0.1";
|
|
1510
|
-
function isDaemonRunning() {
|
|
1511
|
-
const pidFile = import_path5.default.join(import_os2.default.homedir(), ".node9", "daemon.pid");
|
|
1512
|
-
if (import_fs3.default.existsSync(pidFile)) {
|
|
1758
|
+
if (signal) {
|
|
1759
|
+
if (signal.aborted) return resolve("deny");
|
|
1760
|
+
signal.addEventListener("abort", onAbort);
|
|
1761
|
+
}
|
|
1513
1762
|
try {
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1763
|
+
if (process.platform === "darwin") {
|
|
1764
|
+
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"`;
|
|
1765
|
+
const script = `on run argv
|
|
1766
|
+
tell application "System Events"
|
|
1767
|
+
activate
|
|
1768
|
+
display dialog (item 1 of argv) with title (item 2 of argv) ${buttons}
|
|
1769
|
+
end tell
|
|
1770
|
+
end run`;
|
|
1771
|
+
childProcess = (0, import_child_process2.spawn)("osascript", ["-e", script, "--", message, title]);
|
|
1772
|
+
} else if (process.platform === "linux") {
|
|
1773
|
+
const pangoMessage = buildPangoMessage(
|
|
1774
|
+
toolName,
|
|
1775
|
+
formattedArgs,
|
|
1776
|
+
agent,
|
|
1777
|
+
explainableLabel,
|
|
1778
|
+
locked
|
|
1779
|
+
);
|
|
1780
|
+
const argsList = [
|
|
1781
|
+
locked ? "--info" : "--question",
|
|
1782
|
+
"--modal",
|
|
1783
|
+
"--width=480",
|
|
1784
|
+
"--title",
|
|
1785
|
+
title,
|
|
1786
|
+
"--text",
|
|
1787
|
+
pangoMessage,
|
|
1788
|
+
"--ok-label",
|
|
1789
|
+
locked ? "Waiting..." : "Allow \u21B5",
|
|
1790
|
+
"--timeout",
|
|
1791
|
+
"300"
|
|
1792
|
+
];
|
|
1793
|
+
if (!locked) {
|
|
1794
|
+
argsList.push("--cancel-label", "Block \u238B");
|
|
1795
|
+
argsList.push("--extra-button", "Always Allow");
|
|
1796
|
+
}
|
|
1797
|
+
childProcess = (0, import_child_process2.spawn)("zenity", argsList);
|
|
1798
|
+
} else if (process.platform === "win32") {
|
|
1799
|
+
const b64Msg = Buffer.from(message).toString("base64");
|
|
1800
|
+
const b64Title = Buffer.from(title).toString("base64");
|
|
1801
|
+
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 }`;
|
|
1802
|
+
childProcess = (0, import_child_process2.spawn)("powershell", ["-Command", ps]);
|
|
1803
|
+
}
|
|
1804
|
+
let output = "";
|
|
1805
|
+
childProcess?.stdout?.on("data", (d) => output += d.toString());
|
|
1806
|
+
childProcess?.on("close", (code) => {
|
|
1807
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
1808
|
+
if (locked) return resolve("deny");
|
|
1809
|
+
if (output.includes("Always Allow")) return resolve("always_allow");
|
|
1810
|
+
if (code === 0) return resolve("allow");
|
|
1811
|
+
resolve("deny");
|
|
1812
|
+
});
|
|
1518
1813
|
} catch {
|
|
1519
|
-
|
|
1814
|
+
resolve("deny");
|
|
1520
1815
|
}
|
|
1521
|
-
}
|
|
1522
|
-
try {
|
|
1523
|
-
const r = (0, import_child_process2.spawnSync)("ss", ["-Htnp", `sport = :${DAEMON_PORT}`], {
|
|
1524
|
-
encoding: "utf8",
|
|
1525
|
-
timeout: 500
|
|
1526
|
-
});
|
|
1527
|
-
return r.status === 0 && (r.stdout ?? "").includes(`:${DAEMON_PORT}`);
|
|
1528
|
-
} catch {
|
|
1529
|
-
return false;
|
|
1530
|
-
}
|
|
1816
|
+
});
|
|
1531
1817
|
}
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1818
|
+
|
|
1819
|
+
// src/auth/cloud.ts
|
|
1820
|
+
var import_fs7 = __toESM(require("fs"));
|
|
1821
|
+
var import_os6 = __toESM(require("os"));
|
|
1822
|
+
function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
|
|
1823
|
+
return fetch(`${creds.apiUrl}/audit`, {
|
|
1824
|
+
method: "POST",
|
|
1825
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1826
|
+
body: JSON.stringify({
|
|
1827
|
+
toolName,
|
|
1828
|
+
args,
|
|
1829
|
+
checkedBy,
|
|
1830
|
+
context: {
|
|
1831
|
+
agent: meta?.agent,
|
|
1832
|
+
mcpServer: meta?.mcpServer,
|
|
1833
|
+
hostname: import_os6.default.hostname(),
|
|
1834
|
+
cwd: process.cwd(),
|
|
1835
|
+
platform: import_os6.default.platform()
|
|
1836
|
+
}
|
|
1837
|
+
}),
|
|
1838
|
+
signal: AbortSignal.timeout(5e3)
|
|
1839
|
+
}).then(() => {
|
|
1840
|
+
}).catch(() => {
|
|
1841
|
+
});
|
|
1542
1842
|
}
|
|
1543
|
-
async function
|
|
1544
|
-
const
|
|
1545
|
-
const
|
|
1546
|
-
const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
|
|
1547
|
-
const onAbort = () => checkCtrl.abort();
|
|
1548
|
-
if (signal) signal.addEventListener("abort", onAbort);
|
|
1843
|
+
async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
|
|
1844
|
+
const controller = new AbortController();
|
|
1845
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
1549
1846
|
try {
|
|
1550
|
-
const
|
|
1847
|
+
const response = await fetch(creds.apiUrl, {
|
|
1551
1848
|
method: "POST",
|
|
1552
|
-
headers: { "Content-Type": "application/json" },
|
|
1849
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1553
1850
|
body: JSON.stringify({
|
|
1554
1851
|
toolName,
|
|
1555
1852
|
args,
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
activityId,
|
|
1853
|
+
context: {
|
|
1854
|
+
agent: meta?.agent,
|
|
1855
|
+
mcpServer: meta?.mcpServer,
|
|
1856
|
+
hostname: import_os6.default.hostname(),
|
|
1857
|
+
cwd: process.cwd(),
|
|
1858
|
+
platform: import_os6.default.platform()
|
|
1859
|
+
},
|
|
1564
1860
|
...riskMetadata && { riskMetadata }
|
|
1565
1861
|
}),
|
|
1566
|
-
signal:
|
|
1862
|
+
signal: controller.signal
|
|
1863
|
+
});
|
|
1864
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
1865
|
+
return await response.json();
|
|
1866
|
+
} finally {
|
|
1867
|
+
clearTimeout(timeout);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
async function pollNode9SaaS(requestId, creds, signal) {
|
|
1871
|
+
const statusUrl = `${creds.apiUrl}/status/${requestId}`;
|
|
1872
|
+
const POLL_INTERVAL_MS = 1e3;
|
|
1873
|
+
const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
|
|
1874
|
+
while (Date.now() < POLL_DEADLINE) {
|
|
1875
|
+
if (signal.aborted) throw new Error("Aborted");
|
|
1876
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
1877
|
+
try {
|
|
1878
|
+
const pollCtrl = new AbortController();
|
|
1879
|
+
const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
|
|
1880
|
+
const statusRes = await fetch(statusUrl, {
|
|
1881
|
+
headers: { Authorization: `Bearer ${creds.apiKey}` },
|
|
1882
|
+
signal: pollCtrl.signal
|
|
1883
|
+
});
|
|
1884
|
+
clearTimeout(pollTimer);
|
|
1885
|
+
if (!statusRes.ok) continue;
|
|
1886
|
+
const { status, reason } = await statusRes.json();
|
|
1887
|
+
if (status === "APPROVED") {
|
|
1888
|
+
return { approved: true, reason };
|
|
1889
|
+
}
|
|
1890
|
+
if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
|
|
1891
|
+
return { approved: false, reason };
|
|
1892
|
+
}
|
|
1893
|
+
} catch {
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1896
|
+
return { approved: false, reason: "Cloud approval timed out after 10 minutes." };
|
|
1897
|
+
}
|
|
1898
|
+
async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
|
|
1899
|
+
try {
|
|
1900
|
+
const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
|
|
1901
|
+
const ctrl = new AbortController();
|
|
1902
|
+
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
1903
|
+
const res = await fetch(resolveUrl, {
|
|
1904
|
+
method: "PATCH",
|
|
1905
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
1906
|
+
body: JSON.stringify({
|
|
1907
|
+
decision: approved ? "APPROVED" : "DENIED",
|
|
1908
|
+
...decidedBy && { decidedBy }
|
|
1909
|
+
}),
|
|
1910
|
+
signal: ctrl.signal
|
|
1567
1911
|
});
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
|
|
1576
|
-
if (!waitRes.ok) return "deny";
|
|
1577
|
-
const { decision } = await waitRes.json();
|
|
1578
|
-
if (decision === "allow") return "allow";
|
|
1579
|
-
if (decision === "abandoned") return "abandoned";
|
|
1580
|
-
return "deny";
|
|
1581
|
-
} finally {
|
|
1582
|
-
clearTimeout(waitTimer);
|
|
1583
|
-
if (signal) signal.removeEventListener("abort", onWaitAbort);
|
|
1912
|
+
clearTimeout(timer);
|
|
1913
|
+
if (!res.ok) {
|
|
1914
|
+
import_fs7.default.appendFileSync(
|
|
1915
|
+
HOOK_DEBUG_LOG,
|
|
1916
|
+
`[resolve-cloud] PATCH ${resolveUrl} \u2192 HTTP ${res.status}
|
|
1917
|
+
`
|
|
1918
|
+
);
|
|
1584
1919
|
}
|
|
1585
|
-
}
|
|
1586
|
-
|
|
1587
|
-
|
|
1920
|
+
} catch (err) {
|
|
1921
|
+
import_fs7.default.appendFileSync(
|
|
1922
|
+
HOOK_DEBUG_LOG,
|
|
1923
|
+
`[resolve-cloud] PATCH failed for ${requestId}: ${err.message}
|
|
1924
|
+
`
|
|
1925
|
+
);
|
|
1588
1926
|
}
|
|
1589
1927
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
method: "POST",
|
|
1594
|
-
headers: { "Content-Type": "application/json" },
|
|
1595
|
-
body: JSON.stringify({
|
|
1596
|
-
toolName,
|
|
1597
|
-
args,
|
|
1598
|
-
slackDelegated: true,
|
|
1599
|
-
agent: meta?.agent,
|
|
1600
|
-
mcpServer: meta?.mcpServer,
|
|
1601
|
-
...riskMetadata && { riskMetadata }
|
|
1602
|
-
}),
|
|
1603
|
-
signal: AbortSignal.timeout(3e3)
|
|
1604
|
-
});
|
|
1605
|
-
if (!res.ok) throw new Error("Daemon unreachable");
|
|
1606
|
-
const { id } = await res.json();
|
|
1607
|
-
return id;
|
|
1608
|
-
}
|
|
1609
|
-
async function resolveViaDaemon(id, decision, internalToken) {
|
|
1610
|
-
const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
|
|
1611
|
-
await fetch(`${base}/resolve/${id}`, {
|
|
1612
|
-
method: "POST",
|
|
1613
|
-
headers: { "Content-Type": "application/json", "X-Node9-Internal": internalToken },
|
|
1614
|
-
body: JSON.stringify({ decision }),
|
|
1615
|
-
signal: AbortSignal.timeout(3e3)
|
|
1616
|
-
});
|
|
1617
|
-
}
|
|
1618
|
-
var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : import_path5.default.join(import_os2.default.tmpdir(), "node9-activity.sock");
|
|
1928
|
+
|
|
1929
|
+
// src/auth/orchestrator.ts
|
|
1930
|
+
var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : import_path9.default.join(import_os7.default.tmpdir(), "node9-activity.sock");
|
|
1619
1931
|
function notifyActivity(data) {
|
|
1620
1932
|
return new Promise((resolve) => {
|
|
1621
1933
|
try {
|
|
@@ -1631,12 +1943,12 @@ function notifyActivity(data) {
|
|
|
1631
1943
|
}
|
|
1632
1944
|
});
|
|
1633
1945
|
}
|
|
1634
|
-
async function authorizeHeadless(toolName, args,
|
|
1946
|
+
async function authorizeHeadless(toolName, args, meta, options) {
|
|
1635
1947
|
if (!options?.calledFromDaemon) {
|
|
1636
1948
|
const actId = (0, import_crypto.randomUUID)();
|
|
1637
1949
|
const actTs = Date.now();
|
|
1638
1950
|
await notifyActivity({ id: actId, ts: actTs, tool: toolName, args, status: "pending" });
|
|
1639
|
-
const result = await _authorizeHeadlessCore(toolName, args,
|
|
1951
|
+
const result = await _authorizeHeadlessCore(toolName, args, meta, {
|
|
1640
1952
|
...options,
|
|
1641
1953
|
activityId: actId
|
|
1642
1954
|
});
|
|
@@ -1651,14 +1963,14 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
|
|
|
1651
1963
|
}
|
|
1652
1964
|
return result;
|
|
1653
1965
|
}
|
|
1654
|
-
return _authorizeHeadlessCore(toolName, args,
|
|
1966
|
+
return _authorizeHeadlessCore(toolName, args, meta, options);
|
|
1655
1967
|
}
|
|
1656
|
-
async function _authorizeHeadlessCore(toolName, args,
|
|
1968
|
+
async function _authorizeHeadlessCore(toolName, args, meta, options) {
|
|
1657
1969
|
if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
|
|
1658
1970
|
const pauseState = checkPause();
|
|
1659
1971
|
if (pauseState.paused) return { approved: true, checkedBy: "paused" };
|
|
1660
1972
|
const creds = getCredentials();
|
|
1661
|
-
const config = getConfig();
|
|
1973
|
+
const config = getConfig(options?.cwd);
|
|
1662
1974
|
const isTestEnv2 = !!(process.env.VITEST || process.env.NODE_ENV === "test" || process.env.CI || process.env.NODE9_TESTING === "1");
|
|
1663
1975
|
const approvers = {
|
|
1664
1976
|
...config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }
|
|
@@ -1695,651 +2007,276 @@ async function _authorizeHeadlessCore(toolName, args, allowTerminalFallback = fa
|
|
|
1695
2007
|
explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
|
|
1696
2008
|
}
|
|
1697
2009
|
}
|
|
1698
|
-
if (config.settings.mode === "audit") {
|
|
1699
|
-
if (!isIgnoredTool(toolName)) {
|
|
1700
|
-
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
1701
|
-
if (policyResult.decision === "review") {
|
|
1702
|
-
appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
|
|
1703
|
-
if (approvers.cloud && creds?.apiKey) {
|
|
1704
|
-
await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
|
|
1705
|
-
}
|
|
1706
|
-
sendDesktopNotification(
|
|
1707
|
-
"Node9 Audit Mode",
|
|
1708
|
-
`Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
|
|
1709
|
-
);
|
|
1710
|
-
}
|
|
1711
|
-
}
|
|
1712
|
-
return { approved: true, checkedBy: "audit" };
|
|
1713
|
-
}
|
|
1714
|
-
if (!isIgnoredTool(toolName)) {
|
|
1715
|
-
if (getActiveTrustSession(toolName)) {
|
|
1716
|
-
if (approvers.cloud && creds?.apiKey)
|
|
1717
|
-
await auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
1718
|
-
if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
|
|
1719
|
-
return { approved: true, checkedBy: "trust" };
|
|
1720
|
-
}
|
|
1721
|
-
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
1722
|
-
if (policyResult.decision === "allow") {
|
|
1723
|
-
if (approvers.cloud && creds?.apiKey)
|
|
1724
|
-
auditLocalAllow(toolName, args, "local-policy", creds, meta);
|
|
1725
|
-
if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
|
|
1726
|
-
return { approved: true, checkedBy: "local-policy" };
|
|
1727
|
-
}
|
|
1728
|
-
if (policyResult.decision === "block") {
|
|
1729
|
-
if (!isManual) appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta);
|
|
1730
|
-
return {
|
|
1731
|
-
approved: false,
|
|
1732
|
-
reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
|
|
1733
|
-
blockedBy: "local-config",
|
|
1734
|
-
blockedByLabel: policyResult.blockedByLabel
|
|
1735
|
-
};
|
|
1736
|
-
}
|
|
1737
|
-
explainableLabel = policyResult.blockedByLabel || "Local Config";
|
|
1738
|
-
policyMatchedField = policyResult.matchedField;
|
|
1739
|
-
policyMatchedWord = policyResult.matchedWord;
|
|
1740
|
-
riskMetadata = computeRiskMetadata(
|
|
1741
|
-
args,
|
|
1742
|
-
policyResult.tier ?? 6,
|
|
1743
|
-
explainableLabel,
|
|
1744
|
-
policyMatchedField,
|
|
1745
|
-
policyMatchedWord,
|
|
1746
|
-
policyResult.ruleName
|
|
1747
|
-
);
|
|
1748
|
-
const persistent = getPersistentDecision(toolName);
|
|
1749
|
-
if (persistent === "allow") {
|
|
1750
|
-
if (approvers.cloud && creds?.apiKey)
|
|
1751
|
-
await auditLocalAllow(toolName, args, "persistent", creds, meta);
|
|
1752
|
-
if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
|
|
1753
|
-
return { approved: true, checkedBy: "persistent" };
|
|
1754
|
-
}
|
|
1755
|
-
if (persistent === "deny") {
|
|
1756
|
-
if (!isManual) appendLocalAudit(toolName, args, "deny", "persistent-deny", meta);
|
|
1757
|
-
return {
|
|
1758
|
-
approved: false,
|
|
1759
|
-
reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
|
|
1760
|
-
blockedBy: "persistent-deny",
|
|
1761
|
-
blockedByLabel: "Persistent User Rule"
|
|
1762
|
-
};
|
|
1763
|
-
}
|
|
1764
|
-
} else {
|
|
1765
|
-
if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
|
|
1766
|
-
return { approved: true };
|
|
1767
|
-
}
|
|
1768
|
-
let cloudRequestId = null;
|
|
1769
|
-
let isRemoteLocked = false;
|
|
1770
|
-
const cloudEnforced = approvers.cloud && !!creds?.apiKey;
|
|
1771
|
-
if (cloudEnforced) {
|
|
1772
|
-
try {
|
|
1773
|
-
const initResult = await initNode9SaaS(toolName, args, creds, meta, riskMetadata);
|
|
1774
|
-
if (!initResult.pending) {
|
|
1775
|
-
if (initResult.shadowMode) {
|
|
1776
|
-
console.error(
|
|
1777
|
-
import_chalk2.default.yellow(
|
|
1778
|
-
`
|
|
1779
|
-
\u26A0\uFE0F Node9 Shadow Mode: Action allowed, but would have been blocked by company policy.`
|
|
1780
|
-
)
|
|
1781
|
-
);
|
|
1782
|
-
if (initResult.shadowReason) {
|
|
1783
|
-
console.error(import_chalk2.default.dim(` Reason: ${initResult.shadowReason}
|
|
1784
|
-
`));
|
|
1785
|
-
}
|
|
1786
|
-
return { approved: true, checkedBy: "cloud" };
|
|
1787
|
-
}
|
|
1788
|
-
return {
|
|
1789
|
-
approved: !!initResult.approved,
|
|
1790
|
-
reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
|
|
1791
|
-
checkedBy: initResult.approved ? "cloud" : void 0,
|
|
1792
|
-
blockedBy: initResult.approved ? void 0 : "team-policy",
|
|
1793
|
-
blockedByLabel: "Organization Policy (SaaS)"
|
|
1794
|
-
};
|
|
1795
|
-
}
|
|
1796
|
-
cloudRequestId = initResult.requestId || null;
|
|
1797
|
-
isRemoteLocked = !!initResult.remoteApprovalOnly;
|
|
1798
|
-
explainableLabel = "Organization Policy (SaaS)";
|
|
1799
|
-
} catch (err) {
|
|
1800
|
-
const error = err;
|
|
1801
|
-
const isAuthError = error.message.includes("401") || error.message.includes("403");
|
|
1802
|
-
const isNetworkError = error.message.includes("fetch") || error.name === "AbortError" || error.message.includes("ECONNREFUSED");
|
|
1803
|
-
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;
|
|
1804
|
-
console.error(
|
|
1805
|
-
import_chalk2.default.yellow(`
|
|
1806
|
-
\u26A0\uFE0F Node9: Cloud API Handshake failed \u2014 ${reason}`) + import_chalk2.default.dim(`
|
|
1807
|
-
Falling back to local rules...
|
|
1808
|
-
`)
|
|
1809
|
-
);
|
|
1810
|
-
}
|
|
1811
|
-
}
|
|
1812
|
-
if (!options?.calledFromDaemon) {
|
|
1813
|
-
if (cloudEnforced && cloudRequestId) {
|
|
1814
|
-
console.error(
|
|
1815
|
-
import_chalk2.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
|
|
1816
|
-
);
|
|
1817
|
-
console.error(
|
|
1818
|
-
import_chalk2.default.cyan(" Dashboard \u2192 ") + import_chalk2.default.bold("Mission Control > Activity Feed\n")
|
|
1819
|
-
);
|
|
1820
|
-
} else if (!cloudEnforced) {
|
|
1821
|
-
const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
|
|
1822
|
-
console.error(
|
|
1823
|
-
import_chalk2.default.dim(`
|
|
1824
|
-
\u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
|
|
1825
|
-
`)
|
|
1826
|
-
);
|
|
1827
|
-
}
|
|
1828
|
-
}
|
|
1829
|
-
const abortController = new AbortController();
|
|
1830
|
-
const { signal } = abortController;
|
|
1831
|
-
const racePromises = [];
|
|
1832
|
-
const approvalTimeoutMs = config.settings.approvalTimeoutMs ?? 0;
|
|
1833
|
-
if (approvalTimeoutMs > 0) {
|
|
1834
|
-
racePromises.push(
|
|
1835
|
-
new Promise((resolve, reject) => {
|
|
1836
|
-
const timer = setTimeout(() => {
|
|
1837
|
-
resolve({
|
|
1838
|
-
approved: false,
|
|
1839
|
-
reason: `No human response within ${approvalTimeoutMs / 1e3}s \u2014 auto-denied by timeout policy.`,
|
|
1840
|
-
blockedBy: "timeout",
|
|
1841
|
-
blockedByLabel: "Approval Timeout"
|
|
1842
|
-
});
|
|
1843
|
-
}, approvalTimeoutMs);
|
|
1844
|
-
signal.addEventListener("abort", () => {
|
|
1845
|
-
clearTimeout(timer);
|
|
1846
|
-
reject(new Error("Aborted"));
|
|
1847
|
-
});
|
|
1848
|
-
})
|
|
1849
|
-
);
|
|
1850
|
-
}
|
|
1851
|
-
let viewerId = null;
|
|
1852
|
-
const internalToken = getInternalToken();
|
|
1853
|
-
if (cloudEnforced && cloudRequestId) {
|
|
1854
|
-
racePromises.push(
|
|
1855
|
-
(async () => {
|
|
1856
|
-
try {
|
|
1857
|
-
if (isDaemonRunning() && internalToken && !options?.calledFromDaemon) {
|
|
1858
|
-
viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(
|
|
1859
|
-
() => null
|
|
1860
|
-
);
|
|
1861
|
-
}
|
|
1862
|
-
const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
|
|
1863
|
-
return {
|
|
1864
|
-
approved: cloudResult.approved,
|
|
1865
|
-
reason: cloudResult.approved ? void 0 : cloudResult.reason || "Action rejected by organization administrator via Slack.",
|
|
1866
|
-
checkedBy: cloudResult.approved ? "cloud" : void 0,
|
|
1867
|
-
blockedBy: cloudResult.approved ? void 0 : "team-policy",
|
|
1868
|
-
blockedByLabel: "Organization Policy (SaaS)"
|
|
1869
|
-
};
|
|
1870
|
-
} catch (err) {
|
|
1871
|
-
const error = err;
|
|
1872
|
-
if (error.name === "AbortError" || error.message?.includes("Aborted")) throw err;
|
|
1873
|
-
throw err;
|
|
1874
|
-
}
|
|
1875
|
-
})()
|
|
1876
|
-
);
|
|
1877
|
-
}
|
|
1878
|
-
if (approvers.native && !isManual && !options?.calledFromDaemon) {
|
|
1879
|
-
racePromises.push(
|
|
1880
|
-
(async () => {
|
|
1881
|
-
const decision = await askNativePopup(
|
|
1882
|
-
toolName,
|
|
1883
|
-
args,
|
|
1884
|
-
meta?.agent,
|
|
1885
|
-
explainableLabel,
|
|
1886
|
-
isRemoteLocked,
|
|
1887
|
-
signal,
|
|
1888
|
-
policyMatchedField,
|
|
1889
|
-
policyMatchedWord
|
|
1890
|
-
);
|
|
1891
|
-
if (decision === "always_allow") {
|
|
1892
|
-
writeTrustSession(toolName, 36e5);
|
|
1893
|
-
return { approved: true, checkedBy: "trust" };
|
|
1894
|
-
}
|
|
1895
|
-
const isApproved = decision === "allow";
|
|
1896
|
-
return {
|
|
1897
|
-
approved: isApproved,
|
|
1898
|
-
reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
|
|
1899
|
-
checkedBy: isApproved ? "daemon" : void 0,
|
|
1900
|
-
blockedBy: isApproved ? void 0 : "local-decision",
|
|
1901
|
-
blockedByLabel: "User Decision (Native)"
|
|
1902
|
-
};
|
|
1903
|
-
})()
|
|
1904
|
-
);
|
|
1905
|
-
}
|
|
1906
|
-
if (approvers.browser && isDaemonRunning() && !options?.calledFromDaemon && !cloudEnforced) {
|
|
1907
|
-
racePromises.push(
|
|
1908
|
-
(async () => {
|
|
1909
|
-
try {
|
|
1910
|
-
if (!approvers.native && !cloudEnforced) {
|
|
1911
|
-
console.error(
|
|
1912
|
-
import_chalk2.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for browser approval.")
|
|
1913
|
-
);
|
|
1914
|
-
console.error(import_chalk2.default.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
|
|
1915
|
-
`));
|
|
1916
|
-
}
|
|
1917
|
-
const daemonDecision = await askDaemon(
|
|
1918
|
-
toolName,
|
|
1919
|
-
args,
|
|
1920
|
-
meta,
|
|
1921
|
-
signal,
|
|
1922
|
-
riskMetadata,
|
|
1923
|
-
options?.activityId
|
|
1924
|
-
);
|
|
1925
|
-
if (daemonDecision === "abandoned") throw new Error("Abandoned");
|
|
1926
|
-
const isApproved = daemonDecision === "allow";
|
|
1927
|
-
return {
|
|
1928
|
-
approved: isApproved,
|
|
1929
|
-
reason: isApproved ? void 0 : "The human user rejected this action via the Node9 Browser Dashboard.",
|
|
1930
|
-
checkedBy: isApproved ? "daemon" : void 0,
|
|
1931
|
-
blockedBy: isApproved ? void 0 : "local-decision",
|
|
1932
|
-
blockedByLabel: "User Decision (Browser)"
|
|
1933
|
-
};
|
|
1934
|
-
} catch (err) {
|
|
1935
|
-
throw err;
|
|
1936
|
-
}
|
|
1937
|
-
})()
|
|
1938
|
-
);
|
|
1939
|
-
}
|
|
1940
|
-
if (approvers.terminal && allowTerminalFallback && process.stdout.isTTY) {
|
|
1941
|
-
racePromises.push(
|
|
1942
|
-
(async () => {
|
|
1943
|
-
try {
|
|
1944
|
-
if (explainableLabel.includes("DLP")) {
|
|
1945
|
-
console.log(import_chalk2.default.bgRed.white.bold(` \u{1F6A8} NODE9 DLP ALERT \u2014 CREDENTIAL DETECTED `));
|
|
1946
|
-
console.log(
|
|
1947
|
-
import_chalk2.default.red.bold(` A sensitive secret was detected in the tool arguments!`)
|
|
1948
|
-
);
|
|
1949
|
-
} else {
|
|
1950
|
-
console.log(import_chalk2.default.bgRed.white.bold(` \u{1F6D1} NODE9 INTERCEPTOR `));
|
|
1951
|
-
}
|
|
1952
|
-
console.log(`${import_chalk2.default.bold("Action:")} ${import_chalk2.default.red(toolName)}`);
|
|
1953
|
-
console.log(`${import_chalk2.default.bold("Flagged By:")} ${import_chalk2.default.yellow(explainableLabel)}`);
|
|
1954
|
-
if (isRemoteLocked) {
|
|
1955
|
-
console.log(import_chalk2.default.yellow(`\u26A1 LOCKED BY ADMIN POLICY: Waiting for Slack Approval...
|
|
1956
|
-
`));
|
|
1957
|
-
await new Promise((_, reject) => {
|
|
1958
|
-
signal.addEventListener("abort", () => reject(new Error("Aborted by SaaS")));
|
|
1959
|
-
});
|
|
1960
|
-
}
|
|
1961
|
-
const TIMEOUT_MS = 6e4;
|
|
1962
|
-
let timer;
|
|
1963
|
-
const result = await new Promise((resolve, reject) => {
|
|
1964
|
-
timer = setTimeout(() => reject(new Error("Terminal Timeout")), TIMEOUT_MS);
|
|
1965
|
-
(0, import_prompts.confirm)(
|
|
1966
|
-
{ message: `Authorize? (auto-deny in ${TIMEOUT_MS / 1e3}s)`, default: false },
|
|
1967
|
-
{ signal }
|
|
1968
|
-
).then(resolve).catch(reject);
|
|
1969
|
-
});
|
|
1970
|
-
clearTimeout(timer);
|
|
1971
|
-
return {
|
|
1972
|
-
approved: result,
|
|
1973
|
-
reason: result ? void 0 : "The human user typed 'N' in the terminal to reject this action.",
|
|
1974
|
-
checkedBy: result ? "terminal" : void 0,
|
|
1975
|
-
blockedBy: result ? void 0 : "local-decision",
|
|
1976
|
-
blockedByLabel: "User Decision (Terminal)"
|
|
1977
|
-
};
|
|
1978
|
-
} catch (err) {
|
|
1979
|
-
const error = err;
|
|
1980
|
-
if (error.name === "AbortError" || error.message?.includes("Prompt was canceled") || error.message?.includes("Aborted by SaaS"))
|
|
1981
|
-
throw err;
|
|
1982
|
-
if (error.message === "Terminal Timeout") {
|
|
1983
|
-
return {
|
|
1984
|
-
approved: false,
|
|
1985
|
-
reason: "The terminal prompt timed out without a human response.",
|
|
1986
|
-
blockedBy: "local-decision"
|
|
1987
|
-
};
|
|
1988
|
-
}
|
|
1989
|
-
throw err;
|
|
1990
|
-
}
|
|
1991
|
-
})()
|
|
1992
|
-
);
|
|
1993
|
-
}
|
|
1994
|
-
if (racePromises.length === 0) {
|
|
1995
|
-
return {
|
|
1996
|
-
approved: false,
|
|
1997
|
-
noApprovalMechanism: true,
|
|
1998
|
-
reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
|
|
1999
|
-
REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
|
|
2000
|
-
blockedBy: "no-approval-mechanism",
|
|
2001
|
-
blockedByLabel: explainableLabel
|
|
2002
|
-
};
|
|
2003
|
-
}
|
|
2004
|
-
const finalResult = await new Promise((resolve) => {
|
|
2005
|
-
let resolved = false;
|
|
2006
|
-
let failures = 0;
|
|
2007
|
-
const total = racePromises.length;
|
|
2008
|
-
const finish = (res) => {
|
|
2009
|
-
if (!resolved) {
|
|
2010
|
-
resolved = true;
|
|
2011
|
-
abortController.abort();
|
|
2012
|
-
if (viewerId && internalToken) {
|
|
2013
|
-
resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
|
|
2014
|
-
() => null
|
|
2015
|
-
);
|
|
2010
|
+
if (config.settings.mode === "audit") {
|
|
2011
|
+
if (!isIgnoredTool(toolName)) {
|
|
2012
|
+
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
2013
|
+
if (policyResult.decision === "review") {
|
|
2014
|
+
appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
|
|
2015
|
+
if (approvers.cloud && creds?.apiKey) {
|
|
2016
|
+
await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
|
|
2016
2017
|
}
|
|
2017
|
-
resolve(res);
|
|
2018
2018
|
}
|
|
2019
|
-
};
|
|
2020
|
-
for (const p of racePromises) {
|
|
2021
|
-
p.then(finish).catch((err) => {
|
|
2022
|
-
if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
|
|
2023
|
-
return;
|
|
2024
|
-
if (err.message === "Abandoned") {
|
|
2025
|
-
finish({
|
|
2026
|
-
approved: false,
|
|
2027
|
-
reason: "Browser dashboard closed without making a decision.",
|
|
2028
|
-
blockedBy: "local-decision",
|
|
2029
|
-
blockedByLabel: "Browser Dashboard (Abandoned)"
|
|
2030
|
-
});
|
|
2031
|
-
return;
|
|
2032
|
-
}
|
|
2033
|
-
failures++;
|
|
2034
|
-
if (failures === total && !resolved) {
|
|
2035
|
-
finish({ approved: false, reason: "All approval channels failed or disconnected." });
|
|
2036
|
-
}
|
|
2037
|
-
});
|
|
2038
2019
|
}
|
|
2039
|
-
|
|
2040
|
-
if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
|
|
2041
|
-
await resolveNode9SaaS(cloudRequestId, creds, finalResult.approved);
|
|
2020
|
+
return { approved: true, checkedBy: "audit" };
|
|
2042
2021
|
}
|
|
2043
|
-
if (!
|
|
2044
|
-
|
|
2045
|
-
|
|
2022
|
+
if (!isIgnoredTool(toolName)) {
|
|
2023
|
+
if (getActiveTrustSession(toolName)) {
|
|
2024
|
+
if (approvers.cloud && creds?.apiKey)
|
|
2025
|
+
await auditLocalAllow(toolName, args, "trust", creds, meta);
|
|
2026
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
|
|
2027
|
+
return { approved: true, checkedBy: "trust" };
|
|
2028
|
+
}
|
|
2029
|
+
const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
|
|
2030
|
+
if (policyResult.decision === "allow") {
|
|
2031
|
+
if (approvers.cloud && creds?.apiKey)
|
|
2032
|
+
auditLocalAllow(toolName, args, "local-policy", creds, meta);
|
|
2033
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
|
|
2034
|
+
return { approved: true, checkedBy: "local-policy" };
|
|
2035
|
+
}
|
|
2036
|
+
if (policyResult.decision === "block") {
|
|
2037
|
+
if (!isManual) appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta);
|
|
2038
|
+
return {
|
|
2039
|
+
approved: false,
|
|
2040
|
+
reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
|
|
2041
|
+
blockedBy: "local-config",
|
|
2042
|
+
blockedByLabel: policyResult.blockedByLabel
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
explainableLabel = policyResult.blockedByLabel || "Local Config";
|
|
2046
|
+
policyMatchedField = policyResult.matchedField;
|
|
2047
|
+
policyMatchedWord = policyResult.matchedWord;
|
|
2048
|
+
riskMetadata = computeRiskMetadata(
|
|
2046
2049
|
args,
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
+
policyResult.tier ?? 6,
|
|
2051
|
+
explainableLabel,
|
|
2052
|
+
policyMatchedField,
|
|
2053
|
+
policyMatchedWord,
|
|
2054
|
+
policyResult.ruleName
|
|
2050
2055
|
);
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
const projectPath = import_path5.default.join(cwd ?? process.cwd(), "node9.config.json");
|
|
2058
|
-
const globalConfig = tryLoadConfig(globalPath);
|
|
2059
|
-
const projectConfig = tryLoadConfig(projectPath);
|
|
2060
|
-
const mergedSettings = {
|
|
2061
|
-
...DEFAULT_CONFIG.settings,
|
|
2062
|
-
approvers: { ...DEFAULT_CONFIG.settings.approvers }
|
|
2063
|
-
};
|
|
2064
|
-
const mergedPolicy = {
|
|
2065
|
-
sandboxPaths: [...DEFAULT_CONFIG.policy.sandboxPaths],
|
|
2066
|
-
dangerousWords: [...DEFAULT_CONFIG.policy.dangerousWords],
|
|
2067
|
-
ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
|
|
2068
|
-
toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
|
|
2069
|
-
smartRules: [...DEFAULT_CONFIG.policy.smartRules],
|
|
2070
|
-
snapshot: {
|
|
2071
|
-
tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
|
|
2072
|
-
onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
|
|
2073
|
-
ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
|
|
2074
|
-
},
|
|
2075
|
-
dlp: { ...DEFAULT_CONFIG.policy.dlp }
|
|
2076
|
-
};
|
|
2077
|
-
const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
|
|
2078
|
-
const applyLayer = (source) => {
|
|
2079
|
-
if (!source) return;
|
|
2080
|
-
const s = source.settings || {};
|
|
2081
|
-
const p = source.policy || {};
|
|
2082
|
-
if (s.mode !== void 0) mergedSettings.mode = s.mode;
|
|
2083
|
-
if (s.autoStartDaemon !== void 0) mergedSettings.autoStartDaemon = s.autoStartDaemon;
|
|
2084
|
-
if (s.enableUndo !== void 0) mergedSettings.enableUndo = s.enableUndo;
|
|
2085
|
-
if (s.enableHookLogDebug !== void 0)
|
|
2086
|
-
mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
|
|
2087
|
-
if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
|
|
2088
|
-
if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
|
|
2089
|
-
if (s.environment !== void 0) mergedSettings.environment = s.environment;
|
|
2090
|
-
if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
|
|
2091
|
-
if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
|
|
2092
|
-
if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
|
|
2093
|
-
if (p.toolInspection)
|
|
2094
|
-
mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
|
|
2095
|
-
if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
|
|
2096
|
-
if (p.snapshot) {
|
|
2097
|
-
const s2 = p.snapshot;
|
|
2098
|
-
if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
|
|
2099
|
-
if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
|
|
2100
|
-
if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
|
|
2056
|
+
const persistent = getPersistentDecision(toolName);
|
|
2057
|
+
if (persistent === "allow") {
|
|
2058
|
+
if (approvers.cloud && creds?.apiKey)
|
|
2059
|
+
await auditLocalAllow(toolName, args, "persistent", creds, meta);
|
|
2060
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
|
|
2061
|
+
return { approved: true, checkedBy: "persistent" };
|
|
2101
2062
|
}
|
|
2102
|
-
if (
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2063
|
+
if (persistent === "deny") {
|
|
2064
|
+
if (!isManual) appendLocalAudit(toolName, args, "deny", "persistent-deny", meta);
|
|
2065
|
+
return {
|
|
2066
|
+
approved: false,
|
|
2067
|
+
reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
|
|
2068
|
+
blockedBy: "persistent-deny",
|
|
2069
|
+
blockedByLabel: "Persistent User Rule"
|
|
2070
|
+
};
|
|
2106
2071
|
}
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2072
|
+
} else {
|
|
2073
|
+
if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
|
|
2074
|
+
return { approved: true };
|
|
2075
|
+
}
|
|
2076
|
+
let cloudRequestId = null;
|
|
2077
|
+
const cloudEnforced = approvers.cloud && !!creds?.apiKey;
|
|
2078
|
+
if (cloudEnforced) {
|
|
2079
|
+
try {
|
|
2080
|
+
const initResult = await initNode9SaaS(toolName, args, creds, meta, riskMetadata);
|
|
2081
|
+
if (!initResult.pending) {
|
|
2082
|
+
if (initResult.shadowMode) {
|
|
2083
|
+
return { approved: true, checkedBy: "cloud" };
|
|
2084
|
+
}
|
|
2085
|
+
return {
|
|
2086
|
+
approved: !!initResult.approved,
|
|
2087
|
+
reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
|
|
2088
|
+
checkedBy: initResult.approved ? "cloud" : void 0,
|
|
2089
|
+
blockedBy: initResult.approved ? void 0 : "team-policy",
|
|
2090
|
+
blockedByLabel: "Organization Policy (SaaS)"
|
|
2115
2091
|
};
|
|
2116
2092
|
}
|
|
2093
|
+
cloudRequestId = initResult.requestId || null;
|
|
2094
|
+
explainableLabel = "Organization Policy (SaaS)";
|
|
2095
|
+
} catch {
|
|
2117
2096
|
}
|
|
2118
|
-
};
|
|
2119
|
-
applyLayer(globalConfig);
|
|
2120
|
-
applyLayer(projectConfig);
|
|
2121
|
-
const shieldOverrides = readShieldOverrides();
|
|
2122
|
-
for (const shieldName of readActiveShields()) {
|
|
2123
|
-
const shield = getShield(shieldName);
|
|
2124
|
-
if (!shield) continue;
|
|
2125
|
-
const existingRuleNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
2126
|
-
const ruleOverrides = shieldOverrides[shieldName] ?? {};
|
|
2127
|
-
for (const rule of shield.smartRules) {
|
|
2128
|
-
if (!existingRuleNames.has(rule.name)) {
|
|
2129
|
-
const overrideVerdict = rule.name ? ruleOverrides[rule.name] : void 0;
|
|
2130
|
-
mergedPolicy.smartRules.push(
|
|
2131
|
-
overrideVerdict !== void 0 ? { ...rule, verdict: overrideVerdict } : rule
|
|
2132
|
-
);
|
|
2133
|
-
}
|
|
2134
|
-
}
|
|
2135
|
-
const existingWords = new Set(mergedPolicy.dangerousWords);
|
|
2136
|
-
for (const word of shield.dangerousWords) {
|
|
2137
|
-
if (!existingWords.has(word)) mergedPolicy.dangerousWords.push(word);
|
|
2138
|
-
}
|
|
2139
|
-
}
|
|
2140
|
-
const existingAdvisoryNames = new Set(mergedPolicy.smartRules.map((r) => r.name));
|
|
2141
|
-
for (const rule of ADVISORY_SMART_RULES) {
|
|
2142
|
-
if (!existingAdvisoryNames.has(rule.name)) mergedPolicy.smartRules.push(rule);
|
|
2143
|
-
}
|
|
2144
|
-
if (process.env.NODE9_MODE) mergedSettings.mode = process.env.NODE9_MODE;
|
|
2145
|
-
mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
|
|
2146
|
-
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
2147
|
-
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
2148
|
-
mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
|
|
2149
|
-
mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
|
|
2150
|
-
mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
|
|
2151
|
-
const result = {
|
|
2152
|
-
settings: mergedSettings,
|
|
2153
|
-
policy: mergedPolicy,
|
|
2154
|
-
environments: mergedEnvironments
|
|
2155
|
-
};
|
|
2156
|
-
if (!cwd) cachedConfig = result;
|
|
2157
|
-
return result;
|
|
2158
|
-
}
|
|
2159
|
-
function tryLoadConfig(filePath) {
|
|
2160
|
-
if (!import_fs3.default.existsSync(filePath)) return null;
|
|
2161
|
-
let raw;
|
|
2162
|
-
try {
|
|
2163
|
-
raw = JSON.parse(import_fs3.default.readFileSync(filePath, "utf-8"));
|
|
2164
|
-
} catch (err) {
|
|
2165
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2166
|
-
process.stderr.write(
|
|
2167
|
-
`
|
|
2168
|
-
\u26A0\uFE0F Node9: Failed to parse ${filePath}
|
|
2169
|
-
${msg}
|
|
2170
|
-
\u2192 Using default config
|
|
2171
|
-
|
|
2172
|
-
`
|
|
2173
|
-
);
|
|
2174
|
-
return null;
|
|
2175
2097
|
}
|
|
2176
|
-
const
|
|
2177
|
-
const
|
|
2178
|
-
const
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2098
|
+
const abortController = new AbortController();
|
|
2099
|
+
const { signal } = abortController;
|
|
2100
|
+
const racePromises = [];
|
|
2101
|
+
const approvalTimeoutMs = config.settings.approvalTimeoutMs ?? 0;
|
|
2102
|
+
if (approvalTimeoutMs > 0) {
|
|
2103
|
+
racePromises.push(
|
|
2104
|
+
new Promise((resolve, reject) => {
|
|
2105
|
+
const timer = setTimeout(() => {
|
|
2106
|
+
resolve({
|
|
2107
|
+
approved: false,
|
|
2108
|
+
reason: `No human response within ${approvalTimeoutMs / 1e3}s \u2014 auto-denied by timeout policy.`,
|
|
2109
|
+
blockedBy: "timeout",
|
|
2110
|
+
blockedByLabel: "Approval Timeout"
|
|
2111
|
+
});
|
|
2112
|
+
}, approvalTimeoutMs);
|
|
2113
|
+
signal.addEventListener("abort", () => {
|
|
2114
|
+
clearTimeout(timer);
|
|
2115
|
+
reject(new Error("Aborted"));
|
|
2116
|
+
});
|
|
2117
|
+
})
|
|
2118
|
+
);
|
|
2119
|
+
}
|
|
2120
|
+
let viewerId = null;
|
|
2121
|
+
const internalToken = getInternalToken();
|
|
2122
|
+
let daemonEntryId = null;
|
|
2123
|
+
if ((approvers.browser || approvers.terminal) && isDaemonRunning() && !options?.calledFromDaemon) {
|
|
2124
|
+
if (cloudEnforced && cloudRequestId) {
|
|
2125
|
+
viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(() => null);
|
|
2126
|
+
daemonEntryId = viewerId;
|
|
2127
|
+
} else {
|
|
2128
|
+
try {
|
|
2129
|
+
daemonEntryId = await registerDaemonEntry(
|
|
2130
|
+
toolName,
|
|
2131
|
+
args,
|
|
2132
|
+
meta,
|
|
2133
|
+
riskMetadata,
|
|
2134
|
+
options?.activityId,
|
|
2135
|
+
options?.cwd
|
|
2136
|
+
);
|
|
2137
|
+
} catch {
|
|
2138
|
+
}
|
|
2197
2139
|
}
|
|
2198
2140
|
}
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2141
|
+
if (cloudEnforced && cloudRequestId) {
|
|
2142
|
+
racePromises.push(
|
|
2143
|
+
(async () => {
|
|
2144
|
+
try {
|
|
2145
|
+
const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
|
|
2146
|
+
return {
|
|
2147
|
+
approved: cloudResult.approved,
|
|
2148
|
+
reason: cloudResult.approved ? void 0 : cloudResult.reason || "Action rejected by organization administrator via Slack.",
|
|
2149
|
+
checkedBy: cloudResult.approved ? "cloud" : void 0,
|
|
2150
|
+
blockedBy: cloudResult.approved ? void 0 : "team-policy",
|
|
2151
|
+
blockedByLabel: "Organization Policy (SaaS)"
|
|
2152
|
+
};
|
|
2153
|
+
} catch (err) {
|
|
2154
|
+
const error = err;
|
|
2155
|
+
if (error.name === "AbortError" || error.message?.includes("Aborted")) throw err;
|
|
2156
|
+
throw err;
|
|
2157
|
+
}
|
|
2158
|
+
})()
|
|
2208
2159
|
);
|
|
2209
2160
|
}
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
const profileName = process.env.NODE9_PROFILE || "default";
|
|
2229
|
-
const profile = creds[profileName];
|
|
2230
|
-
if (profile?.apiKey) {
|
|
2161
|
+
if (approvers.native && !isManual && !options?.calledFromDaemon) {
|
|
2162
|
+
racePromises.push(
|
|
2163
|
+
(async () => {
|
|
2164
|
+
const decision = await askNativePopup(
|
|
2165
|
+
toolName,
|
|
2166
|
+
args,
|
|
2167
|
+
meta?.agent,
|
|
2168
|
+
explainableLabel,
|
|
2169
|
+
false,
|
|
2170
|
+
signal,
|
|
2171
|
+
policyMatchedField,
|
|
2172
|
+
policyMatchedWord
|
|
2173
|
+
);
|
|
2174
|
+
if (decision === "always_allow") {
|
|
2175
|
+
writeTrustSession(toolName, 36e5);
|
|
2176
|
+
return { approved: true, checkedBy: "trust" };
|
|
2177
|
+
}
|
|
2178
|
+
const isApproved = decision === "allow";
|
|
2231
2179
|
return {
|
|
2232
|
-
|
|
2233
|
-
|
|
2180
|
+
approved: isApproved,
|
|
2181
|
+
reason: isApproved ? void 0 : "The human user clicked 'Block' on the system dialog window.",
|
|
2182
|
+
checkedBy: isApproved ? "daemon" : void 0,
|
|
2183
|
+
blockedBy: isApproved ? void 0 : "local-decision",
|
|
2184
|
+
blockedByLabel: "User Decision (Native)",
|
|
2185
|
+
decisionSource: "native"
|
|
2234
2186
|
};
|
|
2235
|
-
}
|
|
2236
|
-
|
|
2187
|
+
})()
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
if (daemonEntryId && (approvers.browser || approvers.terminal)) {
|
|
2191
|
+
racePromises.push(
|
|
2192
|
+
(async () => {
|
|
2193
|
+
const { decision: daemonDecision, source: decisionSource } = await waitForDaemonDecision(
|
|
2194
|
+
daemonEntryId,
|
|
2195
|
+
signal
|
|
2196
|
+
);
|
|
2197
|
+
if (daemonDecision === "abandoned") throw new Error("Abandoned");
|
|
2198
|
+
const isApproved = daemonDecision === "allow";
|
|
2199
|
+
const src = decisionSource === "terminal" || decisionSource === "browser" ? decisionSource : approvers.browser ? "browser" : "terminal";
|
|
2200
|
+
const via = src === "terminal" ? "Terminal (node9 tail)" : "Browser Dashboard";
|
|
2237
2201
|
return {
|
|
2238
|
-
|
|
2239
|
-
|
|
2202
|
+
approved: isApproved,
|
|
2203
|
+
reason: isApproved ? void 0 : `The human user rejected this action via the Node9 ${via}.`,
|
|
2204
|
+
checkedBy: isApproved ? "daemon" : void 0,
|
|
2205
|
+
blockedBy: isApproved ? void 0 : "local-decision",
|
|
2206
|
+
blockedByLabel: `User Decision (${via})`,
|
|
2207
|
+
decisionSource: src
|
|
2240
2208
|
};
|
|
2209
|
+
})()
|
|
2210
|
+
);
|
|
2211
|
+
}
|
|
2212
|
+
if (racePromises.length === 0) {
|
|
2213
|
+
return {
|
|
2214
|
+
approved: false,
|
|
2215
|
+
noApprovalMechanism: true,
|
|
2216
|
+
reason: `NODE9 SECURITY INTERVENTION: Action blocked by automated policy [${explainableLabel}].
|
|
2217
|
+
REASON: Action blocked because no approval channels are available. (Native/Browser UI is disabled in config, and this terminal is non-interactive).`,
|
|
2218
|
+
blockedBy: "no-approval-mechanism",
|
|
2219
|
+
blockedByLabel: explainableLabel
|
|
2220
|
+
};
|
|
2221
|
+
}
|
|
2222
|
+
const finalResult = await new Promise((resolve) => {
|
|
2223
|
+
let resolved = false;
|
|
2224
|
+
let failures = 0;
|
|
2225
|
+
const total = racePromises.length;
|
|
2226
|
+
const finish = (res) => {
|
|
2227
|
+
if (!resolved) {
|
|
2228
|
+
resolved = true;
|
|
2229
|
+
abortController.abort();
|
|
2230
|
+
if (viewerId && internalToken) {
|
|
2231
|
+
resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
|
|
2232
|
+
() => null
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
resolve(res);
|
|
2241
2236
|
}
|
|
2237
|
+
};
|
|
2238
|
+
for (const p of racePromises) {
|
|
2239
|
+
p.then(finish).catch((err) => {
|
|
2240
|
+
if (err.name === "AbortError" || err.message?.includes("canceled") || err.message?.includes("Aborted"))
|
|
2241
|
+
return;
|
|
2242
|
+
if (err.message === "Abandoned") {
|
|
2243
|
+
finish({
|
|
2244
|
+
approved: false,
|
|
2245
|
+
reason: "Browser dashboard closed without making a decision.",
|
|
2246
|
+
blockedBy: "local-decision",
|
|
2247
|
+
blockedByLabel: "Browser Dashboard (Abandoned)"
|
|
2248
|
+
});
|
|
2249
|
+
return;
|
|
2250
|
+
}
|
|
2251
|
+
failures++;
|
|
2252
|
+
if (failures === total && !resolved) {
|
|
2253
|
+
finish({ approved: false, reason: "All approval channels failed or disconnected." });
|
|
2254
|
+
}
|
|
2255
|
+
});
|
|
2242
2256
|
}
|
|
2243
|
-
}
|
|
2257
|
+
});
|
|
2258
|
+
if (cloudRequestId && creds && finalResult.checkedBy !== "cloud") {
|
|
2259
|
+
await resolveNode9SaaS(
|
|
2260
|
+
cloudRequestId,
|
|
2261
|
+
creds,
|
|
2262
|
+
finalResult.approved,
|
|
2263
|
+
finalResult.decisionSource ?? finalResult.checkedBy ?? "local"
|
|
2264
|
+
);
|
|
2244
2265
|
}
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
async function authorizeAction(toolName, args) {
|
|
2248
|
-
const result = await authorizeHeadless(toolName, args, true);
|
|
2249
|
-
return result.approved;
|
|
2250
|
-
}
|
|
2251
|
-
function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
|
|
2252
|
-
return fetch(`${creds.apiUrl}/audit`, {
|
|
2253
|
-
method: "POST",
|
|
2254
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
2255
|
-
body: JSON.stringify({
|
|
2266
|
+
if (!isManual) {
|
|
2267
|
+
appendLocalAudit(
|
|
2256
2268
|
toolName,
|
|
2257
2269
|
args,
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
hostname: import_os2.default.hostname(),
|
|
2263
|
-
cwd: process.cwd(),
|
|
2264
|
-
platform: import_os2.default.platform()
|
|
2265
|
-
}
|
|
2266
|
-
}),
|
|
2267
|
-
signal: AbortSignal.timeout(5e3)
|
|
2268
|
-
}).then(() => {
|
|
2269
|
-
}).catch(() => {
|
|
2270
|
-
});
|
|
2271
|
-
}
|
|
2272
|
-
async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
|
|
2273
|
-
const controller = new AbortController();
|
|
2274
|
-
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
2275
|
-
try {
|
|
2276
|
-
const response = await fetch(creds.apiUrl, {
|
|
2277
|
-
method: "POST",
|
|
2278
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
2279
|
-
body: JSON.stringify({
|
|
2280
|
-
toolName,
|
|
2281
|
-
args,
|
|
2282
|
-
context: {
|
|
2283
|
-
agent: meta?.agent,
|
|
2284
|
-
mcpServer: meta?.mcpServer,
|
|
2285
|
-
hostname: import_os2.default.hostname(),
|
|
2286
|
-
cwd: process.cwd(),
|
|
2287
|
-
platform: import_os2.default.platform()
|
|
2288
|
-
},
|
|
2289
|
-
...riskMetadata && { riskMetadata }
|
|
2290
|
-
}),
|
|
2291
|
-
signal: controller.signal
|
|
2292
|
-
});
|
|
2293
|
-
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
2294
|
-
return await response.json();
|
|
2295
|
-
} finally {
|
|
2296
|
-
clearTimeout(timeout);
|
|
2297
|
-
}
|
|
2298
|
-
}
|
|
2299
|
-
async function pollNode9SaaS(requestId, creds, signal) {
|
|
2300
|
-
const statusUrl = `${creds.apiUrl}/status/${requestId}`;
|
|
2301
|
-
const POLL_INTERVAL_MS = 1e3;
|
|
2302
|
-
const POLL_DEADLINE = Date.now() + 10 * 60 * 1e3;
|
|
2303
|
-
while (Date.now() < POLL_DEADLINE) {
|
|
2304
|
-
if (signal.aborted) throw new Error("Aborted");
|
|
2305
|
-
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
2306
|
-
try {
|
|
2307
|
-
const pollCtrl = new AbortController();
|
|
2308
|
-
const pollTimer = setTimeout(() => pollCtrl.abort(), 5e3);
|
|
2309
|
-
const statusRes = await fetch(statusUrl, {
|
|
2310
|
-
headers: { Authorization: `Bearer ${creds.apiKey}` },
|
|
2311
|
-
signal: pollCtrl.signal
|
|
2312
|
-
});
|
|
2313
|
-
clearTimeout(pollTimer);
|
|
2314
|
-
if (!statusRes.ok) continue;
|
|
2315
|
-
const { status, reason } = await statusRes.json();
|
|
2316
|
-
if (status === "APPROVED") {
|
|
2317
|
-
console.error(import_chalk2.default.green("\u2705 Approved via Cloud.\n"));
|
|
2318
|
-
return { approved: true, reason };
|
|
2319
|
-
}
|
|
2320
|
-
if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
|
|
2321
|
-
console.error(import_chalk2.default.red("\u274C Denied via Cloud.\n"));
|
|
2322
|
-
return { approved: false, reason };
|
|
2323
|
-
}
|
|
2324
|
-
} catch {
|
|
2325
|
-
}
|
|
2270
|
+
finalResult.approved ? "allow" : "deny",
|
|
2271
|
+
finalResult.checkedBy || finalResult.blockedBy || "unknown",
|
|
2272
|
+
meta
|
|
2273
|
+
);
|
|
2326
2274
|
}
|
|
2327
|
-
return
|
|
2275
|
+
return finalResult;
|
|
2328
2276
|
}
|
|
2329
|
-
async function
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
const ctrl = new AbortController();
|
|
2333
|
-
const timer = setTimeout(() => ctrl.abort(), 5e3);
|
|
2334
|
-
await fetch(resolveUrl, {
|
|
2335
|
-
method: "PATCH",
|
|
2336
|
-
headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
|
|
2337
|
-
body: JSON.stringify({ decision: approved ? "APPROVED" : "DENIED" }),
|
|
2338
|
-
signal: ctrl.signal
|
|
2339
|
-
});
|
|
2340
|
-
clearTimeout(timer);
|
|
2341
|
-
} catch {
|
|
2342
|
-
}
|
|
2277
|
+
async function authorizeAction(toolName, args) {
|
|
2278
|
+
const result = await authorizeHeadless(toolName, args);
|
|
2279
|
+
return result.approved;
|
|
2343
2280
|
}
|
|
2344
2281
|
|
|
2345
2282
|
// src/index.ts
|