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