@node9/proxy 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -30,24 +30,124 @@ var import_commander = require("commander");
30
30
  var import_chalk2 = __toESM(require("chalk"));
31
31
  var import_prompts = require("@inquirer/prompts");
32
32
  var import_fs = __toESM(require("fs"));
33
- var import_path = __toESM(require("path"));
33
+ var import_path3 = __toESM(require("path"));
34
34
  var import_os = __toESM(require("os"));
35
35
  var import_picomatch = __toESM(require("picomatch"));
36
36
  var import_sh_syntax = require("sh-syntax");
37
37
 
38
38
  // src/ui/native.ts
39
39
  var import_child_process = require("child_process");
40
+ var import_path2 = __toESM(require("path"));
40
41
  var import_chalk = __toESM(require("chalk"));
41
- var isTestEnv = () => {
42
- 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";
43
- };
42
+
43
+ // src/context-sniper.ts
44
+ var import_path = __toESM(require("path"));
44
45
  function smartTruncate(str, maxLen = 500) {
45
46
  if (str.length <= maxLen) return str;
46
47
  const edge = Math.floor(maxLen / 2) - 3;
47
48
  return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
48
49
  }
49
- function formatArgs(args) {
50
- if (args === null || args === void 0) return "(none)";
50
+ function extractContext(text, matchedWord) {
51
+ const lines = text.split("\n");
52
+ if (lines.length <= 7 || !matchedWord) {
53
+ return { snippet: smartTruncate(text, 500), lineIndex: -1 };
54
+ }
55
+ const escaped = matchedWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
+ const pattern = new RegExp(`\\b${escaped}\\b`, "i");
57
+ const allHits = lines.map((line, i) => ({ i, line })).filter(({ line }) => pattern.test(line));
58
+ if (allHits.length === 0) return { snippet: smartTruncate(text, 500), lineIndex: -1 };
59
+ const nonComment = allHits.find(({ line }) => {
60
+ const trimmed = line.trim();
61
+ return !trimmed.startsWith("//") && !trimmed.startsWith("#");
62
+ });
63
+ const hitIndex = (nonComment ?? allHits[0]).i;
64
+ const start = Math.max(0, hitIndex - 3);
65
+ const end = Math.min(lines.length, hitIndex + 4);
66
+ const lineIndex = hitIndex - start;
67
+ const snippet = lines.slice(start, end).map((line, i) => `${start + i === hitIndex ? "\u{1F6D1} " : " "}${line}`).join("\n");
68
+ const head = start > 0 ? `... [${start} lines hidden] ...
69
+ ` : "";
70
+ const tail = end < lines.length ? `
71
+ ... [${lines.length - end} lines hidden] ...` : "";
72
+ return { snippet: `${head}${snippet}${tail}`, lineIndex };
73
+ }
74
+ var CODE_KEYS = [
75
+ "command",
76
+ "cmd",
77
+ "shell_command",
78
+ "bash_command",
79
+ "script",
80
+ "code",
81
+ "input",
82
+ "sql",
83
+ "query",
84
+ "arguments",
85
+ "args",
86
+ "param",
87
+ "params",
88
+ "text"
89
+ ];
90
+ function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWord, ruleName) {
91
+ let intent = "EXEC";
92
+ let contextSnippet;
93
+ let contextLineIndex;
94
+ let editFileName;
95
+ let editFilePath;
96
+ let parsed = args;
97
+ if (typeof args === "string") {
98
+ const trimmed = args.trim();
99
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
100
+ try {
101
+ parsed = JSON.parse(trimmed);
102
+ } catch {
103
+ }
104
+ }
105
+ }
106
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
107
+ const obj = parsed;
108
+ if (obj.old_string !== void 0 && obj.new_string !== void 0) {
109
+ intent = "EDIT";
110
+ if (obj.file_path) {
111
+ editFilePath = String(obj.file_path);
112
+ editFileName = import_path.default.basename(editFilePath);
113
+ }
114
+ const result = extractContext(String(obj.new_string), matchedWord);
115
+ contextSnippet = result.snippet;
116
+ if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
117
+ } else if (matchedField && obj[matchedField] !== void 0) {
118
+ const result = extractContext(String(obj[matchedField]), matchedWord);
119
+ contextSnippet = result.snippet;
120
+ if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
121
+ } else {
122
+ const foundKey = Object.keys(obj).find((k) => CODE_KEYS.includes(k.toLowerCase()));
123
+ if (foundKey) {
124
+ const val = obj[foundKey];
125
+ contextSnippet = smartTruncate(typeof val === "string" ? val : JSON.stringify(val), 500);
126
+ }
127
+ }
128
+ } else if (typeof parsed === "string") {
129
+ contextSnippet = smartTruncate(parsed, 500);
130
+ }
131
+ return {
132
+ intent,
133
+ tier,
134
+ blockedByLabel,
135
+ ...matchedWord && { matchedWord },
136
+ ...matchedField && { matchedField },
137
+ ...contextSnippet !== void 0 && { contextSnippet },
138
+ ...contextLineIndex !== void 0 && { contextLineIndex },
139
+ ...editFileName && { editFileName },
140
+ ...editFilePath && { editFilePath },
141
+ ...ruleName && { ruleName }
142
+ };
143
+ }
144
+
145
+ // src/ui/native.ts
146
+ var isTestEnv = () => {
147
+ 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";
148
+ };
149
+ function formatArgs(args, matchedField, matchedWord) {
150
+ if (args === null || args === void 0) return { message: "(none)", intent: "EXEC" };
51
151
  let parsed = args;
52
152
  if (typeof args === "string") {
53
153
  const trimmed = args.trim();
@@ -58,11 +158,39 @@ function formatArgs(args) {
58
158
  parsed = args;
59
159
  }
60
160
  } else {
61
- return smartTruncate(args, 600);
161
+ return { message: smartTruncate(args, 600), intent: "EXEC" };
62
162
  }
63
163
  }
64
164
  if (typeof parsed === "object" && !Array.isArray(parsed)) {
65
165
  const obj = parsed;
166
+ if (obj.old_string !== void 0 && obj.new_string !== void 0) {
167
+ const file = obj.file_path ? import_path2.default.basename(String(obj.file_path)) : "file";
168
+ const oldPreview = smartTruncate(String(obj.old_string), 120);
169
+ const newPreview = extractContext(String(obj.new_string), matchedWord).snippet;
170
+ return {
171
+ intent: "EDIT",
172
+ message: `\u{1F4DD} EDITING: ${file}
173
+ \u{1F4C2} PATH: ${obj.file_path}
174
+
175
+ --- REPLACING ---
176
+ ${oldPreview}
177
+
178
+ +++ NEW CODE +++
179
+ ${newPreview}`
180
+ };
181
+ }
182
+ if (matchedField && obj[matchedField] !== void 0) {
183
+ const otherKeys = Object.keys(obj).filter((k) => k !== matchedField);
184
+ 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(", ")}
185
+
186
+ ` : "";
187
+ const content = extractContext(String(obj[matchedField]), matchedWord).snippet;
188
+ return {
189
+ intent: "EXEC",
190
+ message: `${context}\u{1F6D1} [${matchedField.toUpperCase()}]:
191
+ ${content}`
192
+ };
193
+ }
66
194
  const codeKeys = [
67
195
  "command",
68
196
  "cmd",
@@ -83,14 +211,18 @@ function formatArgs(args) {
83
211
  if (foundKey) {
84
212
  const val = obj[foundKey];
85
213
  const str = typeof val === "string" ? val : JSON.stringify(val);
86
- return `[${foundKey.toUpperCase()}]:
87
- ${smartTruncate(str, 500)}`;
214
+ return {
215
+ intent: "EXEC",
216
+ message: `[${foundKey.toUpperCase()}]:
217
+ ${smartTruncate(str, 500)}`
218
+ };
88
219
  }
89
- return Object.entries(obj).slice(0, 5).map(
220
+ const msg = Object.entries(obj).slice(0, 5).map(
90
221
  ([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
91
222
  ).join("\n");
223
+ return { intent: "EXEC", message: msg };
92
224
  }
93
- return smartTruncate(JSON.stringify(parsed), 200);
225
+ return { intent: "EXEC", message: smartTruncate(JSON.stringify(parsed), 200) };
94
226
  }
95
227
  function sendDesktopNotification(title, body) {
96
228
  if (isTestEnv()) return;
@@ -143,10 +275,11 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
143
275
  }
144
276
  return lines.join("\n");
145
277
  }
146
- async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
278
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord) {
147
279
  if (isTestEnv()) return "deny";
148
- const formattedArgs = formatArgs(args);
149
- const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Approval`;
280
+ const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
281
+ const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
282
+ const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
150
283
  const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
151
284
  process.stderr.write(import_chalk.default.yellow(`
152
285
  \u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
@@ -223,11 +356,113 @@ end run`;
223
356
  });
224
357
  }
225
358
 
359
+ // src/config-schema.ts
360
+ var import_zod = require("zod");
361
+ var noNewlines = import_zod.z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
362
+ message: "Value must not contain literal newline characters (use \\n instead)"
363
+ });
364
+ var validRegex = noNewlines.refine(
365
+ (s) => {
366
+ try {
367
+ new RegExp(s);
368
+ return true;
369
+ } catch {
370
+ return false;
371
+ }
372
+ },
373
+ { message: "Value must be a valid regular expression" }
374
+ );
375
+ var SmartConditionSchema = import_zod.z.object({
376
+ field: import_zod.z.string().min(1, "Condition field must not be empty"),
377
+ op: import_zod.z.enum(["matches", "notMatches", "contains", "notContains", "exists", "notExists"], {
378
+ errorMap: () => ({
379
+ message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists"
380
+ })
381
+ }),
382
+ value: validRegex.optional(),
383
+ flags: import_zod.z.string().optional()
384
+ });
385
+ var SmartRuleSchema = import_zod.z.object({
386
+ name: import_zod.z.string().optional(),
387
+ tool: import_zod.z.string().min(1, "Smart rule tool must not be empty"),
388
+ conditions: import_zod.z.array(SmartConditionSchema).min(1, "Smart rule must have at least one condition"),
389
+ conditionMode: import_zod.z.enum(["all", "any"]).optional(),
390
+ verdict: import_zod.z.enum(["allow", "review", "block"], {
391
+ errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
392
+ }),
393
+ reason: import_zod.z.string().optional()
394
+ });
395
+ var PolicyRuleSchema = import_zod.z.object({
396
+ action: import_zod.z.string().min(1),
397
+ allowPaths: import_zod.z.array(import_zod.z.string()).optional(),
398
+ blockPaths: import_zod.z.array(import_zod.z.string()).optional()
399
+ });
400
+ var ConfigFileSchema = import_zod.z.object({
401
+ version: import_zod.z.string().optional(),
402
+ settings: import_zod.z.object({
403
+ mode: import_zod.z.enum(["standard", "strict", "audit"]).optional(),
404
+ autoStartDaemon: import_zod.z.boolean().optional(),
405
+ enableUndo: import_zod.z.boolean().optional(),
406
+ enableHookLogDebug: import_zod.z.boolean().optional(),
407
+ approvalTimeoutMs: import_zod.z.number().nonnegative().optional(),
408
+ approvers: import_zod.z.object({
409
+ native: import_zod.z.boolean().optional(),
410
+ browser: import_zod.z.boolean().optional(),
411
+ cloud: import_zod.z.boolean().optional(),
412
+ terminal: import_zod.z.boolean().optional()
413
+ }).optional(),
414
+ environment: import_zod.z.string().optional(),
415
+ slackEnabled: import_zod.z.boolean().optional(),
416
+ enableTrustSessions: import_zod.z.boolean().optional(),
417
+ allowGlobalPause: import_zod.z.boolean().optional()
418
+ }).optional(),
419
+ policy: import_zod.z.object({
420
+ sandboxPaths: import_zod.z.array(import_zod.z.string()).optional(),
421
+ dangerousWords: import_zod.z.array(noNewlines).optional(),
422
+ ignoredTools: import_zod.z.array(import_zod.z.string()).optional(),
423
+ toolInspection: import_zod.z.record(import_zod.z.string()).optional(),
424
+ rules: import_zod.z.array(PolicyRuleSchema).optional(),
425
+ smartRules: import_zod.z.array(SmartRuleSchema).optional(),
426
+ snapshot: import_zod.z.object({
427
+ tools: import_zod.z.array(import_zod.z.string()).optional(),
428
+ onlyPaths: import_zod.z.array(import_zod.z.string()).optional(),
429
+ ignorePaths: import_zod.z.array(import_zod.z.string()).optional()
430
+ }).optional()
431
+ }).optional(),
432
+ environments: import_zod.z.record(import_zod.z.object({ requireApproval: import_zod.z.boolean().optional() })).optional()
433
+ }).strict({ message: "Config contains unknown top-level keys" });
434
+ function sanitizeConfig(raw) {
435
+ const result = ConfigFileSchema.safeParse(raw);
436
+ if (result.success) {
437
+ return { sanitized: result.data, error: null };
438
+ }
439
+ const invalidTopLevelKeys = new Set(
440
+ result.error.issues.filter((issue) => issue.path.length > 0).map((issue) => String(issue.path[0]))
441
+ );
442
+ const sanitized = {};
443
+ if (typeof raw === "object" && raw !== null) {
444
+ for (const [key, value] of Object.entries(raw)) {
445
+ if (!invalidTopLevelKeys.has(key)) {
446
+ sanitized[key] = value;
447
+ }
448
+ }
449
+ }
450
+ const lines = result.error.issues.map((issue) => {
451
+ const path8 = issue.path.length > 0 ? issue.path.join(".") : "root";
452
+ return ` \u2022 ${path8}: ${issue.message}`;
453
+ });
454
+ return {
455
+ sanitized,
456
+ error: `Invalid config:
457
+ ${lines.join("\n")}`
458
+ };
459
+ }
460
+
226
461
  // src/core.ts
227
- var PAUSED_FILE = import_path.default.join(import_os.default.homedir(), ".node9", "PAUSED");
228
- var TRUST_FILE = import_path.default.join(import_os.default.homedir(), ".node9", "trust.json");
229
- var LOCAL_AUDIT_LOG = import_path.default.join(import_os.default.homedir(), ".node9", "audit.log");
230
- var HOOK_DEBUG_LOG = import_path.default.join(import_os.default.homedir(), ".node9", "hook-debug.log");
462
+ var PAUSED_FILE = import_path3.default.join(import_os.default.homedir(), ".node9", "PAUSED");
463
+ var TRUST_FILE = import_path3.default.join(import_os.default.homedir(), ".node9", "trust.json");
464
+ var LOCAL_AUDIT_LOG = import_path3.default.join(import_os.default.homedir(), ".node9", "audit.log");
465
+ var HOOK_DEBUG_LOG = import_path3.default.join(import_os.default.homedir(), ".node9", "hook-debug.log");
231
466
  function checkPause() {
232
467
  try {
233
468
  if (!import_fs.default.existsSync(PAUSED_FILE)) return { paused: false };
@@ -245,7 +480,7 @@ function checkPause() {
245
480
  }
246
481
  }
247
482
  function atomicWriteSync(filePath, data, options) {
248
- const dir = import_path.default.dirname(filePath);
483
+ const dir = import_path3.default.dirname(filePath);
249
484
  if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
250
485
  const tmpPath = `${filePath}.${import_os.default.hostname()}.${process.pid}.tmp`;
251
486
  import_fs.default.writeFileSync(tmpPath, data, options);
@@ -296,7 +531,7 @@ function writeTrustSession(toolName, durationMs) {
296
531
  }
297
532
  function appendToLog(logPath, entry) {
298
533
  try {
299
- const dir = import_path.default.dirname(logPath);
534
+ const dir = import_path3.default.dirname(logPath);
300
535
  if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
301
536
  import_fs.default.appendFileSync(logPath, JSON.stringify(entry) + "\n");
302
537
  } catch {
@@ -340,9 +575,21 @@ function matchesPattern(text, patterns) {
340
575
  const withoutDotSlash = text.replace(/^\.\//, "");
341
576
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
342
577
  }
343
- function getNestedValue(obj, path6) {
578
+ function getNestedValue(obj, path8) {
344
579
  if (!obj || typeof obj !== "object") return null;
345
- return path6.split(".").reduce((prev, curr) => prev?.[curr], obj);
580
+ return path8.split(".").reduce((prev, curr) => prev?.[curr], obj);
581
+ }
582
+ function shouldSnapshot(toolName, args, config) {
583
+ if (!config.settings.enableUndo) return false;
584
+ const snap = config.policy.snapshot;
585
+ if (!snap.tools.includes(toolName.toLowerCase())) return false;
586
+ const a = args && typeof args === "object" ? args : {};
587
+ const filePath = String(a.file_path ?? a.path ?? a.filename ?? "");
588
+ if (filePath) {
589
+ if (snap.ignorePaths.length && (0, import_picomatch.default)(snap.ignorePaths)(filePath)) return false;
590
+ if (snap.onlyPaths.length && !(0, import_picomatch.default)(snap.onlyPaths)(filePath)) return false;
591
+ }
592
+ return true;
346
593
  }
347
594
  function evaluateSmartConditions(args, rule) {
348
595
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -479,15 +726,10 @@ function redactSecrets(text) {
479
726
  return redacted;
480
727
  }
481
728
  var DANGEROUS_WORDS = [
482
- "drop",
483
- "truncate",
484
- "purge",
485
- "format",
486
- "destroy",
487
- "terminate",
488
- "revoke",
489
- "docker",
490
- "psql"
729
+ "mkfs",
730
+ // formats/wipes a filesystem partition
731
+ "shred"
732
+ // permanently overwrites file contents (unrecoverable)
491
733
  ];
492
734
  var DEFAULT_CONFIG = {
493
735
  settings: {
@@ -531,7 +773,21 @@ var DEFAULT_CONFIG = {
531
773
  "terminal.execute": "command",
532
774
  "postgres:query": "sql"
533
775
  },
776
+ snapshot: {
777
+ tools: [
778
+ "str_replace_based_edit_tool",
779
+ "write_file",
780
+ "edit_file",
781
+ "create_file",
782
+ "edit",
783
+ "replace"
784
+ ],
785
+ onlyPaths: [],
786
+ ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
787
+ },
534
788
  rules: [
789
+ // Only use the legacy rules format for simple path-based rm control.
790
+ // All other command-level enforcement lives in smartRules below.
535
791
  {
536
792
  action: "rm",
537
793
  allowPaths: [
@@ -548,6 +804,7 @@ var DEFAULT_CONFIG = {
548
804
  }
549
805
  ],
550
806
  smartRules: [
807
+ // ── SQL safety ────────────────────────────────────────────────────────
551
808
  {
552
809
  name: "no-delete-without-where",
553
810
  tool: "*",
@@ -558,6 +815,84 @@ var DEFAULT_CONFIG = {
558
815
  conditionMode: "all",
559
816
  verdict: "review",
560
817
  reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
818
+ },
819
+ {
820
+ name: "review-drop-truncate-shell",
821
+ tool: "bash",
822
+ conditions: [
823
+ {
824
+ field: "command",
825
+ op: "matches",
826
+ value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
827
+ flags: "i"
828
+ }
829
+ ],
830
+ conditionMode: "all",
831
+ verdict: "review",
832
+ reason: "SQL DDL destructive statement inside a shell command"
833
+ },
834
+ // ── Git safety ────────────────────────────────────────────────────────
835
+ {
836
+ name: "block-force-push",
837
+ tool: "bash",
838
+ conditions: [
839
+ {
840
+ field: "command",
841
+ op: "matches",
842
+ value: "git push.*(--force|--force-with-lease|-f\\b)",
843
+ flags: "i"
844
+ }
845
+ ],
846
+ conditionMode: "all",
847
+ verdict: "block",
848
+ reason: "Force push overwrites remote history and cannot be undone"
849
+ },
850
+ {
851
+ name: "review-git-push",
852
+ tool: "bash",
853
+ conditions: [{ field: "command", op: "matches", value: "^\\s*git push\\b", flags: "i" }],
854
+ conditionMode: "all",
855
+ verdict: "review",
856
+ reason: "git push sends changes to a shared remote"
857
+ },
858
+ {
859
+ name: "review-git-destructive",
860
+ tool: "bash",
861
+ conditions: [
862
+ {
863
+ field: "command",
864
+ op: "matches",
865
+ value: "git\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase|tag\\s+-d|branch\\s+-[dD])",
866
+ flags: "i"
867
+ }
868
+ ],
869
+ conditionMode: "all",
870
+ verdict: "review",
871
+ reason: "Destructive git operation \u2014 discards history or working-tree changes"
872
+ },
873
+ // ── Shell safety ──────────────────────────────────────────────────────
874
+ {
875
+ name: "review-sudo",
876
+ tool: "bash",
877
+ conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
878
+ conditionMode: "all",
879
+ verdict: "review",
880
+ reason: "Command requires elevated privileges"
881
+ },
882
+ {
883
+ name: "review-curl-pipe-shell",
884
+ tool: "bash",
885
+ conditions: [
886
+ {
887
+ field: "command",
888
+ op: "matches",
889
+ value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
890
+ flags: "i"
891
+ }
892
+ ],
893
+ conditionMode: "all",
894
+ verdict: "block",
895
+ reason: "Piping remote script into a shell is a supply-chain attack vector"
561
896
  }
562
897
  ]
563
898
  },
@@ -569,7 +904,7 @@ function _resetConfigCache() {
569
904
  }
570
905
  function getGlobalSettings() {
571
906
  try {
572
- const globalConfigPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
907
+ const globalConfigPath = import_path3.default.join(import_os.default.homedir(), ".node9", "config.json");
573
908
  if (import_fs.default.existsSync(globalConfigPath)) {
574
909
  const parsed = JSON.parse(import_fs.default.readFileSync(globalConfigPath, "utf-8"));
575
910
  const settings = parsed.settings || {};
@@ -593,7 +928,7 @@ function getGlobalSettings() {
593
928
  }
594
929
  function getInternalToken() {
595
930
  try {
596
- const pidFile = import_path.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
931
+ const pidFile = import_path3.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
597
932
  if (!import_fs.default.existsSync(pidFile)) return null;
598
933
  const data = JSON.parse(import_fs.default.readFileSync(pidFile, "utf-8"));
599
934
  process.kill(data.pid, 0);
@@ -614,7 +949,9 @@ async function evaluatePolicy(toolName, args, agent) {
614
949
  return {
615
950
  decision: matchedRule.verdict,
616
951
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
617
- reason: matchedRule.reason
952
+ reason: matchedRule.reason,
953
+ tier: 2,
954
+ ruleName: matchedRule.name ?? matchedRule.tool
618
955
  };
619
956
  }
620
957
  }
@@ -629,7 +966,7 @@ async function evaluatePolicy(toolName, args, agent) {
629
966
  pathTokens = analyzed.paths;
630
967
  const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
631
968
  if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
632
- return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
969
+ return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)", tier: 3 };
633
970
  }
634
971
  if (isSqlTool(toolName, config.policy.toolInspection)) {
635
972
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
@@ -652,7 +989,7 @@ async function evaluatePolicy(toolName, args, agent) {
652
989
  );
653
990
  const isRootWipe = allTokens.includes("rm") && (allTokens.includes("/") || allTokens.includes("/*"));
654
991
  if (hasSystemDisaster || isRootWipe) {
655
- return { decision: "review", blockedByLabel: "Manual Nuclear Protection" };
992
+ return { decision: "review", blockedByLabel: "Manual Nuclear Protection", tier: 3 };
656
993
  }
657
994
  return { decision: "allow" };
658
995
  }
@@ -670,14 +1007,16 @@ async function evaluatePolicy(toolName, args, agent) {
670
1007
  if (anyBlocked)
671
1008
  return {
672
1009
  decision: "review",
673
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`
1010
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`,
1011
+ tier: 5
674
1012
  };
675
1013
  const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
676
1014
  if (allAllowed) return { decision: "allow" };
677
1015
  }
678
1016
  return {
679
1017
  decision: "review",
680
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`
1018
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`,
1019
+ tier: 5
681
1020
  };
682
1021
  }
683
1022
  }
@@ -697,23 +1036,44 @@ async function evaluatePolicy(toolName, args, agent) {
697
1036
  })
698
1037
  );
699
1038
  if (isDangerous) {
1039
+ let matchedField;
1040
+ if (matchedDangerousWord && args && typeof args === "object" && !Array.isArray(args)) {
1041
+ const obj = args;
1042
+ for (const [key, value] of Object.entries(obj)) {
1043
+ if (typeof value === "string") {
1044
+ try {
1045
+ if (new RegExp(
1046
+ `\\b${matchedDangerousWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
1047
+ "i"
1048
+ ).test(value)) {
1049
+ matchedField = key;
1050
+ break;
1051
+ }
1052
+ } catch {
1053
+ }
1054
+ }
1055
+ }
1056
+ }
700
1057
  return {
701
1058
  decision: "review",
702
- blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`
1059
+ blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
1060
+ matchedWord: matchedDangerousWord,
1061
+ matchedField,
1062
+ tier: 6
703
1063
  };
704
1064
  }
705
1065
  if (config.settings.mode === "strict") {
706
1066
  const envConfig = getActiveEnvironment(config);
707
1067
  if (envConfig?.requireApproval === false) return { decision: "allow" };
708
- return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
1068
+ return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)", tier: 7 };
709
1069
  }
710
1070
  return { decision: "allow" };
711
1071
  }
712
1072
  async function explainPolicy(toolName, args) {
713
1073
  const steps = [];
714
- const globalPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
715
- const projectPath = import_path.default.join(process.cwd(), "node9.config.json");
716
- const credsPath = import_path.default.join(import_os.default.homedir(), ".node9", "credentials.json");
1074
+ const globalPath = import_path3.default.join(import_os.default.homedir(), ".node9", "config.json");
1075
+ const projectPath = import_path3.default.join(process.cwd(), "node9.config.json");
1076
+ const credsPath = import_path3.default.join(import_os.default.homedir(), ".node9", "credentials.json");
717
1077
  const waterfall = [
718
1078
  {
719
1079
  tier: 1,
@@ -1017,7 +1377,7 @@ var DAEMON_PORT = 7391;
1017
1377
  var DAEMON_HOST = "127.0.0.1";
1018
1378
  function isDaemonRunning() {
1019
1379
  try {
1020
- const pidFile = import_path.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
1380
+ const pidFile = import_path3.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
1021
1381
  if (!import_fs.default.existsSync(pidFile)) return false;
1022
1382
  const { pid, port } = JSON.parse(import_fs.default.readFileSync(pidFile, "utf-8"));
1023
1383
  if (port !== DAEMON_PORT) return false;
@@ -1029,7 +1389,7 @@ function isDaemonRunning() {
1029
1389
  }
1030
1390
  function getPersistentDecision(toolName) {
1031
1391
  try {
1032
- const file = import_path.default.join(import_os.default.homedir(), ".node9", "decisions.json");
1392
+ const file = import_path3.default.join(import_os.default.homedir(), ".node9", "decisions.json");
1033
1393
  if (!import_fs.default.existsSync(file)) return null;
1034
1394
  const decisions = JSON.parse(import_fs.default.readFileSync(file, "utf-8"));
1035
1395
  const d = decisions[toolName];
@@ -1038,7 +1398,7 @@ function getPersistentDecision(toolName) {
1038
1398
  }
1039
1399
  return null;
1040
1400
  }
1041
- async function askDaemon(toolName, args, meta, signal) {
1401
+ async function askDaemon(toolName, args, meta, signal, riskMetadata) {
1042
1402
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1043
1403
  const checkCtrl = new AbortController();
1044
1404
  const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
@@ -1048,7 +1408,13 @@ async function askDaemon(toolName, args, meta, signal) {
1048
1408
  const checkRes = await fetch(`${base}/check`, {
1049
1409
  method: "POST",
1050
1410
  headers: { "Content-Type": "application/json" },
1051
- body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
1411
+ body: JSON.stringify({
1412
+ toolName,
1413
+ args,
1414
+ agent: meta?.agent,
1415
+ mcpServer: meta?.mcpServer,
1416
+ ...riskMetadata && { riskMetadata }
1417
+ }),
1052
1418
  signal: checkCtrl.signal
1053
1419
  });
1054
1420
  if (!checkRes.ok) throw new Error("Daemon fail");
@@ -1073,7 +1439,7 @@ async function askDaemon(toolName, args, meta, signal) {
1073
1439
  if (signal) signal.removeEventListener("abort", onAbort);
1074
1440
  }
1075
1441
  }
1076
- async function notifyDaemonViewer(toolName, args, meta) {
1442
+ async function notifyDaemonViewer(toolName, args, meta, riskMetadata) {
1077
1443
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1078
1444
  const res = await fetch(`${base}/check`, {
1079
1445
  method: "POST",
@@ -1083,7 +1449,8 @@ async function notifyDaemonViewer(toolName, args, meta) {
1083
1449
  args,
1084
1450
  slackDelegated: true,
1085
1451
  agent: meta?.agent,
1086
- mcpServer: meta?.mcpServer
1452
+ mcpServer: meta?.mcpServer,
1453
+ ...riskMetadata && { riskMetadata }
1087
1454
  }),
1088
1455
  signal: AbortSignal.timeout(3e3)
1089
1456
  });
@@ -1100,7 +1467,7 @@ async function resolveViaDaemon(id, decision, internalToken) {
1100
1467
  signal: AbortSignal.timeout(3e3)
1101
1468
  });
1102
1469
  }
1103
- async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
1470
+ async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta, options) {
1104
1471
  if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
1105
1472
  const pauseState = checkPause();
1106
1473
  if (pauseState.paused) return { approved: true, checkedBy: "paused" };
@@ -1120,11 +1487,17 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1120
1487
  }
1121
1488
  const isManual = meta?.agent === "Terminal";
1122
1489
  let explainableLabel = "Local Config";
1490
+ let policyMatchedField;
1491
+ let policyMatchedWord;
1492
+ let riskMetadata;
1123
1493
  if (config.settings.mode === "audit") {
1124
1494
  if (!isIgnoredTool(toolName)) {
1125
1495
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
1126
1496
  if (policyResult.decision === "review") {
1127
1497
  appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
1498
+ if (approvers.cloud && creds?.apiKey) {
1499
+ await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
1500
+ }
1128
1501
  sendDesktopNotification(
1129
1502
  "Node9 Audit Mode",
1130
1503
  `Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
@@ -1135,13 +1508,15 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1135
1508
  }
1136
1509
  if (!isIgnoredTool(toolName)) {
1137
1510
  if (getActiveTrustSession(toolName)) {
1138
- if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
1511
+ if (approvers.cloud && creds?.apiKey)
1512
+ await auditLocalAllow(toolName, args, "trust", creds, meta);
1139
1513
  if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
1140
1514
  return { approved: true, checkedBy: "trust" };
1141
1515
  }
1142
1516
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
1143
1517
  if (policyResult.decision === "allow") {
1144
- if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
1518
+ if (approvers.cloud && creds?.apiKey)
1519
+ auditLocalAllow(toolName, args, "local-policy", creds, meta);
1145
1520
  if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
1146
1521
  return { approved: true, checkedBy: "local-policy" };
1147
1522
  }
@@ -1155,9 +1530,20 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1155
1530
  };
1156
1531
  }
1157
1532
  explainableLabel = policyResult.blockedByLabel || "Local Config";
1533
+ policyMatchedField = policyResult.matchedField;
1534
+ policyMatchedWord = policyResult.matchedWord;
1535
+ riskMetadata = computeRiskMetadata(
1536
+ args,
1537
+ policyResult.tier ?? 6,
1538
+ explainableLabel,
1539
+ policyMatchedField,
1540
+ policyMatchedWord,
1541
+ policyResult.ruleName
1542
+ );
1158
1543
  const persistent = getPersistentDecision(toolName);
1159
1544
  if (persistent === "allow") {
1160
- if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
1545
+ if (approvers.cloud && creds?.apiKey)
1546
+ await auditLocalAllow(toolName, args, "persistent", creds, meta);
1161
1547
  if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
1162
1548
  return { approved: true, checkedBy: "persistent" };
1163
1549
  }
@@ -1171,7 +1557,6 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1171
1557
  };
1172
1558
  }
1173
1559
  } else {
1174
- if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
1175
1560
  if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
1176
1561
  return { approved: true };
1177
1562
  }
@@ -1180,8 +1565,21 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1180
1565
  const cloudEnforced = approvers.cloud && !!creds?.apiKey;
1181
1566
  if (cloudEnforced) {
1182
1567
  try {
1183
- const initResult = await initNode9SaaS(toolName, args, creds, meta);
1568
+ const initResult = await initNode9SaaS(toolName, args, creds, meta, riskMetadata);
1184
1569
  if (!initResult.pending) {
1570
+ if (initResult.shadowMode) {
1571
+ console.error(
1572
+ import_chalk2.default.yellow(
1573
+ `
1574
+ \u26A0\uFE0F Node9 Shadow Mode: Action allowed, but would have been blocked by company policy.`
1575
+ )
1576
+ );
1577
+ if (initResult.shadowReason) {
1578
+ console.error(import_chalk2.default.dim(` Reason: ${initResult.shadowReason}
1579
+ `));
1580
+ }
1581
+ return { approved: true, checkedBy: "cloud" };
1582
+ }
1185
1583
  return {
1186
1584
  approved: !!initResult.approved,
1187
1585
  reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
@@ -1206,18 +1604,22 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1206
1604
  );
1207
1605
  }
1208
1606
  }
1209
- if (cloudEnforced && cloudRequestId) {
1210
- console.error(
1211
- import_chalk2.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
1212
- );
1213
- console.error(import_chalk2.default.cyan(" Dashboard \u2192 ") + import_chalk2.default.bold("Mission Control > Activity Feed\n"));
1214
- } else if (!cloudEnforced) {
1215
- const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
1216
- console.error(
1217
- import_chalk2.default.dim(`
1607
+ if (!options?.calledFromDaemon) {
1608
+ if (cloudEnforced && cloudRequestId) {
1609
+ console.error(
1610
+ import_chalk2.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
1611
+ );
1612
+ console.error(
1613
+ import_chalk2.default.cyan(" Dashboard \u2192 ") + import_chalk2.default.bold("Mission Control > Activity Feed\n")
1614
+ );
1615
+ } else if (!cloudEnforced) {
1616
+ const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
1617
+ console.error(
1618
+ import_chalk2.default.dim(`
1218
1619
  \u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
1219
1620
  `)
1220
- );
1621
+ );
1622
+ }
1221
1623
  }
1222
1624
  const abortController = new AbortController();
1223
1625
  const { signal } = abortController;
@@ -1247,8 +1649,10 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1247
1649
  racePromises.push(
1248
1650
  (async () => {
1249
1651
  try {
1250
- if (isDaemonRunning() && internalToken) {
1251
- viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
1652
+ if (isDaemonRunning() && internalToken && !options?.calledFromDaemon) {
1653
+ viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(
1654
+ () => null
1655
+ );
1252
1656
  }
1253
1657
  const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
1254
1658
  return {
@@ -1266,7 +1670,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1266
1670
  })()
1267
1671
  );
1268
1672
  }
1269
- if (approvers.native && !isManual) {
1673
+ if (approvers.native && !isManual && !options?.calledFromDaemon) {
1270
1674
  racePromises.push(
1271
1675
  (async () => {
1272
1676
  const decision = await askNativePopup(
@@ -1275,7 +1679,9 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1275
1679
  meta?.agent,
1276
1680
  explainableLabel,
1277
1681
  isRemoteLocked,
1278
- signal
1682
+ signal,
1683
+ policyMatchedField,
1684
+ policyMatchedWord
1279
1685
  );
1280
1686
  if (decision === "always_allow") {
1281
1687
  writeTrustSession(toolName, 36e5);
@@ -1292,7 +1698,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1292
1698
  })()
1293
1699
  );
1294
1700
  }
1295
- if (approvers.browser && isDaemonRunning()) {
1701
+ if (approvers.browser && isDaemonRunning() && !options?.calledFromDaemon && !cloudEnforced) {
1296
1702
  racePromises.push(
1297
1703
  (async () => {
1298
1704
  try {
@@ -1303,7 +1709,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1303
1709
  console.error(import_chalk2.default.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
1304
1710
  `));
1305
1711
  }
1306
- const daemonDecision = await askDaemon(toolName, args, meta, signal);
1712
+ const daemonDecision = await askDaemon(toolName, args, meta, signal, riskMetadata);
1307
1713
  if (daemonDecision === "abandoned") throw new Error("Abandoned");
1308
1714
  const isApproved = daemonDecision === "allow";
1309
1715
  return {
@@ -1428,8 +1834,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
1428
1834
  }
1429
1835
  function getConfig() {
1430
1836
  if (cachedConfig) return cachedConfig;
1431
- const globalPath = import_path.default.join(import_os.default.homedir(), ".node9", "config.json");
1432
- const projectPath = import_path.default.join(process.cwd(), "node9.config.json");
1837
+ const globalPath = import_path3.default.join(import_os.default.homedir(), ".node9", "config.json");
1838
+ const projectPath = import_path3.default.join(process.cwd(), "node9.config.json");
1433
1839
  const globalConfig = tryLoadConfig(globalPath);
1434
1840
  const projectConfig = tryLoadConfig(projectPath);
1435
1841
  const mergedSettings = {
@@ -1442,7 +1848,12 @@ function getConfig() {
1442
1848
  ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
1443
1849
  toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
1444
1850
  rules: [...DEFAULT_CONFIG.policy.rules],
1445
- smartRules: [...DEFAULT_CONFIG.policy.smartRules]
1851
+ smartRules: [...DEFAULT_CONFIG.policy.smartRules],
1852
+ snapshot: {
1853
+ tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
1854
+ onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
1855
+ ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
1856
+ }
1446
1857
  };
1447
1858
  const applyLayer = (source) => {
1448
1859
  if (!source) return;
@@ -1454,6 +1865,7 @@ function getConfig() {
1454
1865
  if (s.enableHookLogDebug !== void 0)
1455
1866
  mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
1456
1867
  if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
1868
+ if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
1457
1869
  if (s.environment !== void 0) mergedSettings.environment = s.environment;
1458
1870
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
1459
1871
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
@@ -1462,6 +1874,12 @@ function getConfig() {
1462
1874
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
1463
1875
  if (p.rules) mergedPolicy.rules.push(...p.rules);
1464
1876
  if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
1877
+ if (p.snapshot) {
1878
+ const s2 = p.snapshot;
1879
+ if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
1880
+ if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
1881
+ if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
1882
+ }
1465
1883
  };
1466
1884
  applyLayer(globalConfig);
1467
1885
  applyLayer(projectConfig);
@@ -1469,6 +1887,9 @@ function getConfig() {
1469
1887
  mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
1470
1888
  mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
1471
1889
  mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
1890
+ mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
1891
+ mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
1892
+ mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
1472
1893
  cachedConfig = {
1473
1894
  settings: mergedSettings,
1474
1895
  policy: mergedPolicy,
@@ -1478,11 +1899,33 @@ function getConfig() {
1478
1899
  }
1479
1900
  function tryLoadConfig(filePath) {
1480
1901
  if (!import_fs.default.existsSync(filePath)) return null;
1902
+ let raw;
1481
1903
  try {
1482
- return JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
1483
- } catch {
1904
+ raw = JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
1905
+ } catch (err) {
1906
+ const msg = err instanceof Error ? err.message : String(err);
1907
+ process.stderr.write(
1908
+ `
1909
+ \u26A0\uFE0F Node9: Failed to parse ${filePath}
1910
+ ${msg}
1911
+ \u2192 Using default config
1912
+
1913
+ `
1914
+ );
1484
1915
  return null;
1485
1916
  }
1917
+ const { sanitized, error } = sanitizeConfig(raw);
1918
+ if (error) {
1919
+ process.stderr.write(
1920
+ `
1921
+ \u26A0\uFE0F Node9: Invalid config at ${filePath}:
1922
+ ${error.replace("Invalid config:\n", "")}
1923
+ \u2192 Invalid fields ignored, using defaults for those keys
1924
+
1925
+ `
1926
+ );
1927
+ }
1928
+ return sanitized;
1486
1929
  }
1487
1930
  function getActiveEnvironment(config) {
1488
1931
  const env = config.settings.environment || process.env.NODE_ENV || "development";
@@ -1497,7 +1940,7 @@ function getCredentials() {
1497
1940
  };
1498
1941
  }
1499
1942
  try {
1500
- const credPath = import_path.default.join(import_os.default.homedir(), ".node9", "credentials.json");
1943
+ const credPath = import_path3.default.join(import_os.default.homedir(), ".node9", "credentials.json");
1501
1944
  if (import_fs.default.existsSync(credPath)) {
1502
1945
  const creds = JSON.parse(import_fs.default.readFileSync(credPath, "utf-8"));
1503
1946
  const profileName = process.env.NODE9_PROFILE || "default";
@@ -1520,9 +1963,7 @@ function getCredentials() {
1520
1963
  return null;
1521
1964
  }
1522
1965
  function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1523
- const controller = new AbortController();
1524
- setTimeout(() => controller.abort(), 5e3);
1525
- fetch(`${creds.apiUrl}/audit`, {
1966
+ return fetch(`${creds.apiUrl}/audit`, {
1526
1967
  method: "POST",
1527
1968
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
1528
1969
  body: JSON.stringify({
@@ -1537,11 +1978,12 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1537
1978
  platform: import_os.default.platform()
1538
1979
  }
1539
1980
  }),
1540
- signal: controller.signal
1981
+ signal: AbortSignal.timeout(5e3)
1982
+ }).then(() => {
1541
1983
  }).catch(() => {
1542
1984
  });
1543
1985
  }
1544
- async function initNode9SaaS(toolName, args, creds, meta) {
1986
+ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
1545
1987
  const controller = new AbortController();
1546
1988
  const timeout = setTimeout(() => controller.abort(), 1e4);
1547
1989
  try {
@@ -1557,7 +1999,8 @@ async function initNode9SaaS(toolName, args, creds, meta) {
1557
1999
  hostname: import_os.default.hostname(),
1558
2000
  cwd: process.cwd(),
1559
2001
  platform: import_os.default.platform()
1560
- }
2002
+ },
2003
+ ...riskMetadata && { riskMetadata }
1561
2004
  }),
1562
2005
  signal: controller.signal
1563
2006
  });
@@ -1615,7 +2058,7 @@ async function resolveNode9SaaS(requestId, creds, approved) {
1615
2058
 
1616
2059
  // src/setup.ts
1617
2060
  var import_fs2 = __toESM(require("fs"));
1618
- var import_path2 = __toESM(require("path"));
2061
+ var import_path4 = __toESM(require("path"));
1619
2062
  var import_os2 = __toESM(require("os"));
1620
2063
  var import_chalk3 = __toESM(require("chalk"));
1621
2064
  var import_prompts2 = require("@inquirer/prompts");
@@ -1640,14 +2083,14 @@ function readJson(filePath) {
1640
2083
  return null;
1641
2084
  }
1642
2085
  function writeJson(filePath, data) {
1643
- const dir = import_path2.default.dirname(filePath);
2086
+ const dir = import_path4.default.dirname(filePath);
1644
2087
  if (!import_fs2.default.existsSync(dir)) import_fs2.default.mkdirSync(dir, { recursive: true });
1645
2088
  import_fs2.default.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
1646
2089
  }
1647
2090
  async function setupClaude() {
1648
2091
  const homeDir2 = import_os2.default.homedir();
1649
- const mcpPath = import_path2.default.join(homeDir2, ".claude.json");
1650
- const hooksPath = import_path2.default.join(homeDir2, ".claude", "settings.json");
2092
+ const mcpPath = import_path4.default.join(homeDir2, ".claude.json");
2093
+ const hooksPath = import_path4.default.join(homeDir2, ".claude", "settings.json");
1651
2094
  const claudeConfig = readJson(mcpPath) ?? {};
1652
2095
  const settings = readJson(hooksPath) ?? {};
1653
2096
  const servers = claudeConfig.mcpServers ?? {};
@@ -1722,7 +2165,7 @@ async function setupClaude() {
1722
2165
  }
1723
2166
  async function setupGemini() {
1724
2167
  const homeDir2 = import_os2.default.homedir();
1725
- const settingsPath = import_path2.default.join(homeDir2, ".gemini", "settings.json");
2168
+ const settingsPath = import_path4.default.join(homeDir2, ".gemini", "settings.json");
1726
2169
  const settings = readJson(settingsPath) ?? {};
1727
2170
  const servers = settings.mcpServers ?? {};
1728
2171
  let anythingChanged = false;
@@ -1805,8 +2248,8 @@ async function setupGemini() {
1805
2248
  }
1806
2249
  async function setupCursor() {
1807
2250
  const homeDir2 = import_os2.default.homedir();
1808
- const mcpPath = import_path2.default.join(homeDir2, ".cursor", "mcp.json");
1809
- const hooksPath = import_path2.default.join(homeDir2, ".cursor", "hooks.json");
2251
+ const mcpPath = import_path4.default.join(homeDir2, ".cursor", "mcp.json");
2252
+ const hooksPath = import_path4.default.join(homeDir2, ".cursor", "hooks.json");
1810
2253
  const mcpConfig = readJson(mcpPath) ?? {};
1811
2254
  const hooksFile = readJson(hooksPath) ?? { version: 1 };
1812
2255
  const servers = mcpConfig.mcpServers ?? {};
@@ -2102,6 +2545,55 @@ var ui_default = `<!doctype html>
2102
2545
  white-space: pre-wrap;
2103
2546
  word-break: break-all;
2104
2547
  }
2548
+ /* \u2500\u2500 Context Sniper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
2549
+ .sniper-header {
2550
+ display: flex;
2551
+ align-items: center;
2552
+ gap: 8px;
2553
+ flex-wrap: wrap;
2554
+ margin-bottom: 8px;
2555
+ }
2556
+ .sniper-badge {
2557
+ font-size: 11px;
2558
+ font-weight: 600;
2559
+ padding: 3px 8px;
2560
+ border-radius: 5px;
2561
+ letter-spacing: 0.02em;
2562
+ }
2563
+ .sniper-badge-edit {
2564
+ background: rgba(59, 130, 246, 0.15);
2565
+ color: #60a5fa;
2566
+ border: 1px solid rgba(59, 130, 246, 0.3);
2567
+ }
2568
+ .sniper-badge-exec {
2569
+ background: rgba(239, 68, 68, 0.12);
2570
+ color: #f87171;
2571
+ border: 1px solid rgba(239, 68, 68, 0.25);
2572
+ }
2573
+ .sniper-tier {
2574
+ font-size: 10px;
2575
+ color: var(--muted);
2576
+ font-family: 'Fira Code', monospace;
2577
+ }
2578
+ .sniper-filepath {
2579
+ font-size: 11px;
2580
+ color: #a8b3c4;
2581
+ font-family: 'Fira Code', monospace;
2582
+ margin-bottom: 6px;
2583
+ word-break: break-all;
2584
+ }
2585
+ .sniper-match {
2586
+ font-size: 11px;
2587
+ color: #a8b3c4;
2588
+ margin-bottom: 6px;
2589
+ }
2590
+ .sniper-match code {
2591
+ background: rgba(239, 68, 68, 0.15);
2592
+ color: #f87171;
2593
+ padding: 1px 5px;
2594
+ border-radius: 3px;
2595
+ font-family: 'Fira Code', monospace;
2596
+ }
2105
2597
  .actions {
2106
2598
  display: grid;
2107
2599
  grid-template-columns: 1fr 1fr;
@@ -2608,20 +3100,47 @@ var ui_default = `<!doctype html>
2608
3100
  }, 200);
2609
3101
  }
2610
3102
 
3103
+ function renderPayload(req) {
3104
+ const rm = req.riskMetadata;
3105
+ if (!rm) {
3106
+ // Fallback: raw args for requests without context sniper data
3107
+ const cmd = esc(
3108
+ String(
3109
+ req.args &&
3110
+ (req.args.command ||
3111
+ req.args.cmd ||
3112
+ req.args.script ||
3113
+ JSON.stringify(req.args, null, 2))
3114
+ )
3115
+ );
3116
+ return \`<span class="label">Input Payload</span><pre>\${cmd}</pre>\`;
3117
+ }
3118
+ const isEdit = rm.intent === 'EDIT';
3119
+ const badgeClass = isEdit ? 'sniper-badge-edit' : 'sniper-badge-exec';
3120
+ const badgeLabel = isEdit ? '\u{1F4DD} Code Edit' : '\u{1F6D1} Execution';
3121
+ const tierLabel = \`Tier \${rm.tier} \xB7 \${esc(rm.blockedByLabel)}\`;
3122
+ const fileLine =
3123
+ isEdit && rm.editFilePath
3124
+ ? \`<div class="sniper-filepath">\u{1F4C2} \${esc(rm.editFilePath)}</div>\`
3125
+ : !isEdit && rm.matchedWord
3126
+ ? \`<div class="sniper-match">Matched: <code>\${esc(rm.matchedWord)}</code>\${rm.matchedField ? \` in <code>\${esc(rm.matchedField)}</code>\` : ''}</div>\`
3127
+ : '';
3128
+ const snippetHtml = rm.contextSnippet ? \`<pre>\${esc(rm.contextSnippet)}</pre>\` : '';
3129
+ return \`
3130
+ <div class="sniper-header">
3131
+ <span class="sniper-badge \${badgeClass}">\${badgeLabel}</span>
3132
+ <span class="sniper-tier">\${tierLabel}</span>
3133
+ </div>
3134
+ \${fileLine}
3135
+ \${snippetHtml}
3136
+ \`;
3137
+ }
3138
+
2611
3139
  function addCard(req) {
2612
3140
  if (requests.has(req.id)) return;
2613
3141
  requests.add(req.id);
2614
3142
  refresh();
2615
3143
  const isSlack = !!req.slackDelegated;
2616
- const cmd = esc(
2617
- String(
2618
- req.args &&
2619
- (req.args.command ||
2620
- req.args.cmd ||
2621
- req.args.script ||
2622
- JSON.stringify(req.args, null, 2))
2623
- )
2624
- );
2625
3144
  const card = document.createElement('div');
2626
3145
  card.className = 'card' + (isSlack ? ' slack-viewer' : '');
2627
3146
  card.id = 'c-' + req.id;
@@ -2635,8 +3154,7 @@ var ui_default = `<!doctype html>
2635
3154
  </div>
2636
3155
  <div class="tool-chip">\${esc(req.toolName)}</div>
2637
3156
  \${isSlack ? '<div class="slack-indicator">\u26A1 Awaiting Slack approval \u2014 view only</div>' : ''}
2638
- <span class="label">Input Payload</span>
2639
- <pre>\${cmd}</pre>
3157
+ \${renderPayload(req)}
2640
3158
  <div class="actions" id="act-\${req.id}">
2641
3159
  <button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${dis}>Approve Execution</button>
2642
3160
  <button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${dis}>Block Action</button>
@@ -2844,7 +3362,7 @@ var UI_HTML_TEMPLATE = ui_default;
2844
3362
  // src/daemon/index.ts
2845
3363
  var import_http = __toESM(require("http"));
2846
3364
  var import_fs3 = __toESM(require("fs"));
2847
- var import_path3 = __toESM(require("path"));
3365
+ var import_path5 = __toESM(require("path"));
2848
3366
  var import_os3 = __toESM(require("os"));
2849
3367
  var import_child_process2 = require("child_process");
2850
3368
  var import_crypto = require("crypto");
@@ -2852,14 +3370,14 @@ var import_chalk4 = __toESM(require("chalk"));
2852
3370
  var DAEMON_PORT2 = 7391;
2853
3371
  var DAEMON_HOST2 = "127.0.0.1";
2854
3372
  var homeDir = import_os3.default.homedir();
2855
- var DAEMON_PID_FILE = import_path3.default.join(homeDir, ".node9", "daemon.pid");
2856
- var DECISIONS_FILE = import_path3.default.join(homeDir, ".node9", "decisions.json");
2857
- var GLOBAL_CONFIG_FILE = import_path3.default.join(homeDir, ".node9", "config.json");
2858
- var CREDENTIALS_FILE = import_path3.default.join(homeDir, ".node9", "credentials.json");
2859
- var AUDIT_LOG_FILE = import_path3.default.join(homeDir, ".node9", "audit.log");
2860
- var TRUST_FILE2 = import_path3.default.join(homeDir, ".node9", "trust.json");
3373
+ var DAEMON_PID_FILE = import_path5.default.join(homeDir, ".node9", "daemon.pid");
3374
+ var DECISIONS_FILE = import_path5.default.join(homeDir, ".node9", "decisions.json");
3375
+ var GLOBAL_CONFIG_FILE = import_path5.default.join(homeDir, ".node9", "config.json");
3376
+ var CREDENTIALS_FILE = import_path5.default.join(homeDir, ".node9", "credentials.json");
3377
+ var AUDIT_LOG_FILE = import_path5.default.join(homeDir, ".node9", "audit.log");
3378
+ var TRUST_FILE2 = import_path5.default.join(homeDir, ".node9", "trust.json");
2861
3379
  function atomicWriteSync2(filePath, data, options) {
2862
- const dir = import_path3.default.dirname(filePath);
3380
+ const dir = import_path5.default.dirname(filePath);
2863
3381
  if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
2864
3382
  const tmpPath = `${filePath}.${(0, import_crypto.randomUUID)()}.tmp`;
2865
3383
  import_fs3.default.writeFileSync(tmpPath, data, options);
@@ -2903,7 +3421,7 @@ function appendAuditLog(data) {
2903
3421
  decision: data.decision,
2904
3422
  source: "daemon"
2905
3423
  };
2906
- const dir = import_path3.default.dirname(AUDIT_LOG_FILE);
3424
+ const dir = import_path5.default.dirname(AUDIT_LOG_FILE);
2907
3425
  if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
2908
3426
  import_fs3.default.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + "\n");
2909
3427
  } catch {
@@ -3061,6 +3579,7 @@ data: ${JSON.stringify({
3061
3579
  id: e.id,
3062
3580
  toolName: e.toolName,
3063
3581
  args: e.args,
3582
+ riskMetadata: e.riskMetadata,
3064
3583
  slackDelegated: e.slackDelegated,
3065
3584
  timestamp: e.timestamp,
3066
3585
  agent: e.agent,
@@ -3086,14 +3605,23 @@ data: ${JSON.stringify(readPersistentDecisions())}
3086
3605
  if (req.method === "POST" && pathname === "/check") {
3087
3606
  try {
3088
3607
  resetIdleTimer();
3608
+ _resetConfigCache();
3089
3609
  const body = await readBody(req);
3090
3610
  if (body.length > 65536) return res.writeHead(413).end();
3091
- const { toolName, args, slackDelegated = false, agent, mcpServer } = JSON.parse(body);
3611
+ const {
3612
+ toolName,
3613
+ args,
3614
+ slackDelegated = false,
3615
+ agent,
3616
+ mcpServer,
3617
+ riskMetadata
3618
+ } = JSON.parse(body);
3092
3619
  const id = (0, import_crypto.randomUUID)();
3093
3620
  const entry = {
3094
3621
  id,
3095
3622
  toolName,
3096
3623
  args,
3624
+ riskMetadata: riskMetadata ?? void 0,
3097
3625
  agent: typeof agent === "string" ? agent : void 0,
3098
3626
  mcpServer: typeof mcpServer === "string" ? mcpServer : void 0,
3099
3627
  slackDelegated: !!slackDelegated,
@@ -3108,25 +3636,71 @@ data: ${JSON.stringify(readPersistentDecisions())}
3108
3636
  args: e.args,
3109
3637
  decision: "auto-deny"
3110
3638
  });
3111
- if (e.waiter) e.waiter("deny");
3112
- else e.earlyDecision = "deny";
3639
+ if (e.waiter) e.waiter("deny", "No response \u2014 auto-denied after timeout");
3640
+ else {
3641
+ e.earlyDecision = "deny";
3642
+ e.earlyReason = "No response \u2014 auto-denied after timeout";
3643
+ }
3113
3644
  pending.delete(id);
3114
3645
  broadcast("remove", { id });
3115
3646
  }
3116
3647
  }, AUTO_DENY_MS)
3117
3648
  };
3118
3649
  pending.set(id, entry);
3119
- broadcast("add", {
3120
- id,
3650
+ const browserEnabled = getConfig().settings.approvers?.browser !== false;
3651
+ if (browserEnabled) {
3652
+ broadcast("add", {
3653
+ id,
3654
+ toolName,
3655
+ args,
3656
+ riskMetadata: entry.riskMetadata,
3657
+ slackDelegated: entry.slackDelegated,
3658
+ agent: entry.agent,
3659
+ mcpServer: entry.mcpServer
3660
+ });
3661
+ if (sseClients.size === 0 && !autoStarted)
3662
+ openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
3663
+ }
3664
+ res.writeHead(200, { "Content-Type": "application/json" });
3665
+ res.end(JSON.stringify({ id }));
3666
+ authorizeHeadless(
3121
3667
  toolName,
3122
3668
  args,
3123
- slackDelegated: entry.slackDelegated,
3124
- agent: entry.agent,
3125
- mcpServer: entry.mcpServer
3669
+ false,
3670
+ {
3671
+ agent: typeof agent === "string" ? agent : void 0,
3672
+ mcpServer: typeof mcpServer === "string" ? mcpServer : void 0
3673
+ },
3674
+ { calledFromDaemon: true }
3675
+ ).then((result) => {
3676
+ const e = pending.get(id);
3677
+ if (!e) return;
3678
+ if (result.noApprovalMechanism) return;
3679
+ clearTimeout(e.timer);
3680
+ const decision = result.approved ? "allow" : "deny";
3681
+ appendAuditLog({ toolName: e.toolName, args: e.args, decision });
3682
+ if (e.waiter) {
3683
+ e.waiter(decision, result.reason);
3684
+ pending.delete(id);
3685
+ broadcast("remove", { id });
3686
+ } else {
3687
+ e.earlyDecision = decision;
3688
+ e.earlyReason = result.reason;
3689
+ }
3690
+ }).catch((err) => {
3691
+ const e = pending.get(id);
3692
+ if (!e) return;
3693
+ clearTimeout(e.timer);
3694
+ const reason = err?.reason || "No response \u2014 request timed out";
3695
+ if (e.waiter) e.waiter("deny", reason);
3696
+ else {
3697
+ e.earlyDecision = "deny";
3698
+ e.earlyReason = reason;
3699
+ }
3700
+ pending.delete(id);
3701
+ broadcast("remove", { id });
3126
3702
  });
3127
- if (sseClients.size === 0 && !autoStarted) openBrowser(`http://127.0.0.1:${DAEMON_PORT2}/`);
3128
- res.writeHead(200, { "Content-Type": "application/json" });
3129
- return res.end(JSON.stringify({ id }));
3703
+ return;
3130
3704
  } catch {
3131
3705
  res.writeHead(400).end();
3132
3706
  }
@@ -3136,12 +3710,18 @@ data: ${JSON.stringify(readPersistentDecisions())}
3136
3710
  const entry = pending.get(id);
3137
3711
  if (!entry) return res.writeHead(404).end();
3138
3712
  if (entry.earlyDecision) {
3713
+ pending.delete(id);
3714
+ broadcast("remove", { id });
3139
3715
  res.writeHead(200, { "Content-Type": "application/json" });
3140
- return res.end(JSON.stringify({ decision: entry.earlyDecision }));
3716
+ const body = { decision: entry.earlyDecision };
3717
+ if (entry.earlyReason) body.reason = entry.earlyReason;
3718
+ return res.end(JSON.stringify(body));
3141
3719
  }
3142
- entry.waiter = (d) => {
3720
+ entry.waiter = (d, reason) => {
3143
3721
  res.writeHead(200, { "Content-Type": "application/json" });
3144
- res.end(JSON.stringify({ decision: d }));
3722
+ const body = { decision: d };
3723
+ if (reason) body.reason = reason;
3724
+ res.end(JSON.stringify(body));
3145
3725
  };
3146
3726
  return;
3147
3727
  }
@@ -3151,7 +3731,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
3151
3731
  const id = pathname.split("/").pop();
3152
3732
  const entry = pending.get(id);
3153
3733
  if (!entry) return res.writeHead(404).end();
3154
- const { decision, persist, trustDuration } = JSON.parse(await readBody(req));
3734
+ const { decision, persist, trustDuration, reason } = JSON.parse(await readBody(req));
3155
3735
  if (decision === "trust" && trustDuration) {
3156
3736
  const ms = TRUST_DURATIONS[trustDuration] ?? 60 * 6e4;
3157
3737
  writeTrustEntry(entry.toolName, ms);
@@ -3176,8 +3756,11 @@ data: ${JSON.stringify(readPersistentDecisions())}
3176
3756
  decision: resolvedDecision
3177
3757
  });
3178
3758
  clearTimeout(entry.timer);
3179
- if (entry.waiter) entry.waiter(resolvedDecision);
3180
- else entry.earlyDecision = resolvedDecision;
3759
+ if (entry.waiter) entry.waiter(resolvedDecision, reason);
3760
+ else {
3761
+ entry.earlyDecision = resolvedDecision;
3762
+ entry.earlyReason = reason;
3763
+ }
3181
3764
  pending.delete(id);
3182
3765
  broadcast("remove", { id });
3183
3766
  res.writeHead(200);
@@ -3340,16 +3923,16 @@ var import_execa2 = require("execa");
3340
3923
  var import_chalk5 = __toESM(require("chalk"));
3341
3924
  var import_readline = __toESM(require("readline"));
3342
3925
  var import_fs5 = __toESM(require("fs"));
3343
- var import_path5 = __toESM(require("path"));
3926
+ var import_path7 = __toESM(require("path"));
3344
3927
  var import_os5 = __toESM(require("os"));
3345
3928
 
3346
3929
  // src/undo.ts
3347
3930
  var import_child_process3 = require("child_process");
3348
3931
  var import_fs4 = __toESM(require("fs"));
3349
- var import_path4 = __toESM(require("path"));
3932
+ var import_path6 = __toESM(require("path"));
3350
3933
  var import_os4 = __toESM(require("os"));
3351
- var SNAPSHOT_STACK_PATH = import_path4.default.join(import_os4.default.homedir(), ".node9", "snapshots.json");
3352
- var UNDO_LATEST_PATH = import_path4.default.join(import_os4.default.homedir(), ".node9", "undo_latest.txt");
3934
+ var SNAPSHOT_STACK_PATH = import_path6.default.join(import_os4.default.homedir(), ".node9", "snapshots.json");
3935
+ var UNDO_LATEST_PATH = import_path6.default.join(import_os4.default.homedir(), ".node9", "undo_latest.txt");
3353
3936
  var MAX_SNAPSHOTS = 10;
3354
3937
  function readStack() {
3355
3938
  try {
@@ -3360,7 +3943,7 @@ function readStack() {
3360
3943
  return [];
3361
3944
  }
3362
3945
  function writeStack(stack) {
3363
- const dir = import_path4.default.dirname(SNAPSHOT_STACK_PATH);
3946
+ const dir = import_path6.default.dirname(SNAPSHOT_STACK_PATH);
3364
3947
  if (!import_fs4.default.existsSync(dir)) import_fs4.default.mkdirSync(dir, { recursive: true });
3365
3948
  import_fs4.default.writeFileSync(SNAPSHOT_STACK_PATH, JSON.stringify(stack, null, 2));
3366
3949
  }
@@ -3378,8 +3961,8 @@ function buildArgsSummary(tool, args) {
3378
3961
  async function createShadowSnapshot(tool = "unknown", args = {}) {
3379
3962
  try {
3380
3963
  const cwd = process.cwd();
3381
- if (!import_fs4.default.existsSync(import_path4.default.join(cwd, ".git"))) return null;
3382
- const tempIndex = import_path4.default.join(cwd, ".git", `node9_index_${Date.now()}`);
3964
+ if (!import_fs4.default.existsSync(import_path6.default.join(cwd, ".git"))) return null;
3965
+ const tempIndex = import_path6.default.join(cwd, ".git", `node9_index_${Date.now()}`);
3383
3966
  const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
3384
3967
  (0, import_child_process3.spawnSync)("git", ["add", "-A"], { env });
3385
3968
  const treeRes = (0, import_child_process3.spawnSync)("git", ["write-tree"], { env });
@@ -3443,7 +4026,7 @@ function applyUndo(hash, cwd) {
3443
4026
  const tracked = (0, import_child_process3.spawnSync)("git", ["ls-files"], { cwd: dir }).stdout.toString().trim().split("\n").filter(Boolean);
3444
4027
  const untracked = (0, import_child_process3.spawnSync)("git", ["ls-files", "--others", "--exclude-standard"], { cwd: dir }).stdout.toString().trim().split("\n").filter(Boolean);
3445
4028
  for (const file of [...tracked, ...untracked]) {
3446
- const fullPath = import_path4.default.join(dir, file);
4029
+ const fullPath = import_path6.default.join(dir, file);
3447
4030
  if (!snapshotFiles.has(file) && import_fs4.default.existsSync(fullPath)) {
3448
4031
  import_fs4.default.unlinkSync(fullPath);
3449
4032
  }
@@ -3457,7 +4040,7 @@ function applyUndo(hash, cwd) {
3457
4040
  // src/cli.ts
3458
4041
  var import_prompts3 = require("@inquirer/prompts");
3459
4042
  var { version } = JSON.parse(
3460
- import_fs5.default.readFileSync(import_path5.default.join(__dirname, "../package.json"), "utf-8")
4043
+ import_fs5.default.readFileSync(import_path7.default.join(__dirname, "../package.json"), "utf-8")
3461
4044
  );
3462
4045
  function parseDuration(str) {
3463
4046
  const m = str.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i);
@@ -3650,9 +4233,9 @@ async function runProxy(targetCommand) {
3650
4233
  }
3651
4234
  program.command("login").argument("<apiKey>").option("--local", "Save key for audit/logging only \u2014 local config still controls all decisions").option("--profile <name>", 'Save as a named profile (default: "default")').action((apiKey, options) => {
3652
4235
  const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
3653
- const credPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "credentials.json");
3654
- if (!import_fs5.default.existsSync(import_path5.default.dirname(credPath)))
3655
- import_fs5.default.mkdirSync(import_path5.default.dirname(credPath), { recursive: true });
4236
+ const credPath = import_path7.default.join(import_os5.default.homedir(), ".node9", "credentials.json");
4237
+ if (!import_fs5.default.existsSync(import_path7.default.dirname(credPath)))
4238
+ import_fs5.default.mkdirSync(import_path7.default.dirname(credPath), { recursive: true });
3656
4239
  const profileName = options.profile || "default";
3657
4240
  let existingCreds = {};
3658
4241
  try {
@@ -3671,7 +4254,7 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
3671
4254
  existingCreds[profileName] = { apiKey, apiUrl: DEFAULT_API_URL };
3672
4255
  import_fs5.default.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
3673
4256
  if (profileName === "default") {
3674
- const configPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
4257
+ const configPath = import_path7.default.join(import_os5.default.homedir(), ".node9", "config.json");
3675
4258
  let config = {};
3676
4259
  try {
3677
4260
  if (import_fs5.default.existsSync(configPath))
@@ -3686,10 +4269,12 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
3686
4269
  cloud: true,
3687
4270
  terminal: true
3688
4271
  };
3689
- approvers.cloud = !options.local;
4272
+ if (options.local) {
4273
+ approvers.cloud = false;
4274
+ }
3690
4275
  s.approvers = approvers;
3691
- if (!import_fs5.default.existsSync(import_path5.default.dirname(configPath)))
3692
- import_fs5.default.mkdirSync(import_path5.default.dirname(configPath), { recursive: true });
4276
+ if (!import_fs5.default.existsSync(import_path7.default.dirname(configPath)))
4277
+ import_fs5.default.mkdirSync(import_path7.default.dirname(configPath), { recursive: true });
3693
4278
  import_fs5.default.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
3694
4279
  }
3695
4280
  if (options.profile && profileName !== "default") {
@@ -3775,7 +4360,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3775
4360
  );
3776
4361
  }
3777
4362
  section("Configuration");
3778
- const globalConfigPath = import_path5.default.join(homeDir2, ".node9", "config.json");
4363
+ const globalConfigPath = import_path7.default.join(homeDir2, ".node9", "config.json");
3779
4364
  if (import_fs5.default.existsSync(globalConfigPath)) {
3780
4365
  try {
3781
4366
  JSON.parse(import_fs5.default.readFileSync(globalConfigPath, "utf-8"));
@@ -3786,7 +4371,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3786
4371
  } else {
3787
4372
  warn("~/.node9/config.json not found (using defaults)", "Run: node9 init");
3788
4373
  }
3789
- const projectConfigPath = import_path5.default.join(process.cwd(), "node9.config.json");
4374
+ const projectConfigPath = import_path7.default.join(process.cwd(), "node9.config.json");
3790
4375
  if (import_fs5.default.existsSync(projectConfigPath)) {
3791
4376
  try {
3792
4377
  JSON.parse(import_fs5.default.readFileSync(projectConfigPath, "utf-8"));
@@ -3795,7 +4380,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3795
4380
  fail("node9.config.json is invalid JSON", "Fix the JSON or delete it and run: node9 init");
3796
4381
  }
3797
4382
  }
3798
- const credsPath = import_path5.default.join(homeDir2, ".node9", "credentials.json");
4383
+ const credsPath = import_path7.default.join(homeDir2, ".node9", "credentials.json");
3799
4384
  if (import_fs5.default.existsSync(credsPath)) {
3800
4385
  pass("Cloud credentials found (~/.node9/credentials.json)");
3801
4386
  } else {
@@ -3805,7 +4390,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3805
4390
  );
3806
4391
  }
3807
4392
  section("Agent Hooks");
3808
- const claudeSettingsPath = import_path5.default.join(homeDir2, ".claude", "settings.json");
4393
+ const claudeSettingsPath = import_path7.default.join(homeDir2, ".claude", "settings.json");
3809
4394
  if (import_fs5.default.existsSync(claudeSettingsPath)) {
3810
4395
  try {
3811
4396
  const cs = JSON.parse(import_fs5.default.readFileSync(claudeSettingsPath, "utf-8"));
@@ -3821,7 +4406,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3821
4406
  } else {
3822
4407
  warn("Claude Code \u2014 not configured", "Run: node9 setup claude");
3823
4408
  }
3824
- const geminiSettingsPath = import_path5.default.join(homeDir2, ".gemini", "settings.json");
4409
+ const geminiSettingsPath = import_path7.default.join(homeDir2, ".gemini", "settings.json");
3825
4410
  if (import_fs5.default.existsSync(geminiSettingsPath)) {
3826
4411
  try {
3827
4412
  const gs = JSON.parse(import_fs5.default.readFileSync(geminiSettingsPath, "utf-8"));
@@ -3837,7 +4422,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3837
4422
  } else {
3838
4423
  warn("Gemini CLI \u2014 not configured", "Run: node9 setup gemini (skip if not using Gemini)");
3839
4424
  }
3840
- const cursorHooksPath = import_path5.default.join(homeDir2, ".cursor", "hooks.json");
4425
+ const cursorHooksPath = import_path7.default.join(homeDir2, ".cursor", "hooks.json");
3841
4426
  if (import_fs5.default.existsSync(cursorHooksPath)) {
3842
4427
  try {
3843
4428
  const cur = JSON.parse(import_fs5.default.readFileSync(cursorHooksPath, "utf-8"));
@@ -3942,7 +4527,7 @@ program.command("explain").description(
3942
4527
  console.log("");
3943
4528
  });
3944
4529
  program.command("init").description("Create ~/.node9/config.json with default policy (safe to run multiple times)").option("--force", "Overwrite existing config").option("-m, --mode <mode>", "Set initial security mode (standard, strict, audit)", "standard").action((options) => {
3945
- const configPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
4530
+ const configPath = import_path7.default.join(import_os5.default.homedir(), ".node9", "config.json");
3946
4531
  if (import_fs5.default.existsSync(configPath) && !options.force) {
3947
4532
  console.log(import_chalk5.default.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
3948
4533
  console.log(import_chalk5.default.gray(` Run with --force to overwrite.`));
@@ -3957,7 +4542,7 @@ program.command("init").description("Create ~/.node9/config.json with default po
3957
4542
  mode: safeMode
3958
4543
  }
3959
4544
  };
3960
- const dir = import_path5.default.dirname(configPath);
4545
+ const dir = import_path7.default.dirname(configPath);
3961
4546
  if (!import_fs5.default.existsSync(dir)) import_fs5.default.mkdirSync(dir, { recursive: true });
3962
4547
  import_fs5.default.writeFileSync(configPath, JSON.stringify(configToSave, null, 2));
3963
4548
  console.log(import_chalk5.default.green(`\u2705 Global config created: ${configPath}`));
@@ -3977,7 +4562,7 @@ function formatRelativeTime(timestamp) {
3977
4562
  return new Date(timestamp).toLocaleDateString();
3978
4563
  }
3979
4564
  program.command("audit").description("View local execution audit log").option("--tail <n>", "Number of entries to show", "20").option("--tool <pattern>", "Filter by tool name (substring match)").option("--deny", "Show only denied actions").option("--json", "Output raw JSON").action((options) => {
3980
- const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "audit.log");
4565
+ const logPath = import_path7.default.join(import_os5.default.homedir(), ".node9", "audit.log");
3981
4566
  if (!import_fs5.default.existsSync(logPath)) {
3982
4567
  console.log(
3983
4568
  import_chalk5.default.yellow("No audit logs found. Run node9 with an agent to generate entries.")
@@ -4067,8 +4652,8 @@ program.command("status").description("Show current Node9 mode, policy source, a
4067
4652
  console.log("");
4068
4653
  const modeLabel = settings.mode === "audit" ? import_chalk5.default.blue("audit") : settings.mode === "strict" ? import_chalk5.default.red("strict") : import_chalk5.default.white("standard");
4069
4654
  console.log(` Mode: ${modeLabel}`);
4070
- const projectConfig = import_path5.default.join(process.cwd(), "node9.config.json");
4071
- const globalConfig = import_path5.default.join(import_os5.default.homedir(), ".node9", "config.json");
4655
+ const projectConfig = import_path7.default.join(process.cwd(), "node9.config.json");
4656
+ const globalConfig = import_path7.default.join(import_os5.default.homedir(), ".node9", "config.json");
4072
4657
  console.log(
4073
4658
  ` Local: ${import_fs5.default.existsSync(projectConfig) ? import_chalk5.default.green("Active (node9.config.json)") : import_chalk5.default.gray("Not present")}`
4074
4659
  );
@@ -4136,7 +4721,7 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
4136
4721
  } catch (err) {
4137
4722
  const tempConfig = getConfig();
4138
4723
  if (process.env.NODE9_DEBUG === "1" || tempConfig.settings.enableHookLogDebug) {
4139
- const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
4724
+ const logPath = import_path7.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
4140
4725
  const errMsg = err instanceof Error ? err.message : String(err);
4141
4726
  import_fs5.default.appendFileSync(
4142
4727
  logPath,
@@ -4156,9 +4741,9 @@ RAW: ${raw}
4156
4741
  }
4157
4742
  const config = getConfig();
4158
4743
  if (process.env.NODE9_DEBUG === "1" || config.settings.enableHookLogDebug) {
4159
- const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
4160
- if (!import_fs5.default.existsSync(import_path5.default.dirname(logPath)))
4161
- import_fs5.default.mkdirSync(import_path5.default.dirname(logPath), { recursive: true });
4744
+ const logPath = import_path7.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
4745
+ if (!import_fs5.default.existsSync(import_path7.default.dirname(logPath)))
4746
+ import_fs5.default.mkdirSync(import_path7.default.dirname(logPath), { recursive: true });
4162
4747
  import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
4163
4748
  `);
4164
4749
  }
@@ -4196,16 +4781,7 @@ RAW: ${raw}
4196
4781
  return;
4197
4782
  }
4198
4783
  const meta = { agent, mcpServer };
4199
- const STATE_CHANGING_TOOLS_PRE = [
4200
- "write_file",
4201
- "edit_file",
4202
- "edit",
4203
- "replace",
4204
- "terminal.execute",
4205
- "str_replace_based_edit_tool",
4206
- "create_file"
4207
- ];
4208
- if (config.settings.enableUndo && STATE_CHANGING_TOOLS_PRE.includes(toolName.toLowerCase())) {
4784
+ if (shouldSnapshot(toolName, toolInput, config)) {
4209
4785
  await createShadowSnapshot(toolName, toolInput);
4210
4786
  }
4211
4787
  const result = await authorizeHeadless(toolName, toolInput, false, meta);
@@ -4239,7 +4815,7 @@ RAW: ${raw}
4239
4815
  });
4240
4816
  } catch (err) {
4241
4817
  if (process.env.NODE9_DEBUG === "1") {
4242
- const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
4818
+ const logPath = import_path7.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
4243
4819
  const errMsg = err instanceof Error ? err.message : String(err);
4244
4820
  import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
4245
4821
  `);
@@ -4286,20 +4862,12 @@ program.command("log").description("PostToolUse hook \u2014 records executed too
4286
4862
  decision: "allowed",
4287
4863
  source: "post-hook"
4288
4864
  };
4289
- const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "audit.log");
4290
- if (!import_fs5.default.existsSync(import_path5.default.dirname(logPath)))
4291
- import_fs5.default.mkdirSync(import_path5.default.dirname(logPath), { recursive: true });
4865
+ const logPath = import_path7.default.join(import_os5.default.homedir(), ".node9", "audit.log");
4866
+ if (!import_fs5.default.existsSync(import_path7.default.dirname(logPath)))
4867
+ import_fs5.default.mkdirSync(import_path7.default.dirname(logPath), { recursive: true });
4292
4868
  import_fs5.default.appendFileSync(logPath, JSON.stringify(entry) + "\n");
4293
4869
  const config = getConfig();
4294
- const STATE_CHANGING_TOOLS = [
4295
- "bash",
4296
- "shell",
4297
- "write_file",
4298
- "edit_file",
4299
- "replace",
4300
- "terminal.execute"
4301
- ];
4302
- if (config.settings.enableUndo && STATE_CHANGING_TOOLS.includes(tool.toLowerCase())) {
4870
+ if (shouldSnapshot(tool, {}, config)) {
4303
4871
  await createShadowSnapshot();
4304
4872
  }
4305
4873
  } catch {
@@ -4471,7 +5039,7 @@ process.on("unhandledRejection", (reason) => {
4471
5039
  const isCheckHook = process.argv[2] === "check";
4472
5040
  if (isCheckHook) {
4473
5041
  if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
4474
- const logPath = import_path5.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
5042
+ const logPath = import_path7.default.join(import_os5.default.homedir(), ".node9", "hook-debug.log");
4475
5043
  const msg = reason instanceof Error ? reason.message : String(reason);
4476
5044
  import_fs5.default.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
4477
5045
  `);