@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.
Files changed (6) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +4200 -3668
  3. package/dist/cli.mjs +3937 -3408
  4. package/dist/index.js +1689 -1752
  5. package/dist/index.mjs +1805 -1868
  6. 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/core.ts
38
- var import_chalk2 = __toESM(require("chalk"));
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
- function smartTruncate(str, maxLen = 500) {
58
- if (str.length <= maxLen) return str;
59
- const edge = Math.floor(maxLen / 2) - 3;
60
- return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
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 extractContext(text, matchedWord) {
63
- const lines = text.split("\n");
64
- if (lines.length <= 7 || !matchedWord) {
65
- return { snippet: smartTruncate(text, 500), lineIndex: -1 };
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
- const escaped = matchedWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
68
- const pattern = new RegExp(`\\b${escaped}\\b`, "i");
69
- const allHits = lines.map((line, i) => ({ i, line })).filter(({ line }) => pattern.test(line));
70
- if (allHits.length === 0) return { snippet: smartTruncate(text, 500), lineIndex: -1 };
71
- const nonComment = allHits.find(({ line }) => {
72
- const trimmed = line.trim();
73
- return !trimmed.startsWith("//") && !trimmed.startsWith("#");
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
- var CODE_KEYS = [
87
- "command",
88
- "cmd",
89
- "shell_command",
90
- "bash_command",
91
- "script",
92
- "code",
93
- "input",
94
- "sql",
95
- "query",
96
- "arguments",
97
- "args",
98
- "param",
99
- "params",
100
- "text"
101
- ];
102
- function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWord, ruleName) {
103
- let intent = "EXEC";
104
- let contextSnippet;
105
- let contextLineIndex;
106
- let editFileName;
107
- let editFilePath;
108
- let parsed = args;
109
- if (typeof args === "string") {
110
- const trimmed = args.trim();
111
- if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
112
- try {
113
- parsed = JSON.parse(trimmed);
114
- } catch {
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
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
119
- const obj = parsed;
120
- if (obj.old_string !== void 0 && obj.new_string !== void 0) {
121
- intent = "EDIT";
122
- if (obj.file_path) {
123
- editFilePath = String(obj.file_path);
124
- editFileName = import_path.default.basename(editFilePath);
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
- intent,
145
- tier,
146
- blockedByLabel,
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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 import_fs = __toESM(require("fs"));
481
- var import_path3 = __toESM(require("path"));
482
- var import_os = __toESM(require("os"));
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: "aws\\s+s3.*rb\\s|aws\\s+s3api\\s+delete-bucket",
538
+ value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
568
539
  flags: "i"
569
540
  }
570
541
  ],
571
- verdict: "block",
572
- reason: "S3 bucket deletion is irreversible \u2014 blocked by AWS shield"
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: "shield:aws:review-iam-changes",
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: "aws\\s+iam\\s+(create|delete|attach|detach|put|remove)",
554
+ value: "^\\s*git\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
582
555
  flags: "i"
583
556
  }
584
557
  ],
585
- verdict: "review",
586
- reason: "IAM changes require human approval (AWS shield)"
558
+ conditionMode: "all",
559
+ verdict: "block",
560
+ reason: "Force push overwrites remote history and cannot be undone"
587
561
  },
588
562
  {
589
- name: "shield:aws:block-ec2-terminate",
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: "aws\\s+ec2\\s+terminate-instances",
569
+ value: "^\\s*git\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
596
570
  flags: "i"
597
571
  }
598
572
  ],
599
- verdict: "block",
600
- reason: "EC2 instance termination is irreversible \u2014 blocked by AWS shield"
573
+ conditionMode: "all",
574
+ verdict: "review",
575
+ reason: "git push sends changes to a shared remote"
601
576
  },
602
577
  {
603
- name: "shield:aws:review-rds-delete",
604
- tool: "*",
578
+ name: "review-git-destructive",
579
+ tool: "bash",
605
580
  conditions: [
606
- { field: "command", op: "matches", value: "aws\\s+rds\\s+delete-", flags: "i" }
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: "RDS deletion requires human approval (AWS shield)"
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: "shield:filesystem:review-chmod-777",
594
+ name: "review-sudo",
621
595
  tool: "bash",
622
- conditions: [
623
- { field: "command", op: "matches", value: "chmod\\s+(777|a\\+rwx)", flags: "i" }
624
- ],
596
+ conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
597
+ conditionMode: "all",
625
598
  verdict: "review",
626
- reason: "chmod 777 requires human approval (filesystem shield)"
599
+ reason: "Command requires elevated privileges"
627
600
  },
628
601
  {
629
- name: "shield:filesystem:review-write-etc",
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: "(tee|\\bcp\\b|\\bmv\\b|install|>+)\\s+.*\\/etc\\/"
608
+ value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
609
+ flags: "i"
638
610
  }
639
611
  ],
640
- verdict: "review",
641
- reason: "Writing to /etc requires human approval (filesystem shield)"
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
- // dd removed: too common as a legitimate tool (disk imaging, file ops).
645
- // mkfs removed: already in the built-in DANGEROUS_WORDS baseline.
646
- // wipefs retained: rarely legitimate in an agent context and not in built-ins.
647
- dangerousWords: ["wipefs"]
648
- }
617
+ dlp: { enabled: true, scanIgnoredTools: true }
618
+ },
619
+ environments: {}
649
620
  };
650
- function resolveShieldName(input) {
651
- const lower = input.toLowerCase();
652
- if (SHIELDS[lower]) return lower;
653
- for (const [name, def] of Object.entries(SHIELDS)) {
654
- if (def.aliases.includes(lower)) return name;
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 getShield(name) {
659
- const resolved = resolveShieldName(name);
660
- return resolved ? SHIELDS[resolved] : null;
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 validateOverrides(raw) {
667
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
668
- const result = {};
669
- for (const [shieldName, rules] of Object.entries(raw)) {
670
- if (!rules || typeof rules !== "object" || Array.isArray(rules)) continue;
671
- const validRules = {};
672
- for (const [ruleName, verdict] of Object.entries(rules)) {
673
- if (isShieldVerdict(verdict)) {
674
- validRules[ruleName] = verdict;
675
- } else {
676
- process.stderr.write(
677
- `[node9] Warning: shields.json contains invalid verdict "${String(verdict)}" for ${shieldName}/${ruleName} \u2014 entry ignored. File may be corrupted or tampered with.
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
- if (Object.keys(validRules).length > 0) result[shieldName] = validRules;
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 readShieldsFile() {
821
+ function tryLoadConfig(filePath) {
822
+ if (!import_fs3.default.existsSync(filePath)) return null;
823
+ let raw;
687
824
  try {
688
- const raw = import_fs.default.readFileSync(SHIELDS_STATE_FILE, "utf-8");
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
- if (err.code !== "ENOENT") {
697
- process.stderr.write(`[node9] Warning: could not read shields state: ${String(err)}
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
- function readActiveShields() {
704
- return readShieldsFile().active;
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 readShieldOverrides() {
707
- return readShieldsFile().overrides ?? {};
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 import_fs2 = __toESM(require("fs"));
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 = import_fs2.default.realpathSync.native(absolute);
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
- const now = Date.now();
950
- trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > now);
951
- trust.entries.push({ tool: toolName, expiry: now + durationMs });
952
- atomicWriteSync(TRUST_FILE, JSON.stringify(trust, null, 2));
953
- } catch (err) {
954
- if (process.env.NODE9_DEBUG === "1") {
955
- console.error("[Node9 Trust Error]:", err);
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
- function appendToLog(logPath, entry) {
960
- try {
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, path6) {
1084
+ function getNestedValue(obj, path10) {
1006
1085
  if (!obj || typeof obj !== "object") return null;
1007
- return path6.split(".").reduce((prev, curr) => prev?.[curr], obj);
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 redactSecrets(text) {
1132
- if (!text) return text;
1133
- let redacted = text;
1134
- redacted = redacted.replace(
1135
- /(authorization:\s*(?:bearer|basic)\s+)[a-zA-Z0-9._\-\/\\=]+/gi,
1136
- "$1********"
1137
- );
1138
- redacted = redacted.replace(
1139
- /(api[_-]?key|secret|password|token)([:=]\s*['"]?)[a-zA-Z0-9._\-]{8,}/gi,
1140
- "$1$2********"
1141
- );
1142
- return redacted;
1143
- }
1144
- var DANGEROUS_WORDS = [
1145
- "mkfs",
1146
- // formats/wipes a filesystem partition
1147
- "shred"
1148
- // permanently overwrites file contents (unrecoverable)
1149
- ];
1150
- var DEFAULT_CONFIG = {
1151
- version: "1.0",
1152
- settings: {
1153
- mode: "audit",
1154
- autoStartDaemon: true,
1155
- enableUndo: true,
1156
- // 🔥 ALWAYS TRUE BY DEFAULT for the safety net
1157
- enableHookLogDebug: true,
1158
- approvalTimeoutMs: 3e4,
1159
- // 30-second auto-deny timeout
1160
- flightRecorder: true,
1161
- approvers: { native: true, browser: true, cloud: false, terminal: true }
1162
- },
1163
- policy: {
1164
- sandboxPaths: ["/tmp/**", "**/sandbox/**", "**/test-results/**"],
1165
- dangerousWords: DANGEROUS_WORDS,
1166
- ignoredTools: [
1167
- "list_*",
1168
- "get_*",
1169
- "read_*",
1170
- "describe_*",
1171
- "read",
1172
- "glob",
1173
- "grep",
1174
- "ls",
1175
- "notebookread",
1176
- "notebookedit",
1177
- "webfetch",
1178
- "websearch",
1179
- "exitplanmode",
1180
- "askuserquestion",
1181
- "agent",
1182
- "task*",
1183
- "toolsearch",
1184
- "mcp__ide__*",
1185
- "getDiagnostics"
1186
- ],
1187
- toolInspection: {
1188
- bash: "command",
1189
- shell: "command",
1190
- run_shell_command: "command",
1191
- "terminal.execute": "command",
1192
- "postgres:query": "sql"
1193
- },
1194
- snapshot: {
1195
- tools: [
1196
- "str_replace_based_edit_tool",
1197
- "write_file",
1198
- "edit_file",
1199
- "create_file",
1200
- "edit",
1201
- "replace"
1202
- ],
1203
- onlyPaths: [],
1204
- ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
1205
- },
1206
- smartRules: [
1207
- // ── rm safety (critical always evaluated first) ──────────────────────
1208
- {
1209
- name: "block-rm-rf-home",
1210
- tool: "bash",
1211
- conditionMode: "all",
1212
- conditions: [
1213
- {
1214
- field: "command",
1215
- op: "matches",
1216
- value: "rm\\b.*(-[rRfF]*[rR][rRfF]*|--recursive)"
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
- dlp: { enabled: true, scanIgnoredTools: true }
1326
- },
1327
- environments: {}
1328
- };
1329
- var ADVISORY_SMART_RULES = [
1330
- // ── rm safety ─────────────────────────────────────────────────────────────
1331
- // tool: '*' so they cover bash, shell, run_shell_command, and Gemini's Shell.
1332
- // Pattern '(^|&&|\|\||;)\s*rm\b' matches rm as a shell command (including in
1333
- // chained commands like 'cat foo && rm bar') but avoids false-positives on 'docker rm'.
1334
- {
1335
- name: "allow-rm-safe-paths",
1336
- tool: "*",
1337
- conditionMode: "all",
1338
- conditions: [
1339
- { field: "command", op: "matches", value: "(^|&&|\\|\\||;)\\s*rm\\b" },
1340
- {
1341
- field: "command",
1342
- op: "matches",
1343
- // Matches known-safe build artifact paths in the command.
1344
- value: "(node_modules|\\bdist\\b|\\.next|\\bcoverage\\b|\\.cache|\\btmp\\b|\\btemp\\b|\\.DS_Store)(\\/|\\s|$)"
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
- verdict: "allow",
1348
- reason: "Deleting a known-safe build artifact path"
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
- var cachedConfig = null;
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 = import_path5.default.join(import_os2.default.homedir(), ".node9", "daemon.pid");
1390
- if (!import_fs3.default.existsSync(pidFile)) return null;
1391
- const data = JSON.parse(import_fs3.default.readFileSync(pidFile, "utf-8"));
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 evaluatePolicy(toolName, args, agent) {
1399
- const config = getConfig();
1400
- if (matchesPattern(toolName, config.policy.ignoredTools)) return { decision: "allow" };
1401
- if (config.policy.smartRules.length > 0) {
1402
- const matchedRule = config.policy.smartRules.find(
1403
- (rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
1404
- );
1405
- if (matchedRule) {
1406
- if (matchedRule.verdict === "allow")
1407
- return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
1408
- return {
1409
- decision: matchedRule.verdict,
1410
- blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
1411
- reason: matchedRule.reason,
1412
- tier: 2,
1413
- ruleName: matchedRule.name ?? matchedRule.tool
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
- let allTokens = [];
1418
- let pathTokens = [];
1419
- const shellCommand = extractShellCommand(toolName, args, config.policy.toolInspection);
1420
- if (shellCommand) {
1421
- const analyzed = await analyzeShellCommand(shellCommand);
1422
- allTokens = analyzed.allTokens;
1423
- pathTokens = analyzed.paths;
1424
- const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
1425
- if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
1426
- return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)", tier: 3 };
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
- if (isSqlTool(toolName, config.policy.toolInspection)) {
1429
- allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
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
- allTokens = tokenize(toolName);
1433
- if (args && typeof args === "object") {
1434
- const flattenedArgs = JSON.stringify(args).toLowerCase();
1435
- const extraTokens = flattenedArgs.split(/[^a-zA-Z0-9]+/).filter((t) => t.length > 1);
1436
- allTokens.push(...extraTokens);
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
- const isManual = agent === "Terminal";
1440
- if (isManual) {
1441
- const SYSTEM_DISASTER_COMMANDS = ["mkfs", "shred", "dd", "drop", "truncate", "purge"];
1442
- const hasSystemDisaster = allTokens.some(
1443
- (t) => SYSTEM_DISASTER_COMMANDS.includes(t.toLowerCase())
1444
- );
1445
- const isRootWipe = allTokens.includes("rm") && (allTokens.includes("/") || allTokens.includes("/*"));
1446
- if (hasSystemDisaster || isRootWipe) {
1447
- return { decision: "review", blockedByLabel: "Manual Nuclear Protection", tier: 3 };
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
- return { decision: "allow" };
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
- if (pathTokens.length > 0 && config.policy.sandboxPaths.length > 0) {
1452
- const allInSandbox = pathTokens.every((p) => matchesPattern(p, config.policy.sandboxPaths));
1453
- if (allInSandbox) return { decision: "allow" };
1703
+ return { intent: "EXEC", message: smartTruncate(JSON.stringify(parsed), 200) };
1704
+ }
1705
+ function escapePango(text) {
1706
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
- let matchedDangerousWord;
1456
- const isDangerous = allTokens.some(
1457
- (token) => config.policy.dangerousWords.some((word) => {
1458
- const w = word.toLowerCase();
1459
- const hit = token === w || (() => {
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
- return new RegExp(`\\b${w}\\b`, "i").test(token);
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
- if (config.settings.mode === "strict") {
1498
- const envConfig = getActiveEnvironment(config);
1499
- if (envConfig?.requireApproval === false) return { decision: "allow" };
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
- const { pid, port } = JSON.parse(import_fs3.default.readFileSync(pidFile, "utf-8"));
1515
- if (port !== DAEMON_PORT) return false;
1516
- process.kill(pid, 0);
1517
- return true;
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
- return false;
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
- function getPersistentDecision(toolName) {
1533
- try {
1534
- const file = import_path5.default.join(import_os2.default.homedir(), ".node9", "decisions.json");
1535
- if (!import_fs3.default.existsSync(file)) return null;
1536
- const decisions = JSON.parse(import_fs3.default.readFileSync(file, "utf-8"));
1537
- const d = decisions[toolName];
1538
- if (d === "allow" || d === "deny") return d;
1539
- } catch {
1540
- }
1541
- return null;
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 askDaemon(toolName, args, meta, signal, riskMetadata, activityId) {
1544
- const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1545
- const checkCtrl = new AbortController();
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 checkRes = await fetch(`${base}/check`, {
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
- agent: meta?.agent,
1557
- mcpServer: meta?.mcpServer,
1558
- fromCLI: true,
1559
- // Pass the flight-recorder ID so the daemon uses the same UUID for
1560
- // activity-result as the CLI used for the pending activity event.
1561
- // Without this, the two UUIDs never match and tail.ts never resolves
1562
- // the pending item.
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: checkCtrl.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
- if (!checkRes.ok) throw new Error("Daemon fail");
1569
- const { id } = await checkRes.json();
1570
- const waitCtrl = new AbortController();
1571
- const waitTimer = setTimeout(() => waitCtrl.abort(), 12e4);
1572
- const onWaitAbort = () => waitCtrl.abort();
1573
- if (signal) signal.addEventListener("abort", onWaitAbort);
1574
- try {
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
- } finally {
1586
- clearTimeout(checkTimer);
1587
- if (signal) signal.removeEventListener("abort", onAbort);
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
- async function notifyDaemonViewer(toolName, args, meta, riskMetadata) {
1591
- const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1592
- const res = await fetch(`${base}/check`, {
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, allowTerminalFallback = false, meta, options) {
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, allowTerminalFallback, meta, {
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, allowTerminalFallback, meta, options);
1966
+ return _authorizeHeadlessCore(toolName, args, meta, options);
1655
1967
  }
1656
- async function _authorizeHeadlessCore(toolName, args, allowTerminalFallback = false, meta, options) {
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 (!isManual) {
2044
- appendLocalAudit(
2045
- toolName,
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
- finalResult.approved ? "allow" : "deny",
2048
- finalResult.checkedBy || finalResult.blockedBy || "unknown",
2049
- meta
2050
+ policyResult.tier ?? 6,
2051
+ explainableLabel,
2052
+ policyMatchedField,
2053
+ policyMatchedWord,
2054
+ policyResult.ruleName
2050
2055
  );
2051
- }
2052
- return finalResult;
2053
- }
2054
- function getConfig(cwd) {
2055
- if (!cwd && cachedConfig) return cachedConfig;
2056
- const globalPath = import_path5.default.join(import_os2.default.homedir(), ".node9", "config.json");
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 (p.dlp) {
2103
- const d = p.dlp;
2104
- if (d.enabled !== void 0) mergedPolicy.dlp.enabled = d.enabled;
2105
- if (d.scanIgnoredTools !== void 0) mergedPolicy.dlp.scanIgnoredTools = d.scanIgnoredTools;
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
- const envs = source.environments || {};
2108
- for (const [envName, envConfig] of Object.entries(envs)) {
2109
- if (envConfig && typeof envConfig === "object") {
2110
- const ec = envConfig;
2111
- mergedEnvironments[envName] = {
2112
- ...mergedEnvironments[envName],
2113
- // Validate field types before merging — do not blindly spread user input
2114
- ...typeof ec.requireApproval === "boolean" ? { requireApproval: ec.requireApproval } : {}
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 SUPPORTED_VERSION = "1.0";
2177
- const SUPPORTED_MAJOR = SUPPORTED_VERSION.split(".")[0];
2178
- const fileVersion = raw?.version;
2179
- if (fileVersion !== void 0) {
2180
- const vStr = String(fileVersion);
2181
- const fileMajor = vStr.split(".")[0];
2182
- if (fileMajor !== SUPPORTED_MAJOR) {
2183
- process.stderr.write(
2184
- `
2185
- \u274C Node9: Config at ${filePath} has version "${vStr}" \u2014 major version is incompatible with this release (expected "${SUPPORTED_VERSION}"). Config will not be loaded.
2186
-
2187
- `
2188
- );
2189
- return null;
2190
- } else if (vStr !== SUPPORTED_VERSION) {
2191
- process.stderr.write(
2192
- `
2193
- \u26A0\uFE0F Node9: Config at ${filePath} declares version "${vStr}" \u2014 expected "${SUPPORTED_VERSION}". Continuing with best-effort parsing.
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
- const { sanitized, error } = sanitizeConfig(raw);
2200
- if (error) {
2201
- process.stderr.write(
2202
- `
2203
- \u26A0\uFE0F Node9: Invalid config at ${filePath}:
2204
- ${error.replace("Invalid config:\n", "")}
2205
- \u2192 Invalid fields ignored, using defaults for those keys
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
- return sanitized;
2211
- }
2212
- function getActiveEnvironment(config) {
2213
- const env = config.settings.environment || process.env.NODE_ENV || "development";
2214
- return config.environments[env] ?? null;
2215
- }
2216
- function getCredentials() {
2217
- const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
2218
- if (process.env.NODE9_API_KEY) {
2219
- return {
2220
- apiKey: process.env.NODE9_API_KEY,
2221
- apiUrl: process.env.NODE9_API_URL || DEFAULT_API_URL
2222
- };
2223
- }
2224
- try {
2225
- const credPath = import_path5.default.join(import_os2.default.homedir(), ".node9", "credentials.json");
2226
- if (import_fs3.default.existsSync(credPath)) {
2227
- const creds = JSON.parse(import_fs3.default.readFileSync(credPath, "utf-8"));
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
- apiKey: profile.apiKey,
2233
- apiUrl: profile.apiUrl || DEFAULT_API_URL
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
- if (creds.apiKey) {
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
- apiKey: creds.apiKey,
2239
- apiUrl: creds.apiUrl || DEFAULT_API_URL
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
- } catch {
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
- return null;
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
- checkedBy,
2259
- context: {
2260
- agent: meta?.agent,
2261
- mcpServer: meta?.mcpServer,
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 { approved: false, reason: "Cloud approval timed out after 10 minutes." };
2275
+ return finalResult;
2328
2276
  }
2329
- async function resolveNode9SaaS(requestId, creds, approved) {
2330
- try {
2331
- const resolveUrl = `${creds.apiUrl}/requests/${requestId}`;
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