@node9/proxy 1.5.2 → 1.5.3

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
@@ -198,12 +198,21 @@ var SmartRuleSchema = import_zod.z.object({
198
198
  verdict: import_zod.z.enum(["allow", "review", "block"], {
199
199
  errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
200
200
  }),
201
- reason: import_zod.z.string().optional()
201
+ reason: import_zod.z.string().optional(),
202
+ // Unknown predicate names are filtered out rather than failing the whole rule.
203
+ // Failing the whole z.array() would cause sanitizeConfig to drop the entire
204
+ // `policy` top-level key, silently disabling ALL smart rules in the config.
205
+ dependsOnState: import_zod.z.array(import_zod.z.string()).transform(
206
+ (arr) => arr.filter(
207
+ (p) => p === "no_test_passed_since_last_edit"
208
+ )
209
+ ).optional(),
210
+ recoveryCommand: import_zod.z.string().optional()
202
211
  });
203
212
  var ConfigFileSchema = import_zod.z.object({
204
213
  version: import_zod.z.string().optional(),
205
214
  settings: import_zod.z.object({
206
- mode: import_zod.z.enum(["standard", "strict", "audit"]).optional(),
215
+ mode: import_zod.z.enum(["standard", "strict", "audit", "observe"]).optional(),
207
216
  autoStartDaemon: import_zod.z.boolean().optional(),
208
217
  enableUndo: import_zod.z.boolean().optional(),
209
218
  enableHookLogDebug: import_zod.z.boolean().optional(),
@@ -257,8 +266,8 @@ function sanitizeConfig(raw) {
257
266
  }
258
267
  }
259
268
  const lines = result.error.issues.map((issue) => {
260
- const path14 = issue.path.length > 0 ? issue.path.join(".") : "root";
261
- return ` \u2022 ${path14}: ${issue.message}`;
269
+ const path13 = issue.path.length > 0 ? issue.path.join(".") : "root";
270
+ return ` \u2022 ${path13}: ${issue.message}`;
262
271
  });
263
272
  return {
264
273
  sanitized,
@@ -816,12 +825,17 @@ function getConfig(cwd) {
816
825
  if (s.approvalTimeoutSeconds !== void 0 && s.approvalTimeoutMs === void 0)
817
826
  mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
818
827
  if (s.environment !== void 0) mergedSettings.environment = s.environment;
828
+ if (s.hud !== void 0) mergedSettings.hud = { ...mergedSettings.hud, ...s.hud };
819
829
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
820
830
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
821
831
  if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
822
832
  if (p.toolInspection)
823
833
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
824
- if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
834
+ if (p.smartRules) {
835
+ const defaultBlocks = mergedPolicy.smartRules.filter((r) => r.verdict === "block");
836
+ const defaultNonBlocks = mergedPolicy.smartRules.filter((r) => r.verdict !== "block");
837
+ mergedPolicy.smartRules = [...defaultBlocks, ...p.smartRules, ...defaultNonBlocks];
838
+ }
825
839
  if (p.snapshot) {
826
840
  const s2 = p.snapshot;
827
841
  if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
@@ -1607,9 +1621,9 @@ function matchesPattern(text, patterns) {
1607
1621
  const withoutDotSlash = text.replace(/^\.\//, "");
1608
1622
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
1609
1623
  }
1610
- function getNestedValue(obj, path14) {
1624
+ function getNestedValue(obj, path13) {
1611
1625
  if (!obj || typeof obj !== "object") return null;
1612
- return path14.split(".").reduce((prev, curr) => prev?.[curr], obj);
1626
+ return path13.split(".").reduce((prev, curr) => prev?.[curr], obj);
1613
1627
  }
1614
1628
  function evaluateSmartConditions(args, rule) {
1615
1629
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -1748,7 +1762,13 @@ async function evaluatePolicy(toolName, args, agent, cwd) {
1748
1762
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
1749
1763
  reason: matchedRule.reason,
1750
1764
  tier: 2,
1751
- ruleName: matchedRule.name ?? matchedRule.tool
1765
+ ruleName: matchedRule.name ?? matchedRule.tool,
1766
+ ...matchedRule.verdict === "block" && matchedRule.dependsOnState?.length && {
1767
+ dependsOnStatePredicates: matchedRule.dependsOnState
1768
+ },
1769
+ ...matchedRule.verdict === "block" && matchedRule.recoveryCommand && {
1770
+ recoveryCommand: matchedRule.recoveryCommand
1771
+ }
1752
1772
  };
1753
1773
  }
1754
1774
  }
@@ -1980,9 +2000,39 @@ function getPersistentDecision(toolName) {
1980
2000
 
1981
2001
  // src/auth/daemon.ts
1982
2002
  var import_fs8 = __toESM(require("fs"));
2003
+ var import_net = __toESM(require("net"));
1983
2004
  var import_path10 = __toESM(require("path"));
1984
2005
  var import_os7 = __toESM(require("os"));
1985
2006
  var import_child_process = require("child_process");
2007
+ var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : import_path10.default.join(import_os7.default.tmpdir(), "node9-activity.sock");
2008
+ function notifyActivitySocket(data) {
2009
+ return new Promise((resolve) => {
2010
+ try {
2011
+ const payload = JSON.stringify(data);
2012
+ const sock = import_net.default.createConnection(ACTIVITY_SOCKET_PATH);
2013
+ sock.on("connect", () => {
2014
+ sock.on("close", resolve);
2015
+ sock.end(payload);
2016
+ });
2017
+ sock.on("error", resolve);
2018
+ } catch {
2019
+ resolve();
2020
+ }
2021
+ });
2022
+ }
2023
+ async function checkStatePredicates(predicates) {
2024
+ if (predicates.length === 0) return {};
2025
+ try {
2026
+ const qs = predicates.map(encodeURIComponent).join(",");
2027
+ const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/state/check?predicates=${qs}`, {
2028
+ signal: AbortSignal.timeout(100)
2029
+ });
2030
+ if (!res.ok) return null;
2031
+ return await res.json();
2032
+ } catch {
2033
+ return null;
2034
+ }
2035
+ }
1986
2036
  var DAEMON_PORT = 7391;
1987
2037
  var DAEMON_HOST = "127.0.0.1";
1988
2038
  function getInternalToken() {
@@ -2018,7 +2068,7 @@ function isDaemonRunning() {
2018
2068
  return false;
2019
2069
  }
2020
2070
  }
2021
- async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd) {
2071
+ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd, recoveryCommand, skipBackgroundAuth, viewOnly) {
2022
2072
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
2023
2073
  const ctrl = new AbortController();
2024
2074
  const timer = setTimeout(() => ctrl.abort(), 5e3);
@@ -2036,7 +2086,10 @@ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityI
2036
2086
  // activity-result as the CLI used for the pending activity event.
2037
2087
  activityId,
2038
2088
  ...riskMetadata && { riskMetadata },
2039
- ...cwd && { cwd }
2089
+ ...cwd && { cwd },
2090
+ ...recoveryCommand && { recoveryCommand },
2091
+ ...skipBackgroundAuth && { skipBackgroundAuth: true },
2092
+ ...viewOnly && { viewOnly: true }
2040
2093
  }),
2041
2094
  signal: ctrl.signal
2042
2095
  });
@@ -2056,10 +2109,10 @@ async function waitForDaemonDecision(id, signal) {
2056
2109
  try {
2057
2110
  const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
2058
2111
  if (!waitRes.ok) return { decision: "deny" };
2059
- const { decision, source } = await waitRes.json();
2112
+ const { decision, source, reason } = await waitRes.json();
2060
2113
  if (decision === "allow") return { decision: "allow", source };
2061
2114
  if (decision === "abandoned") return { decision: "abandoned", source };
2062
- return { decision: "deny", source };
2115
+ return { decision: "deny", source, reason };
2063
2116
  } finally {
2064
2117
  clearTimeout(waitTimer);
2065
2118
  if (signal) signal.removeEventListener("abort", onAbort);
@@ -2134,9 +2187,6 @@ async function resolveViaDaemon(id, decision, internalToken, source) {
2134
2187
  }
2135
2188
 
2136
2189
  // src/auth/orchestrator.ts
2137
- var import_net = __toESM(require("net"));
2138
- var import_path13 = __toESM(require("path"));
2139
- var import_os9 = __toESM(require("os"));
2140
2190
  var import_crypto2 = require("crypto");
2141
2191
 
2142
2192
  // src/ui/native.ts
@@ -2619,21 +2669,8 @@ function isNetworkTool(toolName, args) {
2619
2669
  }
2620
2670
  return false;
2621
2671
  }
2622
- var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : import_path13.default.join(import_os9.default.tmpdir(), "node9-activity.sock");
2623
2672
  function notifyActivity(data) {
2624
- return new Promise((resolve) => {
2625
- try {
2626
- const payload = JSON.stringify(data);
2627
- const sock = import_net.default.createConnection(ACTIVITY_SOCKET_PATH);
2628
- sock.on("connect", () => {
2629
- sock.on("close", resolve);
2630
- sock.end(payload);
2631
- });
2632
- sock.on("error", resolve);
2633
- } catch {
2634
- resolve();
2635
- }
2636
- });
2673
+ return notifyActivitySocket(data);
2637
2674
  }
2638
2675
  async function authorizeHeadless(toolName, args, meta, options) {
2639
2676
  if (!options?.calledFromDaemon) {
@@ -2650,7 +2687,9 @@ async function authorizeHeadless(toolName, args, meta, options) {
2650
2687
  tool: toolName,
2651
2688
  ts: actTs,
2652
2689
  status: result.approved ? "allow" : result.blockedByLabel?.includes("DLP") ? "dlp" : result.blockedByLabel?.includes("Taint") ? "taint" : "block",
2653
- label: result.blockedByLabel
2690
+ label: result.blockedByLabel,
2691
+ ruleHit: result.ruleHit,
2692
+ observeWouldBlock: result.observeWouldBlock
2654
2693
  });
2655
2694
  }
2656
2695
  return result;
@@ -2677,10 +2716,12 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2677
2716
  appendHookDebug(toolName, args, meta, hashAuditArgs);
2678
2717
  }
2679
2718
  const isManual = meta?.agent === "Terminal";
2719
+ const isObserveMode = config.settings.mode === "observe";
2680
2720
  let explainableLabel = "Local Config";
2681
2721
  let policyMatchedField;
2682
2722
  let policyMatchedWord;
2683
2723
  let riskMetadata;
2724
+ let statefulRecoveryCommand;
2684
2725
  let taintWarning = null;
2685
2726
  if (isNetworkTool(toolName, args)) {
2686
2727
  const filePaths = extractFilePaths(toolName, args);
@@ -2701,10 +2742,26 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2701
2742
  if (dlpMatch) {
2702
2743
  const dlpReason = `\u{1F6A8} DATA LOSS PREVENTION: ${dlpMatch.patternName} detected in field "${dlpMatch.fieldPath}" (${dlpMatch.redactedSample})`;
2703
2744
  if (dlpMatch.severity === "block") {
2704
- if (!isManual) appendLocalAudit(toolName, args, "deny", "dlp-block", meta, true);
2745
+ if (!isManual)
2746
+ appendLocalAudit(
2747
+ toolName,
2748
+ args,
2749
+ "deny",
2750
+ isObserveMode ? "observe-mode-dlp-would-block" : "dlp-block",
2751
+ meta,
2752
+ true
2753
+ );
2705
2754
  if (isWriteTool(toolName) && filePath) {
2706
2755
  await notifyTaint(filePath, `DLP:${dlpMatch.patternName}`);
2707
2756
  }
2757
+ if (isObserveMode) {
2758
+ return {
2759
+ approved: true,
2760
+ checkedBy: "audit",
2761
+ observeWouldBlock: true,
2762
+ blockedByLabel: "\u{1F6A8} Node9 DLP (Secret Detected)"
2763
+ };
2764
+ }
2708
2765
  return {
2709
2766
  approved: false,
2710
2767
  reason: dlpReason,
@@ -2717,6 +2774,31 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2717
2774
  explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
2718
2775
  }
2719
2776
  }
2777
+ if (isObserveMode) {
2778
+ if (!isIgnoredTool(toolName)) {
2779
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent, options?.cwd);
2780
+ const wouldBlock = policyResult.decision === "block";
2781
+ if (!isManual)
2782
+ appendLocalAudit(
2783
+ toolName,
2784
+ args,
2785
+ "allow",
2786
+ wouldBlock ? "observe-mode-would-block" : "observe-mode",
2787
+ meta,
2788
+ hashAuditArgs
2789
+ );
2790
+ return {
2791
+ approved: true,
2792
+ checkedBy: "audit",
2793
+ ...wouldBlock && {
2794
+ observeWouldBlock: true,
2795
+ blockedByLabel: policyResult.blockedByLabel,
2796
+ ruleHit: policyResult.ruleName
2797
+ }
2798
+ };
2799
+ }
2800
+ return { approved: true, checkedBy: "audit" };
2801
+ }
2720
2802
  if (config.settings.mode === "audit") {
2721
2803
  if (!isIgnoredTool(toolName)) {
2722
2804
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent, options?.cwd);
@@ -2744,14 +2826,34 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2744
2826
  return { approved: true, checkedBy: "local-policy" };
2745
2827
  }
2746
2828
  if (policyResult.decision === "block") {
2747
- if (!isManual)
2748
- appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta, hashAuditArgs);
2749
- return {
2750
- approved: false,
2751
- reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
2752
- blockedBy: "local-config",
2753
- blockedByLabel: policyResult.blockedByLabel
2754
- };
2829
+ if (policyResult.dependsOnStatePredicates?.length) {
2830
+ const stateResults = await checkStatePredicates(policyResult.dependsOnStatePredicates);
2831
+ const predicatesMet = stateResults !== null && policyResult.dependsOnStatePredicates.every((p) => stateResults[p] === true);
2832
+ if (stateResults === null && !isManual) {
2833
+ appendToLog(HOOK_DEBUG_LOG, {
2834
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2835
+ event: "state-check-fail-open",
2836
+ tool: toolName,
2837
+ rule: policyResult.ruleName,
2838
+ predicates: policyResult.dependsOnStatePredicates,
2839
+ reason: "daemon unreachable or /state/check timed out \u2014 block rule downgraded to review"
2840
+ });
2841
+ }
2842
+ if (predicatesMet && policyResult.recoveryCommand) {
2843
+ statefulRecoveryCommand = policyResult.recoveryCommand;
2844
+ }
2845
+ } else {
2846
+ if (!isManual)
2847
+ appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta, hashAuditArgs);
2848
+ return {
2849
+ approved: false,
2850
+ reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
2851
+ blockedBy: "local-config",
2852
+ blockedByLabel: policyResult.blockedByLabel,
2853
+ ruleHit: policyResult.ruleName,
2854
+ ...policyResult.recoveryCommand && { recoveryCommand: policyResult.recoveryCommand }
2855
+ };
2856
+ }
2755
2857
  }
2756
2858
  explainableLabel = policyResult.blockedByLabel || "Local Config";
2757
2859
  policyMatchedField = policyResult.matchedField;
@@ -2858,7 +2960,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2858
2960
  meta,
2859
2961
  riskMetadata,
2860
2962
  options?.activityId,
2861
- options?.cwd
2963
+ options?.cwd,
2964
+ statefulRecoveryCommand
2862
2965
  );
2863
2966
  daemonEntryId = entry.id;
2864
2967
  daemonAllowCount = entry.allowCount;
@@ -2919,20 +3022,26 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2919
3022
  if (daemonEntryId && (approvers.browser || approvers.terminal)) {
2920
3023
  racePromises.push(
2921
3024
  (async () => {
2922
- const { decision: daemonDecision, source: decisionSource } = await waitForDaemonDecision(
2923
- daemonEntryId,
2924
- signal
2925
- );
3025
+ const {
3026
+ decision: daemonDecision,
3027
+ source: decisionSource,
3028
+ reason: daemonReason
3029
+ } = await waitForDaemonDecision(daemonEntryId, signal);
2926
3030
  if (daemonDecision === "abandoned") throw new Error("Abandoned");
2927
3031
  const isApproved = daemonDecision === "allow";
2928
- const src = decisionSource === "terminal" || decisionSource === "browser" ? decisionSource : approvers.browser ? "browser" : "terminal";
3032
+ const isRedirect = decisionSource === "terminal-redirect";
3033
+ const src = decisionSource === "terminal" || decisionSource === "terminal-redirect" || decisionSource === "browser" ? decisionSource === "browser" ? "browser" : "terminal" : approvers.browser ? "browser" : "terminal";
2929
3034
  const via = src === "terminal" ? "Terminal (node9 tail)" : "Browser Dashboard";
2930
3035
  return {
2931
3036
  approved: isApproved,
2932
- reason: isApproved ? void 0 : `The human user rejected this action via the Node9 ${via}.`,
3037
+ reason: isApproved ? void 0 : (
3038
+ // Use the redirect reason from the tail when choice [2] was selected;
3039
+ // otherwise fall back to the generic rejection message.
3040
+ isRedirect && daemonReason || `The human user rejected this action via the Node9 ${via}.`
3041
+ ),
2933
3042
  checkedBy: isApproved ? "daemon" : void 0,
2934
3043
  blockedBy: isApproved ? void 0 : "local-decision",
2935
- blockedByLabel: `User Decision (${via})`,
3044
+ blockedByLabel: isRedirect ? "Steered Redirect (Terminal)" : `User Decision (${via})`,
2936
3045
  decisionSource: src
2937
3046
  };
2938
3047
  })()