@node9/proxy 1.0.8 → 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.mjs CHANGED
@@ -7,18 +7,18 @@ import { Command } from "commander";
7
7
  import chalk2 from "chalk";
8
8
  import { confirm } from "@inquirer/prompts";
9
9
  import fs from "fs";
10
- import path2 from "path";
10
+ import path3 from "path";
11
11
  import os from "os";
12
12
  import pm from "picomatch";
13
13
  import { parse } from "sh-syntax";
14
14
 
15
15
  // src/ui/native.ts
16
16
  import { spawn } from "child_process";
17
- import path from "path";
17
+ import path2 from "path";
18
18
  import chalk from "chalk";
19
- var isTestEnv = () => {
20
- 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";
21
- };
19
+
20
+ // src/context-sniper.ts
21
+ import path from "path";
22
22
  function smartTruncate(str, maxLen = 500) {
23
23
  if (str.length <= maxLen) return str;
24
24
  const edge = Math.floor(maxLen / 2) - 3;
@@ -26,11 +26,13 @@ function smartTruncate(str, maxLen = 500) {
26
26
  }
27
27
  function extractContext(text, matchedWord) {
28
28
  const lines = text.split("\n");
29
- if (lines.length <= 7 || !matchedWord) return smartTruncate(text, 500);
29
+ if (lines.length <= 7 || !matchedWord) {
30
+ return { snippet: smartTruncate(text, 500), lineIndex: -1 };
31
+ }
30
32
  const escaped = matchedWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
31
33
  const pattern = new RegExp(`\\b${escaped}\\b`, "i");
32
34
  const allHits = lines.map((line, i) => ({ i, line })).filter(({ line }) => pattern.test(line));
33
- if (allHits.length === 0) return smartTruncate(text, 500);
35
+ if (allHits.length === 0) return { snippet: smartTruncate(text, 500), lineIndex: -1 };
34
36
  const nonComment = allHits.find(({ line }) => {
35
37
  const trimmed = line.trim();
36
38
  return !trimmed.startsWith("//") && !trimmed.startsWith("#");
@@ -38,13 +40,89 @@ function extractContext(text, matchedWord) {
38
40
  const hitIndex = (nonComment ?? allHits[0]).i;
39
41
  const start = Math.max(0, hitIndex - 3);
40
42
  const end = Math.min(lines.length, hitIndex + 4);
43
+ const lineIndex = hitIndex - start;
41
44
  const snippet = lines.slice(start, end).map((line, i) => `${start + i === hitIndex ? "\u{1F6D1} " : " "}${line}`).join("\n");
42
45
  const head = start > 0 ? `... [${start} lines hidden] ...
43
46
  ` : "";
44
47
  const tail = end < lines.length ? `
45
48
  ... [${lines.length - end} lines hidden] ...` : "";
46
- return `${head}${snippet}${tail}`;
49
+ return { snippet: `${head}${snippet}${tail}`, lineIndex };
50
+ }
51
+ var CODE_KEYS = [
52
+ "command",
53
+ "cmd",
54
+ "shell_command",
55
+ "bash_command",
56
+ "script",
57
+ "code",
58
+ "input",
59
+ "sql",
60
+ "query",
61
+ "arguments",
62
+ "args",
63
+ "param",
64
+ "params",
65
+ "text"
66
+ ];
67
+ function computeRiskMetadata(args, tier, blockedByLabel, matchedField, matchedWord, ruleName) {
68
+ let intent = "EXEC";
69
+ let contextSnippet;
70
+ let contextLineIndex;
71
+ let editFileName;
72
+ let editFilePath;
73
+ let parsed = args;
74
+ if (typeof args === "string") {
75
+ const trimmed = args.trim();
76
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
77
+ try {
78
+ parsed = JSON.parse(trimmed);
79
+ } catch {
80
+ }
81
+ }
82
+ }
83
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
84
+ const obj = parsed;
85
+ if (obj.old_string !== void 0 && obj.new_string !== void 0) {
86
+ intent = "EDIT";
87
+ if (obj.file_path) {
88
+ editFilePath = String(obj.file_path);
89
+ editFileName = path.basename(editFilePath);
90
+ }
91
+ const result = extractContext(String(obj.new_string), matchedWord);
92
+ contextSnippet = result.snippet;
93
+ if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
94
+ } else if (matchedField && obj[matchedField] !== void 0) {
95
+ const result = extractContext(String(obj[matchedField]), matchedWord);
96
+ contextSnippet = result.snippet;
97
+ if (result.lineIndex >= 0) contextLineIndex = result.lineIndex;
98
+ } else {
99
+ const foundKey = Object.keys(obj).find((k) => CODE_KEYS.includes(k.toLowerCase()));
100
+ if (foundKey) {
101
+ const val = obj[foundKey];
102
+ contextSnippet = smartTruncate(typeof val === "string" ? val : JSON.stringify(val), 500);
103
+ }
104
+ }
105
+ } else if (typeof parsed === "string") {
106
+ contextSnippet = smartTruncate(parsed, 500);
107
+ }
108
+ return {
109
+ intent,
110
+ tier,
111
+ blockedByLabel,
112
+ ...matchedWord && { matchedWord },
113
+ ...matchedField && { matchedField },
114
+ ...contextSnippet !== void 0 && { contextSnippet },
115
+ ...contextLineIndex !== void 0 && { contextLineIndex },
116
+ ...editFileName && { editFileName },
117
+ ...editFilePath && { editFilePath },
118
+ ...ruleName && { ruleName }
119
+ };
47
120
  }
121
+
122
+ // src/ui/native.ts
123
+ var isTestEnv = () => {
124
+ 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";
125
+ };
48
126
  function formatArgs(args, matchedField, matchedWord) {
49
127
  if (args === null || args === void 0) return { message: "(none)", intent: "EXEC" };
50
128
  let parsed = args;
@@ -63,9 +141,9 @@ function formatArgs(args, matchedField, matchedWord) {
63
141
  if (typeof parsed === "object" && !Array.isArray(parsed)) {
64
142
  const obj = parsed;
65
143
  if (obj.old_string !== void 0 && obj.new_string !== void 0) {
66
- const file = obj.file_path ? path.basename(String(obj.file_path)) : "file";
144
+ const file = obj.file_path ? path2.basename(String(obj.file_path)) : "file";
67
145
  const oldPreview = smartTruncate(String(obj.old_string), 120);
68
- const newPreview = extractContext(String(obj.new_string), matchedWord);
146
+ const newPreview = extractContext(String(obj.new_string), matchedWord).snippet;
69
147
  return {
70
148
  intent: "EDIT",
71
149
  message: `\u{1F4DD} EDITING: ${file}
@@ -83,7 +161,7 @@ ${newPreview}`
83
161
  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(", ")}
84
162
 
85
163
  ` : "";
86
- const content = extractContext(String(obj[matchedField]), matchedWord);
164
+ const content = extractContext(String(obj[matchedField]), matchedWord).snippet;
87
165
  return {
88
166
  intent: "EXEC",
89
167
  message: `${context}\u{1F6D1} [${matchedField.toUpperCase()}]:
@@ -255,11 +333,113 @@ end run`;
255
333
  });
256
334
  }
257
335
 
336
+ // src/config-schema.ts
337
+ import { z } from "zod";
338
+ var noNewlines = z.string().refine((s) => !s.includes("\n") && !s.includes("\r"), {
339
+ message: "Value must not contain literal newline characters (use \\n instead)"
340
+ });
341
+ var validRegex = noNewlines.refine(
342
+ (s) => {
343
+ try {
344
+ new RegExp(s);
345
+ return true;
346
+ } catch {
347
+ return false;
348
+ }
349
+ },
350
+ { message: "Value must be a valid regular expression" }
351
+ );
352
+ var SmartConditionSchema = z.object({
353
+ field: z.string().min(1, "Condition field must not be empty"),
354
+ op: z.enum(["matches", "notMatches", "contains", "notContains", "exists", "notExists"], {
355
+ errorMap: () => ({
356
+ message: "op must be one of: matches, notMatches, contains, notContains, exists, notExists"
357
+ })
358
+ }),
359
+ value: validRegex.optional(),
360
+ flags: z.string().optional()
361
+ });
362
+ var SmartRuleSchema = z.object({
363
+ name: z.string().optional(),
364
+ tool: z.string().min(1, "Smart rule tool must not be empty"),
365
+ conditions: z.array(SmartConditionSchema).min(1, "Smart rule must have at least one condition"),
366
+ conditionMode: z.enum(["all", "any"]).optional(),
367
+ verdict: z.enum(["allow", "review", "block"], {
368
+ errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
369
+ }),
370
+ reason: z.string().optional()
371
+ });
372
+ var PolicyRuleSchema = z.object({
373
+ action: z.string().min(1),
374
+ allowPaths: z.array(z.string()).optional(),
375
+ blockPaths: z.array(z.string()).optional()
376
+ });
377
+ var ConfigFileSchema = z.object({
378
+ version: z.string().optional(),
379
+ settings: z.object({
380
+ mode: z.enum(["standard", "strict", "audit"]).optional(),
381
+ autoStartDaemon: z.boolean().optional(),
382
+ enableUndo: z.boolean().optional(),
383
+ enableHookLogDebug: z.boolean().optional(),
384
+ approvalTimeoutMs: z.number().nonnegative().optional(),
385
+ approvers: z.object({
386
+ native: z.boolean().optional(),
387
+ browser: z.boolean().optional(),
388
+ cloud: z.boolean().optional(),
389
+ terminal: z.boolean().optional()
390
+ }).optional(),
391
+ environment: z.string().optional(),
392
+ slackEnabled: z.boolean().optional(),
393
+ enableTrustSessions: z.boolean().optional(),
394
+ allowGlobalPause: z.boolean().optional()
395
+ }).optional(),
396
+ policy: z.object({
397
+ sandboxPaths: z.array(z.string()).optional(),
398
+ dangerousWords: z.array(noNewlines).optional(),
399
+ ignoredTools: z.array(z.string()).optional(),
400
+ toolInspection: z.record(z.string()).optional(),
401
+ rules: z.array(PolicyRuleSchema).optional(),
402
+ smartRules: z.array(SmartRuleSchema).optional(),
403
+ snapshot: z.object({
404
+ tools: z.array(z.string()).optional(),
405
+ onlyPaths: z.array(z.string()).optional(),
406
+ ignorePaths: z.array(z.string()).optional()
407
+ }).optional()
408
+ }).optional(),
409
+ environments: z.record(z.object({ requireApproval: z.boolean().optional() })).optional()
410
+ }).strict({ message: "Config contains unknown top-level keys" });
411
+ function sanitizeConfig(raw) {
412
+ const result = ConfigFileSchema.safeParse(raw);
413
+ if (result.success) {
414
+ return { sanitized: result.data, error: null };
415
+ }
416
+ const invalidTopLevelKeys = new Set(
417
+ result.error.issues.filter((issue) => issue.path.length > 0).map((issue) => String(issue.path[0]))
418
+ );
419
+ const sanitized = {};
420
+ if (typeof raw === "object" && raw !== null) {
421
+ for (const [key, value] of Object.entries(raw)) {
422
+ if (!invalidTopLevelKeys.has(key)) {
423
+ sanitized[key] = value;
424
+ }
425
+ }
426
+ }
427
+ const lines = result.error.issues.map((issue) => {
428
+ const path8 = issue.path.length > 0 ? issue.path.join(".") : "root";
429
+ return ` \u2022 ${path8}: ${issue.message}`;
430
+ });
431
+ return {
432
+ sanitized,
433
+ error: `Invalid config:
434
+ ${lines.join("\n")}`
435
+ };
436
+ }
437
+
258
438
  // src/core.ts
259
- var PAUSED_FILE = path2.join(os.homedir(), ".node9", "PAUSED");
260
- var TRUST_FILE = path2.join(os.homedir(), ".node9", "trust.json");
261
- var LOCAL_AUDIT_LOG = path2.join(os.homedir(), ".node9", "audit.log");
262
- var HOOK_DEBUG_LOG = path2.join(os.homedir(), ".node9", "hook-debug.log");
439
+ var PAUSED_FILE = path3.join(os.homedir(), ".node9", "PAUSED");
440
+ var TRUST_FILE = path3.join(os.homedir(), ".node9", "trust.json");
441
+ var LOCAL_AUDIT_LOG = path3.join(os.homedir(), ".node9", "audit.log");
442
+ var HOOK_DEBUG_LOG = path3.join(os.homedir(), ".node9", "hook-debug.log");
263
443
  function checkPause() {
264
444
  try {
265
445
  if (!fs.existsSync(PAUSED_FILE)) return { paused: false };
@@ -277,7 +457,7 @@ function checkPause() {
277
457
  }
278
458
  }
279
459
  function atomicWriteSync(filePath, data, options) {
280
- const dir = path2.dirname(filePath);
460
+ const dir = path3.dirname(filePath);
281
461
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
282
462
  const tmpPath = `${filePath}.${os.hostname()}.${process.pid}.tmp`;
283
463
  fs.writeFileSync(tmpPath, data, options);
@@ -328,7 +508,7 @@ function writeTrustSession(toolName, durationMs) {
328
508
  }
329
509
  function appendToLog(logPath, entry) {
330
510
  try {
331
- const dir = path2.dirname(logPath);
511
+ const dir = path3.dirname(logPath);
332
512
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
333
513
  fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
334
514
  } catch {
@@ -372,9 +552,9 @@ function matchesPattern(text, patterns) {
372
552
  const withoutDotSlash = text.replace(/^\.\//, "");
373
553
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
374
554
  }
375
- function getNestedValue(obj, path7) {
555
+ function getNestedValue(obj, path8) {
376
556
  if (!obj || typeof obj !== "object") return null;
377
- return path7.split(".").reduce((prev, curr) => prev?.[curr], obj);
557
+ return path8.split(".").reduce((prev, curr) => prev?.[curr], obj);
378
558
  }
379
559
  function shouldSnapshot(toolName, args, config) {
380
560
  if (!config.settings.enableUndo) return false;
@@ -523,15 +703,10 @@ function redactSecrets(text) {
523
703
  return redacted;
524
704
  }
525
705
  var DANGEROUS_WORDS = [
526
- "drop",
527
- "truncate",
528
- "purge",
529
- "format",
530
- "destroy",
531
- "terminate",
532
- "revoke",
533
- "docker",
534
- "psql"
706
+ "mkfs",
707
+ // formats/wipes a filesystem partition
708
+ "shred"
709
+ // permanently overwrites file contents (unrecoverable)
535
710
  ];
536
711
  var DEFAULT_CONFIG = {
537
712
  settings: {
@@ -588,6 +763,8 @@ var DEFAULT_CONFIG = {
588
763
  ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
589
764
  },
590
765
  rules: [
766
+ // Only use the legacy rules format for simple path-based rm control.
767
+ // All other command-level enforcement lives in smartRules below.
591
768
  {
592
769
  action: "rm",
593
770
  allowPaths: [
@@ -604,6 +781,7 @@ var DEFAULT_CONFIG = {
604
781
  }
605
782
  ],
606
783
  smartRules: [
784
+ // ── SQL safety ────────────────────────────────────────────────────────
607
785
  {
608
786
  name: "no-delete-without-where",
609
787
  tool: "*",
@@ -614,6 +792,84 @@ var DEFAULT_CONFIG = {
614
792
  conditionMode: "all",
615
793
  verdict: "review",
616
794
  reason: "DELETE/UPDATE without WHERE clause \u2014 would affect every row in the table"
795
+ },
796
+ {
797
+ name: "review-drop-truncate-shell",
798
+ tool: "bash",
799
+ conditions: [
800
+ {
801
+ field: "command",
802
+ op: "matches",
803
+ value: "\\b(DROP|TRUNCATE)\\s+(TABLE|DATABASE|SCHEMA|INDEX)",
804
+ flags: "i"
805
+ }
806
+ ],
807
+ conditionMode: "all",
808
+ verdict: "review",
809
+ reason: "SQL DDL destructive statement inside a shell command"
810
+ },
811
+ // ── Git safety ────────────────────────────────────────────────────────
812
+ {
813
+ name: "block-force-push",
814
+ tool: "bash",
815
+ conditions: [
816
+ {
817
+ field: "command",
818
+ op: "matches",
819
+ value: "git push.*(--force|--force-with-lease|-f\\b)",
820
+ flags: "i"
821
+ }
822
+ ],
823
+ conditionMode: "all",
824
+ verdict: "block",
825
+ reason: "Force push overwrites remote history and cannot be undone"
826
+ },
827
+ {
828
+ name: "review-git-push",
829
+ tool: "bash",
830
+ conditions: [{ field: "command", op: "matches", value: "^\\s*git push\\b", flags: "i" }],
831
+ conditionMode: "all",
832
+ verdict: "review",
833
+ reason: "git push sends changes to a shared remote"
834
+ },
835
+ {
836
+ name: "review-git-destructive",
837
+ tool: "bash",
838
+ conditions: [
839
+ {
840
+ field: "command",
841
+ op: "matches",
842
+ value: "git\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase|tag\\s+-d|branch\\s+-[dD])",
843
+ flags: "i"
844
+ }
845
+ ],
846
+ conditionMode: "all",
847
+ verdict: "review",
848
+ reason: "Destructive git operation \u2014 discards history or working-tree changes"
849
+ },
850
+ // ── Shell safety ──────────────────────────────────────────────────────
851
+ {
852
+ name: "review-sudo",
853
+ tool: "bash",
854
+ conditions: [{ field: "command", op: "matches", value: "^\\s*sudo\\s", flags: "i" }],
855
+ conditionMode: "all",
856
+ verdict: "review",
857
+ reason: "Command requires elevated privileges"
858
+ },
859
+ {
860
+ name: "review-curl-pipe-shell",
861
+ tool: "bash",
862
+ conditions: [
863
+ {
864
+ field: "command",
865
+ op: "matches",
866
+ value: "(curl|wget)[^|]*\\|\\s*(ba|z|da|fi|c|k)?sh",
867
+ flags: "i"
868
+ }
869
+ ],
870
+ conditionMode: "all",
871
+ verdict: "block",
872
+ reason: "Piping remote script into a shell is a supply-chain attack vector"
617
873
  }
618
874
  ]
619
875
  },
@@ -625,7 +881,7 @@ function _resetConfigCache() {
625
881
  }
626
882
  function getGlobalSettings() {
627
883
  try {
628
- const globalConfigPath = path2.join(os.homedir(), ".node9", "config.json");
884
+ const globalConfigPath = path3.join(os.homedir(), ".node9", "config.json");
629
885
  if (fs.existsSync(globalConfigPath)) {
630
886
  const parsed = JSON.parse(fs.readFileSync(globalConfigPath, "utf-8"));
631
887
  const settings = parsed.settings || {};
@@ -649,7 +905,7 @@ function getGlobalSettings() {
649
905
  }
650
906
  function getInternalToken() {
651
907
  try {
652
- const pidFile = path2.join(os.homedir(), ".node9", "daemon.pid");
908
+ const pidFile = path3.join(os.homedir(), ".node9", "daemon.pid");
653
909
  if (!fs.existsSync(pidFile)) return null;
654
910
  const data = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
655
911
  process.kill(data.pid, 0);
@@ -670,7 +926,9 @@ async function evaluatePolicy(toolName, args, agent) {
670
926
  return {
671
927
  decision: matchedRule.verdict,
672
928
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
673
- reason: matchedRule.reason
929
+ reason: matchedRule.reason,
930
+ tier: 2,
931
+ ruleName: matchedRule.name ?? matchedRule.tool
674
932
  };
675
933
  }
676
934
  }
@@ -685,7 +943,7 @@ async function evaluatePolicy(toolName, args, agent) {
685
943
  pathTokens = analyzed.paths;
686
944
  const INLINE_EXEC_PATTERN = /^(python3?|bash|sh|zsh|perl|ruby|node|php|lua)\s+(-c|-e|-eval)\s/i;
687
945
  if (INLINE_EXEC_PATTERN.test(shellCommand.trim())) {
688
- return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)" };
946
+ return { decision: "review", blockedByLabel: "Node9 Standard (Inline Execution)", tier: 3 };
689
947
  }
690
948
  if (isSqlTool(toolName, config.policy.toolInspection)) {
691
949
  allTokens = allTokens.filter((t) => !SQL_DML_KEYWORDS.has(t.toLowerCase()));
@@ -708,7 +966,7 @@ async function evaluatePolicy(toolName, args, agent) {
708
966
  );
709
967
  const isRootWipe = allTokens.includes("rm") && (allTokens.includes("/") || allTokens.includes("/*"));
710
968
  if (hasSystemDisaster || isRootWipe) {
711
- return { decision: "review", blockedByLabel: "Manual Nuclear Protection" };
969
+ return { decision: "review", blockedByLabel: "Manual Nuclear Protection", tier: 3 };
712
970
  }
713
971
  return { decision: "allow" };
714
972
  }
@@ -726,14 +984,16 @@ async function evaluatePolicy(toolName, args, agent) {
726
984
  if (anyBlocked)
727
985
  return {
728
986
  decision: "review",
729
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`
987
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (path blocked)`,
988
+ tier: 5
730
989
  };
731
990
  const allAllowed = pathTokens.every((p) => matchesPattern(p, rule.allowPaths || []));
732
991
  if (allAllowed) return { decision: "allow" };
733
992
  }
734
993
  return {
735
994
  decision: "review",
736
- blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`
995
+ blockedByLabel: `Project/Global Config \u2014 rule "${rule.action}" (default block)`,
996
+ tier: 5
737
997
  };
738
998
  }
739
999
  }
@@ -775,21 +1035,22 @@ async function evaluatePolicy(toolName, args, agent) {
775
1035
  decision: "review",
776
1036
  blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
777
1037
  matchedWord: matchedDangerousWord,
778
- matchedField
1038
+ matchedField,
1039
+ tier: 6
779
1040
  };
780
1041
  }
781
1042
  if (config.settings.mode === "strict") {
782
1043
  const envConfig = getActiveEnvironment(config);
783
1044
  if (envConfig?.requireApproval === false) return { decision: "allow" };
784
- return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)" };
1045
+ return { decision: "review", blockedByLabel: "Global Config (Strict Mode Active)", tier: 7 };
785
1046
  }
786
1047
  return { decision: "allow" };
787
1048
  }
788
1049
  async function explainPolicy(toolName, args) {
789
1050
  const steps = [];
790
- const globalPath = path2.join(os.homedir(), ".node9", "config.json");
791
- const projectPath = path2.join(process.cwd(), "node9.config.json");
792
- const credsPath = path2.join(os.homedir(), ".node9", "credentials.json");
1051
+ const globalPath = path3.join(os.homedir(), ".node9", "config.json");
1052
+ const projectPath = path3.join(process.cwd(), "node9.config.json");
1053
+ const credsPath = path3.join(os.homedir(), ".node9", "credentials.json");
793
1054
  const waterfall = [
794
1055
  {
795
1056
  tier: 1,
@@ -1093,7 +1354,7 @@ var DAEMON_PORT = 7391;
1093
1354
  var DAEMON_HOST = "127.0.0.1";
1094
1355
  function isDaemonRunning() {
1095
1356
  try {
1096
- const pidFile = path2.join(os.homedir(), ".node9", "daemon.pid");
1357
+ const pidFile = path3.join(os.homedir(), ".node9", "daemon.pid");
1097
1358
  if (!fs.existsSync(pidFile)) return false;
1098
1359
  const { pid, port } = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
1099
1360
  if (port !== DAEMON_PORT) return false;
@@ -1105,7 +1366,7 @@ function isDaemonRunning() {
1105
1366
  }
1106
1367
  function getPersistentDecision(toolName) {
1107
1368
  try {
1108
- const file = path2.join(os.homedir(), ".node9", "decisions.json");
1369
+ const file = path3.join(os.homedir(), ".node9", "decisions.json");
1109
1370
  if (!fs.existsSync(file)) return null;
1110
1371
  const decisions = JSON.parse(fs.readFileSync(file, "utf-8"));
1111
1372
  const d = decisions[toolName];
@@ -1114,7 +1375,7 @@ function getPersistentDecision(toolName) {
1114
1375
  }
1115
1376
  return null;
1116
1377
  }
1117
- async function askDaemon(toolName, args, meta, signal) {
1378
+ async function askDaemon(toolName, args, meta, signal, riskMetadata) {
1118
1379
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1119
1380
  const checkCtrl = new AbortController();
1120
1381
  const checkTimer = setTimeout(() => checkCtrl.abort(), 5e3);
@@ -1124,7 +1385,13 @@ async function askDaemon(toolName, args, meta, signal) {
1124
1385
  const checkRes = await fetch(`${base}/check`, {
1125
1386
  method: "POST",
1126
1387
  headers: { "Content-Type": "application/json" },
1127
- body: JSON.stringify({ toolName, args, agent: meta?.agent, mcpServer: meta?.mcpServer }),
1388
+ body: JSON.stringify({
1389
+ toolName,
1390
+ args,
1391
+ agent: meta?.agent,
1392
+ mcpServer: meta?.mcpServer,
1393
+ ...riskMetadata && { riskMetadata }
1394
+ }),
1128
1395
  signal: checkCtrl.signal
1129
1396
  });
1130
1397
  if (!checkRes.ok) throw new Error("Daemon fail");
@@ -1149,7 +1416,7 @@ async function askDaemon(toolName, args, meta, signal) {
1149
1416
  if (signal) signal.removeEventListener("abort", onAbort);
1150
1417
  }
1151
1418
  }
1152
- async function notifyDaemonViewer(toolName, args, meta) {
1419
+ async function notifyDaemonViewer(toolName, args, meta, riskMetadata) {
1153
1420
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1154
1421
  const res = await fetch(`${base}/check`, {
1155
1422
  method: "POST",
@@ -1159,7 +1426,8 @@ async function notifyDaemonViewer(toolName, args, meta) {
1159
1426
  args,
1160
1427
  slackDelegated: true,
1161
1428
  agent: meta?.agent,
1162
- mcpServer: meta?.mcpServer
1429
+ mcpServer: meta?.mcpServer,
1430
+ ...riskMetadata && { riskMetadata }
1163
1431
  }),
1164
1432
  signal: AbortSignal.timeout(3e3)
1165
1433
  });
@@ -1198,11 +1466,15 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1198
1466
  let explainableLabel = "Local Config";
1199
1467
  let policyMatchedField;
1200
1468
  let policyMatchedWord;
1469
+ let riskMetadata;
1201
1470
  if (config.settings.mode === "audit") {
1202
1471
  if (!isIgnoredTool(toolName)) {
1203
1472
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
1204
1473
  if (policyResult.decision === "review") {
1205
1474
  appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
1475
+ if (approvers.cloud && creds?.apiKey) {
1476
+ await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
1477
+ }
1206
1478
  sendDesktopNotification(
1207
1479
  "Node9 Audit Mode",
1208
1480
  `Would have blocked "${toolName}" (${policyResult.blockedByLabel || "Local Config"}) \u2014 running in audit mode`
@@ -1213,13 +1485,15 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1213
1485
  }
1214
1486
  if (!isIgnoredTool(toolName)) {
1215
1487
  if (getActiveTrustSession(toolName)) {
1216
- if (creds?.apiKey) auditLocalAllow(toolName, args, "trust", creds, meta);
1488
+ if (approvers.cloud && creds?.apiKey)
1489
+ await auditLocalAllow(toolName, args, "trust", creds, meta);
1217
1490
  if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
1218
1491
  return { approved: true, checkedBy: "trust" };
1219
1492
  }
1220
1493
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
1221
1494
  if (policyResult.decision === "allow") {
1222
- if (creds?.apiKey) auditLocalAllow(toolName, args, "local-policy", creds, meta);
1495
+ if (approvers.cloud && creds?.apiKey)
1496
+ auditLocalAllow(toolName, args, "local-policy", creds, meta);
1223
1497
  if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
1224
1498
  return { approved: true, checkedBy: "local-policy" };
1225
1499
  }
@@ -1235,9 +1509,18 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1235
1509
  explainableLabel = policyResult.blockedByLabel || "Local Config";
1236
1510
  policyMatchedField = policyResult.matchedField;
1237
1511
  policyMatchedWord = policyResult.matchedWord;
1512
+ riskMetadata = computeRiskMetadata(
1513
+ args,
1514
+ policyResult.tier ?? 6,
1515
+ explainableLabel,
1516
+ policyMatchedField,
1517
+ policyMatchedWord,
1518
+ policyResult.ruleName
1519
+ );
1238
1520
  const persistent = getPersistentDecision(toolName);
1239
1521
  if (persistent === "allow") {
1240
- if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
1522
+ if (approvers.cloud && creds?.apiKey)
1523
+ await auditLocalAllow(toolName, args, "persistent", creds, meta);
1241
1524
  if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
1242
1525
  return { approved: true, checkedBy: "persistent" };
1243
1526
  }
@@ -1251,7 +1534,6 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1251
1534
  };
1252
1535
  }
1253
1536
  } else {
1254
- if (creds?.apiKey) auditLocalAllow(toolName, args, "ignoredTools", creds, meta);
1255
1537
  if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
1256
1538
  return { approved: true };
1257
1539
  }
@@ -1260,8 +1542,21 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1260
1542
  const cloudEnforced = approvers.cloud && !!creds?.apiKey;
1261
1543
  if (cloudEnforced) {
1262
1544
  try {
1263
- const initResult = await initNode9SaaS(toolName, args, creds, meta);
1545
+ const initResult = await initNode9SaaS(toolName, args, creds, meta, riskMetadata);
1264
1546
  if (!initResult.pending) {
1547
+ if (initResult.shadowMode) {
1548
+ console.error(
1549
+ chalk2.yellow(
1550
+ `
1551
+ \u26A0\uFE0F Node9 Shadow Mode: Action allowed, but would have been blocked by company policy.`
1552
+ )
1553
+ );
1554
+ if (initResult.shadowReason) {
1555
+ console.error(chalk2.dim(` Reason: ${initResult.shadowReason}
1556
+ `));
1557
+ }
1558
+ return { approved: true, checkedBy: "cloud" };
1559
+ }
1265
1560
  return {
1266
1561
  approved: !!initResult.approved,
1267
1562
  reason: initResult.reason || (initResult.approved ? void 0 : "Action rejected by organization policy."),
@@ -1286,18 +1581,22 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1286
1581
  );
1287
1582
  }
1288
1583
  }
1289
- if (cloudEnforced && cloudRequestId) {
1290
- console.error(
1291
- chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
1292
- );
1293
- console.error(chalk2.cyan(" Dashboard \u2192 ") + chalk2.bold("Mission Control > Activity Feed\n"));
1294
- } else if (!cloudEnforced) {
1295
- const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
1296
- console.error(
1297
- chalk2.dim(`
1584
+ if (!options?.calledFromDaemon) {
1585
+ if (cloudEnforced && cloudRequestId) {
1586
+ console.error(
1587
+ chalk2.yellow("\n\u{1F6E1}\uFE0F Node9: Action suspended \u2014 waiting for Organization approval.")
1588
+ );
1589
+ console.error(
1590
+ chalk2.cyan(" Dashboard \u2192 ") + chalk2.bold("Mission Control > Activity Feed\n")
1591
+ );
1592
+ } else if (!cloudEnforced) {
1593
+ const cloudOffReason = !creds?.apiKey ? "no API key \u2014 run `node9 login` to connect" : "privacy mode (cloud disabled)";
1594
+ console.error(
1595
+ chalk2.dim(`
1298
1596
  \u{1F6E1}\uFE0F Node9: intercepted "${toolName}" \u2014 cloud off (${cloudOffReason})
1299
1597
  `)
1300
- );
1598
+ );
1599
+ }
1301
1600
  }
1302
1601
  const abortController = new AbortController();
1303
1602
  const { signal } = abortController;
@@ -1328,7 +1627,9 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1328
1627
  (async () => {
1329
1628
  try {
1330
1629
  if (isDaemonRunning() && internalToken && !options?.calledFromDaemon) {
1331
- viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
1630
+ viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(
1631
+ () => null
1632
+ );
1332
1633
  }
1333
1634
  const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
1334
1635
  return {
@@ -1346,7 +1647,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1346
1647
  })()
1347
1648
  );
1348
1649
  }
1349
- if (approvers.native && !isManual) {
1650
+ if (approvers.native && !isManual && !options?.calledFromDaemon) {
1350
1651
  racePromises.push(
1351
1652
  (async () => {
1352
1653
  const decision = await askNativePopup(
@@ -1374,7 +1675,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1374
1675
  })()
1375
1676
  );
1376
1677
  }
1377
- if (approvers.browser && isDaemonRunning() && !options?.calledFromDaemon) {
1678
+ if (approvers.browser && isDaemonRunning() && !options?.calledFromDaemon && !cloudEnforced) {
1378
1679
  racePromises.push(
1379
1680
  (async () => {
1380
1681
  try {
@@ -1385,7 +1686,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
1385
1686
  console.error(chalk2.cyan(` URL \u2192 http://${DAEMON_HOST}:${DAEMON_PORT}/
1386
1687
  `));
1387
1688
  }
1388
- const daemonDecision = await askDaemon(toolName, args, meta, signal);
1689
+ const daemonDecision = await askDaemon(toolName, args, meta, signal, riskMetadata);
1389
1690
  if (daemonDecision === "abandoned") throw new Error("Abandoned");
1390
1691
  const isApproved = daemonDecision === "allow";
1391
1692
  return {
@@ -1510,8 +1811,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
1510
1811
  }
1511
1812
  function getConfig() {
1512
1813
  if (cachedConfig) return cachedConfig;
1513
- const globalPath = path2.join(os.homedir(), ".node9", "config.json");
1514
- const projectPath = path2.join(process.cwd(), "node9.config.json");
1814
+ const globalPath = path3.join(os.homedir(), ".node9", "config.json");
1815
+ const projectPath = path3.join(process.cwd(), "node9.config.json");
1515
1816
  const globalConfig = tryLoadConfig(globalPath);
1516
1817
  const projectConfig = tryLoadConfig(projectPath);
1517
1818
  const mergedSettings = {
@@ -1575,11 +1876,33 @@ function getConfig() {
1575
1876
  }
1576
1877
  function tryLoadConfig(filePath) {
1577
1878
  if (!fs.existsSync(filePath)) return null;
1879
+ let raw;
1578
1880
  try {
1579
- return JSON.parse(fs.readFileSync(filePath, "utf-8"));
1580
- } catch {
1881
+ raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1882
+ } catch (err) {
1883
+ const msg = err instanceof Error ? err.message : String(err);
1884
+ process.stderr.write(
1885
+ `
1886
+ \u26A0\uFE0F Node9: Failed to parse ${filePath}
1887
+ ${msg}
1888
+ \u2192 Using default config
1889
+
1890
+ `
1891
+ );
1581
1892
  return null;
1582
1893
  }
1894
+ const { sanitized, error } = sanitizeConfig(raw);
1895
+ if (error) {
1896
+ process.stderr.write(
1897
+ `
1898
+ \u26A0\uFE0F Node9: Invalid config at ${filePath}:
1899
+ ${error.replace("Invalid config:\n", "")}
1900
+ \u2192 Invalid fields ignored, using defaults for those keys
1901
+
1902
+ `
1903
+ );
1904
+ }
1905
+ return sanitized;
1583
1906
  }
1584
1907
  function getActiveEnvironment(config) {
1585
1908
  const env = config.settings.environment || process.env.NODE_ENV || "development";
@@ -1594,7 +1917,7 @@ function getCredentials() {
1594
1917
  };
1595
1918
  }
1596
1919
  try {
1597
- const credPath = path2.join(os.homedir(), ".node9", "credentials.json");
1920
+ const credPath = path3.join(os.homedir(), ".node9", "credentials.json");
1598
1921
  if (fs.existsSync(credPath)) {
1599
1922
  const creds = JSON.parse(fs.readFileSync(credPath, "utf-8"));
1600
1923
  const profileName = process.env.NODE9_PROFILE || "default";
@@ -1617,9 +1940,7 @@ function getCredentials() {
1617
1940
  return null;
1618
1941
  }
1619
1942
  function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1620
- const controller = new AbortController();
1621
- setTimeout(() => controller.abort(), 5e3);
1622
- fetch(`${creds.apiUrl}/audit`, {
1943
+ return fetch(`${creds.apiUrl}/audit`, {
1623
1944
  method: "POST",
1624
1945
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${creds.apiKey}` },
1625
1946
  body: JSON.stringify({
@@ -1634,11 +1955,12 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
1634
1955
  platform: os.platform()
1635
1956
  }
1636
1957
  }),
1637
- signal: controller.signal
1958
+ signal: AbortSignal.timeout(5e3)
1959
+ }).then(() => {
1638
1960
  }).catch(() => {
1639
1961
  });
1640
1962
  }
1641
- async function initNode9SaaS(toolName, args, creds, meta) {
1963
+ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
1642
1964
  const controller = new AbortController();
1643
1965
  const timeout = setTimeout(() => controller.abort(), 1e4);
1644
1966
  try {
@@ -1654,7 +1976,8 @@ async function initNode9SaaS(toolName, args, creds, meta) {
1654
1976
  hostname: os.hostname(),
1655
1977
  cwd: process.cwd(),
1656
1978
  platform: os.platform()
1657
- }
1979
+ },
1980
+ ...riskMetadata && { riskMetadata }
1658
1981
  }),
1659
1982
  signal: controller.signal
1660
1983
  });
@@ -1712,7 +2035,7 @@ async function resolveNode9SaaS(requestId, creds, approved) {
1712
2035
 
1713
2036
  // src/setup.ts
1714
2037
  import fs2 from "fs";
1715
- import path3 from "path";
2038
+ import path4 from "path";
1716
2039
  import os2 from "os";
1717
2040
  import chalk3 from "chalk";
1718
2041
  import { confirm as confirm2 } from "@inquirer/prompts";
@@ -1737,14 +2060,14 @@ function readJson(filePath) {
1737
2060
  return null;
1738
2061
  }
1739
2062
  function writeJson(filePath, data) {
1740
- const dir = path3.dirname(filePath);
2063
+ const dir = path4.dirname(filePath);
1741
2064
  if (!fs2.existsSync(dir)) fs2.mkdirSync(dir, { recursive: true });
1742
2065
  fs2.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
1743
2066
  }
1744
2067
  async function setupClaude() {
1745
2068
  const homeDir2 = os2.homedir();
1746
- const mcpPath = path3.join(homeDir2, ".claude.json");
1747
- const hooksPath = path3.join(homeDir2, ".claude", "settings.json");
2069
+ const mcpPath = path4.join(homeDir2, ".claude.json");
2070
+ const hooksPath = path4.join(homeDir2, ".claude", "settings.json");
1748
2071
  const claudeConfig = readJson(mcpPath) ?? {};
1749
2072
  const settings = readJson(hooksPath) ?? {};
1750
2073
  const servers = claudeConfig.mcpServers ?? {};
@@ -1819,7 +2142,7 @@ async function setupClaude() {
1819
2142
  }
1820
2143
  async function setupGemini() {
1821
2144
  const homeDir2 = os2.homedir();
1822
- const settingsPath = path3.join(homeDir2, ".gemini", "settings.json");
2145
+ const settingsPath = path4.join(homeDir2, ".gemini", "settings.json");
1823
2146
  const settings = readJson(settingsPath) ?? {};
1824
2147
  const servers = settings.mcpServers ?? {};
1825
2148
  let anythingChanged = false;
@@ -1902,8 +2225,8 @@ async function setupGemini() {
1902
2225
  }
1903
2226
  async function setupCursor() {
1904
2227
  const homeDir2 = os2.homedir();
1905
- const mcpPath = path3.join(homeDir2, ".cursor", "mcp.json");
1906
- const hooksPath = path3.join(homeDir2, ".cursor", "hooks.json");
2228
+ const mcpPath = path4.join(homeDir2, ".cursor", "mcp.json");
2229
+ const hooksPath = path4.join(homeDir2, ".cursor", "hooks.json");
1907
2230
  const mcpConfig = readJson(mcpPath) ?? {};
1908
2231
  const hooksFile = readJson(hooksPath) ?? { version: 1 };
1909
2232
  const servers = mcpConfig.mcpServers ?? {};
@@ -2199,6 +2522,55 @@ var ui_default = `<!doctype html>
2199
2522
  white-space: pre-wrap;
2200
2523
  word-break: break-all;
2201
2524
  }
2525
+ /* \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 */
2526
+ .sniper-header {
2527
+ display: flex;
2528
+ align-items: center;
2529
+ gap: 8px;
2530
+ flex-wrap: wrap;
2531
+ margin-bottom: 8px;
2532
+ }
2533
+ .sniper-badge {
2534
+ font-size: 11px;
2535
+ font-weight: 600;
2536
+ padding: 3px 8px;
2537
+ border-radius: 5px;
2538
+ letter-spacing: 0.02em;
2539
+ }
2540
+ .sniper-badge-edit {
2541
+ background: rgba(59, 130, 246, 0.15);
2542
+ color: #60a5fa;
2543
+ border: 1px solid rgba(59, 130, 246, 0.3);
2544
+ }
2545
+ .sniper-badge-exec {
2546
+ background: rgba(239, 68, 68, 0.12);
2547
+ color: #f87171;
2548
+ border: 1px solid rgba(239, 68, 68, 0.25);
2549
+ }
2550
+ .sniper-tier {
2551
+ font-size: 10px;
2552
+ color: var(--muted);
2553
+ font-family: 'Fira Code', monospace;
2554
+ }
2555
+ .sniper-filepath {
2556
+ font-size: 11px;
2557
+ color: #a8b3c4;
2558
+ font-family: 'Fira Code', monospace;
2559
+ margin-bottom: 6px;
2560
+ word-break: break-all;
2561
+ }
2562
+ .sniper-match {
2563
+ font-size: 11px;
2564
+ color: #a8b3c4;
2565
+ margin-bottom: 6px;
2566
+ }
2567
+ .sniper-match code {
2568
+ background: rgba(239, 68, 68, 0.15);
2569
+ color: #f87171;
2570
+ padding: 1px 5px;
2571
+ border-radius: 3px;
2572
+ font-family: 'Fira Code', monospace;
2573
+ }
2202
2574
  .actions {
2203
2575
  display: grid;
2204
2576
  grid-template-columns: 1fr 1fr;
@@ -2705,20 +3077,47 @@ var ui_default = `<!doctype html>
2705
3077
  }, 200);
2706
3078
  }
2707
3079
 
3080
+ function renderPayload(req) {
3081
+ const rm = req.riskMetadata;
3082
+ if (!rm) {
3083
+ // Fallback: raw args for requests without context sniper data
3084
+ const cmd = esc(
3085
+ String(
3086
+ req.args &&
3087
+ (req.args.command ||
3088
+ req.args.cmd ||
3089
+ req.args.script ||
3090
+ JSON.stringify(req.args, null, 2))
3091
+ )
3092
+ );
3093
+ return \`<span class="label">Input Payload</span><pre>\${cmd}</pre>\`;
3094
+ }
3095
+ const isEdit = rm.intent === 'EDIT';
3096
+ const badgeClass = isEdit ? 'sniper-badge-edit' : 'sniper-badge-exec';
3097
+ const badgeLabel = isEdit ? '\u{1F4DD} Code Edit' : '\u{1F6D1} Execution';
3098
+ const tierLabel = \`Tier \${rm.tier} \xB7 \${esc(rm.blockedByLabel)}\`;
3099
+ const fileLine =
3100
+ isEdit && rm.editFilePath
3101
+ ? \`<div class="sniper-filepath">\u{1F4C2} \${esc(rm.editFilePath)}</div>\`
3102
+ : !isEdit && rm.matchedWord
3103
+ ? \`<div class="sniper-match">Matched: <code>\${esc(rm.matchedWord)}</code>\${rm.matchedField ? \` in <code>\${esc(rm.matchedField)}</code>\` : ''}</div>\`
3104
+ : '';
3105
+ const snippetHtml = rm.contextSnippet ? \`<pre>\${esc(rm.contextSnippet)}</pre>\` : '';
3106
+ return \`
3107
+ <div class="sniper-header">
3108
+ <span class="sniper-badge \${badgeClass}">\${badgeLabel}</span>
3109
+ <span class="sniper-tier">\${tierLabel}</span>
3110
+ </div>
3111
+ \${fileLine}
3112
+ \${snippetHtml}
3113
+ \`;
3114
+ }
3115
+
2708
3116
  function addCard(req) {
2709
3117
  if (requests.has(req.id)) return;
2710
3118
  requests.add(req.id);
2711
3119
  refresh();
2712
3120
  const isSlack = !!req.slackDelegated;
2713
- const cmd = esc(
2714
- String(
2715
- req.args &&
2716
- (req.args.command ||
2717
- req.args.cmd ||
2718
- req.args.script ||
2719
- JSON.stringify(req.args, null, 2))
2720
- )
2721
- );
2722
3121
  const card = document.createElement('div');
2723
3122
  card.className = 'card' + (isSlack ? ' slack-viewer' : '');
2724
3123
  card.id = 'c-' + req.id;
@@ -2732,8 +3131,7 @@ var ui_default = `<!doctype html>
2732
3131
  </div>
2733
3132
  <div class="tool-chip">\${esc(req.toolName)}</div>
2734
3133
  \${isSlack ? '<div class="slack-indicator">\u26A1 Awaiting Slack approval \u2014 view only</div>' : ''}
2735
- <span class="label">Input Payload</span>
2736
- <pre>\${cmd}</pre>
3134
+ \${renderPayload(req)}
2737
3135
  <div class="actions" id="act-\${req.id}">
2738
3136
  <button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${dis}>Approve Execution</button>
2739
3137
  <button class="btn-deny" onclick="sendDecision('\${req.id}','deny',false)" \${dis}>Block Action</button>
@@ -2941,7 +3339,7 @@ var UI_HTML_TEMPLATE = ui_default;
2941
3339
  // src/daemon/index.ts
2942
3340
  import http from "http";
2943
3341
  import fs3 from "fs";
2944
- import path4 from "path";
3342
+ import path5 from "path";
2945
3343
  import os3 from "os";
2946
3344
  import { spawn as spawn2 } from "child_process";
2947
3345
  import { randomUUID } from "crypto";
@@ -2949,14 +3347,14 @@ import chalk4 from "chalk";
2949
3347
  var DAEMON_PORT2 = 7391;
2950
3348
  var DAEMON_HOST2 = "127.0.0.1";
2951
3349
  var homeDir = os3.homedir();
2952
- var DAEMON_PID_FILE = path4.join(homeDir, ".node9", "daemon.pid");
2953
- var DECISIONS_FILE = path4.join(homeDir, ".node9", "decisions.json");
2954
- var GLOBAL_CONFIG_FILE = path4.join(homeDir, ".node9", "config.json");
2955
- var CREDENTIALS_FILE = path4.join(homeDir, ".node9", "credentials.json");
2956
- var AUDIT_LOG_FILE = path4.join(homeDir, ".node9", "audit.log");
2957
- var TRUST_FILE2 = path4.join(homeDir, ".node9", "trust.json");
3350
+ var DAEMON_PID_FILE = path5.join(homeDir, ".node9", "daemon.pid");
3351
+ var DECISIONS_FILE = path5.join(homeDir, ".node9", "decisions.json");
3352
+ var GLOBAL_CONFIG_FILE = path5.join(homeDir, ".node9", "config.json");
3353
+ var CREDENTIALS_FILE = path5.join(homeDir, ".node9", "credentials.json");
3354
+ var AUDIT_LOG_FILE = path5.join(homeDir, ".node9", "audit.log");
3355
+ var TRUST_FILE2 = path5.join(homeDir, ".node9", "trust.json");
2958
3356
  function atomicWriteSync2(filePath, data, options) {
2959
- const dir = path4.dirname(filePath);
3357
+ const dir = path5.dirname(filePath);
2960
3358
  if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
2961
3359
  const tmpPath = `${filePath}.${randomUUID()}.tmp`;
2962
3360
  fs3.writeFileSync(tmpPath, data, options);
@@ -3000,7 +3398,7 @@ function appendAuditLog(data) {
3000
3398
  decision: data.decision,
3001
3399
  source: "daemon"
3002
3400
  };
3003
- const dir = path4.dirname(AUDIT_LOG_FILE);
3401
+ const dir = path5.dirname(AUDIT_LOG_FILE);
3004
3402
  if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
3005
3403
  fs3.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + "\n");
3006
3404
  } catch {
@@ -3158,6 +3556,7 @@ data: ${JSON.stringify({
3158
3556
  id: e.id,
3159
3557
  toolName: e.toolName,
3160
3558
  args: e.args,
3559
+ riskMetadata: e.riskMetadata,
3161
3560
  slackDelegated: e.slackDelegated,
3162
3561
  timestamp: e.timestamp,
3163
3562
  agent: e.agent,
@@ -3183,14 +3582,23 @@ data: ${JSON.stringify(readPersistentDecisions())}
3183
3582
  if (req.method === "POST" && pathname === "/check") {
3184
3583
  try {
3185
3584
  resetIdleTimer();
3585
+ _resetConfigCache();
3186
3586
  const body = await readBody(req);
3187
3587
  if (body.length > 65536) return res.writeHead(413).end();
3188
- const { toolName, args, slackDelegated = false, agent, mcpServer } = JSON.parse(body);
3588
+ const {
3589
+ toolName,
3590
+ args,
3591
+ slackDelegated = false,
3592
+ agent,
3593
+ mcpServer,
3594
+ riskMetadata
3595
+ } = JSON.parse(body);
3189
3596
  const id = randomUUID();
3190
3597
  const entry = {
3191
3598
  id,
3192
3599
  toolName,
3193
3600
  args,
3601
+ riskMetadata: riskMetadata ?? void 0,
3194
3602
  agent: typeof agent === "string" ? agent : void 0,
3195
3603
  mcpServer: typeof mcpServer === "string" ? mcpServer : void 0,
3196
3604
  slackDelegated: !!slackDelegated,
@@ -3222,6 +3630,7 @@ data: ${JSON.stringify(readPersistentDecisions())}
3222
3630
  id,
3223
3631
  toolName,
3224
3632
  args,
3633
+ riskMetadata: entry.riskMetadata,
3225
3634
  slackDelegated: entry.slackDelegated,
3226
3635
  agent: entry.agent,
3227
3636
  mcpServer: entry.mcpServer
@@ -3491,16 +3900,16 @@ import { execa } from "execa";
3491
3900
  import chalk5 from "chalk";
3492
3901
  import readline from "readline";
3493
3902
  import fs5 from "fs";
3494
- import path6 from "path";
3903
+ import path7 from "path";
3495
3904
  import os5 from "os";
3496
3905
 
3497
3906
  // src/undo.ts
3498
3907
  import { spawnSync } from "child_process";
3499
3908
  import fs4 from "fs";
3500
- import path5 from "path";
3909
+ import path6 from "path";
3501
3910
  import os4 from "os";
3502
- var SNAPSHOT_STACK_PATH = path5.join(os4.homedir(), ".node9", "snapshots.json");
3503
- var UNDO_LATEST_PATH = path5.join(os4.homedir(), ".node9", "undo_latest.txt");
3911
+ var SNAPSHOT_STACK_PATH = path6.join(os4.homedir(), ".node9", "snapshots.json");
3912
+ var UNDO_LATEST_PATH = path6.join(os4.homedir(), ".node9", "undo_latest.txt");
3504
3913
  var MAX_SNAPSHOTS = 10;
3505
3914
  function readStack() {
3506
3915
  try {
@@ -3511,7 +3920,7 @@ function readStack() {
3511
3920
  return [];
3512
3921
  }
3513
3922
  function writeStack(stack) {
3514
- const dir = path5.dirname(SNAPSHOT_STACK_PATH);
3923
+ const dir = path6.dirname(SNAPSHOT_STACK_PATH);
3515
3924
  if (!fs4.existsSync(dir)) fs4.mkdirSync(dir, { recursive: true });
3516
3925
  fs4.writeFileSync(SNAPSHOT_STACK_PATH, JSON.stringify(stack, null, 2));
3517
3926
  }
@@ -3529,8 +3938,8 @@ function buildArgsSummary(tool, args) {
3529
3938
  async function createShadowSnapshot(tool = "unknown", args = {}) {
3530
3939
  try {
3531
3940
  const cwd = process.cwd();
3532
- if (!fs4.existsSync(path5.join(cwd, ".git"))) return null;
3533
- const tempIndex = path5.join(cwd, ".git", `node9_index_${Date.now()}`);
3941
+ if (!fs4.existsSync(path6.join(cwd, ".git"))) return null;
3942
+ const tempIndex = path6.join(cwd, ".git", `node9_index_${Date.now()}`);
3534
3943
  const env = { ...process.env, GIT_INDEX_FILE: tempIndex };
3535
3944
  spawnSync("git", ["add", "-A"], { env });
3536
3945
  const treeRes = spawnSync("git", ["write-tree"], { env });
@@ -3594,7 +4003,7 @@ function applyUndo(hash, cwd) {
3594
4003
  const tracked = spawnSync("git", ["ls-files"], { cwd: dir }).stdout.toString().trim().split("\n").filter(Boolean);
3595
4004
  const untracked = spawnSync("git", ["ls-files", "--others", "--exclude-standard"], { cwd: dir }).stdout.toString().trim().split("\n").filter(Boolean);
3596
4005
  for (const file of [...tracked, ...untracked]) {
3597
- const fullPath = path5.join(dir, file);
4006
+ const fullPath = path6.join(dir, file);
3598
4007
  if (!snapshotFiles.has(file) && fs4.existsSync(fullPath)) {
3599
4008
  fs4.unlinkSync(fullPath);
3600
4009
  }
@@ -3608,7 +4017,7 @@ function applyUndo(hash, cwd) {
3608
4017
  // src/cli.ts
3609
4018
  import { confirm as confirm3 } from "@inquirer/prompts";
3610
4019
  var { version } = JSON.parse(
3611
- fs5.readFileSync(path6.join(__dirname, "../package.json"), "utf-8")
4020
+ fs5.readFileSync(path7.join(__dirname, "../package.json"), "utf-8")
3612
4021
  );
3613
4022
  function parseDuration(str) {
3614
4023
  const m = str.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)?$/i);
@@ -3801,9 +4210,9 @@ async function runProxy(targetCommand) {
3801
4210
  }
3802
4211
  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) => {
3803
4212
  const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
3804
- const credPath = path6.join(os5.homedir(), ".node9", "credentials.json");
3805
- if (!fs5.existsSync(path6.dirname(credPath)))
3806
- fs5.mkdirSync(path6.dirname(credPath), { recursive: true });
4213
+ const credPath = path7.join(os5.homedir(), ".node9", "credentials.json");
4214
+ if (!fs5.existsSync(path7.dirname(credPath)))
4215
+ fs5.mkdirSync(path7.dirname(credPath), { recursive: true });
3807
4216
  const profileName = options.profile || "default";
3808
4217
  let existingCreds = {};
3809
4218
  try {
@@ -3822,7 +4231,7 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
3822
4231
  existingCreds[profileName] = { apiKey, apiUrl: DEFAULT_API_URL };
3823
4232
  fs5.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
3824
4233
  if (profileName === "default") {
3825
- const configPath = path6.join(os5.homedir(), ".node9", "config.json");
4234
+ const configPath = path7.join(os5.homedir(), ".node9", "config.json");
3826
4235
  let config = {};
3827
4236
  try {
3828
4237
  if (fs5.existsSync(configPath))
@@ -3837,10 +4246,12 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
3837
4246
  cloud: true,
3838
4247
  terminal: true
3839
4248
  };
3840
- approvers.cloud = !options.local;
4249
+ if (options.local) {
4250
+ approvers.cloud = false;
4251
+ }
3841
4252
  s.approvers = approvers;
3842
- if (!fs5.existsSync(path6.dirname(configPath)))
3843
- fs5.mkdirSync(path6.dirname(configPath), { recursive: true });
4253
+ if (!fs5.existsSync(path7.dirname(configPath)))
4254
+ fs5.mkdirSync(path7.dirname(configPath), { recursive: true });
3844
4255
  fs5.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
3845
4256
  }
3846
4257
  if (options.profile && profileName !== "default") {
@@ -3926,7 +4337,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3926
4337
  );
3927
4338
  }
3928
4339
  section("Configuration");
3929
- const globalConfigPath = path6.join(homeDir2, ".node9", "config.json");
4340
+ const globalConfigPath = path7.join(homeDir2, ".node9", "config.json");
3930
4341
  if (fs5.existsSync(globalConfigPath)) {
3931
4342
  try {
3932
4343
  JSON.parse(fs5.readFileSync(globalConfigPath, "utf-8"));
@@ -3937,7 +4348,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3937
4348
  } else {
3938
4349
  warn("~/.node9/config.json not found (using defaults)", "Run: node9 init");
3939
4350
  }
3940
- const projectConfigPath = path6.join(process.cwd(), "node9.config.json");
4351
+ const projectConfigPath = path7.join(process.cwd(), "node9.config.json");
3941
4352
  if (fs5.existsSync(projectConfigPath)) {
3942
4353
  try {
3943
4354
  JSON.parse(fs5.readFileSync(projectConfigPath, "utf-8"));
@@ -3946,7 +4357,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3946
4357
  fail("node9.config.json is invalid JSON", "Fix the JSON or delete it and run: node9 init");
3947
4358
  }
3948
4359
  }
3949
- const credsPath = path6.join(homeDir2, ".node9", "credentials.json");
4360
+ const credsPath = path7.join(homeDir2, ".node9", "credentials.json");
3950
4361
  if (fs5.existsSync(credsPath)) {
3951
4362
  pass("Cloud credentials found (~/.node9/credentials.json)");
3952
4363
  } else {
@@ -3956,7 +4367,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3956
4367
  );
3957
4368
  }
3958
4369
  section("Agent Hooks");
3959
- const claudeSettingsPath = path6.join(homeDir2, ".claude", "settings.json");
4370
+ const claudeSettingsPath = path7.join(homeDir2, ".claude", "settings.json");
3960
4371
  if (fs5.existsSync(claudeSettingsPath)) {
3961
4372
  try {
3962
4373
  const cs = JSON.parse(fs5.readFileSync(claudeSettingsPath, "utf-8"));
@@ -3972,7 +4383,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3972
4383
  } else {
3973
4384
  warn("Claude Code \u2014 not configured", "Run: node9 setup claude");
3974
4385
  }
3975
- const geminiSettingsPath = path6.join(homeDir2, ".gemini", "settings.json");
4386
+ const geminiSettingsPath = path7.join(homeDir2, ".gemini", "settings.json");
3976
4387
  if (fs5.existsSync(geminiSettingsPath)) {
3977
4388
  try {
3978
4389
  const gs = JSON.parse(fs5.readFileSync(geminiSettingsPath, "utf-8"));
@@ -3988,7 +4399,7 @@ program.command("doctor").description("Check that Node9 is installed and configu
3988
4399
  } else {
3989
4400
  warn("Gemini CLI \u2014 not configured", "Run: node9 setup gemini (skip if not using Gemini)");
3990
4401
  }
3991
- const cursorHooksPath = path6.join(homeDir2, ".cursor", "hooks.json");
4402
+ const cursorHooksPath = path7.join(homeDir2, ".cursor", "hooks.json");
3992
4403
  if (fs5.existsSync(cursorHooksPath)) {
3993
4404
  try {
3994
4405
  const cur = JSON.parse(fs5.readFileSync(cursorHooksPath, "utf-8"));
@@ -4093,7 +4504,7 @@ program.command("explain").description(
4093
4504
  console.log("");
4094
4505
  });
4095
4506
  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) => {
4096
- const configPath = path6.join(os5.homedir(), ".node9", "config.json");
4507
+ const configPath = path7.join(os5.homedir(), ".node9", "config.json");
4097
4508
  if (fs5.existsSync(configPath) && !options.force) {
4098
4509
  console.log(chalk5.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
4099
4510
  console.log(chalk5.gray(` Run with --force to overwrite.`));
@@ -4108,7 +4519,7 @@ program.command("init").description("Create ~/.node9/config.json with default po
4108
4519
  mode: safeMode
4109
4520
  }
4110
4521
  };
4111
- const dir = path6.dirname(configPath);
4522
+ const dir = path7.dirname(configPath);
4112
4523
  if (!fs5.existsSync(dir)) fs5.mkdirSync(dir, { recursive: true });
4113
4524
  fs5.writeFileSync(configPath, JSON.stringify(configToSave, null, 2));
4114
4525
  console.log(chalk5.green(`\u2705 Global config created: ${configPath}`));
@@ -4128,7 +4539,7 @@ function formatRelativeTime(timestamp) {
4128
4539
  return new Date(timestamp).toLocaleDateString();
4129
4540
  }
4130
4541
  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) => {
4131
- const logPath = path6.join(os5.homedir(), ".node9", "audit.log");
4542
+ const logPath = path7.join(os5.homedir(), ".node9", "audit.log");
4132
4543
  if (!fs5.existsSync(logPath)) {
4133
4544
  console.log(
4134
4545
  chalk5.yellow("No audit logs found. Run node9 with an agent to generate entries.")
@@ -4218,8 +4629,8 @@ program.command("status").description("Show current Node9 mode, policy source, a
4218
4629
  console.log("");
4219
4630
  const modeLabel = settings.mode === "audit" ? chalk5.blue("audit") : settings.mode === "strict" ? chalk5.red("strict") : chalk5.white("standard");
4220
4631
  console.log(` Mode: ${modeLabel}`);
4221
- const projectConfig = path6.join(process.cwd(), "node9.config.json");
4222
- const globalConfig = path6.join(os5.homedir(), ".node9", "config.json");
4632
+ const projectConfig = path7.join(process.cwd(), "node9.config.json");
4633
+ const globalConfig = path7.join(os5.homedir(), ".node9", "config.json");
4223
4634
  console.log(
4224
4635
  ` Local: ${fs5.existsSync(projectConfig) ? chalk5.green("Active (node9.config.json)") : chalk5.gray("Not present")}`
4225
4636
  );
@@ -4287,7 +4698,7 @@ program.command("check").description("Hook handler \u2014 evaluates a tool call
4287
4698
  } catch (err) {
4288
4699
  const tempConfig = getConfig();
4289
4700
  if (process.env.NODE9_DEBUG === "1" || tempConfig.settings.enableHookLogDebug) {
4290
- const logPath = path6.join(os5.homedir(), ".node9", "hook-debug.log");
4701
+ const logPath = path7.join(os5.homedir(), ".node9", "hook-debug.log");
4291
4702
  const errMsg = err instanceof Error ? err.message : String(err);
4292
4703
  fs5.appendFileSync(
4293
4704
  logPath,
@@ -4307,9 +4718,9 @@ RAW: ${raw}
4307
4718
  }
4308
4719
  const config = getConfig();
4309
4720
  if (process.env.NODE9_DEBUG === "1" || config.settings.enableHookLogDebug) {
4310
- const logPath = path6.join(os5.homedir(), ".node9", "hook-debug.log");
4311
- if (!fs5.existsSync(path6.dirname(logPath)))
4312
- fs5.mkdirSync(path6.dirname(logPath), { recursive: true });
4721
+ const logPath = path7.join(os5.homedir(), ".node9", "hook-debug.log");
4722
+ if (!fs5.existsSync(path7.dirname(logPath)))
4723
+ fs5.mkdirSync(path7.dirname(logPath), { recursive: true });
4313
4724
  fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
4314
4725
  `);
4315
4726
  }
@@ -4381,7 +4792,7 @@ RAW: ${raw}
4381
4792
  });
4382
4793
  } catch (err) {
4383
4794
  if (process.env.NODE9_DEBUG === "1") {
4384
- const logPath = path6.join(os5.homedir(), ".node9", "hook-debug.log");
4795
+ const logPath = path7.join(os5.homedir(), ".node9", "hook-debug.log");
4385
4796
  const errMsg = err instanceof Error ? err.message : String(err);
4386
4797
  fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
4387
4798
  `);
@@ -4428,9 +4839,9 @@ program.command("log").description("PostToolUse hook \u2014 records executed too
4428
4839
  decision: "allowed",
4429
4840
  source: "post-hook"
4430
4841
  };
4431
- const logPath = path6.join(os5.homedir(), ".node9", "audit.log");
4432
- if (!fs5.existsSync(path6.dirname(logPath)))
4433
- fs5.mkdirSync(path6.dirname(logPath), { recursive: true });
4842
+ const logPath = path7.join(os5.homedir(), ".node9", "audit.log");
4843
+ if (!fs5.existsSync(path7.dirname(logPath)))
4844
+ fs5.mkdirSync(path7.dirname(logPath), { recursive: true });
4434
4845
  fs5.appendFileSync(logPath, JSON.stringify(entry) + "\n");
4435
4846
  const config = getConfig();
4436
4847
  if (shouldSnapshot(tool, {}, config)) {
@@ -4605,7 +5016,7 @@ process.on("unhandledRejection", (reason) => {
4605
5016
  const isCheckHook = process.argv[2] === "check";
4606
5017
  if (isCheckHook) {
4607
5018
  if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
4608
- const logPath = path6.join(os5.homedir(), ".node9", "hook-debug.log");
5019
+ const logPath = path7.join(os5.homedir(), ".node9", "hook-debug.log");
4609
5020
  const msg = reason instanceof Error ? reason.message : String(reason);
4610
5021
  fs5.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
4611
5022
  `);