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