@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/index.mjs CHANGED
@@ -2,24 +2,124 @@
2
2
  import chalk2 from "chalk";
3
3
  import { confirm } from "@inquirer/prompts";
4
4
  import fs from "fs";
5
- import path from "path";
5
+ import path3 from "path";
6
6
  import os from "os";
7
7
  import pm from "picomatch";
8
8
  import { parse } from "sh-syntax";
9
9
 
10
10
  // src/ui/native.ts
11
11
  import { spawn } from "child_process";
12
+ import path2 from "path";
12
13
  import chalk from "chalk";
13
- var isTestEnv = () => {
14
- 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";
15
- };
14
+
15
+ // src/context-sniper.ts
16
+ import path from "path";
16
17
  function smartTruncate(str, maxLen = 500) {
17
18
  if (str.length <= maxLen) return str;
18
19
  const edge = Math.floor(maxLen / 2) - 3;
19
20
  return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
20
21
  }
21
- function formatArgs(args) {
22
- if (args === null || args === void 0) return "(none)";
22
+ function extractContext(text, matchedWord) {
23
+ const lines = text.split("\n");
24
+ if (lines.length <= 7 || !matchedWord) {
25
+ return { snippet: smartTruncate(text, 500), lineIndex: -1 };
26
+ }
27
+ const escaped = matchedWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
28
+ const pattern = new RegExp(`\\b${escaped}\\b`, "i");
29
+ const allHits = lines.map((line, i) => ({ i, line })).filter(({ line }) => pattern.test(line));
30
+ if (allHits.length === 0) return { snippet: smartTruncate(text, 500), lineIndex: -1 };
31
+ const nonComment = allHits.find(({ line }) => {
32
+ const trimmed = line.trim();
33
+ return !trimmed.startsWith("//") && !trimmed.startsWith("#");
34
+ });
35
+ const hitIndex = (nonComment ?? allHits[0]).i;
36
+ const start = Math.max(0, hitIndex - 3);
37
+ const end = Math.min(lines.length, hitIndex + 4);
38
+ const lineIndex = hitIndex - start;
39
+ const snippet = lines.slice(start, end).map((line, i) => `${start + i === hitIndex ? "\u{1F6D1} " : " "}${line}`).join("\n");
40
+ const head = start > 0 ? `... [${start} lines hidden] ...
41
+ ` : "";
42
+ const tail = end < lines.length ? `
43
+ ... [${lines.length - end} lines hidden] ...` : "";
44
+ return { snippet: `${head}${snippet}${tail}`, lineIndex };
45
+ }
46
+ var CODE_KEYS = [
47
+ "command",
48
+ "cmd",
49
+ "shell_command",
50
+ "bash_command",
51
+ "script",
52
+ "code",
53
+ "input",
54
+ "sql",
55
+ "query",
56
+ "arguments",
57
+ "args",
58
+ "param",
59
+ "params",
60
+ "text"
61
+ ];
62
+ function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWord, ruleName) {
63
+ let intent = "EXEC";
64
+ let contextSnippet;
65
+ let contextLineIndex;
66
+ let editFileName;
67
+ let editFilePath;
68
+ let parsed = args;
69
+ if (typeof args === "string") {
70
+ const trimmed = args.trim();
71
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
72
+ try {
73
+ parsed = JSON.parse(trimmed);
74
+ } catch {
75
+ }
76
+ }
77
+ }
78
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
79
+ const obj = parsed;
80
+ if (obj.old_string !== void 0 && obj.new_string !== void 0) {
81
+ intent = "EDIT";
82
+ if (obj.file_path) {
83
+ editFilePath = String(obj.file_path);
84
+ editFileName = path.basename(editFilePath);
85
+ }
86
+ const result = extractContext(String(obj.new_string), matchedWord);
87
+ contextSnippet = result.snippet;
88
+ if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
89
+ } else if (matchedField && obj[matchedField] !== void 0) {
90
+ const result = extractContext(String(obj[matchedField]), matchedWord);
91
+ contextSnippet = result.snippet;
92
+ if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
93
+ } else {
94
+ const foundKey = Object.keys(obj).find((k) => CODE_KEYS.includes(k.toLowerCase()));
95
+ if (foundKey) {
96
+ const val = obj[foundKey];
97
+ contextSnippet = smartTruncate(typeof val === "string" ? val : JSON.stringify(val), 500);
98
+ }
99
+ }
100
+ } else if (typeof parsed === "string") {
101
+ contextSnippet = smartTruncate(parsed, 500);
102
+ }
103
+ return {
104
+ intent,
105
+ tier,
106
+ blockedByLabel,
107
+ ...matchedWord && { matchedWord },
108
+ ...matchedField && { matchedField },
109
+ ...contextSnippet !== void 0 && { contextSnippet },
110
+ ...contextLineIndex !== void 0 && { contextLineIndex },
111
+ ...editFileName && { editFileName },
112
+ ...editFilePath && { editFilePath },
113
+ ...ruleName && { ruleName }
114
+ };
115
+ }
116
+
117
+ // src/ui/native.ts
118
+ var isTestEnv = () => {
119
+ 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";
120
+ };
121
+ function formatArgs(args, matchedField, matchedWord) {
122
+ if (args === null || args === void 0) return { message: "(none)", intent: "EXEC" };
23
123
  let parsed = args;
24
124
  if (typeof args === "string") {
25
125
  const trimmed = args.trim();
@@ -30,11 +130,39 @@ function formatArgs(args) {
30
130
  parsed = args;
31
131
  }
32
132
  } else {
33
- return smartTruncate(args, 600);
133
+ return { message: smartTruncate(args, 600), intent: "EXEC" };
34
134
  }
35
135
  }
36
136
  if (typeof parsed === "object" && !Array.isArray(parsed)) {
37
137
  const obj = parsed;
138
+ if (obj.old_string !== void 0 && obj.new_string !== void 0) {
139
+ const file = obj.file_path ? path2.basename(String(obj.file_path)) : "file";
140
+ const oldPreview = smartTruncate(String(obj.old_string), 120);
141
+ const newPreview = extractContext(String(obj.new_string), matchedWord).snippet;
142
+ return {
143
+ intent: "EDIT",
144
+ message: `\u{1F4DD} EDITING: ${file}
145
+ \u{1F4C2} PATH: ${obj.file_path}
146
+
147
+ --- REPLACING ---
148
+ ${oldPreview}
149
+
150
+ +++ NEW CODE +++
151
+ ${newPreview}`
152
+ };
153
+ }
154
+ if (matchedField && obj[matchedField] !== void 0) {
155
+ const otherKeys = Object.keys(obj).filter((k) => k !== matchedField);
156
+ 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(", ")}
157
+
158
+ ` : "";
159
+ const content = extractContext(String(obj[matchedField]), matchedWord).snippet;
160
+ return {
161
+ intent: "EXEC",
162
+ message: `${context}\u{1F6D1} [${matchedField.toUpperCase()}]:
163
+ ${content}`
164
+ };
165
+ }
38
166
  const codeKeys = [
39
167
  "command",
40
168
  "cmd",
@@ -55,14 +183,18 @@ function formatArgs(args) {
55
183
  if (foundKey) {
56
184
  const val = obj[foundKey];
57
185
  const str = typeof val === "string" ? val : JSON.stringify(val);
58
- return `[${foundKey.toUpperCase()}]:
59
- ${smartTruncate(str, 500)}`;
186
+ return {
187
+ intent: "EXEC",
188
+ message: `[${foundKey.toUpperCase()}]:
189
+ ${smartTruncate(str, 500)}`
190
+ };
60
191
  }
61
- return Object.entries(obj).slice(0, 5).map(
192
+ const msg = Object.entries(obj).slice(0, 5).map(
62
193
  ([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
63
194
  ).join("\n");
195
+ return { intent: "EXEC", message: msg };
64
196
  }
65
- return smartTruncate(JSON.stringify(parsed), 200);
197
+ return { intent: "EXEC", message: smartTruncate(JSON.stringify(parsed), 200) };
66
198
  }
67
199
  function sendDesktopNotification(title, body) {
68
200
  if (isTestEnv()) return;
@@ -115,10 +247,11 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
115
247
  }
116
248
  return lines.join("\n");
117
249
  }
118
- async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
250
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord) {
119
251
  if (isTestEnv()) return "deny";
120
- const formattedArgs = formatArgs(args);
121
- const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Approval`;
252
+ const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
253
+ const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
254
+ const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
122
255
  const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
123
256
  process.stderr.write(chalk.yellow(`
124
257
  \u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
@@ -195,11 +328,113 @@ end run`;
195
328
  });
196
329
  }
197
330
 
331
+ // src/config-schema.ts
332
+ import { z } from "zod";
333
+ var noNewlines = z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
334
+ message: "Value must not contain literal newline characters (use \\n instead)"
335
+ });
336
+ var validRegex = noNewlines.refine(
337
+ (s) => {
338
+ try {
339
+ new RegExp(s);
340
+ return true;
341
+ } catch {
342
+ return false;
343
+ }
344
+ },
345
+ { message: "Value must be a valid regular expression" }
346
+ );
347
+ var SmartConditionSchema = z.object({
348
+ field: z.string().min(1, "Condition field must not be empty"),
349
+ op: z.enum(["matches", "notMatches", "contains", "notContains", "exists", "notExists"], {
350
+ errorMap: () => ({
351
+ message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists"
352
+ })
353
+ }),
354
+ value: validRegex.optional(),
355
+ flags: z.string().optional()
356
+ });
357
+ var SmartRuleSchema = z.object({
358
+ name: z.string().optional(),
359
+ tool: z.string().min(1, "Smart rule tool must not be empty"),
360
+ conditions: z.array(SmartConditionSchema).min(1, "Smart rule must have at least one condition"),
361
+ conditionMode: z.enum(["all", "any"]).optional(),
362
+ verdict: z.enum(["allow", "review", "block"], {
363
+ errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
364
+ }),
365
+ reason: z.string().optional()
366
+ });
367
+ var PolicyRuleSchema = z.object({
368
+ action: z.string().min(1),
369
+ allowPaths: z.array(z.string()).optional(),
370
+ blockPaths: z.array(z.string()).optional()
371
+ });
372
+ var ConfigFileSchema = z.object({
373
+ version: z.string().optional(),
374
+ settings: z.object({
375
+ mode: z.enum(["standard", "strict", "audit"]).optional(),
376
+ autoStartDaemon: z.boolean().optional(),
377
+ enableUndo: z.boolean().optional(),
378
+ enableHookLogDebug: z.boolean().optional(),
379
+ approvalTimeoutMs: z.number().nonnegative().optional(),
380
+ approvers: z.object({
381
+ native: z.boolean().optional(),
382
+ browser: z.boolean().optional(),
383
+ cloud: z.boolean().optional(),
384
+ terminal: z.boolean().optional()
385
+ }).optional(),
386
+ environment: z.string().optional(),
387
+ slackEnabled: z.boolean().optional(),
388
+ enableTrustSessions: z.boolean().optional(),
389
+ allowGlobalPause: z.boolean().optional()
390
+ }).optional(),
391
+ policy: z.object({
392
+ sandboxPaths: z.array(z.string()).optional(),
393
+ dangerousWords: z.array(noNewlines).optional(),
394
+ ignoredTools: z.array(z.string()).optional(),
395
+ toolInspection: z.record(z.string()).optional(),
396
+ rules: z.array(PolicyRuleSchema).optional(),
397
+ smartRules: z.array(SmartRuleSchema).optional(),
398
+ snapshot: z.object({
399
+ tools: z.array(z.string()).optional(),
400
+ onlyPaths: z.array(z.string()).optional(),
401
+ ignorePaths: z.array(z.string()).optional()
402
+ }).optional()
403
+ }).optional(),
404
+ environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
405
+ }).strict({ message: "Config contains unknown top-level keys" });
406
+ function sanitizeConfig(raw) {
407
+ const result = ConfigFileSchema.safeParse(raw);
408
+ if (result.success) {
409
+ return { sanitized: result.data, error: null };
410
+ }
411
+ const invalidTopLevelKeys = new Set(
412
+ result.error.issues.filter((issue) => issue.path.length > 0).map((issue) => String(issue.path[0]))
413
+ );
414
+ const sanitized = {};
415
+ if (typeof raw === "object" && raw !== null) {
416
+ for (const [key, value] of Object.entries(raw)) {
417
+ if (!invalidTopLevelKeys.has(key)) {
418
+ sanitized[key] = value;
419
+ }
420
+ }
421
+ }
422
+ const lines = result.error.issues.map((issue) => {
423
+ const path4 = issue.path.length > 0 ? issue.path.join(".") : "root";
424
+ return ` \u2022 ${path4}: ${issue.message}`;
425
+ });
426
+ return {
427
+ sanitized,
428
+ error: `Invalid config:
429
+ ${lines.join("\n")}`
430
+ };
431
+ }
432
+
198
433
  // src/core.ts
199
- var PAUSED_FILE = path.join(os.homedir(), ".node9", "PAUSED");
200
- var TRUST_FILE = path.join(os.homedir(), ".node9", "trust.json");
201
- var LOCAL_AUDIT_LOG = path.join(os.homedir(), ".node9", "audit.log");
202
- var HOOK_DEBUG_LOG = path.join(os.homedir(), ".node9", "hook-debug.log");
434
+ var PAUSED_FILE = path3.join(os.homedir(), ".node9", "PAUSED");
435
+ var TRUST_FILE = path3.join(os.homedir(), ".node9", "trust.json");
436
+ var LOCAL_AUDIT_LOG = path3.join(os.homedir(), ".node9", "audit.log");
437
+ var HOOK_DEBUG_LOG = path3.join(os.homedir(), ".node9", "hook-debug.log");
203
438
  function checkPause() {
204
439
  try {
205
440
  if (!fs.existsSync(PAUSED_FILE)) return { paused: false };
@@ -217,7 +452,7 @@ function checkPause() {
217
452
  }
218
453
  }
219
454
  function atomicWriteSync(filePath, data, options) {
220
- const dir = path.dirname(filePath);
455
+ const dir = path3.dirname(filePath);
221
456
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
222
457
  const tmpPath = `${filePath}.${os.hostname()}.${process.pid}.tmp`;
223
458
  fs.writeFileSync(tmpPath, data, options);
@@ -258,7 +493,7 @@ function writeTrustSession(toolName, durationMs) {
258
493
  }
259
494
  function appendToLog(logPath, entry) {
260
495
  try {
261
- const dir = path.dirname(logPath);
496
+ const dir = path3.dirname(logPath);
262
497
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
263
498
  fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
264
499
  } catch {
@@ -302,9 +537,9 @@ function matchesPattern(text, patterns) {
302
537
  const withoutDotSlash = text.replace(/^\.\//, "");
303
538
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
304
539
  }
305
- function getNestedValue(obj, path2) {
540
+ function getNestedValue(obj, path4) {
306
541
  if (!obj || typeof obj !== "object") return null;
307
- return path2.split(".").reduce((prev, curr) => prev?.[curr], obj);
542
+ return path4.split(".").reduce((prev, curr) => prev?.[curr], obj);
308
543
  }
309
544
  function evaluateSmartConditions(args, rule) {
310
545
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -441,15 +676,10 @@ function redactSecrets(text) {
441
676
  return redacted;
442
677
  }
443
678
  var DANGEROUS_WORDS = [
444
- "drop",
445
- "truncate",
446
- "purge",
447
- "format",
448
- "destroy",
449
- "terminate",
450
- "revoke",
451
- "docker",
452
- "psql"
679
+ "mkfs",
680
+ // formats/wipes a filesystem partition
681
+ "shred"
682
+ // permanently overwrites file contents (unrecoverable)
453
683
  ];
454
684
  var DEFAULT_CONFIG = {
455
685
  settings: {
@@ -493,7 +723,21 @@ var DEFAULT_CONFIG = {
493
723
  "terminal.execute": "command",
494
724
  "postgres:query": "sql"
495
725
  },
726
+ snapshot: {
727
+ tools: [
728
+ "str_replace_based_edit_tool",
729
+ "write_file",
730
+ "edit_file",
731
+ "create_file",
732
+ "edit",
733
+ "replace"
734
+ ],
735
+ onlyPaths: [],
736
+ ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
737
+ },
496
738
  rules: [
739
+ // Only use the legacy rules format for simple path-based rm control.
740
+ // All other command-level enforcement lives in smartRules below.
497
741
  {
498
742
  action: "rm",
499
743
  allowPaths: [
@@ -510,6 +754,7 @@ var DEFAULT_CONFIG = {
510
754
  }
511
755
  ],
512
756
  smartRules: [
757
+ // ── SQL safety ────────────────────────────────────────────────────────
513
758
  {
514
759
  name: "no-delete-without-where",
515
760
  tool: "*",
@@ -520,6 +765,84 @@ var DEFAULT_CONFIG = {
520
765
  conditionMode: "all",
521
766
  verdict: "review",
522
767
  reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
768
+ },
769
+ {
770
+ name: "review-drop-truncate-shell",
771
+ tool: "bash",
772
+ conditions: [
773
+ {
774
+ field: "command",
775
+ op: "matches",
776
+ value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
777
+ flags: "i"
778
+ }
779
+ ],
780
+ conditionMode: "all",
781
+ verdict: "review",
782
+ reason: "SQL DDL destructive statement inside a shell command"
783
+ },
784
+ // ── Git safety ────────────────────────────────────────────────────────
785
+ {
786
+ name: "block-force-push",
787
+ tool: "bash",
788
+ conditions: [
789
+ {
790
+ field: "command",
791
+ op: "matches",
792
+ value: "git push.*(--force|--force-with-lease|-f\\b)",
793
+ flags: "i"
794
+ }
795
+ ],
796
+ conditionMode: "all",
797
+ verdict: "block",
798
+ reason: "Force push overwrites remote history and cannot be undone"
799
+ },
800
+ {
801
+ name: "review-git-push",
802
+ tool: "bash",
803
+ conditions: [{ field: "command", op: "matches", value: "^\\s*git push\\b", flags: "i" }],
804
+ conditionMode: "all",
805
+ verdict: "review",
806
+ reason: "git push sends changes to a shared remote"
807
+ },
808
+ {
809
+ name: "review-git-destructive",
810
+ tool: "bash",
811
+ conditions: [
812
+ {
813
+ field: "command",
814
+ op: "matches",
815
+ value: "git\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase|tag\\s+-d|branch\\s+-[dD])",
816
+ flags: "i"
817
+ }
818
+ ],
819
+ conditionMode: "all",
820
+ verdict: "review",
821
+ reason: "Destructive git operation \u2014 discards history or working-tree changes"
822
+ },
823
+ // ── Shell safety ──────────────────────────────────────────────────────
824
+ {
825
+ name: "review-sudo",
826
+ tool: "bash",
827
+ conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
828
+ conditionMode: "all",
829
+ verdict: "review",
830
+ reason: "Command requires elevated privileges"
831
+ },
832
+ {
833
+ name: "review-curl-pipe-shell",
834
+ tool: "bash",
835
+ conditions: [
836
+ {
837
+ field: "command",
838
+ op: "matches",
839
+ value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
840
+ flags: "i"
841
+ }
842
+ ],
843
+ conditionMode: "all",
844
+ verdict: "block",
845
+ reason: "Piping remote script into a shell is a supply-chain attack vector"
523
846
  }
524
847
  ]
525
848
  },
@@ -528,7 +851,7 @@ var DEFAULT_CONFIG = {
528
851
  var cachedConfig = null;
529
852
  function getInternalToken() {
530
853
  try {
531
- const pidFile = path.join(os.homedir(), ".node9", "daemon.pid");
854
+ const pidFile = path3.join(os.homedir(), ".node9", "daemon.pid");
532
855
  if (!fs.existsSync(pidFile)) return null;
533
856
  const data = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
534
857
  process.kill(data.pid, 0);
@@ -549,7 +872,9 @@ async function evaluatePolicy(toolName, args, agent) {
549
872
  return {
550
873
  decision: matchedRule.verdict,
551
874
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
552
- reason: matchedRule.reason
875
+ reason: matchedRule.reason,
876
+ tier: 2,
877
+ ruleName: matchedRule.name ?? matchedRule.tool
553
878
  };
554
879
  }
555
880
  }
@@ -564,7 +889,7 @@ async function evaluatePolicy(toolName, args, agent) {
564
889
  pathTokens = analyzed.paths;
565
890
  const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
566
891
  if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
567
- return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
892
+ return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)", tier: 3 };
568
893
  }
569
894
  if (isSqlTool(toolName, config.policy.toolInspection)) {
570
895
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
@@ -587,7 +912,7 @@ async function evaluatePolicy(toolName, args, agent) {
587
912
  );
588
913
  const isRootWipe = allTokens.includes("rm") && (allTokens.includes("/") || allTokens.includes("/*"));
589
914
  if (hasSystemDisaster || isRootWipe) {
590
- return { decision: "review", blockedByLabel: "Manual Nuclear Protection" };
915
+ return { decision: "review", blockedByLabel: "Manual Nuclear Protection", tier: 3 };
591
916
  }
592
917
  return { decision: "allow" };
593
918
  }
@@ -605,14 +930,16 @@ async function evaluatePolicy(toolName, args, agent) {
605
930
  if (anyBlocked)
606
931
  return {
607
932
  decision: "review",
608
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`
933
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`,
934
+ tier: 5
609
935
  };
610
936
  const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
611
937
  if (allAllowed) return { decision: "allow" };
612
938
  }
613
939
  return {
614
940
  decision: "review",
615
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`
941
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`,
942
+ tier: 5
616
943
  };
617
944
  }
618
945
  }
@@ -632,15 +959,36 @@ async function evaluatePolicy(toolName, args, agent) {
632
959
  })
633
960
  );
634
961
  if (isDangerous) {
962
+ let matchedField;
963
+ if (matchedDangerousWord && args && typeof args === "object" && !Array.isArray(args)) {
964
+ const obj = args;
965
+ for (const [key, value] of Object.entries(obj)) {
966
+ if (typeof value === "string") {
967
+ try {
968
+ if (new RegExp(
969
+ `\\b${matchedDangerousWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
970
+ "i"
971
+ ).test(value)) {
972
+ matchedField = key;
973
+ break;
974
+ }
975
+ } catch {
976
+ }
977
+ }
978
+ }
979
+ }
635
980
  return {
636
981
  decision: "review",
637
- blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`
982
+ blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
983
+ matchedWord: matchedDangerousWord,
984
+ matchedField,
985
+ tier: 6
638
986
  };
639
987
  }
640
988
  if (config.settings.mode === "strict") {
641
989
  const envConfig = getActiveEnvironment(config);
642
990
  if (envConfig?.requireApproval === false) return { decision: "allow" };
643
- return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
991
+ return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)", tier: 7 };
644
992
  }
645
993
  return { decision: "allow" };
646
994
  }
@@ -652,7 +1000,7 @@ var DAEMON_PORT = 7391;
652
1000
  var DAEMON_HOST = "127.0.0.1";
653
1001
  function isDaemonRunning() {
654
1002
  try {
655
- const pidFile = path.join(os.homedir(), ".node9", "daemon.pid");
1003
+ const pidFile = path3.join(os.homedir(), ".node9", "daemon.pid");
656
1004
  if (!fs.existsSync(pidFile)) return false;
657
1005
  const { pid, port } = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
658
1006
  if (port !== DAEMON_PORT) return false;
@@ -664,7 +1012,7 @@ function isDaemonRunning() {
664
1012
  }
665
1013
  function getPersistentDecision(toolName) {
666
1014
  try {
667
- const file = path.join(os.homedir(), ".node9", "decisions.json");
1015
+ const file = path3.join(os.homedir(), ".node9", "decisions.json");
668
1016
  if (!fs.existsSync(file)) return null;
669
1017
  const decisions = JSON.parse(fs.readFileSync(file, "utf-8"));
670
1018
  const d = decisions[toolName];
@@ -673,7 +1021,7 @@ function getPersistentDecision(toolName) {
673
1021
  }
674
1022
  return null;
675
1023
  }
676
- async function askDaemon(toolName, args, meta, signal) {
1024
+ async function askDaemon(toolName, args, meta, signal, riskMetadata) {
677
1025
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
678
1026
  const checkCtrl = new AbortController();
679
1027
  const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
@@ -683,7 +1031,13 @@ async function askDaemon(toolName, args, meta, signal) {
683
1031
  const checkRes = await fetch(`${base}/check`, {
684
1032
  method: "POST",
685
1033
  headers: { "Content-Type": "application/json" },
686
- body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
1034
+ body: JSON.stringify({
1035
+ toolName,
1036
+ args,
1037
+ agent: meta?.agent,
1038
+ mcpServer: meta?.mcpServer,
1039
+ ...riskMetadata && { riskMetadata }
1040
+ }),
687
1041
  signal: checkCtrl.signal
688
1042
  });
689
1043
  if (!checkRes.ok) throw new Error("Daemon fail");
@@ -708,7 +1062,7 @@ async function askDaemon(toolName, args, meta, signal) {
708
1062
  if (signal) signal.removeEventListener("abort", onAbort);
709
1063
  }
710
1064
  }
711
- async function notifyDaemonViewer(toolName, args, meta) {
1065
+ async function notifyDaemonViewer(toolName, args, meta, riskMetadata) {
712
1066
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
713
1067
  const res = await fetch(`${base}/check`, {
714
1068
  method: "POST",
@@ -718,7 +1072,8 @@ async function notifyDaemonViewer(toolName, args, meta) {
718
1072
  args,
719
1073
  slackDelegated: true,
720
1074
  agent: meta?.agent,
721
- mcpServer: meta?.mcpServer
1075
+ mcpServer: meta?.mcpServer,
1076
+ ...riskMetadata && { riskMetadata }
722
1077
  }),
723
1078
  signal: AbortSignal.timeout(3e3)
724
1079
  });
@@ -735,7 +1090,7 @@ async function resolveViaDaemon(id, decision, internalToken) {
735
1090
  signal: AbortSignal.timeout(3e3)
736
1091
  });
737
1092
  }
738
- async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
1093
+ async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta, options) {
739
1094
  if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
740
1095
  const pauseState = checkPause();
741
1096
  if (pauseState.paused) return { approved: true, checkedBy: "paused" };
@@ -755,11 +1110,17 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
755
1110
  }
756
1111
  const isManual = meta?.agent === "Terminal";
757
1112
  let explainableLabel = "Local Config";
1113
+ let policyMatchedField;
1114
+ let policyMatchedWord;
1115
+ let riskMetadata;
758
1116
  if (config.settings.mode === "audit") {
759
1117
  if (!isIgnoredTool(toolName)) {
760
1118
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
761
1119
  if (policyResult.decision === "review") {
762
1120
  appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
1121
+ if (approvers.cloud && creds?.apiKey) {
1122
+ await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
1123
+ }
763
1124
  sendDesktopNotification(
764
1125
  "Node9 Audit Mode",
765
1126
  `Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
@@ -770,13 +1131,15 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
770
1131
  }
771
1132
  if (!isIgnoredTool(toolName)) {
772
1133
  if (getActiveTrustSession(toolName)) {
773
- if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
1134
+ if (approvers.cloud && creds?.apiKey)
1135
+ await auditLocalAllow(toolName, args, "trust", creds, meta);
774
1136
  if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
775
1137
  return { approved: true, checkedBy: "trust" };
776
1138
  }
777
1139
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
778
1140
  if (policyResult.decision === "allow") {
779
- if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
1141
+ if (approvers.cloud && creds?.apiKey)
1142
+ auditLocalAllow(toolName, args, "local-policy", creds, meta);
780
1143
  if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
781
1144
  return { approved: true, checkedBy: "local-policy" };
782
1145
  }
@@ -790,9 +1153,20 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
790
1153
  };
791
1154
  }
792
1155
  explainableLabel = policyResult.blockedByLabel || "Local Config";
1156
+ policyMatchedField = policyResult.matchedField;
1157
+ policyMatchedWord = policyResult.matchedWord;
1158
+ riskMetadata = computeRiskMetadata(
1159
+ args,
1160
+ policyResult.tier ?? 6,
1161
+ explainableLabel,
1162
+ policyMatchedField,
1163
+ policyMatchedWord,
1164
+ policyResult.ruleName
1165
+ );
793
1166
  const persistent = getPersistentDecision(toolName);
794
1167
  if (persistent === "allow") {
795
- if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
1168
+ if (approvers.cloud && creds?.apiKey)
1169
+ await auditLocalAllow(toolName, args, "persistent", creds, meta);
796
1170
  if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
797
1171
  return { approved: true, checkedBy: "persistent" };
798
1172
  }
@@ -806,7 +1180,6 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
806
1180
  };
807
1181
  }
808
1182
  } else {
809
- if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
810
1183
  if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
811
1184
  return { approved: true };
812
1185
  }
@@ -815,8 +1188,21 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
815
1188
  const cloudEnforced = approvers.cloud && !!creds?.apiKey;
816
1189
  if (cloudEnforced) {
817
1190
  try {
818
- const initResult = await initNode9SaaS(toolName, args, creds, meta);
1191
+ const initResult = await initNode9SaaS(toolName, args, creds, meta, riskMetadata);
819
1192
  if (!initResult.pending) {
1193
+ if (initResult.shadowMode) {
1194
+ console.error(
1195
+ chalk2.yellow(
1196
+ `
1197
+ \u26A0\uFE0F Node9 Shadow Mode: Action allowed, but would have been blocked by company policy.`
1198
+ )
1199
+ );
1200
+ if (initResult.shadowReason) {
1201
+ console.error(chalk2.dim(` Reason: ${initResult.shadowReason}
1202
+ `));
1203
+ }
1204
+ return { approved: true, checkedBy: "cloud" };
1205
+ }
820
1206
  return {
821
1207
  approved: !!initResult.approved,
822
1208
  reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
@@ -841,18 +1227,22 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
841
1227
  );
842
1228
  }
843
1229
  }
844
- if (cloudEnforced && cloudRequestId) {
845
- console.error(
846
- chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
847
- );
848
- console.error(chalk2.cyan(" Dashboard \u2192 ") + chalk2.bold("Mission Control > Activity Feed\n"));
849
- } else if (!cloudEnforced) {
850
- const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
851
- console.error(
852
- chalk2.dim(`
1230
+ if (!options?.calledFromDaemon) {
1231
+ if (cloudEnforced && cloudRequestId) {
1232
+ console.error(
1233
+ chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
1234
+ );
1235
+ console.error(
1236
+ chalk2.cyan(" Dashboard \u2192 ") + chalk2.bold("Mission Control > Activity Feed\n")
1237
+ );
1238
+ } else if (!cloudEnforced) {
1239
+ const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
1240
+ console.error(
1241
+ chalk2.dim(`
853
1242
  \u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
854
1243
  `)
855
- );
1244
+ );
1245
+ }
856
1246
  }
857
1247
  const abortController = new AbortController();
858
1248
  const { signal } = abortController;
@@ -882,8 +1272,10 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
882
1272
  racePromises.push(
883
1273
  (async () => {
884
1274
  try {
885
- if (isDaemonRunning() && internalToken) {
886
- viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
1275
+ if (isDaemonRunning() && internalToken && !options?.calledFromDaemon) {
1276
+ viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(
1277
+ () => null
1278
+ );
887
1279
  }
888
1280
  const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
889
1281
  return {
@@ -901,7 +1293,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
901
1293
  })()
902
1294
  );
903
1295
  }
904
- if (approvers.native && !isManual) {
1296
+ if (approvers.native && !isManual && !options?.calledFromDaemon) {
905
1297
  racePromises.push(
906
1298
  (async () => {
907
1299
  const decision = await askNativePopup(
@@ -910,7 +1302,9 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
910
1302
  meta?.agent,
911
1303
  explainableLabel,
912
1304
  isRemoteLocked,
913
- signal
1305
+ signal,
1306
+ policyMatchedField,
1307
+ policyMatchedWord
914
1308
  );
915
1309
  if (decision === "always_allow") {
916
1310
  writeTrustSession(toolName, 36e5);
@@ -927,7 +1321,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
927
1321
  })()
928
1322
  );
929
1323
  }
930
- if (approvers.browser && isDaemonRunning()) {
1324
+ if (approvers.browser && isDaemonRunning() && !options?.calledFromDaemon && !cloudEnforced) {
931
1325
  racePromises.push(
932
1326
  (async () => {
933
1327
  try {
@@ -938,7 +1332,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
938
1332
  console.error(chalk2.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
939
1333
  `));
940
1334
  }
941
- const daemonDecision = await askDaemon(toolName, args, meta, signal);
1335
+ const daemonDecision = await askDaemon(toolName, args, meta, signal, riskMetadata);
942
1336
  if (daemonDecision === "abandoned") throw new Error("Abandoned");
943
1337
  const isApproved = daemonDecision === "allow";
944
1338
  return {
@@ -1063,8 +1457,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
1063
1457
  }
1064
1458
  function getConfig() {
1065
1459
  if (cachedConfig) return cachedConfig;
1066
- const globalPath = path.join(os.homedir(), ".node9", "config.json");
1067
- const projectPath = path.join(process.cwd(), "node9.config.json");
1460
+ const globalPath = path3.join(os.homedir(), ".node9", "config.json");
1461
+ const projectPath = path3.join(process.cwd(), "node9.config.json");
1068
1462
  const globalConfig = tryLoadConfig(globalPath);
1069
1463
  const projectConfig = tryLoadConfig(projectPath);
1070
1464
  const mergedSettings = {
@@ -1077,7 +1471,12 @@ function getConfig() {
1077
1471
  ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
1078
1472
  toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
1079
1473
  rules: [...DEFAULT_CONFIG.policy.rules],
1080
- smartRules: [...DEFAULT_CONFIG.policy.smartRules]
1474
+ smartRules: [...DEFAULT_CONFIG.policy.smartRules],
1475
+ snapshot: {
1476
+ tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
1477
+ onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
1478
+ ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
1479
+ }
1081
1480
  };
1082
1481
  const applyLayer = (source) => {
1083
1482
  if (!source) return;
@@ -1089,6 +1488,7 @@ function getConfig() {
1089
1488
  if (s.enableHookLogDebug !== void 0)
1090
1489
  mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
1091
1490
  if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
1491
+ if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
1092
1492
  if (s.environment !== void 0) mergedSettings.environment = s.environment;
1093
1493
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
1094
1494
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
@@ -1097,6 +1497,12 @@ function getConfig() {
1097
1497
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
1098
1498
  if (p.rules) mergedPolicy.rules.push(...p.rules);
1099
1499
  if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
1500
+ if (p.snapshot) {
1501
+ const s2 = p.snapshot;
1502
+ if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
1503
+ if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
1504
+ if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
1505
+ }
1100
1506
  };
1101
1507
  applyLayer(globalConfig);
1102
1508
  applyLayer(projectConfig);
@@ -1104,6 +1510,9 @@ function getConfig() {
1104
1510
  mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
1105
1511
  mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
1106
1512
  mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
1513
+ mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
1514
+ mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
1515
+ mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
1107
1516
  cachedConfig = {
1108
1517
  settings: mergedSettings,
1109
1518
  policy: mergedPolicy,
@@ -1113,11 +1522,33 @@ function getConfig() {
1113
1522
  }
1114
1523
  function tryLoadConfig(filePath) {
1115
1524
  if (!fs.existsSync(filePath)) return null;
1525
+ let raw;
1116
1526
  try {
1117
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
1118
- } catch {
1527
+ raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1528
+ } catch (err) {
1529
+ const msg = err instanceof Error ? err.message : String(err);
1530
+ process.stderr.write(
1531
+ `
1532
+ \u26A0\uFE0F Node9: Failed to parse ${filePath}
1533
+ ${msg}
1534
+ \u2192 Using default config
1535
+
1536
+ `
1537
+ );
1119
1538
  return null;
1120
1539
  }
1540
+ const { sanitized, error } = sanitizeConfig(raw);
1541
+ if (error) {
1542
+ process.stderr.write(
1543
+ `
1544
+ \u26A0\uFE0F Node9: Invalid config at ${filePath}:
1545
+ ${error.replace("Invalid config:\n", "")}
1546
+ \u2192 Invalid fields ignored, using defaults for those keys
1547
+
1548
+ `
1549
+ );
1550
+ }
1551
+ return sanitized;
1121
1552
  }
1122
1553
  function getActiveEnvironment(config) {
1123
1554
  const env = config.settings.environment || process.env.NODE_ENV || "development";
@@ -1132,7 +1563,7 @@ function getCredentials() {
1132
1563
  };
1133
1564
  }
1134
1565
  try {
1135
- const credPath = path.join(os.homedir(), ".node9", "credentials.json");
1566
+ const credPath = path3.join(os.homedir(), ".node9", "credentials.json");
1136
1567
  if (fs.existsSync(credPath)) {
1137
1568
  const creds = JSON.parse(fs.readFileSync(credPath, "utf-8"));
1138
1569
  const profileName = process.env.NODE9_PROFILE || "default";
@@ -1159,9 +1590,7 @@ async function authorizeAction(toolName, args) {
1159
1590
  return result.approved;
1160
1591
  }
1161
1592
  function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1162
- const controller = new AbortController();
1163
- setTimeout(() => controller.abort(), 5e3);
1164
- fetch(`${creds.apiUrl}/audit`, {
1593
+ return fetch(`${creds.apiUrl}/audit`, {
1165
1594
  method: "POST",
1166
1595
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
1167
1596
  body: JSON.stringify({
@@ -1176,11 +1605,12 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1176
1605
  platform: os.platform()
1177
1606
  }
1178
1607
  }),
1179
- signal: controller.signal
1608
+ signal: AbortSignal.timeout(5e3)
1609
+ }).then(() => {
1180
1610
  }).catch(() => {
1181
1611
  });
1182
1612
  }
1183
- async function initNode9SaaS(toolName, args, creds, meta) {
1613
+ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
1184
1614
  const controller = new AbortController();
1185
1615
  const timeout = setTimeout(() => controller.abort(), 1e4);
1186
1616
  try {
@@ -1196,7 +1626,8 @@ async function initNode9SaaS(toolName, args, creds, meta) {
1196
1626
  hostname: os.hostname(),
1197
1627
  cwd: process.cwd(),
1198
1628
  platform: os.platform()
1199
- }
1629
+ },
1630
+ ...riskMetadata && { riskMetadata }
1200
1631
  }),
1201
1632
  signal: controller.signal
1202
1633
  });