@node9/proxy 1.0.8 → 1.0.10

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.js CHANGED
@@ -38,18 +38,18 @@ module.exports = __toCommonJS(src_exports);
38
38
  var import_chalk2 = __toESM(require("chalk"));
39
39
  var import_prompts = require("@inquirer/prompts");
40
40
  var import_fs = __toESM(require("fs"));
41
- var import_path2 = __toESM(require("path"));
41
+ var import_path3 = __toESM(require("path"));
42
42
  var import_os = __toESM(require("os"));
43
43
  var import_picomatch = __toESM(require("picomatch"));
44
44
  var import_sh_syntax = require("sh-syntax");
45
45
 
46
46
  // src/ui/native.ts
47
47
  var import_child_process = require("child_process");
48
- var import_path = __toESM(require("path"));
48
+ var import_path2 = __toESM(require("path"));
49
49
  var import_chalk = __toESM(require("chalk"));
50
- var isTestEnv = () => {
51
- 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";
52
- };
50
+
51
+ // src/context-sniper.ts
52
+ var import_path = __toESM(require("path"));
53
53
  function smartTruncate(str, maxLen = 500) {
54
54
  if (str.length <= maxLen) return str;
55
55
  const edge = Math.floor(maxLen / 2) - 3;
@@ -57,11 +57,13 @@ function smartTruncate(str, maxLen = 500) {
57
57
  }
58
58
  function extractContext(text, matchedWord) {
59
59
  const lines = text.split("\n");
60
- if (lines.length <= 7 || !matchedWord) return smartTruncate(text, 500);
60
+ if (lines.length <= 7 || !matchedWord) {
61
+ return { snippet: smartTruncate(text, 500), lineIndex: -1 };
62
+ }
61
63
  const escaped = matchedWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
62
64
  const pattern = new RegExp(`\\b${escaped}\\b`, "i");
63
65
  const allHits = lines.map((line, i) => ({ i, line })).filter(({ line }) => pattern.test(line));
64
- if (allHits.length === 0) return smartTruncate(text, 500);
66
+ if (allHits.length === 0) return { snippet: smartTruncate(text, 500), lineIndex: -1 };
65
67
  const nonComment = allHits.find(({ line }) => {
66
68
  const trimmed = line.trim();
67
69
  return !trimmed.startsWith("//") && !trimmed.startsWith("#");
@@ -69,13 +71,89 @@ function extractContext(text, matchedWord) {
69
71
  const hitIndex = (nonComment ?? allHits[0]).i;
70
72
  const start = Math.max(0, hitIndex - 3);
71
73
  const end = Math.min(lines.length, hitIndex + 4);
74
+ const lineIndex = hitIndex - start;
72
75
  const snippet = lines.slice(start, end).map((line, i) => `${start + i === hitIndex ? "\u{1F6D1} " : " "}${line}`).join("\n");
73
76
  const head = start > 0 ? `... [${start} lines hidden] ...
74
77
  ` : "";
75
78
  const tail = end < lines.length ? `
76
79
  ... [${lines.length - end} lines hidden] ...` : "";
77
- return `${head}${snippet}${tail}`;
80
+ return { snippet: `${head}${snippet}${tail}`, lineIndex };
78
81
  }
82
+ var CODE_KEYS = [
83
+ "command",
84
+ "cmd",
85
+ "shell_command",
86
+ "bash_command",
87
+ "script",
88
+ "code",
89
+ "input",
90
+ "sql",
91
+ "query",
92
+ "arguments",
93
+ "args",
94
+ "param",
95
+ "params",
96
+ "text"
97
+ ];
98
+ function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWord, ruleName) {
99
+ let intent = "EXEC";
100
+ let contextSnippet;
101
+ let contextLineIndex;
102
+ let editFileName;
103
+ let editFilePath;
104
+ let parsed = args;
105
+ if (typeof args === "string") {
106
+ const trimmed = args.trim();
107
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
108
+ try {
109
+ parsed = JSON.parse(trimmed);
110
+ } catch {
111
+ }
112
+ }
113
+ }
114
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
115
+ const obj = parsed;
116
+ if (obj.old_string !== void 0 && obj.new_string !== void 0) {
117
+ intent = "EDIT";
118
+ if (obj.file_path) {
119
+ editFilePath = String(obj.file_path);
120
+ editFileName = import_path.default.basename(editFilePath);
121
+ }
122
+ const result = extractContext(String(obj.new_string), matchedWord);
123
+ contextSnippet = result.snippet;
124
+ if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
125
+ } else if (matchedField && obj[matchedField] !== void 0) {
126
+ const result = extractContext(String(obj[matchedField]), matchedWord);
127
+ contextSnippet = result.snippet;
128
+ if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
129
+ } else {
130
+ const foundKey = Object.keys(obj).find((k) => CODE_KEYS.includes(k.toLowerCase()));
131
+ if (foundKey) {
132
+ const val = obj[foundKey];
133
+ contextSnippet = smartTruncate(typeof val === "string" ? val : JSON.stringify(val), 500);
134
+ }
135
+ }
136
+ } else if (typeof parsed === "string") {
137
+ contextSnippet = smartTruncate(parsed, 500);
138
+ }
139
+ return {
140
+ intent,
141
+ tier,
142
+ blockedByLabel,
143
+ ...matchedWord && { matchedWord },
144
+ ...matchedField && { matchedField },
145
+ ...contextSnippet !== void 0 && { contextSnippet },
146
+ ...contextLineIndex !== void 0 && { contextLineIndex },
147
+ ...editFileName && { editFileName },
148
+ ...editFilePath && { editFilePath },
149
+ ...ruleName && { ruleName }
150
+ };
151
+ }
152
+
153
+ // src/ui/native.ts
154
+ var isTestEnv = () => {
155
+ 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";
156
+ };
79
157
  function formatArgs(args, matchedField, matchedWord) {
80
158
  if (args === null || args === void 0) return { message: "(none)", intent: "EXEC" };
81
159
  let parsed = args;
@@ -94,9 +172,9 @@ function formatArgs(args, matchedField, matchedWord) {
94
172
  if (typeof parsed === "object" && !Array.isArray(parsed)) {
95
173
  const obj = parsed;
96
174
  if (obj.old_string !== void 0 && obj.new_string !== void 0) {
97
- const file = obj.file_path ? import_path.default.basename(String(obj.file_path)) : "file";
175
+ const file = obj.file_path ? import_path2.default.basename(String(obj.file_path)) : "file";
98
176
  const oldPreview = smartTruncate(String(obj.old_string), 120);
99
- const newPreview = extractContext(String(obj.new_string), matchedWord);
177
+ const newPreview = extractContext(String(obj.new_string), matchedWord).snippet;
100
178
  return {
101
179
  intent: "EDIT",
102
180
  message: `\u{1F4DD} EDITING: ${file}
@@ -114,7 +192,7 @@ ${newPreview}`
114
192
  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(", ")}
115
193
 
116
194
  ` : "";
117
- const content = extractContext(String(obj[matchedField]), matchedWord);
195
+ const content = extractContext(String(obj[matchedField]), matchedWord).snippet;
118
196
  return {
119
197
  intent: "EXEC",
120
198
  message: `${context}\u{1F6D1} [${matchedField.toUpperCase()}]:
@@ -286,11 +364,113 @@ end run`;
286
364
  });
287
365
  }
288
366
 
367
+ // src/config-schema.ts
368
+ var import_zod = require("zod");
369
+ var noNewlines = import_zod.z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
370
+ message: "Value must not contain literal newline characters (use \\n instead)"
371
+ });
372
+ var validRegex = noNewlines.refine(
373
+ (s) => {
374
+ try {
375
+ new RegExp(s);
376
+ return true;
377
+ } catch {
378
+ return false;
379
+ }
380
+ },
381
+ { message: "Value must be a valid regular expression" }
382
+ );
383
+ var SmartConditionSchema = import_zod.z.object({
384
+ field: import_zod.z.string().min(1, "Condition field must not be empty"),
385
+ op: import_zod.z.enum(["matches", "notMatches", "contains", "notContains", "exists", "notExists"], {
386
+ errorMap: () => ({
387
+ message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists"
388
+ })
389
+ }),
390
+ value: validRegex.optional(),
391
+ flags: import_zod.z.string().optional()
392
+ });
393
+ var SmartRuleSchema = import_zod.z.object({
394
+ name: import_zod.z.string().optional(),
395
+ tool: import_zod.z.string().min(1, "Smart rule tool must not be empty"),
396
+ conditions: import_zod.z.array(SmartConditionSchema).min(1, "Smart rule must have at least one condition"),
397
+ conditionMode: import_zod.z.enum(["all", "any"]).optional(),
398
+ verdict: import_zod.z.enum(["allow", "review", "block"], {
399
+ errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
400
+ }),
401
+ reason: import_zod.z.string().optional()
402
+ });
403
+ var PolicyRuleSchema = import_zod.z.object({
404
+ action: import_zod.z.string().min(1),
405
+ allowPaths: import_zod.z.array(import_zod.z.string()).optional(),
406
+ blockPaths: import_zod.z.array(import_zod.z.string()).optional()
407
+ });
408
+ var ConfigFileSchema = import_zod.z.object({
409
+ version: import_zod.z.string().optional(),
410
+ settings: import_zod.z.object({
411
+ mode: import_zod.z.enum(["standard", "strict", "audit"]).optional(),
412
+ autoStartDaemon: import_zod.z.boolean().optional(),
413
+ enableUndo: import_zod.z.boolean().optional(),
414
+ enableHookLogDebug: import_zod.z.boolean().optional(),
415
+ approvalTimeoutMs: import_zod.z.number().nonnegative().optional(),
416
+ approvers: import_zod.z.object({
417
+ native: import_zod.z.boolean().optional(),
418
+ browser: import_zod.z.boolean().optional(),
419
+ cloud: import_zod.z.boolean().optional(),
420
+ terminal: import_zod.z.boolean().optional()
421
+ }).optional(),
422
+ environment: import_zod.z.string().optional(),
423
+ slackEnabled: import_zod.z.boolean().optional(),
424
+ enableTrustSessions: import_zod.z.boolean().optional(),
425
+ allowGlobalPause: import_zod.z.boolean().optional()
426
+ }).optional(),
427
+ policy: import_zod.z.object({
428
+ sandboxPaths: import_zod.z.array(import_zod.z.string()).optional(),
429
+ dangerousWords: import_zod.z.array(noNewlines).optional(),
430
+ ignoredTools: import_zod.z.array(import_zod.z.string()).optional(),
431
+ toolInspection: import_zod.z.record(import_zod.z.string()).optional(),
432
+ rules: import_zod.z.array(PolicyRuleSchema).optional(),
433
+ smartRules: import_zod.z.array(SmartRuleSchema).optional(),
434
+ snapshot: import_zod.z.object({
435
+ tools: import_zod.z.array(import_zod.z.string()).optional(),
436
+ onlyPaths: import_zod.z.array(import_zod.z.string()).optional(),
437
+ ignorePaths: import_zod.z.array(import_zod.z.string()).optional()
438
+ }).optional()
439
+ }).optional(),
440
+ environments: import_zod.z.record(import_zod.z.object({ requireApproval: import_zod.z.boolean().optional() })).optional()
441
+ }).strict({ message: "Config contains unknown top-level keys" });
442
+ function sanitizeConfig(raw) {
443
+ const result = ConfigFileSchema.safeParse(raw);
444
+ if (result.success) {
445
+ return { sanitized: result.data, error: null };
446
+ }
447
+ const invalidTopLevelKeys = new Set(
448
+ result.error.issues.filter((issue) => issue.path.length > 0).map((issue) => String(issue.path[0]))
449
+ );
450
+ const sanitized = {};
451
+ if (typeof raw === "object" && raw !== null) {
452
+ for (const [key, value] of Object.entries(raw)) {
453
+ if (!invalidTopLevelKeys.has(key)) {
454
+ sanitized[key] = value;
455
+ }
456
+ }
457
+ }
458
+ const lines = result.error.issues.map((issue) => {
459
+ const path4 = issue.path.length > 0 ? issue.path.join(".") : "root";
460
+ return ` \u2022 ${path4}: ${issue.message}`;
461
+ });
462
+ return {
463
+ sanitized,
464
+ error: `Invalid config:
465
+ ${lines.join("\n")}`
466
+ };
467
+ }
468
+
289
469
  // src/core.ts
290
- var PAUSED_FILE = import_path2.default.join(import_os.default.homedir(), ".node9", "PAUSED");
291
- var TRUST_FILE = import_path2.default.join(import_os.default.homedir(), ".node9", "trust.json");
292
- var LOCAL_AUDIT_LOG = import_path2.default.join(import_os.default.homedir(), ".node9", "audit.log");
293
- var HOOK_DEBUG_LOG = import_path2.default.join(import_os.default.homedir(), ".node9", "hook-debug.log");
470
+ var PAUSED_FILE = import_path3.default.join(import_os.default.homedir(), ".node9", "PAUSED");
471
+ var TRUST_FILE = import_path3.default.join(import_os.default.homedir(), ".node9", "trust.json");
472
+ var LOCAL_AUDIT_LOG = import_path3.default.join(import_os.default.homedir(), ".node9", "audit.log");
473
+ var HOOK_DEBUG_LOG = import_path3.default.join(import_os.default.homedir(), ".node9", "hook-debug.log");
294
474
  function checkPause() {
295
475
  try {
296
476
  if (!import_fs.default.existsSync(PAUSED_FILE)) return { paused: false };
@@ -308,7 +488,7 @@ function checkPause() {
308
488
  }
309
489
  }
310
490
  function atomicWriteSync(filePath, data, options) {
311
- const dir = import_path2.default.dirname(filePath);
491
+ const dir = import_path3.default.dirname(filePath);
312
492
  if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
313
493
  const tmpPath = `${filePath}.${import_os.default.hostname()}.${process.pid}.tmp`;
314
494
  import_fs.default.writeFileSync(tmpPath, data, options);
@@ -349,7 +529,7 @@ function writeTrustSession(toolName, durationMs) {
349
529
  }
350
530
  function appendToLog(logPath, entry) {
351
531
  try {
352
- const dir = import_path2.default.dirname(logPath);
532
+ const dir = import_path3.default.dirname(logPath);
353
533
  if (!import_fs.default.existsSync(dir)) import_fs.default.mkdirSync(dir, { recursive: true });
354
534
  import_fs.default.appendFileSync(logPath, JSON.stringify(entry) + "\n");
355
535
  } catch {
@@ -393,9 +573,9 @@ function matchesPattern(text, patterns) {
393
573
  const withoutDotSlash = text.replace(/^\.\//, "");
394
574
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
395
575
  }
396
- function getNestedValue(obj, path3) {
576
+ function getNestedValue(obj, path4) {
397
577
  if (!obj || typeof obj !== "object") return null;
398
- return path3.split(".").reduce((prev, curr) => prev?.[curr], obj);
578
+ return path4.split(".").reduce((prev, curr) => prev?.[curr], obj);
399
579
  }
400
580
  function evaluateSmartConditions(args, rule) {
401
581
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -532,15 +712,10 @@ function redactSecrets(text) {
532
712
  return redacted;
533
713
  }
534
714
  var DANGEROUS_WORDS = [
535
- "drop",
536
- "truncate",
537
- "purge",
538
- "format",
539
- "destroy",
540
- "terminate",
541
- "revoke",
542
- "docker",
543
- "psql"
715
+ "mkfs",
716
+ // formats/wipes a filesystem partition
717
+ "shred"
718
+ // permanently overwrites file contents (unrecoverable)
544
719
  ];
545
720
  var DEFAULT_CONFIG = {
546
721
  settings: {
@@ -597,6 +772,8 @@ var DEFAULT_CONFIG = {
597
772
  ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
598
773
  },
599
774
  rules: [
775
+ // Only use the legacy rules format for simple path-based rm control.
776
+ // All other command-level enforcement lives in smartRules below.
600
777
  {
601
778
  action: "rm",
602
779
  allowPaths: [
@@ -613,6 +790,7 @@ var DEFAULT_CONFIG = {
613
790
  }
614
791
  ],
615
792
  smartRules: [
793
+ // ── SQL safety ────────────────────────────────────────────────────────
616
794
  {
617
795
  name: "no-delete-without-where",
618
796
  tool: "*",
@@ -623,6 +801,84 @@ var DEFAULT_CONFIG = {
623
801
  conditionMode: "all",
624
802
  verdict: "review",
625
803
  reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
804
+ },
805
+ {
806
+ name: "review-drop-truncate-shell",
807
+ tool: "bash",
808
+ conditions: [
809
+ {
810
+ field: "command",
811
+ op: "matches",
812
+ value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
813
+ flags: "i"
814
+ }
815
+ ],
816
+ conditionMode: "all",
817
+ verdict: "review",
818
+ reason: "SQL DDL destructive statement inside a shell command"
819
+ },
820
+ // ── Git safety ────────────────────────────────────────────────────────
821
+ {
822
+ name: "block-force-push",
823
+ tool: "bash",
824
+ conditions: [
825
+ {
826
+ field: "command",
827
+ op: "matches",
828
+ value: "git push.*(--force|--force-with-lease|-f\\b)",
829
+ flags: "i"
830
+ }
831
+ ],
832
+ conditionMode: "all",
833
+ verdict: "block",
834
+ reason: "Force push overwrites remote history and cannot be undone"
835
+ },
836
+ {
837
+ name: "review-git-push",
838
+ tool: "bash",
839
+ conditions: [{ field: "command", op: "matches", value: "^\\s*git\\s+push\\b", flags: "i" }],
840
+ conditionMode: "all",
841
+ verdict: "review",
842
+ reason: "git push sends changes to a shared remote"
843
+ },
844
+ {
845
+ name: "review-git-destructive",
846
+ tool: "bash",
847
+ conditions: [
848
+ {
849
+ field: "command",
850
+ op: "matches",
851
+ value: "git\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase|tag\\s+-d|branch\\s+-[dD])",
852
+ flags: "i"
853
+ }
854
+ ],
855
+ conditionMode: "all",
856
+ verdict: "review",
857
+ reason: "Destructive git operation \u2014 discards history or working-tree changes"
858
+ },
859
+ // ── Shell safety ──────────────────────────────────────────────────────
860
+ {
861
+ name: "review-sudo",
862
+ tool: "bash",
863
+ conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
864
+ conditionMode: "all",
865
+ verdict: "review",
866
+ reason: "Command requires elevated privileges"
867
+ },
868
+ {
869
+ name: "review-curl-pipe-shell",
870
+ tool: "bash",
871
+ conditions: [
872
+ {
873
+ field: "command",
874
+ op: "matches",
875
+ value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
876
+ flags: "i"
877
+ }
878
+ ],
879
+ conditionMode: "all",
880
+ verdict: "block",
881
+ reason: "Piping remote script into a shell is a supply-chain attack vector"
626
882
  }
627
883
  ]
628
884
  },
@@ -631,7 +887,7 @@ var DEFAULT_CONFIG = {
631
887
  var cachedConfig = null;
632
888
  function getInternalToken() {
633
889
  try {
634
- const pidFile = import_path2.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
890
+ const pidFile = import_path3.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
635
891
  if (!import_fs.default.existsSync(pidFile)) return null;
636
892
  const data = JSON.parse(import_fs.default.readFileSync(pidFile, "utf-8"));
637
893
  process.kill(data.pid, 0);
@@ -652,7 +908,9 @@ async function evaluatePolicy(toolName, args, agent) {
652
908
  return {
653
909
  decision: matchedRule.verdict,
654
910
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
655
- reason: matchedRule.reason
911
+ reason: matchedRule.reason,
912
+ tier: 2,
913
+ ruleName: matchedRule.name ?? matchedRule.tool
656
914
  };
657
915
  }
658
916
  }
@@ -667,7 +925,7 @@ async function evaluatePolicy(toolName, args, agent) {
667
925
  pathTokens = analyzed.paths;
668
926
  const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
669
927
  if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
670
- return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
928
+ return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)", tier: 3 };
671
929
  }
672
930
  if (isSqlTool(toolName, config.policy.toolInspection)) {
673
931
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
@@ -690,7 +948,7 @@ async function evaluatePolicy(toolName, args, agent) {
690
948
  );
691
949
  const isRootWipe = allTokens.includes("rm") && (allTokens.includes("/") || allTokens.includes("/*"));
692
950
  if (hasSystemDisaster || isRootWipe) {
693
- return { decision: "review", blockedByLabel: "Manual Nuclear Protection" };
951
+ return { decision: "review", blockedByLabel: "Manual Nuclear Protection", tier: 3 };
694
952
  }
695
953
  return { decision: "allow" };
696
954
  }
@@ -708,14 +966,16 @@ async function evaluatePolicy(toolName, args, agent) {
708
966
  if (anyBlocked)
709
967
  return {
710
968
  decision: "review",
711
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`
969
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`,
970
+ tier: 5
712
971
  };
713
972
  const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
714
973
  if (allAllowed) return { decision: "allow" };
715
974
  }
716
975
  return {
717
976
  decision: "review",
718
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`
977
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`,
978
+ tier: 5
719
979
  };
720
980
  }
721
981
  }
@@ -757,13 +1017,14 @@ async function evaluatePolicy(toolName, args, agent) {
757
1017
  decision: "review",
758
1018
  blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
759
1019
  matchedWord: matchedDangerousWord,
760
- matchedField
1020
+ matchedField,
1021
+ tier: 6
761
1022
  };
762
1023
  }
763
1024
  if (config.settings.mode === "strict") {
764
1025
  const envConfig = getActiveEnvironment(config);
765
1026
  if (envConfig?.requireApproval === false) return { decision: "allow" };
766
- return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
1027
+ return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)", tier: 7 };
767
1028
  }
768
1029
  return { decision: "allow" };
769
1030
  }
@@ -775,7 +1036,7 @@ var DAEMON_PORT = 7391;
775
1036
  var DAEMON_HOST = "127.0.0.1";
776
1037
  function isDaemonRunning() {
777
1038
  try {
778
- const pidFile = import_path2.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
1039
+ const pidFile = import_path3.default.join(import_os.default.homedir(), ".node9", "daemon.pid");
779
1040
  if (!import_fs.default.existsSync(pidFile)) return false;
780
1041
  const { pid, port } = JSON.parse(import_fs.default.readFileSync(pidFile, "utf-8"));
781
1042
  if (port !== DAEMON_PORT) return false;
@@ -787,7 +1048,7 @@ function isDaemonRunning() {
787
1048
  }
788
1049
  function getPersistentDecision(toolName) {
789
1050
  try {
790
- const file = import_path2.default.join(import_os.default.homedir(), ".node9", "decisions.json");
1051
+ const file = import_path3.default.join(import_os.default.homedir(), ".node9", "decisions.json");
791
1052
  if (!import_fs.default.existsSync(file)) return null;
792
1053
  const decisions = JSON.parse(import_fs.default.readFileSync(file, "utf-8"));
793
1054
  const d = decisions[toolName];
@@ -796,7 +1057,7 @@ function getPersistentDecision(toolName) {
796
1057
  }
797
1058
  return null;
798
1059
  }
799
- async function askDaemon(toolName, args, meta, signal) {
1060
+ async function askDaemon(toolName, args, meta, signal, riskMetadata) {
800
1061
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
801
1062
  const checkCtrl = new AbortController();
802
1063
  const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
@@ -806,7 +1067,13 @@ async function askDaemon(toolName, args, meta, signal) {
806
1067
  const checkRes = await fetch(`${base}/check`, {
807
1068
  method: "POST",
808
1069
  headers: { "Content-Type": "application/json" },
809
- body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
1070
+ body: JSON.stringify({
1071
+ toolName,
1072
+ args,
1073
+ agent: meta?.agent,
1074
+ mcpServer: meta?.mcpServer,
1075
+ ...riskMetadata && { riskMetadata }
1076
+ }),
810
1077
  signal: checkCtrl.signal
811
1078
  });
812
1079
  if (!checkRes.ok) throw new Error("Daemon fail");
@@ -831,7 +1098,7 @@ async function askDaemon(toolName, args, meta, signal) {
831
1098
  if (signal) signal.removeEventListener("abort", onAbort);
832
1099
  }
833
1100
  }
834
- async function notifyDaemonViewer(toolName, args, meta) {
1101
+ async function notifyDaemonViewer(toolName, args, meta, riskMetadata) {
835
1102
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
836
1103
  const res = await fetch(`${base}/check`, {
837
1104
  method: "POST",
@@ -841,7 +1108,8 @@ async function notifyDaemonViewer(toolName, args, meta) {
841
1108
  args,
842
1109
  slackDelegated: true,
843
1110
  agent: meta?.agent,
844
- mcpServer: meta?.mcpServer
1111
+ mcpServer: meta?.mcpServer,
1112
+ ...riskMetadata && { riskMetadata }
845
1113
  }),
846
1114
  signal: AbortSignal.timeout(3e3)
847
1115
  });
@@ -880,11 +1148,15 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
880
1148
  let explainableLabel = "Local Config";
881
1149
  let policyMatchedField;
882
1150
  let policyMatchedWord;
1151
+ let riskMetadata;
883
1152
  if (config.settings.mode === "audit") {
884
1153
  if (!isIgnoredTool(toolName)) {
885
1154
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
886
1155
  if (policyResult.decision === "review") {
887
1156
  appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
1157
+ if (approvers.cloud && creds?.apiKey) {
1158
+ await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
1159
+ }
888
1160
  sendDesktopNotification(
889
1161
  "Node9 Audit Mode",
890
1162
  `Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
@@ -895,13 +1167,15 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
895
1167
  }
896
1168
  if (!isIgnoredTool(toolName)) {
897
1169
  if (getActiveTrustSession(toolName)) {
898
- if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
1170
+ if (approvers.cloud && creds?.apiKey)
1171
+ await auditLocalAllow(toolName, args, "trust", creds, meta);
899
1172
  if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
900
1173
  return { approved: true, checkedBy: "trust" };
901
1174
  }
902
1175
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
903
1176
  if (policyResult.decision === "allow") {
904
- if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
1177
+ if (approvers.cloud && creds?.apiKey)
1178
+ auditLocalAllow(toolName, args, "local-policy", creds, meta);
905
1179
  if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
906
1180
  return { approved: true, checkedBy: "local-policy" };
907
1181
  }
@@ -917,9 +1191,18 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
917
1191
  explainableLabel = policyResult.blockedByLabel || "Local Config";
918
1192
  policyMatchedField = policyResult.matchedField;
919
1193
  policyMatchedWord = policyResult.matchedWord;
1194
+ riskMetadata = computeRiskMetadata(
1195
+ args,
1196
+ policyResult.tier ?? 6,
1197
+ explainableLabel,
1198
+ policyMatchedField,
1199
+ policyMatchedWord,
1200
+ policyResult.ruleName
1201
+ );
920
1202
  const persistent = getPersistentDecision(toolName);
921
1203
  if (persistent === "allow") {
922
- if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
1204
+ if (approvers.cloud && creds?.apiKey)
1205
+ await auditLocalAllow(toolName, args, "persistent", creds, meta);
923
1206
  if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
924
1207
  return { approved: true, checkedBy: "persistent" };
925
1208
  }
@@ -933,7 +1216,6 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
933
1216
  };
934
1217
  }
935
1218
  } else {
936
- if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
937
1219
  if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
938
1220
  return { approved: true };
939
1221
  }
@@ -942,8 +1224,21 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
942
1224
  const cloudEnforced = approvers.cloud && !!creds?.apiKey;
943
1225
  if (cloudEnforced) {
944
1226
  try {
945
- const initResult = await initNode9SaaS(toolName, args, creds, meta);
1227
+ const initResult = await initNode9SaaS(toolName, args, creds, meta, riskMetadata);
946
1228
  if (!initResult.pending) {
1229
+ if (initResult.shadowMode) {
1230
+ console.error(
1231
+ import_chalk2.default.yellow(
1232
+ `
1233
+ \u26A0\uFE0F Node9 Shadow Mode: Action allowed, but would have been blocked by company policy.`
1234
+ )
1235
+ );
1236
+ if (initResult.shadowReason) {
1237
+ console.error(import_chalk2.default.dim(` Reason: ${initResult.shadowReason}
1238
+ `));
1239
+ }
1240
+ return { approved: true, checkedBy: "cloud" };
1241
+ }
947
1242
  return {
948
1243
  approved: !!initResult.approved,
949
1244
  reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
@@ -968,18 +1263,22 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
968
1263
  );
969
1264
  }
970
1265
  }
971
- if (cloudEnforced && cloudRequestId) {
972
- console.error(
973
- import_chalk2.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
974
- );
975
- console.error(import_chalk2.default.cyan(" Dashboard \u2192 ") + import_chalk2.default.bold("Mission Control > Activity Feed\n"));
976
- } else if (!cloudEnforced) {
977
- const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
978
- console.error(
979
- import_chalk2.default.dim(`
1266
+ if (!options?.calledFromDaemon) {
1267
+ if (cloudEnforced && cloudRequestId) {
1268
+ console.error(
1269
+ import_chalk2.default.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
1270
+ );
1271
+ console.error(
1272
+ import_chalk2.default.cyan(" Dashboard \u2192 ") + import_chalk2.default.bold("Mission Control > Activity Feed\n")
1273
+ );
1274
+ } else if (!cloudEnforced) {
1275
+ const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
1276
+ console.error(
1277
+ import_chalk2.default.dim(`
980
1278
  \u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
981
1279
  `)
982
- );
1280
+ );
1281
+ }
983
1282
  }
984
1283
  const abortController = new AbortController();
985
1284
  const { signal } = abortController;
@@ -1010,7 +1309,9 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1010
1309
  (async () => {
1011
1310
  try {
1012
1311
  if (isDaemonRunning() && internalToken && !options?.calledFromDaemon) {
1013
- viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
1312
+ viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(
1313
+ () => null
1314
+ );
1014
1315
  }
1015
1316
  const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
1016
1317
  return {
@@ -1028,7 +1329,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1028
1329
  })()
1029
1330
  );
1030
1331
  }
1031
- if (approvers.native && !isManual) {
1332
+ if (approvers.native && !isManual && !options?.calledFromDaemon) {
1032
1333
  racePromises.push(
1033
1334
  (async () => {
1034
1335
  const decision = await askNativePopup(
@@ -1056,7 +1357,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1056
1357
  })()
1057
1358
  );
1058
1359
  }
1059
- if (approvers.browser && isDaemonRunning() && !options?.calledFromDaemon) {
1360
+ if (approvers.browser && isDaemonRunning() && !options?.calledFromDaemon && !cloudEnforced) {
1060
1361
  racePromises.push(
1061
1362
  (async () => {
1062
1363
  try {
@@ -1067,7 +1368,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1067
1368
  console.error(import_chalk2.default.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
1068
1369
  `));
1069
1370
  }
1070
- const daemonDecision = await askDaemon(toolName, args, meta, signal);
1371
+ const daemonDecision = await askDaemon(toolName, args, meta, signal, riskMetadata);
1071
1372
  if (daemonDecision === "abandoned") throw new Error("Abandoned");
1072
1373
  const isApproved = daemonDecision === "allow";
1073
1374
  return {
@@ -1192,8 +1493,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
1192
1493
  }
1193
1494
  function getConfig() {
1194
1495
  if (cachedConfig) return cachedConfig;
1195
- const globalPath = import_path2.default.join(import_os.default.homedir(), ".node9", "config.json");
1196
- const projectPath = import_path2.default.join(process.cwd(), "node9.config.json");
1496
+ const globalPath = import_path3.default.join(import_os.default.homedir(), ".node9", "config.json");
1497
+ const projectPath = import_path3.default.join(process.cwd(), "node9.config.json");
1197
1498
  const globalConfig = tryLoadConfig(globalPath);
1198
1499
  const projectConfig = tryLoadConfig(projectPath);
1199
1500
  const mergedSettings = {
@@ -1213,6 +1514,7 @@ function getConfig() {
1213
1514
  ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
1214
1515
  }
1215
1516
  };
1517
+ const mergedEnvironments = { ...DEFAULT_CONFIG.environments };
1216
1518
  const applyLayer = (source) => {
1217
1519
  if (!source) return;
1218
1520
  const s = source.settings || {};
@@ -1238,6 +1540,17 @@ function getConfig() {
1238
1540
  if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
1239
1541
  if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
1240
1542
  }
1543
+ const envs = source.environments || {};
1544
+ for (const [envName, envConfig] of Object.entries(envs)) {
1545
+ if (envConfig && typeof envConfig === "object") {
1546
+ const ec = envConfig;
1547
+ mergedEnvironments[envName] = {
1548
+ ...mergedEnvironments[envName],
1549
+ // Validate field types before merging — do not blindly spread user input
1550
+ ...typeof ec.requireApproval === "boolean" ? { requireApproval: ec.requireApproval } : {}
1551
+ };
1552
+ }
1553
+ }
1241
1554
  };
1242
1555
  applyLayer(globalConfig);
1243
1556
  applyLayer(projectConfig);
@@ -1251,17 +1564,62 @@ function getConfig() {
1251
1564
  cachedConfig = {
1252
1565
  settings: mergedSettings,
1253
1566
  policy: mergedPolicy,
1254
- environments: {}
1567
+ environments: mergedEnvironments
1255
1568
  };
1256
1569
  return cachedConfig;
1257
1570
  }
1258
1571
  function tryLoadConfig(filePath) {
1259
1572
  if (!import_fs.default.existsSync(filePath)) return null;
1573
+ let raw;
1260
1574
  try {
1261
- return JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
1262
- } catch {
1575
+ raw = JSON.parse(import_fs.default.readFileSync(filePath, "utf-8"));
1576
+ } catch (err) {
1577
+ const msg = err instanceof Error ? err.message : String(err);
1578
+ process.stderr.write(
1579
+ `
1580
+ \u26A0\uFE0F Node9: Failed to parse ${filePath}
1581
+ ${msg}
1582
+ \u2192 Using default config
1583
+
1584
+ `
1585
+ );
1263
1586
  return null;
1264
1587
  }
1588
+ const SUPPORTED_VERSION = "1.0";
1589
+ const SUPPORTED_MAJOR = SUPPORTED_VERSION.split(".")[0];
1590
+ const fileVersion = raw?.version;
1591
+ if (fileVersion !== void 0) {
1592
+ const vStr = String(fileVersion);
1593
+ const fileMajor = vStr.split(".")[0];
1594
+ if (fileMajor !== SUPPORTED_MAJOR) {
1595
+ process.stderr.write(
1596
+ `
1597
+ \u274C Node9: Config at ${filePath} has version "${vStr}" \u2014 major version is incompatible with this release (expected "${SUPPORTED_VERSION}"). Config will not be loaded.
1598
+
1599
+ `
1600
+ );
1601
+ return null;
1602
+ } else if (vStr !== SUPPORTED_VERSION) {
1603
+ process.stderr.write(
1604
+ `
1605
+ \u26A0\uFE0F Node9: Config at ${filePath} declares version "${vStr}" \u2014 expected "${SUPPORTED_VERSION}". Continuing with best-effort parsing.
1606
+
1607
+ `
1608
+ );
1609
+ }
1610
+ }
1611
+ const { sanitized, error } = sanitizeConfig(raw);
1612
+ if (error) {
1613
+ process.stderr.write(
1614
+ `
1615
+ \u26A0\uFE0F Node9: Invalid config at ${filePath}:
1616
+ ${error.replace("Invalid config:\n", "")}
1617
+ \u2192 Invalid fields ignored, using defaults for those keys
1618
+
1619
+ `
1620
+ );
1621
+ }
1622
+ return sanitized;
1265
1623
  }
1266
1624
  function getActiveEnvironment(config) {
1267
1625
  const env = config.settings.environment || process.env.NODE_ENV || "development";
@@ -1276,7 +1634,7 @@ function getCredentials() {
1276
1634
  };
1277
1635
  }
1278
1636
  try {
1279
- const credPath = import_path2.default.join(import_os.default.homedir(), ".node9", "credentials.json");
1637
+ const credPath = import_path3.default.join(import_os.default.homedir(), ".node9", "credentials.json");
1280
1638
  if (import_fs.default.existsSync(credPath)) {
1281
1639
  const creds = JSON.parse(import_fs.default.readFileSync(credPath, "utf-8"));
1282
1640
  const profileName = process.env.NODE9_PROFILE || "default";
@@ -1303,9 +1661,7 @@ async function authorizeAction(toolName, args) {
1303
1661
  return result.approved;
1304
1662
  }
1305
1663
  function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1306
- const controller = new AbortController();
1307
- setTimeout(() => controller.abort(), 5e3);
1308
- fetch(`${creds.apiUrl}/audit`, {
1664
+ return fetch(`${creds.apiUrl}/audit`, {
1309
1665
  method: "POST",
1310
1666
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
1311
1667
  body: JSON.stringify({
@@ -1320,11 +1676,12 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1320
1676
  platform: import_os.default.platform()
1321
1677
  }
1322
1678
  }),
1323
- signal: controller.signal
1679
+ signal: AbortSignal.timeout(5e3)
1680
+ }).then(() => {
1324
1681
  }).catch(() => {
1325
1682
  });
1326
1683
  }
1327
- async function initNode9SaaS(toolName, args, creds, meta) {
1684
+ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
1328
1685
  const controller = new AbortController();
1329
1686
  const timeout = setTimeout(() => controller.abort(), 1e4);
1330
1687
  try {
@@ -1340,7 +1697,8 @@ async function initNode9SaaS(toolName, args, creds, meta) {
1340
1697
  hostname: import_os.default.hostname(),
1341
1698
  cwd: process.cwd(),
1342
1699
  platform: import_os.default.platform()
1343
- }
1700
+ },
1701
+ ...riskMetadata && { riskMetadata }
1344
1702
  }),
1345
1703
  signal: controller.signal
1346
1704
  });