@node9/proxy 1.4.0 → 1.5.0

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
@@ -1492,16 +1492,35 @@ function readTrustedHosts() {
1492
1492
  return [];
1493
1493
  }
1494
1494
  }
1495
+ var _cache = null;
1496
+ var CACHE_TTL_MS = 5e3;
1497
+ function getFileMtime() {
1498
+ try {
1499
+ return import_fs6.default.statSync(getTrustedHostsPath()).mtimeMs;
1500
+ } catch {
1501
+ return 0;
1502
+ }
1503
+ }
1504
+ function getCachedHosts() {
1505
+ const now = Date.now();
1506
+ if (_cache && now < _cache.expiry) {
1507
+ const mtime = getFileMtime();
1508
+ if (mtime === _cache.mtime) return _cache.hosts;
1509
+ }
1510
+ const hosts = readTrustedHosts();
1511
+ _cache = { hosts, expiry: now + CACHE_TTL_MS, mtime: getFileMtime() };
1512
+ return hosts;
1513
+ }
1495
1514
  function normalizeHost(raw) {
1496
1515
  return raw.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^[^@]+@/, "").replace(/:\d+$/, "");
1497
1516
  }
1498
1517
  function isTrustedHost(host) {
1499
1518
  const normalized = normalizeHost(host);
1500
- return readTrustedHosts().some((entry) => {
1519
+ return getCachedHosts().some((entry) => {
1501
1520
  const entryHost = entry.host.toLowerCase();
1502
1521
  if (entryHost.startsWith("*.")) {
1503
1522
  const domain = entryHost.slice(2);
1504
- return normalized === domain || normalized.endsWith("." + domain);
1523
+ return normalized.endsWith("." + domain);
1505
1524
  }
1506
1525
  return normalized === entryHost;
1507
1526
  });
@@ -1698,7 +1717,12 @@ async function evaluatePolicy(toolName, args, agent, cwd) {
1698
1717
  };
1699
1718
  }
1700
1719
  if (allTrusted) {
1701
- return { decision: "allow" };
1720
+ return {
1721
+ decision: "allow",
1722
+ blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
1723
+ reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
1724
+ tier: 3
1725
+ };
1702
1726
  }
1703
1727
  return {
1704
1728
  decision: "review",
@@ -1950,8 +1974,8 @@ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityI
1950
1974
  signal: ctrl.signal
1951
1975
  });
1952
1976
  if (!res.ok) throw new Error("Daemon fail");
1953
- const { id } = await res.json();
1954
- return id;
1977
+ const { id, allowCount } = await res.json();
1978
+ return { id, allowCount: allowCount ?? 1 };
1955
1979
  } finally {
1956
1980
  clearTimeout(timer);
1957
1981
  }
@@ -1990,15 +2014,15 @@ async function notifyDaemonViewer(toolName, args, meta, riskMetadata) {
1990
2014
  signal: AbortSignal.timeout(3e3)
1991
2015
  });
1992
2016
  if (!res.ok) throw new Error("Daemon unreachable");
1993
- const { id } = await res.json();
1994
- return id;
2017
+ const { id, allowCount } = await res.json();
2018
+ return { id, allowCount: allowCount ?? 1 };
1995
2019
  }
1996
- async function resolveViaDaemon(id, decision, internalToken) {
2020
+ async function resolveViaDaemon(id, decision, internalToken, source) {
1997
2021
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1998
2022
  await fetch(`${base}/resolve/${id}`, {
1999
2023
  method: "POST",
2000
2024
  headers: { "Content-Type": "application/json", "X-Node9-Internal": internalToken },
2001
- body: JSON.stringify({ decision }),
2025
+ body: JSON.stringify({ decision, ...source && { source } }),
2002
2026
  signal: AbortSignal.timeout(3e3)
2003
2027
  });
2004
2028
  }
@@ -2200,20 +2224,24 @@ ${smartTruncate(str, 500)}`
2200
2224
  function escapePango(text) {
2201
2225
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2202
2226
  }
2203
- function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
2227
+ function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1) {
2204
2228
  const lines = [];
2205
2229
  if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
2206
2230
  lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
2207
2231
  lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
2208
2232
  lines.push("");
2209
2233
  lines.push(formattedArgs);
2234
+ if (allowCount >= 3) {
2235
+ lines.push("");
2236
+ lines.push(`\u{1F4A1} Approved ${allowCount - 1}\xD7 before \u2014 "Always Allow" creates a permanent rule`);
2237
+ }
2210
2238
  if (!locked) {
2211
2239
  lines.push("");
2212
2240
  lines.push('\u21B5 Enter = Allow \u21B5 | \u238B Esc = Block \u238B | "Always Allow" = never ask again');
2213
2241
  }
2214
2242
  return lines.join("\n");
2215
2243
  }
2216
- function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
2244
+ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1) {
2217
2245
  const lines = [];
2218
2246
  if (locked) {
2219
2247
  lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
@@ -2225,6 +2253,12 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
2225
2253
  lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
2226
2254
  lines.push("");
2227
2255
  lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
2256
+ if (allowCount >= 3) {
2257
+ lines.push("");
2258
+ lines.push(
2259
+ `<span foreground="#f0c040">\u{1F4A1} Approved ${allowCount - 1}\xD7 before \u2014 "Always Allow" creates a permanent rule</span>`
2260
+ );
2261
+ }
2228
2262
  if (!locked) {
2229
2263
  lines.push("");
2230
2264
  lines.push(
@@ -2233,12 +2267,19 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
2233
2267
  }
2234
2268
  return lines.join("\n");
2235
2269
  }
2236
- async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord) {
2270
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord, allowCount = 1) {
2237
2271
  if (isTestEnv()) return "deny";
2238
2272
  const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
2239
2273
  const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
2240
2274
  const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
2241
- const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
2275
+ const message = buildPlainMessage(
2276
+ toolName,
2277
+ formattedArgs,
2278
+ agent,
2279
+ explainableLabel,
2280
+ locked,
2281
+ allowCount
2282
+ );
2242
2283
  return new Promise((resolve) => {
2243
2284
  let childProcess = null;
2244
2285
  const onAbort = () => {
@@ -2270,7 +2311,8 @@ end run`;
2270
2311
  formattedArgs,
2271
2312
  agent,
2272
2313
  explainableLabel,
2273
- locked
2314
+ locked,
2315
+ allowCount
2274
2316
  );
2275
2317
  const argsList = [
2276
2318
  locked ? "--info" : "--question",
@@ -2615,13 +2657,16 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2615
2657
  let viewerId = null;
2616
2658
  const internalToken = getInternalToken();
2617
2659
  let daemonEntryId = null;
2660
+ let daemonAllowCount = 1;
2618
2661
  if ((approvers.browser || approvers.terminal) && isDaemonRunning() && !options?.calledFromDaemon) {
2619
2662
  if (cloudEnforced && cloudRequestId) {
2620
- viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(() => null);
2663
+ const viewer = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(() => null);
2664
+ viewerId = viewer?.id ?? null;
2621
2665
  daemonEntryId = viewerId;
2666
+ if (viewer) daemonAllowCount = viewer.allowCount;
2622
2667
  } else {
2623
2668
  try {
2624
- daemonEntryId = await registerDaemonEntry(
2669
+ const entry = await registerDaemonEntry(
2625
2670
  toolName,
2626
2671
  args,
2627
2672
  meta,
@@ -2629,6 +2674,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2629
2674
  options?.activityId,
2630
2675
  options?.cwd
2631
2676
  );
2677
+ daemonEntryId = entry.id;
2678
+ daemonAllowCount = entry.allowCount;
2632
2679
  } catch {
2633
2680
  }
2634
2681
  }
@@ -2664,7 +2711,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2664
2711
  false,
2665
2712
  signal,
2666
2713
  policyMatchedField,
2667
- policyMatchedWord
2714
+ policyMatchedWord,
2715
+ daemonAllowCount
2668
2716
  );
2669
2717
  if (decision === "always_allow") {
2670
2718
  writeTrustSession(toolName, 36e5);
@@ -2722,10 +2770,13 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
2722
2770
  if (!resolved) {
2723
2771
  resolved = true;
2724
2772
  abortController.abort();
2725
- if (viewerId && internalToken) {
2726
- resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
2727
- () => null
2728
- );
2773
+ if (daemonEntryId && internalToken) {
2774
+ resolveViaDaemon(
2775
+ daemonEntryId,
2776
+ res.approved ? "allow" : "deny",
2777
+ internalToken,
2778
+ res.decisionSource
2779
+ ).catch(() => null);
2729
2780
  }
2730
2781
  resolve(res);
2731
2782
  }
package/dist/index.mjs CHANGED
@@ -1456,16 +1456,35 @@ function readTrustedHosts() {
1456
1456
  return [];
1457
1457
  }
1458
1458
  }
1459
+ var _cache = null;
1460
+ var CACHE_TTL_MS = 5e3;
1461
+ function getFileMtime() {
1462
+ try {
1463
+ return fs6.statSync(getTrustedHostsPath()).mtimeMs;
1464
+ } catch {
1465
+ return 0;
1466
+ }
1467
+ }
1468
+ function getCachedHosts() {
1469
+ const now = Date.now();
1470
+ if (_cache && now < _cache.expiry) {
1471
+ const mtime = getFileMtime();
1472
+ if (mtime === _cache.mtime) return _cache.hosts;
1473
+ }
1474
+ const hosts = readTrustedHosts();
1475
+ _cache = { hosts, expiry: now + CACHE_TTL_MS, mtime: getFileMtime() };
1476
+ return hosts;
1477
+ }
1459
1478
  function normalizeHost(raw) {
1460
1479
  return raw.toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/^[^@]+@/, "").replace(/:\d+$/, "");
1461
1480
  }
1462
1481
  function isTrustedHost(host) {
1463
1482
  const normalized = normalizeHost(host);
1464
- return readTrustedHosts().some((entry) => {
1483
+ return getCachedHosts().some((entry) => {
1465
1484
  const entryHost = entry.host.toLowerCase();
1466
1485
  if (entryHost.startsWith("*.")) {
1467
1486
  const domain = entryHost.slice(2);
1468
- return normalized === domain || normalized.endsWith("." + domain);
1487
+ return normalized.endsWith("." + domain);
1469
1488
  }
1470
1489
  return normalized === entryHost;
1471
1490
  });
@@ -1662,7 +1681,12 @@ async function evaluatePolicy(toolName, args, agent, cwd) {
1662
1681
  };
1663
1682
  }
1664
1683
  if (allTrusted) {
1665
- return { decision: "allow" };
1684
+ return {
1685
+ decision: "allow",
1686
+ blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
1687
+ reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
1688
+ tier: 3
1689
+ };
1666
1690
  }
1667
1691
  return {
1668
1692
  decision: "review",
@@ -1914,8 +1938,8 @@ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityI
1914
1938
  signal: ctrl.signal
1915
1939
  });
1916
1940
  if (!res.ok) throw new Error("Daemon fail");
1917
- const { id } = await res.json();
1918
- return id;
1941
+ const { id, allowCount } = await res.json();
1942
+ return { id, allowCount: allowCount ?? 1 };
1919
1943
  } finally {
1920
1944
  clearTimeout(timer);
1921
1945
  }
@@ -1954,15 +1978,15 @@ async function notifyDaemonViewer(toolName, args, meta, riskMetadata) {
1954
1978
  signal: AbortSignal.timeout(3e3)
1955
1979
  });
1956
1980
  if (!res.ok) throw new Error("Daemon unreachable");
1957
- const { id } = await res.json();
1958
- return id;
1981
+ const { id, allowCount } = await res.json();
1982
+ return { id, allowCount: allowCount ?? 1 };
1959
1983
  }
1960
- async function resolveViaDaemon(id, decision, internalToken) {
1984
+ async function resolveViaDaemon(id, decision, internalToken, source) {
1961
1985
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1962
1986
  await fetch(`${base}/resolve/${id}`, {
1963
1987
  method: "POST",
1964
1988
  headers: { "Content-Type": "application/json", "X-Node9-Internal": internalToken },
1965
- body: JSON.stringify({ decision }),
1989
+ body: JSON.stringify({ decision, ...source && { source } }),
1966
1990
  signal: AbortSignal.timeout(3e3)
1967
1991
  });
1968
1992
  }
@@ -2164,20 +2188,24 @@ ${smartTruncate(str, 500)}`
2164
2188
  function escapePango(text) {
2165
2189
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2166
2190
  }
2167
- function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
2191
+ function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1) {
2168
2192
  const lines = [];
2169
2193
  if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
2170
2194
  lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
2171
2195
  lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
2172
2196
  lines.push("");
2173
2197
  lines.push(formattedArgs);
2198
+ if (allowCount >= 3) {
2199
+ lines.push("");
2200
+ lines.push(`\u{1F4A1} Approved ${allowCount - 1}\xD7 before \u2014 "Always Allow" creates a permanent rule`);
2201
+ }
2174
2202
  if (!locked) {
2175
2203
  lines.push("");
2176
2204
  lines.push('\u21B5 Enter = Allow \u21B5 | \u238B Esc = Block \u238B | "Always Allow" = never ask again');
2177
2205
  }
2178
2206
  return lines.join("\n");
2179
2207
  }
2180
- function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
2208
+ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1) {
2181
2209
  const lines = [];
2182
2210
  if (locked) {
2183
2211
  lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
@@ -2189,6 +2217,12 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
2189
2217
  lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
2190
2218
  lines.push("");
2191
2219
  lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
2220
+ if (allowCount >= 3) {
2221
+ lines.push("");
2222
+ lines.push(
2223
+ `<span foreground="#f0c040">\u{1F4A1} Approved ${allowCount - 1}\xD7 before \u2014 "Always Allow" creates a permanent rule</span>`
2224
+ );
2225
+ }
2192
2226
  if (!locked) {
2193
2227
  lines.push("");
2194
2228
  lines.push(
@@ -2197,12 +2231,19 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
2197
2231
  }
2198
2232
  return lines.join("\n");
2199
2233
  }
2200
- async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord) {
2234
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord, allowCount = 1) {
2201
2235
  if (isTestEnv()) return "deny";
2202
2236
  const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
2203
2237
  const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
2204
2238
  const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
2205
- const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
2239
+ const message = buildPlainMessage(
2240
+ toolName,
2241
+ formattedArgs,
2242
+ agent,
2243
+ explainableLabel,
2244
+ locked,
2245
+ allowCount
2246
+ );
2206
2247
  return new Promise((resolve) => {
2207
2248
  let childProcess = null;
2208
2249
  const onAbort = () => {
@@ -2234,7 +2275,8 @@ end run`;
2234
2275
  formattedArgs,
2235
2276
  agent,
2236
2277
  explainableLabel,
2237
- locked
2278
+ locked,
2279
+ allowCount
2238
2280
  );
2239
2281
  const argsList = [
2240
2282
  locked ? "--info" : "--question",
@@ -2579,13 +2621,16 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2579
2621
  let viewerId = null;
2580
2622
  const internalToken = getInternalToken();
2581
2623
  let daemonEntryId = null;
2624
+ let daemonAllowCount = 1;
2582
2625
  if ((approvers.browser || approvers.terminal) && isDaemonRunning() && !options?.calledFromDaemon) {
2583
2626
  if (cloudEnforced && cloudRequestId) {
2584
- viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(() => null);
2627
+ const viewer = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(() => null);
2628
+ viewerId = viewer?.id ?? null;
2585
2629
  daemonEntryId = viewerId;
2630
+ if (viewer) daemonAllowCount = viewer.allowCount;
2586
2631
  } else {
2587
2632
  try {
2588
- daemonEntryId = await registerDaemonEntry(
2633
+ const entry = await registerDaemonEntry(
2589
2634
  toolName,
2590
2635
  args,
2591
2636
  meta,
@@ -2593,6 +2638,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2593
2638
  options?.activityId,
2594
2639
  options?.cwd
2595
2640
  );
2641
+ daemonEntryId = entry.id;
2642
+ daemonAllowCount = entry.allowCount;
2596
2643
  } catch {
2597
2644
  }
2598
2645
  }
@@ -2628,7 +2675,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2628
2675
  false,
2629
2676
  signal,
2630
2677
  policyMatchedField,
2631
- policyMatchedWord
2678
+ policyMatchedWord,
2679
+ daemonAllowCount
2632
2680
  );
2633
2681
  if (decision === "always_allow") {
2634
2682
  writeTrustSession(toolName, 36e5);
@@ -2686,10 +2734,13 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
2686
2734
  if (!resolved) {
2687
2735
  resolved = true;
2688
2736
  abortController.abort();
2689
- if (viewerId && internalToken) {
2690
- resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
2691
- () => null
2692
- );
2737
+ if (daemonEntryId && internalToken) {
2738
+ resolveViaDaemon(
2739
+ daemonEntryId,
2740
+ res.approved ? "allow" : "deny",
2741
+ internalToken,
2742
+ res.decisionSource
2743
+ ).catch(() => null);
2693
2744
  }
2694
2745
  resolve(res);
2695
2746
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
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",