@node9/proxy 1.0.7 → 1.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -2,13 +2,14 @@
2
2
  import chalk2 from "chalk";
3
3
  import { confirm } from "@inquirer/prompts";
4
4
  import fs from "fs";
5
- import path from "path";
5
+ import path2 from "path";
6
6
  import os from "os";
7
7
  import pm from "picomatch";
8
8
  import { parse } from "sh-syntax";
9
9
 
10
10
  // src/ui/native.ts
11
11
  import { spawn } from "child_process";
12
+ import path from "path";
12
13
  import chalk from "chalk";
13
14
  var isTestEnv = () => {
14
15
  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";
@@ -18,8 +19,29 @@ function smartTruncate(str, maxLen = 500) {
18
19
  const edge = Math.floor(maxLen / 2) - 3;
19
20
  return `${str.slice(0, edge)} ... ${str.slice(-edge)}`;
20
21
  }
21
- function formatArgs(args) {
22
- if (args === null || args === void 0) return "(none)";
22
+ function extractContext(text, matchedWord) {
23
+ const lines = text.split("\n");
24
+ if (lines.length <= 7 || !matchedWord) return smartTruncate(text, 500);
25
+ const escaped = matchedWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
26
+ const pattern = new RegExp(`\\b${escaped}\\b`, "i");
27
+ const allHits = lines.map((line, i) => ({ i, line })).filter(({ line }) => pattern.test(line));
28
+ if (allHits.length === 0) return smartTruncate(text, 500);
29
+ const nonComment = allHits.find(({ line }) => {
30
+ const trimmed = line.trim();
31
+ return !trimmed.startsWith("//") && !trimmed.startsWith("#");
32
+ });
33
+ const hitIndex = (nonComment ?? allHits[0]).i;
34
+ const start = Math.max(0, hitIndex - 3);
35
+ const end = Math.min(lines.length, hitIndex + 4);
36
+ const snippet = lines.slice(start, end).map((line, i) => `${start + i === hitIndex ? "\u{1F6D1} " : " "}${line}`).join("\n");
37
+ const head = start > 0 ? `... [${start} lines hidden] ...
38
+ ` : "";
39
+ const tail = end < lines.length ? `
40
+ ... [${lines.length - end} lines hidden] ...` : "";
41
+ return `${head}${snippet}${tail}`;
42
+ }
43
+ function formatArgs(args, matchedField, matchedWord) {
44
+ if (args === null || args === void 0) return { message: "(none)", intent: "EXEC" };
23
45
  let parsed = args;
24
46
  if (typeof args === "string") {
25
47
  const trimmed = args.trim();
@@ -30,11 +52,39 @@ function formatArgs(args) {
30
52
  parsed = args;
31
53
  }
32
54
  } else {
33
- return smartTruncate(args, 600);
55
+ return { message: smartTruncate(args, 600), intent: "EXEC" };
34
56
  }
35
57
  }
36
58
  if (typeof parsed === "object" && !Array.isArray(parsed)) {
37
59
  const obj = parsed;
60
+ if (obj.old_string !== void 0 && obj.new_string !== void 0) {
61
+ const file = obj.file_path ? path.basename(String(obj.file_path)) : "file";
62
+ const oldPreview = smartTruncate(String(obj.old_string), 120);
63
+ const newPreview = extractContext(String(obj.new_string), matchedWord);
64
+ return {
65
+ intent: "EDIT",
66
+ message: `\u{1F4DD} EDITING: ${file}
67
+ \u{1F4C2} PATH: ${obj.file_path}
68
+
69
+ --- REPLACING ---
70
+ ${oldPreview}
71
+
72
+ +++ NEW CODE +++
73
+ ${newPreview}`
74
+ };
75
+ }
76
+ if (matchedField && obj[matchedField] !== void 0) {
77
+ const otherKeys = Object.keys(obj).filter((k) => k !== matchedField);
78
+ 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(", ")}
79
+
80
+ ` : "";
81
+ const content = extractContext(String(obj[matchedField]), matchedWord);
82
+ return {
83
+ intent: "EXEC",
84
+ message: `${context}\u{1F6D1} [${matchedField.toUpperCase()}]:
85
+ ${content}`
86
+ };
87
+ }
38
88
  const codeKeys = [
39
89
  "command",
40
90
  "cmd",
@@ -55,14 +105,18 @@ function formatArgs(args) {
55
105
  if (foundKey) {
56
106
  const val = obj[foundKey];
57
107
  const str = typeof val === "string" ? val : JSON.stringify(val);
58
- return `[${foundKey.toUpperCase()}]:
59
- ${smartTruncate(str, 500)}`;
108
+ return {
109
+ intent: "EXEC",
110
+ message: `[${foundKey.toUpperCase()}]:
111
+ ${smartTruncate(str, 500)}`
112
+ };
60
113
  }
61
- return Object.entries(obj).slice(0, 5).map(
114
+ const msg = Object.entries(obj).slice(0, 5).map(
62
115
  ([k, v]) => ` ${k}: ${smartTruncate(typeof v === "string" ? v : JSON.stringify(v), 300)}`
63
116
  ).join("\n");
117
+ return { intent: "EXEC", message: msg };
64
118
  }
65
- return smartTruncate(JSON.stringify(parsed), 200);
119
+ return { intent: "EXEC", message: smartTruncate(JSON.stringify(parsed), 200) };
66
120
  }
67
121
  function sendDesktopNotification(title, body) {
68
122
  if (isTestEnv()) return;
@@ -115,10 +169,11 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
115
169
  }
116
170
  return lines.join("\n");
117
171
  }
118
- async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal) {
172
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord) {
119
173
  if (isTestEnv()) return "deny";
120
- const formattedArgs = formatArgs(args);
121
- const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 Action Approval`;
174
+ const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
175
+ const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
176
+ const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
122
177
  const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
123
178
  process.stderr.write(chalk.yellow(`
124
179
  \u{1F6E1}\uFE0F Node9: Intercepted "${toolName}" \u2014 awaiting user...
@@ -196,10 +251,10 @@ end run`;
196
251
  }
197
252
 
198
253
  // src/core.ts
199
- var PAUSED_FILE = path.join(os.homedir(), ".node9", "PAUSED");
200
- var TRUST_FILE = path.join(os.homedir(), ".node9", "trust.json");
201
- var LOCAL_AUDIT_LOG = path.join(os.homedir(), ".node9", "audit.log");
202
- var HOOK_DEBUG_LOG = path.join(os.homedir(), ".node9", "hook-debug.log");
254
+ var PAUSED_FILE = path2.join(os.homedir(), ".node9", "PAUSED");
255
+ var TRUST_FILE = path2.join(os.homedir(), ".node9", "trust.json");
256
+ var LOCAL_AUDIT_LOG = path2.join(os.homedir(), ".node9", "audit.log");
257
+ var HOOK_DEBUG_LOG = path2.join(os.homedir(), ".node9", "hook-debug.log");
203
258
  function checkPause() {
204
259
  try {
205
260
  if (!fs.existsSync(PAUSED_FILE)) return { paused: false };
@@ -217,7 +272,7 @@ function checkPause() {
217
272
  }
218
273
  }
219
274
  function atomicWriteSync(filePath, data, options) {
220
- const dir = path.dirname(filePath);
275
+ const dir = path2.dirname(filePath);
221
276
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
222
277
  const tmpPath = `${filePath}.${os.hostname()}.${process.pid}.tmp`;
223
278
  fs.writeFileSync(tmpPath, data, options);
@@ -258,7 +313,7 @@ function writeTrustSession(toolName, durationMs) {
258
313
  }
259
314
  function appendToLog(logPath, entry) {
260
315
  try {
261
- const dir = path.dirname(logPath);
316
+ const dir = path2.dirname(logPath);
262
317
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
263
318
  fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
264
319
  } catch {
@@ -302,9 +357,9 @@ function matchesPattern(text, patterns) {
302
357
  const withoutDotSlash = text.replace(/^\.\//, "");
303
358
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
304
359
  }
305
- function getNestedValue(obj, path2) {
360
+ function getNestedValue(obj, path3) {
306
361
  if (!obj || typeof obj !== "object") return null;
307
- return path2.split(".").reduce((prev, curr) => prev?.[curr], obj);
362
+ return path3.split(".").reduce((prev, curr) => prev?.[curr], obj);
308
363
  }
309
364
  function evaluateSmartConditions(args, rule) {
310
365
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -493,6 +548,18 @@ var DEFAULT_CONFIG = {
493
548
  "terminal.execute": "command",
494
549
  "postgres:query": "sql"
495
550
  },
551
+ snapshot: {
552
+ tools: [
553
+ "str_replace_based_edit_tool",
554
+ "write_file",
555
+ "edit_file",
556
+ "create_file",
557
+ "edit",
558
+ "replace"
559
+ ],
560
+ onlyPaths: [],
561
+ ignorePaths: ["**/node_modules/**", "dist/**", "build/**", ".next/**", "**/*.log"]
562
+ },
496
563
  rules: [
497
564
  {
498
565
  action: "rm",
@@ -528,7 +595,7 @@ var DEFAULT_CONFIG = {
528
595
  var cachedConfig = null;
529
596
  function getInternalToken() {
530
597
  try {
531
- const pidFile = path.join(os.homedir(), ".node9", "daemon.pid");
598
+ const pidFile = path2.join(os.homedir(), ".node9", "daemon.pid");
532
599
  if (!fs.existsSync(pidFile)) return null;
533
600
  const data = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
534
601
  process.kill(data.pid, 0);
@@ -632,9 +699,29 @@ async function evaluatePolicy(toolName, args, agent) {
632
699
  })
633
700
  );
634
701
  if (isDangerous) {
702
+ let matchedField;
703
+ if (matchedDangerousWord && args && typeof args === "object" && !Array.isArray(args)) {
704
+ const obj = args;
705
+ for (const [key, value] of Object.entries(obj)) {
706
+ if (typeof value === "string") {
707
+ try {
708
+ if (new RegExp(
709
+ `\\b${matchedDangerousWord.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`,
710
+ "i"
711
+ ).test(value)) {
712
+ matchedField = key;
713
+ break;
714
+ }
715
+ } catch {
716
+ }
717
+ }
718
+ }
719
+ }
635
720
  return {
636
721
  decision: "review",
637
- blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`
722
+ blockedByLabel: `Project/Global Config \u2014 dangerous word: "${matchedDangerousWord}"`,
723
+ matchedWord: matchedDangerousWord,
724
+ matchedField
638
725
  };
639
726
  }
640
727
  if (config.settings.mode === "strict") {
@@ -652,7 +739,7 @@ var DAEMON_PORT = 7391;
652
739
  var DAEMON_HOST = "127.0.0.1";
653
740
  function isDaemonRunning() {
654
741
  try {
655
- const pidFile = path.join(os.homedir(), ".node9", "daemon.pid");
742
+ const pidFile = path2.join(os.homedir(), ".node9", "daemon.pid");
656
743
  if (!fs.existsSync(pidFile)) return false;
657
744
  const { pid, port } = JSON.parse(fs.readFileSync(pidFile, "utf-8"));
658
745
  if (port !== DAEMON_PORT) return false;
@@ -664,7 +751,7 @@ function isDaemonRunning() {
664
751
  }
665
752
  function getPersistentDecision(toolName) {
666
753
  try {
667
- const file = path.join(os.homedir(), ".node9", "decisions.json");
754
+ const file = path2.join(os.homedir(), ".node9", "decisions.json");
668
755
  if (!fs.existsSync(file)) return null;
669
756
  const decisions = JSON.parse(fs.readFileSync(file, "utf-8"));
670
757
  const d = decisions[toolName];
@@ -735,7 +822,7 @@ async function resolveViaDaemon(id, decision, internalToken) {
735
822
  signal: AbortSignal.timeout(3e3)
736
823
  });
737
824
  }
738
- async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta) {
825
+ async function authorizeHeadless(toolName, args, allowTerminalFallback = false, meta, options) {
739
826
  if (process.env.NODE9_PAUSED === "1") return { approved: true, checkedBy: "paused" };
740
827
  const pauseState = checkPause();
741
828
  if (pauseState.paused) return { approved: true, checkedBy: "paused" };
@@ -755,6 +842,8 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
755
842
  }
756
843
  const isManual = meta?.agent === "Terminal";
757
844
  let explainableLabel = "Local Config";
845
+ let policyMatchedField;
846
+ let policyMatchedWord;
758
847
  if (config.settings.mode === "audit") {
759
848
  if (!isIgnoredTool(toolName)) {
760
849
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
@@ -790,6 +879,8 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
790
879
  };
791
880
  }
792
881
  explainableLabel = policyResult.blockedByLabel || "Local Config";
882
+ policyMatchedField = policyResult.matchedField;
883
+ policyMatchedWord = policyResult.matchedWord;
793
884
  const persistent = getPersistentDecision(toolName);
794
885
  if (persistent === "allow") {
795
886
  if (creds?.apiKey) auditLocalAllow(toolName, args, "persistent", creds, meta);
@@ -882,7 +973,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
882
973
  racePromises.push(
883
974
  (async () => {
884
975
  try {
885
- if (isDaemonRunning() && internalToken) {
976
+ if (isDaemonRunning() && internalToken && !options?.calledFromDaemon) {
886
977
  viewerId = await notifyDaemonViewer(toolName, args, meta).catch(() => null);
887
978
  }
888
979
  const cloudResult = await pollNode9SaaS(cloudRequestId, creds, signal);
@@ -910,7 +1001,9 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
910
1001
  meta?.agent,
911
1002
  explainableLabel,
912
1003
  isRemoteLocked,
913
- signal
1004
+ signal,
1005
+ policyMatchedField,
1006
+ policyMatchedWord
914
1007
  );
915
1008
  if (decision === "always_allow") {
916
1009
  writeTrustSession(toolName, 36e5);
@@ -927,7 +1020,7 @@ async function authorizeHeadless(toolName, args, allowTerminalFallback = false,
927
1020
  })()
928
1021
  );
929
1022
  }
930
- if (approvers.browser && isDaemonRunning()) {
1023
+ if (approvers.browser && isDaemonRunning() && !options?.calledFromDaemon) {
931
1024
  racePromises.push(
932
1025
  (async () => {
933
1026
  try {
@@ -1063,8 +1156,8 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
1063
1156
  }
1064
1157
  function getConfig() {
1065
1158
  if (cachedConfig) return cachedConfig;
1066
- const globalPath = path.join(os.homedir(), ".node9", "config.json");
1067
- const projectPath = path.join(process.cwd(), "node9.config.json");
1159
+ const globalPath = path2.join(os.homedir(), ".node9", "config.json");
1160
+ const projectPath = path2.join(process.cwd(), "node9.config.json");
1068
1161
  const globalConfig = tryLoadConfig(globalPath);
1069
1162
  const projectConfig = tryLoadConfig(projectPath);
1070
1163
  const mergedSettings = {
@@ -1077,7 +1170,12 @@ function getConfig() {
1077
1170
  ignoredTools: [...DEFAULT_CONFIG.policy.ignoredTools],
1078
1171
  toolInspection: { ...DEFAULT_CONFIG.policy.toolInspection },
1079
1172
  rules: [...DEFAULT_CONFIG.policy.rules],
1080
- smartRules: [...DEFAULT_CONFIG.policy.smartRules]
1173
+ smartRules: [...DEFAULT_CONFIG.policy.smartRules],
1174
+ snapshot: {
1175
+ tools: [...DEFAULT_CONFIG.policy.snapshot.tools],
1176
+ onlyPaths: [...DEFAULT_CONFIG.policy.snapshot.onlyPaths],
1177
+ ignorePaths: [...DEFAULT_CONFIG.policy.snapshot.ignorePaths]
1178
+ }
1081
1179
  };
1082
1180
  const applyLayer = (source) => {
1083
1181
  if (!source) return;
@@ -1089,6 +1187,7 @@ function getConfig() {
1089
1187
  if (s.enableHookLogDebug !== void 0)
1090
1188
  mergedSettings.enableHookLogDebug = s.enableHookLogDebug;
1091
1189
  if (s.approvers) mergedSettings.approvers = { ...mergedSettings.approvers, ...s.approvers };
1190
+ if (s.approvalTimeoutMs !== void 0) mergedSettings.approvalTimeoutMs = s.approvalTimeoutMs;
1092
1191
  if (s.environment !== void 0) mergedSettings.environment = s.environment;
1093
1192
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
1094
1193
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
@@ -1097,6 +1196,12 @@ function getConfig() {
1097
1196
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
1098
1197
  if (p.rules) mergedPolicy.rules.push(...p.rules);
1099
1198
  if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
1199
+ if (p.snapshot) {
1200
+ const s2 = p.snapshot;
1201
+ if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
1202
+ if (s2.onlyPaths) mergedPolicy.snapshot.onlyPaths.push(...s2.onlyPaths);
1203
+ if (s2.ignorePaths) mergedPolicy.snapshot.ignorePaths.push(...s2.ignorePaths);
1204
+ }
1100
1205
  };
1101
1206
  applyLayer(globalConfig);
1102
1207
  applyLayer(projectConfig);
@@ -1104,6 +1209,9 @@ function getConfig() {
1104
1209
  mergedPolicy.sandboxPaths = [...new Set(mergedPolicy.sandboxPaths)];
1105
1210
  mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
1106
1211
  mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
1212
+ mergedPolicy.snapshot.tools = [...new Set(mergedPolicy.snapshot.tools)];
1213
+ mergedPolicy.snapshot.onlyPaths = [...new Set(mergedPolicy.snapshot.onlyPaths)];
1214
+ mergedPolicy.snapshot.ignorePaths = [...new Set(mergedPolicy.snapshot.ignorePaths)];
1107
1215
  cachedConfig = {
1108
1216
  settings: mergedSettings,
1109
1217
  policy: mergedPolicy,
@@ -1132,7 +1240,7 @@ function getCredentials() {
1132
1240
  };
1133
1241
  }
1134
1242
  try {
1135
- const credPath = path.join(os.homedir(), ".node9", "credentials.json");
1243
+ const credPath = path2.join(os.homedir(), ".node9", "credentials.json");
1136
1244
  if (fs.existsSync(credPath)) {
1137
1245
  const creds = JSON.parse(fs.readFileSync(credPath, "utf-8"));
1138
1246
  const profileName = process.env.NODE9_PROFILE || "default";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "The Sudo Command for AI Agents. Execution Security for Claude Code & MCP.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -41,7 +41,7 @@
41
41
  "hitl"
42
42
  ],
43
43
  "author": "Nadav <nadav@node9.ai>",
44
- "license": "MIT",
44
+ "license": "Apache-2.0",
45
45
  "files": [
46
46
  "dist",
47
47
  "README.md",
@@ -73,6 +73,8 @@
73
73
  "sh-syntax": "^0.5.8"
74
74
  },
75
75
  "devDependencies": {
76
+ "@anthropic-ai/sdk": "^0.78.0",
77
+ "@octokit/rest": "^22.0.1",
76
78
  "@semantic-release/commit-analyzer": "^13.0.1",
77
79
  "@semantic-release/git": "^10.0.1",
78
80
  "@semantic-release/github": "^12.0.6",