@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.mjs CHANGED
@@ -168,12 +168,21 @@ var SmartRuleSchema = z.object({
168
168
  verdict: z.enum(["allow", "review", "block"], {
169
169
  errorMap: () => ({ message: "verdict must be one of: allow, review, block" })
170
170
  }),
171
- reason: z.string().optional()
171
+ reason: z.string().optional(),
172
+ // Unknown predicate names are filtered out rather than failing the whole rule.
173
+ // Failing the whole z.array() would cause sanitizeConfig to drop the entire
174
+ // `policy` top-level key, silently disabling ALL smart rules in the config.
175
+ dependsOnState: z.array(z.string()).transform(
176
+ (arr) => arr.filter(
177
+ (p) => p === "no_test_passed_since_last_edit"
178
+ )
179
+ ).optional(),
180
+ recoveryCommand: z.string().optional()
172
181
  });
173
182
  var ConfigFileSchema = z.object({
174
183
  version: z.string().optional(),
175
184
  settings: z.object({
176
- mode: z.enum(["standard", "strict", "audit"]).optional(),
185
+ mode: z.enum(["standard", "strict", "audit", "observe"]).optional(),
177
186
  autoStartDaemon: z.boolean().optional(),
178
187
  enableUndo: z.boolean().optional(),
179
188
  enableHookLogDebug: z.boolean().optional(),
@@ -227,8 +236,8 @@ function sanitizeConfig(raw) {
227
236
  }
228
237
  }
229
238
  const lines = result.error.issues.map((issue) => {
230
- const path14 = issue.path.length > 0 ? issue.path.join(".") : "root";
231
- return ` \u2022 ${path14}: ${issue.message}`;
239
+ const path13 = issue.path.length > 0 ? issue.path.join(".") : "root";
240
+ return ` \u2022 ${path13}: ${issue.message}`;
232
241
  });
233
242
  return {
234
243
  sanitized,
@@ -786,12 +795,17 @@ function getConfig(cwd) {
786
795
  if (s.approvalTimeoutSeconds !== void 0 && s.approvalTimeoutMs === void 0)
787
796
  mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
788
797
  if (s.environment !== void 0) mergedSettings.environment = s.environment;
798
+ if (s.hud !== void 0) mergedSettings.hud = { ...mergedSettings.hud, ...s.hud };
789
799
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
790
800
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
791
801
  if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
792
802
  if (p.toolInspection)
793
803
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
794
- if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
804
+ if (p.smartRules) {
805
+ const defaultBlocks = mergedPolicy.smartRules.filter((r) => r.verdict === "block");
806
+ const defaultNonBlocks = mergedPolicy.smartRules.filter((r) => r.verdict !== "block");
807
+ mergedPolicy.smartRules = [...defaultBlocks, ...p.smartRules, ...defaultNonBlocks];
808
+ }
795
809
  if (p.snapshot) {
796
810
  const s2 = p.snapshot;
797
811
  if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
@@ -1577,9 +1591,9 @@ function matchesPattern(text, patterns) {
1577
1591
  const withoutDotSlash = text.replace(/^\.\//, "");
1578
1592
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
1579
1593
  }
1580
- function getNestedValue(obj, path14) {
1594
+ function getNestedValue(obj, path13) {
1581
1595
  if (!obj || typeof obj !== "object") return null;
1582
- return path14.split(".").reduce((prev, curr) => prev?.[curr], obj);
1596
+ return path13.split(".").reduce((prev, curr) => prev?.[curr], obj);
1583
1597
  }
1584
1598
  function evaluateSmartConditions(args, rule) {
1585
1599
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -1718,7 +1732,13 @@ async function evaluatePolicy(toolName, args, agent, cwd) {
1718
1732
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
1719
1733
  reason: matchedRule.reason,
1720
1734
  tier: 2,
1721
- ruleName: matchedRule.name ?? matchedRule.tool
1735
+ ruleName: matchedRule.name ?? matchedRule.tool,
1736
+ ...matchedRule.verdict === "block" && matchedRule.dependsOnState?.length && {
1737
+ dependsOnStatePredicates: matchedRule.dependsOnState
1738
+ },
1739
+ ...matchedRule.verdict === "block" && matchedRule.recoveryCommand && {
1740
+ recoveryCommand: matchedRule.recoveryCommand
1741
+ }
1722
1742
  };
1723
1743
  }
1724
1744
  }
@@ -1950,9 +1970,39 @@ function getPersistentDecision(toolName) {
1950
1970
 
1951
1971
  // src/auth/daemon.ts
1952
1972
  import fs8 from "fs";
1973
+ import net from "net";
1953
1974
  import path10 from "path";
1954
1975
  import os7 from "os";
1955
1976
  import { spawnSync } from "child_process";
1977
+ var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path10.join(os7.tmpdir(), "node9-activity.sock");
1978
+ function notifyActivitySocket(data) {
1979
+ return new Promise((resolve) => {
1980
+ try {
1981
+ const payload = JSON.stringify(data);
1982
+ const sock = net.createConnection(ACTIVITY_SOCKET_PATH);
1983
+ sock.on("connect", () => {
1984
+ sock.on("close", resolve);
1985
+ sock.end(payload);
1986
+ });
1987
+ sock.on("error", resolve);
1988
+ } catch {
1989
+ resolve();
1990
+ }
1991
+ });
1992
+ }
1993
+ async function checkStatePredicates(predicates) {
1994
+ if (predicates.length === 0) return {};
1995
+ try {
1996
+ const qs = predicates.map(encodeURIComponent).join(",");
1997
+ const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/state/check?predicates=${qs}`, {
1998
+ signal: AbortSignal.timeout(100)
1999
+ });
2000
+ if (!res.ok) return null;
2001
+ return await res.json();
2002
+ } catch {
2003
+ return null;
2004
+ }
2005
+ }
1956
2006
  var DAEMON_PORT = 7391;
1957
2007
  var DAEMON_HOST = "127.0.0.1";
1958
2008
  function getInternalToken() {
@@ -1988,7 +2038,7 @@ function isDaemonRunning() {
1988
2038
  return false;
1989
2039
  }
1990
2040
  }
1991
- async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd) {
2041
+ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd, recoveryCommand, skipBackgroundAuth, viewOnly) {
1992
2042
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
1993
2043
  const ctrl = new AbortController();
1994
2044
  const timer = setTimeout(() => ctrl.abort(), 5e3);
@@ -2006,7 +2056,10 @@ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityI
2006
2056
  // activity-result as the CLI used for the pending activity event.
2007
2057
  activityId,
2008
2058
  ...riskMetadata && { riskMetadata },
2009
- ...cwd && { cwd }
2059
+ ...cwd && { cwd },
2060
+ ...recoveryCommand && { recoveryCommand },
2061
+ ...skipBackgroundAuth && { skipBackgroundAuth: true },
2062
+ ...viewOnly && { viewOnly: true }
2010
2063
  }),
2011
2064
  signal: ctrl.signal
2012
2065
  });
@@ -2026,10 +2079,10 @@ async function waitForDaemonDecision(id, signal) {
2026
2079
  try {
2027
2080
  const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
2028
2081
  if (!waitRes.ok) return { decision: "deny" };
2029
- const { decision, source } = await waitRes.json();
2082
+ const { decision, source, reason } = await waitRes.json();
2030
2083
  if (decision === "allow") return { decision: "allow", source };
2031
2084
  if (decision === "abandoned") return { decision: "abandoned", source };
2032
- return { decision: "deny", source };
2085
+ return { decision: "deny", source, reason };
2033
2086
  } finally {
2034
2087
  clearTimeout(waitTimer);
2035
2088
  if (signal) signal.removeEventListener("abort", onAbort);
@@ -2104,9 +2157,6 @@ async function resolveViaDaemon(id, decision, internalToken, source) {
2104
2157
  }
2105
2158
 
2106
2159
  // src/auth/orchestrator.ts
2107
- import net from "net";
2108
- import path13 from "path";
2109
- import os9 from "os";
2110
2160
  import { randomUUID } from "crypto";
2111
2161
 
2112
2162
  // src/ui/native.ts
@@ -2589,21 +2639,8 @@ function isNetworkTool(toolName, args) {
2589
2639
  }
2590
2640
  return false;
2591
2641
  }
2592
- var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path13.join(os9.tmpdir(), "node9-activity.sock");
2593
2642
  function notifyActivity(data) {
2594
- return new Promise((resolve) => {
2595
- try {
2596
- const payload = JSON.stringify(data);
2597
- const sock = net.createConnection(ACTIVITY_SOCKET_PATH);
2598
- sock.on("connect", () => {
2599
- sock.on("close", resolve);
2600
- sock.end(payload);
2601
- });
2602
- sock.on("error", resolve);
2603
- } catch {
2604
- resolve();
2605
- }
2606
- });
2643
+ return notifyActivitySocket(data);
2607
2644
  }
2608
2645
  async function authorizeHeadless(toolName, args, meta, options) {
2609
2646
  if (!options?.calledFromDaemon) {
@@ -2620,7 +2657,9 @@ async function authorizeHeadless(toolName, args, meta, options) {
2620
2657
  tool: toolName,
2621
2658
  ts: actTs,
2622
2659
  status: result.approved ? "allow" : result.blockedByLabel?.includes("DLP") ? "dlp" : result.blockedByLabel?.includes("Taint") ? "taint" : "block",
2623
- label: result.blockedByLabel
2660
+ label: result.blockedByLabel,
2661
+ ruleHit: result.ruleHit,
2662
+ observeWouldBlock: result.observeWouldBlock
2624
2663
  });
2625
2664
  }
2626
2665
  return result;
@@ -2647,10 +2686,12 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2647
2686
  appendHookDebug(toolName, args, meta, hashAuditArgs);
2648
2687
  }
2649
2688
  const isManual = meta?.agent === "Terminal";
2689
+ const isObserveMode = config.settings.mode === "observe";
2650
2690
  let explainableLabel = "Local Config";
2651
2691
  let policyMatchedField;
2652
2692
  let policyMatchedWord;
2653
2693
  let riskMetadata;
2694
+ let statefulRecoveryCommand;
2654
2695
  let taintWarning = null;
2655
2696
  if (isNetworkTool(toolName, args)) {
2656
2697
  const filePaths = extractFilePaths(toolName, args);
@@ -2671,10 +2712,26 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2671
2712
  if (dlpMatch) {
2672
2713
  const dlpReason = `\u{1F6A8} DATA LOSS PREVENTION: ${dlpMatch.patternName} detected in field "${dlpMatch.fieldPath}" (${dlpMatch.redactedSample})`;
2673
2714
  if (dlpMatch.severity === "block") {
2674
- if (!isManual) appendLocalAudit(toolName, args, "deny", "dlp-block", meta, true);
2715
+ if (!isManual)
2716
+ appendLocalAudit(
2717
+ toolName,
2718
+ args,
2719
+ "deny",
2720
+ isObserveMode ? "observe-mode-dlp-would-block" : "dlp-block",
2721
+ meta,
2722
+ true
2723
+ );
2675
2724
  if (isWriteTool(toolName) && filePath) {
2676
2725
  await notifyTaint(filePath, `DLP:${dlpMatch.patternName}`);
2677
2726
  }
2727
+ if (isObserveMode) {
2728
+ return {
2729
+ approved: true,
2730
+ checkedBy: "audit",
2731
+ observeWouldBlock: true,
2732
+ blockedByLabel: "\u{1F6A8} Node9 DLP (Secret Detected)"
2733
+ };
2734
+ }
2678
2735
  return {
2679
2736
  approved: false,
2680
2737
  reason: dlpReason,
@@ -2687,6 +2744,31 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2687
2744
  explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
2688
2745
  }
2689
2746
  }
2747
+ if (isObserveMode) {
2748
+ if (!isIgnoredTool(toolName)) {
2749
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent, options?.cwd);
2750
+ const wouldBlock = policyResult.decision === "block";
2751
+ if (!isManual)
2752
+ appendLocalAudit(
2753
+ toolName,
2754
+ args,
2755
+ "allow",
2756
+ wouldBlock ? "observe-mode-would-block" : "observe-mode",
2757
+ meta,
2758
+ hashAuditArgs
2759
+ );
2760
+ return {
2761
+ approved: true,
2762
+ checkedBy: "audit",
2763
+ ...wouldBlock && {
2764
+ observeWouldBlock: true,
2765
+ blockedByLabel: policyResult.blockedByLabel,
2766
+ ruleHit: policyResult.ruleName
2767
+ }
2768
+ };
2769
+ }
2770
+ return { approved: true, checkedBy: "audit" };
2771
+ }
2690
2772
  if (config.settings.mode === "audit") {
2691
2773
  if (!isIgnoredTool(toolName)) {
2692
2774
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent, options?.cwd);
@@ -2714,14 +2796,34 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2714
2796
  return { approved: true, checkedBy: "local-policy" };
2715
2797
  }
2716
2798
  if (policyResult.decision === "block") {
2717
- if (!isManual)
2718
- appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta, hashAuditArgs);
2719
- return {
2720
- approved: false,
2721
- reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
2722
- blockedBy: "local-config",
2723
- blockedByLabel: policyResult.blockedByLabel
2724
- };
2799
+ if (policyResult.dependsOnStatePredicates?.length) {
2800
+ const stateResults = await checkStatePredicates(policyResult.dependsOnStatePredicates);
2801
+ const predicatesMet = stateResults !== null && policyResult.dependsOnStatePredicates.every((p) => stateResults[p] === true);
2802
+ if (stateResults === null && !isManual) {
2803
+ appendToLog(HOOK_DEBUG_LOG, {
2804
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2805
+ event: "state-check-fail-open",
2806
+ tool: toolName,
2807
+ rule: policyResult.ruleName,
2808
+ predicates: policyResult.dependsOnStatePredicates,
2809
+ reason: "daemon unreachable or /state/check timed out \u2014 block rule downgraded to review"
2810
+ });
2811
+ }
2812
+ if (predicatesMet && policyResult.recoveryCommand) {
2813
+ statefulRecoveryCommand = policyResult.recoveryCommand;
2814
+ }
2815
+ } else {
2816
+ if (!isManual)
2817
+ appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta, hashAuditArgs);
2818
+ return {
2819
+ approved: false,
2820
+ reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
2821
+ blockedBy: "local-config",
2822
+ blockedByLabel: policyResult.blockedByLabel,
2823
+ ruleHit: policyResult.ruleName,
2824
+ ...policyResult.recoveryCommand && { recoveryCommand: policyResult.recoveryCommand }
2825
+ };
2826
+ }
2725
2827
  }
2726
2828
  explainableLabel = policyResult.blockedByLabel || "Local Config";
2727
2829
  policyMatchedField = policyResult.matchedField;
@@ -2828,7 +2930,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2828
2930
  meta,
2829
2931
  riskMetadata,
2830
2932
  options?.activityId,
2831
- options?.cwd
2933
+ options?.cwd,
2934
+ statefulRecoveryCommand
2832
2935
  );
2833
2936
  daemonEntryId = entry.id;
2834
2937
  daemonAllowCount = entry.allowCount;
@@ -2889,20 +2992,26 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2889
2992
  if (daemonEntryId && (approvers.browser || approvers.terminal)) {
2890
2993
  racePromises.push(
2891
2994
  (async () => {
2892
- const { decision: daemonDecision, source: decisionSource } = await waitForDaemonDecision(
2893
- daemonEntryId,
2894
- signal
2895
- );
2995
+ const {
2996
+ decision: daemonDecision,
2997
+ source: decisionSource,
2998
+ reason: daemonReason
2999
+ } = await waitForDaemonDecision(daemonEntryId, signal);
2896
3000
  if (daemonDecision === "abandoned") throw new Error("Abandoned");
2897
3001
  const isApproved = daemonDecision === "allow";
2898
- const src = decisionSource === "terminal" || decisionSource === "browser" ? decisionSource : approvers.browser ? "browser" : "terminal";
3002
+ const isRedirect = decisionSource === "terminal-redirect";
3003
+ const src = decisionSource === "terminal" || decisionSource === "terminal-redirect" || decisionSource === "browser" ? decisionSource === "browser" ? "browser" : "terminal" : approvers.browser ? "browser" : "terminal";
2899
3004
  const via = src === "terminal" ? "Terminal (node9 tail)" : "Browser Dashboard";
2900
3005
  return {
2901
3006
  approved: isApproved,
2902
- reason: isApproved ? void 0 : `The human user rejected this action via the Node9 ${via}.`,
3007
+ reason: isApproved ? void 0 : (
3008
+ // Use the redirect reason from the tail when choice [2] was selected;
3009
+ // otherwise fall back to the generic rejection message.
3010
+ isRedirect && daemonReason || `The human user rejected this action via the Node9 ${via}.`
3011
+ ),
2903
3012
  checkedBy: isApproved ? "daemon" : void 0,
2904
3013
  blockedBy: isApproved ? void 0 : "local-decision",
2905
- blockedByLabel: `User Decision (${via})`,
3014
+ blockedByLabel: isRedirect ? "Steered Redirect (Terminal)" : `User Decision (${via})`,
2906
3015
  decisionSource: src
2907
3016
  };
2908
3017
  })()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
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",