@node9/proxy 1.1.7 → 1.2.0

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