@node9/proxy 1.5.2 → 1.5.4

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(),
@@ -572,7 +581,9 @@ var DEFAULT_CONFIG = {
572
581
  {
573
582
  field: "command",
574
583
  op: "matches",
575
- value: "rm\\b.*(-[rRfF]*[rR][rRfF]*|--recursive)"
584
+ // Require the recursive flag to be preceded by whitespace so that
585
+ // filenames containing "-r" (e.g. "ai-review.yml") don't false-positive.
586
+ value: "rm\\b.*\\s(-[rRfF]*[rR][rRfF]*|--recursive)(\\s|$)"
576
587
  },
577
588
  {
578
589
  field: "command",
@@ -816,12 +827,17 @@ function getConfig(cwd) {
816
827
  if (s.approvalTimeoutSeconds !== void 0 && s.approvalTimeoutMs === void 0)
817
828
  mergedSettings.approvalTimeoutMs = s.approvalTimeoutSeconds * 1e3;
818
829
  if (s.environment !== void 0) mergedSettings.environment = s.environment;
830
+ if (s.hud !== void 0) mergedSettings.hud = { ...mergedSettings.hud, ...s.hud };
819
831
  if (p.sandboxPaths) mergedPolicy.sandboxPaths.push(...p.sandboxPaths);
820
832
  if (p.ignoredTools) mergedPolicy.ignoredTools.push(...p.ignoredTools);
821
833
  if (p.dangerousWords) mergedPolicy.dangerousWords = [...p.dangerousWords];
822
834
  if (p.toolInspection)
823
835
  mergedPolicy.toolInspection = { ...mergedPolicy.toolInspection, ...p.toolInspection };
824
- if (p.smartRules) mergedPolicy.smartRules.push(...p.smartRules);
836
+ if (p.smartRules) {
837
+ const defaultBlocks = mergedPolicy.smartRules.filter((r) => r.verdict === "block");
838
+ const defaultNonBlocks = mergedPolicy.smartRules.filter((r) => r.verdict !== "block");
839
+ mergedPolicy.smartRules = [...defaultBlocks, ...p.smartRules, ...defaultNonBlocks];
840
+ }
825
841
  if (p.snapshot) {
826
842
  const s2 = p.snapshot;
827
843
  if (s2.tools) mergedPolicy.snapshot.tools.push(...s2.tools);
@@ -1748,7 +1764,13 @@ async function evaluatePolicy(toolName, args, agent, cwd) {
1748
1764
  blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
1749
1765
  reason: matchedRule.reason,
1750
1766
  tier: 2,
1751
- ruleName: matchedRule.name ?? matchedRule.tool
1767
+ ruleName: matchedRule.name ?? matchedRule.tool,
1768
+ ...matchedRule.verdict === "block" && matchedRule.dependsOnState?.length && {
1769
+ dependsOnStatePredicates: matchedRule.dependsOnState
1770
+ },
1771
+ ...matchedRule.verdict === "block" && matchedRule.recoveryCommand && {
1772
+ recoveryCommand: matchedRule.recoveryCommand
1773
+ }
1752
1774
  };
1753
1775
  }
1754
1776
  }
@@ -1980,9 +2002,39 @@ function getPersistentDecision(toolName) {
1980
2002
 
1981
2003
  // src/auth/daemon.ts
1982
2004
  var import_fs8 = __toESM(require("fs"));
2005
+ var import_net = __toESM(require("net"));
1983
2006
  var import_path10 = __toESM(require("path"));
1984
2007
  var import_os7 = __toESM(require("os"));
1985
2008
  var import_child_process = require("child_process");
2009
+ var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : import_path10.default.join(import_os7.default.tmpdir(), "node9-activity.sock");
2010
+ function notifyActivitySocket(data) {
2011
+ return new Promise((resolve) => {
2012
+ try {
2013
+ const payload = JSON.stringify(data);
2014
+ const sock = import_net.default.createConnection(ACTIVITY_SOCKET_PATH);
2015
+ sock.on("connect", () => {
2016
+ sock.on("close", resolve);
2017
+ sock.end(payload);
2018
+ });
2019
+ sock.on("error", resolve);
2020
+ } catch {
2021
+ resolve();
2022
+ }
2023
+ });
2024
+ }
2025
+ async function checkStatePredicates(predicates) {
2026
+ if (predicates.length === 0) return {};
2027
+ try {
2028
+ const qs = predicates.map(encodeURIComponent).join(",");
2029
+ const res = await fetch(`http://${DAEMON_HOST}:${DAEMON_PORT}/state/check?predicates=${qs}`, {
2030
+ signal: AbortSignal.timeout(100)
2031
+ });
2032
+ if (!res.ok) return null;
2033
+ return await res.json();
2034
+ } catch {
2035
+ return null;
2036
+ }
2037
+ }
1986
2038
  var DAEMON_PORT = 7391;
1987
2039
  var DAEMON_HOST = "127.0.0.1";
1988
2040
  function getInternalToken() {
@@ -2018,7 +2070,7 @@ function isDaemonRunning() {
2018
2070
  return false;
2019
2071
  }
2020
2072
  }
2021
- async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd) {
2073
+ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityId, cwd, recoveryCommand, skipBackgroundAuth, viewOnly) {
2022
2074
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
2023
2075
  const ctrl = new AbortController();
2024
2076
  const timer = setTimeout(() => ctrl.abort(), 5e3);
@@ -2036,7 +2088,10 @@ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityI
2036
2088
  // activity-result as the CLI used for the pending activity event.
2037
2089
  activityId,
2038
2090
  ...riskMetadata && { riskMetadata },
2039
- ...cwd && { cwd }
2091
+ ...cwd && { cwd },
2092
+ ...recoveryCommand && { recoveryCommand },
2093
+ ...skipBackgroundAuth && { skipBackgroundAuth: true },
2094
+ ...viewOnly && { viewOnly: true }
2040
2095
  }),
2041
2096
  signal: ctrl.signal
2042
2097
  });
@@ -2056,10 +2111,10 @@ async function waitForDaemonDecision(id, signal) {
2056
2111
  try {
2057
2112
  const waitRes = await fetch(`${base}/wait/${id}`, { signal: waitCtrl.signal });
2058
2113
  if (!waitRes.ok) return { decision: "deny" };
2059
- const { decision, source } = await waitRes.json();
2114
+ const { decision, source, reason } = await waitRes.json();
2060
2115
  if (decision === "allow") return { decision: "allow", source };
2061
2116
  if (decision === "abandoned") return { decision: "abandoned", source };
2062
- return { decision: "deny", source };
2117
+ return { decision: "deny", source, reason };
2063
2118
  } finally {
2064
2119
  clearTimeout(waitTimer);
2065
2120
  if (signal) signal.removeEventListener("abort", onAbort);
@@ -2134,9 +2189,6 @@ async function resolveViaDaemon(id, decision, internalToken, source) {
2134
2189
  }
2135
2190
 
2136
2191
  // 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
2192
  var import_crypto2 = require("crypto");
2141
2193
 
2142
2194
  // src/ui/native.ts
@@ -2465,6 +2517,7 @@ init_audit();
2465
2517
  // src/auth/cloud.ts
2466
2518
  var import_fs9 = __toESM(require("fs"));
2467
2519
  var import_os8 = __toESM(require("os"));
2520
+ var import_path13 = __toESM(require("path"));
2468
2521
  init_audit();
2469
2522
  function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
2470
2523
  return fetch(`${creds.apiUrl}/audit`, {
@@ -2490,6 +2543,33 @@ function auditLocalAllow(toolName, args, checkedBy, creds, meta) {
2490
2543
  async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
2491
2544
  const controller = new AbortController();
2492
2545
  const timeout = setTimeout(() => controller.abort(), 1e4);
2546
+ if (!creds.apiKey) throw new Error("Node9 API Key is missing");
2547
+ let ciContext;
2548
+ if (process.env.CI) {
2549
+ try {
2550
+ const ciContextPath = import_path13.default.join(import_os8.default.homedir(), ".node9", "ci-context.json");
2551
+ const stats = import_fs9.default.statSync(ciContextPath);
2552
+ if (stats.size > 1e4) throw new Error("ci-context.json exceeds 10 KB");
2553
+ const raw = import_fs9.default.readFileSync(ciContextPath, "utf8");
2554
+ const parsed = JSON.parse(raw);
2555
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
2556
+ throw new Error("ci-context.json is not a plain object");
2557
+ }
2558
+ const p = parsed;
2559
+ ciContext = {
2560
+ tests_after: p["tests_after"],
2561
+ files_changed: p["files_changed"],
2562
+ issues_found: p["issues_found"],
2563
+ issues_fixed: p["issues_fixed"],
2564
+ github_repository: p["github_repository"],
2565
+ github_head_ref: p["github_head_ref"],
2566
+ iteration: p["iteration"],
2567
+ draft_pr_number: p["draft_pr_number"],
2568
+ draft_pr_url: p["draft_pr_url"]
2569
+ };
2570
+ } catch {
2571
+ }
2572
+ }
2493
2573
  try {
2494
2574
  const response = await fetch(creds.apiUrl, {
2495
2575
  method: "POST",
@@ -2504,7 +2584,8 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata) {
2504
2584
  cwd: process.cwd(),
2505
2585
  platform: import_os8.default.platform()
2506
2586
  },
2507
- ...riskMetadata && { riskMetadata }
2587
+ ...riskMetadata && { riskMetadata },
2588
+ ...ciContext && { ciContext }
2508
2589
  }),
2509
2590
  signal: controller.signal
2510
2591
  });
@@ -2530,12 +2611,17 @@ async function pollNode9SaaS(requestId, creds, signal) {
2530
2611
  });
2531
2612
  clearTimeout(pollTimer);
2532
2613
  if (!statusRes.ok) continue;
2533
- const { status, reason } = await statusRes.json();
2614
+ const statusBody = await statusRes.json();
2615
+ const { status } = statusBody;
2534
2616
  if (status === "APPROVED") {
2535
- return { approved: true, reason };
2617
+ return { approved: true, reason: statusBody.reason };
2536
2618
  }
2537
2619
  if (status === "DENIED" || status === "AUTO_BLOCKED" || status === "TIMED_OUT") {
2538
- return { approved: false, reason };
2620
+ return { approved: false, reason: statusBody.reason };
2621
+ }
2622
+ if (status === "FIX") {
2623
+ const feedbackText = statusBody.feedbackText ?? statusBody.reason ?? "Run again with feedback.";
2624
+ return { approved: false, reason: feedbackText };
2539
2625
  }
2540
2626
  } catch {
2541
2627
  }
@@ -2619,21 +2705,8 @@ function isNetworkTool(toolName, args) {
2619
2705
  }
2620
2706
  return false;
2621
2707
  }
2622
- var ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : import_path13.default.join(import_os9.default.tmpdir(), "node9-activity.sock");
2623
2708
  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
- });
2709
+ return notifyActivitySocket(data);
2637
2710
  }
2638
2711
  async function authorizeHeadless(toolName, args, meta, options) {
2639
2712
  if (!options?.calledFromDaemon) {
@@ -2650,7 +2723,9 @@ async function authorizeHeadless(toolName, args, meta, options) {
2650
2723
  tool: toolName,
2651
2724
  ts: actTs,
2652
2725
  status: result.approved ? "allow" : result.blockedByLabel?.includes("DLP") ? "dlp" : result.blockedByLabel?.includes("Taint") ? "taint" : "block",
2653
- label: result.blockedByLabel
2726
+ label: result.blockedByLabel,
2727
+ ruleHit: result.ruleHit,
2728
+ observeWouldBlock: result.observeWouldBlock
2654
2729
  });
2655
2730
  }
2656
2731
  return result;
@@ -2677,10 +2752,12 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2677
2752
  appendHookDebug(toolName, args, meta, hashAuditArgs);
2678
2753
  }
2679
2754
  const isManual = meta?.agent === "Terminal";
2755
+ const isObserveMode = config.settings.mode === "observe";
2680
2756
  let explainableLabel = "Local Config";
2681
2757
  let policyMatchedField;
2682
2758
  let policyMatchedWord;
2683
2759
  let riskMetadata;
2760
+ let statefulRecoveryCommand;
2684
2761
  let taintWarning = null;
2685
2762
  if (isNetworkTool(toolName, args)) {
2686
2763
  const filePaths = extractFilePaths(toolName, args);
@@ -2701,10 +2778,26 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2701
2778
  if (dlpMatch) {
2702
2779
  const dlpReason = `\u{1F6A8} DATA LOSS PREVENTION: ${dlpMatch.patternName} detected in field "${dlpMatch.fieldPath}" (${dlpMatch.redactedSample})`;
2703
2780
  if (dlpMatch.severity === "block") {
2704
- if (!isManual) appendLocalAudit(toolName, args, "deny", "dlp-block", meta, true);
2781
+ if (!isManual)
2782
+ appendLocalAudit(
2783
+ toolName,
2784
+ args,
2785
+ "deny",
2786
+ isObserveMode ? "observe-mode-dlp-would-block" : "dlp-block",
2787
+ meta,
2788
+ true
2789
+ );
2705
2790
  if (isWriteTool(toolName) && filePath) {
2706
2791
  await notifyTaint(filePath, `DLP:${dlpMatch.patternName}`);
2707
2792
  }
2793
+ if (isObserveMode) {
2794
+ return {
2795
+ approved: true,
2796
+ checkedBy: "audit",
2797
+ observeWouldBlock: true,
2798
+ blockedByLabel: "\u{1F6A8} Node9 DLP (Secret Detected)"
2799
+ };
2800
+ }
2708
2801
  return {
2709
2802
  approved: false,
2710
2803
  reason: dlpReason,
@@ -2717,6 +2810,31 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2717
2810
  explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
2718
2811
  }
2719
2812
  }
2813
+ if (isObserveMode) {
2814
+ if (!isIgnoredTool(toolName)) {
2815
+ const policyResult = await evaluatePolicy(toolName, args, meta?.agent, options?.cwd);
2816
+ const wouldBlock = policyResult.decision === "block";
2817
+ if (!isManual)
2818
+ appendLocalAudit(
2819
+ toolName,
2820
+ args,
2821
+ "allow",
2822
+ wouldBlock ? "observe-mode-would-block" : "observe-mode",
2823
+ meta,
2824
+ hashAuditArgs
2825
+ );
2826
+ return {
2827
+ approved: true,
2828
+ checkedBy: "audit",
2829
+ ...wouldBlock && {
2830
+ observeWouldBlock: true,
2831
+ blockedByLabel: policyResult.blockedByLabel,
2832
+ ruleHit: policyResult.ruleName
2833
+ }
2834
+ };
2835
+ }
2836
+ return { approved: true, checkedBy: "audit" };
2837
+ }
2720
2838
  if (config.settings.mode === "audit") {
2721
2839
  if (!isIgnoredTool(toolName)) {
2722
2840
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent, options?.cwd);
@@ -2739,19 +2857,46 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2739
2857
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
2740
2858
  if (policyResult.decision === "allow") {
2741
2859
  if (approvers.cloud && creds?.apiKey)
2742
- auditLocalAllow(toolName, args, "local-policy", creds, meta);
2860
+ await auditLocalAllow(toolName, args, "local-policy", creds, meta);
2743
2861
  if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta, hashAuditArgs);
2744
2862
  return { approved: true, checkedBy: "local-policy" };
2745
2863
  }
2746
2864
  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
- };
2865
+ if (policyResult.dependsOnStatePredicates?.length) {
2866
+ const stateResults = await checkStatePredicates(policyResult.dependsOnStatePredicates);
2867
+ const predicatesMet = stateResults !== null && policyResult.dependsOnStatePredicates.every((p) => stateResults[p] === true);
2868
+ if (stateResults === null && !isManual) {
2869
+ appendToLog(HOOK_DEBUG_LOG, {
2870
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2871
+ event: "state-check-fail-open",
2872
+ tool: toolName,
2873
+ rule: policyResult.ruleName,
2874
+ predicates: policyResult.dependsOnStatePredicates,
2875
+ reason: "daemon unreachable or /state/check timed out \u2014 block rule downgraded to review"
2876
+ });
2877
+ }
2878
+ if (predicatesMet && policyResult.recoveryCommand) {
2879
+ statefulRecoveryCommand = policyResult.recoveryCommand;
2880
+ }
2881
+ } else if (isDaemonRunning() && !isTestEnv2) {
2882
+ if (!isManual)
2883
+ appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta, hashAuditArgs);
2884
+ if (approvers.cloud && creds?.apiKey)
2885
+ auditLocalAllow(toolName, args, "smart-rule-block", creds, meta);
2886
+ } else {
2887
+ if (!isManual)
2888
+ appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta, hashAuditArgs);
2889
+ if (approvers.cloud && creds?.apiKey)
2890
+ auditLocalAllow(toolName, args, "smart-rule-block", creds, meta);
2891
+ return {
2892
+ approved: false,
2893
+ reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
2894
+ blockedBy: "local-config",
2895
+ blockedByLabel: policyResult.blockedByLabel,
2896
+ ruleHit: policyResult.ruleName,
2897
+ ...policyResult.recoveryCommand && { recoveryCommand: policyResult.recoveryCommand }
2898
+ };
2899
+ }
2755
2900
  }
2756
2901
  explainableLabel = policyResult.blockedByLabel || "Local Config";
2757
2902
  policyMatchedField = policyResult.matchedField;
@@ -2858,7 +3003,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2858
3003
  meta,
2859
3004
  riskMetadata,
2860
3005
  options?.activityId,
2861
- options?.cwd
3006
+ options?.cwd,
3007
+ statefulRecoveryCommand
2862
3008
  );
2863
3009
  daemonEntryId = entry.id;
2864
3010
  daemonAllowCount = entry.allowCount;
@@ -2919,20 +3065,26 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2919
3065
  if (daemonEntryId && (approvers.browser || approvers.terminal)) {
2920
3066
  racePromises.push(
2921
3067
  (async () => {
2922
- const { decision: daemonDecision, source: decisionSource } = await waitForDaemonDecision(
2923
- daemonEntryId,
2924
- signal
2925
- );
3068
+ const {
3069
+ decision: daemonDecision,
3070
+ source: decisionSource,
3071
+ reason: daemonReason
3072
+ } = await waitForDaemonDecision(daemonEntryId, signal);
2926
3073
  if (daemonDecision === "abandoned") throw new Error("Abandoned");
2927
3074
  const isApproved = daemonDecision === "allow";
2928
- const src = decisionSource === "terminal" || decisionSource === "browser" ? decisionSource : approvers.browser ? "browser" : "terminal";
3075
+ const isRedirect = decisionSource === "terminal-redirect";
3076
+ const src = decisionSource === "terminal" || decisionSource === "terminal-redirect" || decisionSource === "browser" ? decisionSource === "browser" ? "browser" : "terminal" : approvers.browser ? "browser" : "terminal";
2929
3077
  const via = src === "terminal" ? "Terminal (node9 tail)" : "Browser Dashboard";
2930
3078
  return {
2931
3079
  approved: isApproved,
2932
- reason: isApproved ? void 0 : `The human user rejected this action via the Node9 ${via}.`,
3080
+ reason: isApproved ? void 0 : (
3081
+ // Use the redirect reason from the tail when choice [2] was selected;
3082
+ // otherwise fall back to the generic rejection message.
3083
+ isRedirect && daemonReason || `The human user rejected this action via the Node9 ${via}.`
3084
+ ),
2933
3085
  checkedBy: isApproved ? "daemon" : void 0,
2934
3086
  blockedBy: isApproved ? void 0 : "local-decision",
2935
- blockedByLabel: `User Decision (${via})`,
3087
+ blockedByLabel: isRedirect ? "Steered Redirect (Terminal)" : `User Decision (${via})`,
2936
3088
  decisionSource: src
2937
3089
  };
2938
3090
  })()