@node9/proxy 1.4.0 → 1.5.1

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/cli.mjs CHANGED
@@ -9,7 +9,51 @@ var __export = (target, all) => {
9
9
  __defProp(target, name, { get: all[name], enumerable: true });
10
10
  };
11
11
 
12
+ // src/audit/hasher.ts
13
+ import { createHash } from "crypto";
14
+ function canonicalise(value) {
15
+ return _canonicalise(value, /* @__PURE__ */ new WeakSet());
16
+ }
17
+ function _canonicalise(value, seen) {
18
+ if (value === null || typeof value !== "object") return value;
19
+ if (value instanceof Date) return value.toISOString();
20
+ if (value instanceof RegExp) return value.toString();
21
+ if (Buffer.isBuffer(value)) return value.toString("base64");
22
+ if (seen.has(value)) return "[Circular]";
23
+ seen.add(value);
24
+ let result;
25
+ if (Array.isArray(value)) {
26
+ result = value.map((v) => _canonicalise(v, seen));
27
+ } else {
28
+ const obj = value;
29
+ result = Object.fromEntries(
30
+ Object.keys(obj).sort().map((k) => [k, _canonicalise(obj[k], seen)])
31
+ );
32
+ }
33
+ seen.delete(value);
34
+ return result;
35
+ }
36
+ function hashArgs(args) {
37
+ const canonical = JSON.stringify(canonicalise(args) ?? null);
38
+ return createHash("sha256").update(canonical).digest("hex").slice(0, 32);
39
+ }
40
+ var init_hasher = __esm({
41
+ "src/audit/hasher.ts"() {
42
+ "use strict";
43
+ }
44
+ });
45
+
12
46
  // src/audit/index.ts
47
+ var audit_exports = {};
48
+ __export(audit_exports, {
49
+ HOOK_DEBUG_LOG: () => HOOK_DEBUG_LOG,
50
+ LOCAL_AUDIT_LOG: () => LOCAL_AUDIT_LOG,
51
+ appendConfigAudit: () => appendConfigAudit,
52
+ appendHookDebug: () => appendHookDebug,
53
+ appendLocalAudit: () => appendLocalAudit,
54
+ appendToLog: () => appendToLog,
55
+ redactSecrets: () => redactSecrets
56
+ });
13
57
  import fs from "fs";
14
58
  import path from "path";
15
59
  import os from "os";
@@ -34,24 +78,24 @@ function appendToLog(logPath, entry) {
34
78
  } catch {
35
79
  }
36
80
  }
37
- function appendHookDebug(toolName, args, meta) {
38
- const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
81
+ function appendHookDebug(toolName, args, meta, auditHashArgsEnabled) {
82
+ const argsField = auditHashArgsEnabled ? { argsHash: hashArgs(args) } : { args: args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {} };
39
83
  appendToLog(HOOK_DEBUG_LOG, {
40
84
  ts: (/* @__PURE__ */ new Date()).toISOString(),
41
85
  tool: toolName,
42
- args: safeArgs,
86
+ ...argsField,
43
87
  agent: meta?.agent,
44
88
  mcpServer: meta?.mcpServer,
45
89
  hostname: os.hostname(),
46
90
  cwd: process.cwd()
47
91
  });
48
92
  }
49
- function appendLocalAudit(toolName, args, decision, checkedBy, meta) {
50
- const safeArgs = args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {};
93
+ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashArgsEnabled) {
94
+ const argsField = auditHashArgsEnabled ? { argsHash: hashArgs(args) } : { args: args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {} };
51
95
  appendToLog(LOCAL_AUDIT_LOG, {
52
96
  ts: (/* @__PURE__ */ new Date()).toISOString(),
53
97
  tool: toolName,
54
- args: safeArgs,
98
+ ...argsField,
55
99
  decision,
56
100
  checkedBy,
57
101
  agent: meta?.agent,
@@ -70,6 +114,7 @@ var LOCAL_AUDIT_LOG, HOOK_DEBUG_LOG;
70
114
  var init_audit = __esm({
71
115
  "src/audit/index.ts"() {
72
116
  "use strict";
117
+ init_hasher();
73
118
  LOCAL_AUDIT_LOG = path.join(os.homedir(), ".node9", "audit.log");
74
119
  HOOK_DEBUG_LOG = path.join(os.homedir(), ".node9", "hook-debug.log");
75
120
  }
@@ -94,8 +139,8 @@ function sanitizeConfig(raw) {
94
139
  }
95
140
  }
96
141
  const lines = result.error.issues.map((issue) => {
97
- const path25 = issue.path.length > 0 ? issue.path.join(".") : "root";
98
- return ` \u2022 ${path25}: ${issue.message}`;
142
+ const path28 = issue.path.length > 0 ? issue.path.join(".") : "root";
143
+ return ` \u2022 ${path28}: ${issue.message}`;
99
144
  });
100
145
  return {
101
146
  sanitized,
@@ -167,7 +212,8 @@ var init_config_schema = __esm({
167
212
  environment: z.string().optional(),
168
213
  slackEnabled: z.boolean().optional(),
169
214
  enableTrustSessions: z.boolean().optional(),
170
- allowGlobalPause: z.boolean().optional()
215
+ allowGlobalPause: z.boolean().optional(),
216
+ auditHashArgs: z.boolean().optional()
171
217
  }).optional(),
172
218
  policy: z.object({
173
219
  sandboxPaths: z.array(z.string()).optional(),
@@ -725,6 +771,7 @@ var init_config = __esm({
725
771
  approvalTimeoutMs: 12e4,
726
772
  // 120-second auto-deny timeout
727
773
  flightRecorder: true,
774
+ auditHashArgs: true,
728
775
  approvers: { native: true, browser: true, cloud: false, terminal: true }
729
776
  },
730
777
  policy: {
@@ -1605,17 +1652,44 @@ function readTrustedHosts() {
1605
1652
  return [];
1606
1653
  }
1607
1654
  }
1655
+ function getFileMtime() {
1656
+ try {
1657
+ return fs6.statSync(getTrustedHostsPath()).mtimeMs;
1658
+ } catch {
1659
+ return 0;
1660
+ }
1661
+ }
1662
+ function getCachedHosts() {
1663
+ const now = Date.now();
1664
+ if (_cache && now < _cache.expiry) {
1665
+ const mtime = getFileMtime();
1666
+ if (mtime === _cache.mtime) return _cache.hosts;
1667
+ }
1668
+ const hosts = readTrustedHosts();
1669
+ _cache = { hosts, expiry: now + CACHE_TTL_MS, mtime: getFileMtime() };
1670
+ return hosts;
1671
+ }
1608
1672
  function writeTrustedHosts(hosts) {
1609
1673
  const filePath = getTrustedHostsPath();
1610
1674
  fs6.mkdirSync(path7.dirname(filePath), { recursive: true });
1611
1675
  const tmp = filePath + ".node9-tmp";
1612
- fs6.writeFileSync(tmp, JSON.stringify({ hosts }, null, 2));
1676
+ fs6.writeFileSync(tmp, JSON.stringify({ hosts }, null, 2), { mode: 384 });
1613
1677
  fs6.renameSync(tmp, filePath);
1678
+ _cache = { hosts, expiry: Date.now() + CACHE_TTL_MS, mtime: getFileMtime() };
1614
1679
  }
1615
1680
  function addTrustedHost(host) {
1681
+ const normalized = normalizeHost(host);
1682
+ if (normalized.startsWith("*.")) {
1683
+ const base = normalized.slice(2);
1684
+ if (!base.includes(".")) {
1685
+ throw new Error(
1686
+ `Wildcard pattern '${normalized}' is too broad \u2014 the base domain must have at least one dot (e.g. '*.mycompany.com', not '*.com').`
1687
+ );
1688
+ }
1689
+ }
1616
1690
  const hosts = readTrustedHosts();
1617
- if (hosts.some((h) => h.host === host)) return;
1618
- hosts.push({ host, addedAt: Date.now(), addedBy: "user" });
1691
+ if (hosts.some((h) => h.host === normalized)) return;
1692
+ hosts.push({ host: normalized, addedAt: Date.now(), addedBy: "user" });
1619
1693
  writeTrustedHosts(hosts);
1620
1694
  }
1621
1695
  function removeTrustedHost(host) {
@@ -1630,18 +1704,21 @@ function normalizeHost(raw) {
1630
1704
  }
1631
1705
  function isTrustedHost(host) {
1632
1706
  const normalized = normalizeHost(host);
1633
- return readTrustedHosts().some((entry) => {
1707
+ return getCachedHosts().some((entry) => {
1634
1708
  const entryHost = entry.host.toLowerCase();
1635
1709
  if (entryHost.startsWith("*.")) {
1636
1710
  const domain = entryHost.slice(2);
1637
- return normalized === domain || normalized.endsWith("." + domain);
1711
+ return normalized.endsWith("." + domain);
1638
1712
  }
1639
1713
  return normalized === entryHost;
1640
1714
  });
1641
1715
  }
1716
+ var _cache, CACHE_TTL_MS;
1642
1717
  var init_trusted_hosts = __esm({
1643
1718
  "src/auth/trusted-hosts.ts"() {
1644
1719
  "use strict";
1720
+ _cache = null;
1721
+ CACHE_TTL_MS = 5e3;
1645
1722
  }
1646
1723
  });
1647
1724
 
@@ -1664,9 +1741,9 @@ function matchesPattern(text, patterns) {
1664
1741
  const withoutDotSlash = text.replace(/^\.\//, "");
1665
1742
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
1666
1743
  }
1667
- function getNestedValue(obj, path25) {
1744
+ function getNestedValue(obj, path28) {
1668
1745
  if (!obj || typeof obj !== "object") return null;
1669
- return path25.split(".").reduce((prev, curr) => prev?.[curr], obj);
1746
+ return path28.split(".").reduce((prev, curr) => prev?.[curr], obj);
1670
1747
  }
1671
1748
  function shouldSnapshot(toolName, args, config) {
1672
1749
  if (!config.settings.enableUndo) return false;
@@ -1852,7 +1929,12 @@ async function evaluatePolicy(toolName, args, agent, cwd) {
1852
1929
  };
1853
1930
  }
1854
1931
  if (allTrusted) {
1855
- return { decision: "allow" };
1932
+ return {
1933
+ decision: "allow",
1934
+ blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
1935
+ reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
1936
+ tier: 3
1937
+ };
1856
1938
  }
1857
1939
  return {
1858
1940
  decision: "review",
@@ -2393,8 +2475,8 @@ async function registerDaemonEntry(toolName, args, meta, riskMetadata, activityI
2393
2475
  signal: ctrl.signal
2394
2476
  });
2395
2477
  if (!res.ok) throw new Error("Daemon fail");
2396
- const { id } = await res.json();
2397
- return id;
2478
+ const { id, allowCount } = await res.json();
2479
+ return { id, allowCount: allowCount ?? 1 };
2398
2480
  } finally {
2399
2481
  clearTimeout(timer);
2400
2482
  }
@@ -2433,15 +2515,67 @@ async function notifyDaemonViewer(toolName, args, meta, riskMetadata) {
2433
2515
  signal: AbortSignal.timeout(3e3)
2434
2516
  });
2435
2517
  if (!res.ok) throw new Error("Daemon unreachable");
2436
- const { id } = await res.json();
2437
- return id;
2518
+ const { id, allowCount } = await res.json();
2519
+ return { id, allowCount: allowCount ?? 1 };
2520
+ }
2521
+ async function notifyTaint(filePath, source) {
2522
+ if (!isDaemonRunning()) return;
2523
+ const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
2524
+ try {
2525
+ await fetch(`${base}/taint`, {
2526
+ method: "POST",
2527
+ headers: { "Content-Type": "application/json" },
2528
+ body: JSON.stringify({ path: filePath, source }),
2529
+ signal: AbortSignal.timeout(1e3)
2530
+ });
2531
+ } catch {
2532
+ }
2533
+ }
2534
+ async function notifyTaintPropagate(src, dest, clearSource = false) {
2535
+ if (!isDaemonRunning()) return;
2536
+ const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
2537
+ try {
2538
+ await fetch(`${base}/taint/propagate`, {
2539
+ method: "POST",
2540
+ headers: { "Content-Type": "application/json" },
2541
+ body: JSON.stringify({ src, dest, clearSource }),
2542
+ signal: AbortSignal.timeout(1e3)
2543
+ });
2544
+ } catch {
2545
+ }
2546
+ }
2547
+ async function checkTaint(paths) {
2548
+ if (paths.length === 0) return { tainted: false };
2549
+ if (!isDaemonRunning()) return { tainted: false, daemonUnavailable: true };
2550
+ const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
2551
+ try {
2552
+ const res = await fetch(`${base}/taint/check`, {
2553
+ method: "POST",
2554
+ headers: { "Content-Type": "application/json" },
2555
+ body: JSON.stringify({ paths }),
2556
+ signal: AbortSignal.timeout(2e3)
2557
+ });
2558
+ return await res.json();
2559
+ } catch (err) {
2560
+ try {
2561
+ const { appendToLog: appendToLog2, HOOK_DEBUG_LOG: HOOK_DEBUG_LOG2 } = await Promise.resolve().then(() => (init_audit(), audit_exports));
2562
+ appendToLog2(HOOK_DEBUG_LOG2, {
2563
+ ts: (/* @__PURE__ */ new Date()).toISOString(),
2564
+ event: "checkTaint-error",
2565
+ error: String(err),
2566
+ paths
2567
+ });
2568
+ } catch {
2569
+ }
2570
+ return { tainted: false, daemonUnavailable: true };
2571
+ }
2438
2572
  }
2439
- async function resolveViaDaemon(id, decision, internalToken) {
2573
+ async function resolveViaDaemon(id, decision, internalToken, source) {
2440
2574
  const base = `http://${DAEMON_HOST}:${DAEMON_PORT}`;
2441
2575
  await fetch(`${base}/resolve/${id}`, {
2442
2576
  method: "POST",
2443
2577
  headers: { "Content-Type": "application/json", "X-Node9-Internal": internalToken },
2444
- body: JSON.stringify({ decision }),
2578
+ body: JSON.stringify({ decision, ...source && { source } }),
2445
2579
  signal: AbortSignal.timeout(3e3)
2446
2580
  });
2447
2581
  }
@@ -2646,20 +2780,24 @@ ${smartTruncate(str, 500)}`
2646
2780
  function escapePango(text) {
2647
2781
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2648
2782
  }
2649
- function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
2783
+ function buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1) {
2650
2784
  const lines = [];
2651
2785
  if (locked) lines.push("\u26A0\uFE0F LOCKED BY ADMIN POLICY\n");
2652
2786
  lines.push(`\u{1F916} ${agent || "AI Agent"} | \u{1F527} ${toolName}`);
2653
2787
  lines.push(`\u{1F6E1}\uFE0F ${explainableLabel || "Security Policy"}`);
2654
2788
  lines.push("");
2655
2789
  lines.push(formattedArgs);
2790
+ if (allowCount >= 3) {
2791
+ lines.push("");
2792
+ lines.push(`\u{1F4A1} Approved ${allowCount - 1}\xD7 before \u2014 "Always Allow" creates a permanent rule`);
2793
+ }
2656
2794
  if (!locked) {
2657
2795
  lines.push("");
2658
2796
  lines.push('\u21B5 Enter = Allow \u21B5 | \u238B Esc = Block \u238B | "Always Allow" = never ask again');
2659
2797
  }
2660
2798
  return lines.join("\n");
2661
2799
  }
2662
- function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked) {
2800
+ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, locked, allowCount = 1) {
2663
2801
  const lines = [];
2664
2802
  if (locked) {
2665
2803
  lines.push('<span foreground="red" weight="bold">\u26A0\uFE0F LOCKED BY ADMIN POLICY</span>');
@@ -2671,6 +2809,12 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
2671
2809
  lines.push(`<i>\u{1F6E1}\uFE0F ${escapePango(explainableLabel || "Security Policy")}</i>`);
2672
2810
  lines.push("");
2673
2811
  lines.push(`<tt>${escapePango(formattedArgs)}</tt>`);
2812
+ if (allowCount >= 3) {
2813
+ lines.push("");
2814
+ lines.push(
2815
+ `<span foreground="#f0c040">\u{1F4A1} Approved ${allowCount - 1}\xD7 before \u2014 "Always Allow" creates a permanent rule</span>`
2816
+ );
2817
+ }
2674
2818
  if (!locked) {
2675
2819
  lines.push("");
2676
2820
  lines.push(
@@ -2679,12 +2823,19 @@ function buildPangoMessage(toolName, formattedArgs, agent, explainableLabel, loc
2679
2823
  }
2680
2824
  return lines.join("\n");
2681
2825
  }
2682
- async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord) {
2826
+ async function askNativePopup(toolName, args, agent, explainableLabel, locked = false, signal, matchedField, matchedWord, allowCount = 1) {
2683
2827
  if (isTestEnv()) return "deny";
2684
2828
  const { message: formattedArgs, intent } = formatArgs(args, matchedField, matchedWord);
2685
2829
  const intentLabel = intent === "EDIT" ? "Code Edit" : "Action Approval";
2686
2830
  const title = locked ? `\u26A1 Node9 \u2014 Locked` : `\u{1F6E1}\uFE0F Node9 \u2014 ${intentLabel}`;
2687
- const message = buildPlainMessage(toolName, formattedArgs, agent, explainableLabel, locked);
2831
+ const message = buildPlainMessage(
2832
+ toolName,
2833
+ formattedArgs,
2834
+ agent,
2835
+ explainableLabel,
2836
+ locked,
2837
+ allowCount
2838
+ );
2688
2839
  return new Promise((resolve) => {
2689
2840
  let childProcess = null;
2690
2841
  const onAbort = () => {
@@ -2716,7 +2867,8 @@ end run`;
2716
2867
  formattedArgs,
2717
2868
  agent,
2718
2869
  explainableLabel,
2719
- locked
2870
+ locked,
2871
+ allowCount
2720
2872
  );
2721
2873
  const argsList = [
2722
2874
  locked ? "--info" : "--question",
@@ -2888,6 +3040,40 @@ import net from "net";
2888
3040
  import path13 from "path";
2889
3041
  import os10 from "os";
2890
3042
  import { randomUUID } from "crypto";
3043
+ function isWriteTool(toolName) {
3044
+ const t = toolName.toLowerCase().replace(/[^a-z_]/g, "_");
3045
+ return WRITE_TOOLS.has(t);
3046
+ }
3047
+ function extractFilePaths(toolName, args) {
3048
+ const paths = [];
3049
+ if (!args || typeof args !== "object" || Array.isArray(args)) return paths;
3050
+ const a = args;
3051
+ for (const key of ["file_path", "path", "filename", "source", "src", "input"]) {
3052
+ if (typeof a[key] === "string" && a[key]) paths.push(a[key]);
3053
+ }
3054
+ const cmd = typeof a.command === "string" ? a.command : typeof a.cmd === "string" ? a.cmd : "";
3055
+ if (cmd) {
3056
+ for (const m of cmd.matchAll(/(?:-T|--upload-file|--data(?:-binary)?)\s+@?(\S+)/g)) {
3057
+ paths.push(m[1]);
3058
+ }
3059
+ for (const m of cmd.matchAll(/\b(?:scp|rsync)\s+(?:-\S+\s+)*(\S+)\s+\S+@/g)) {
3060
+ paths.push(m[1]);
3061
+ }
3062
+ for (const m of cmd.matchAll(/<\s*(\S+)/g)) {
3063
+ paths.push(m[1]);
3064
+ }
3065
+ }
3066
+ return paths.filter(Boolean);
3067
+ }
3068
+ function isNetworkTool(toolName, args) {
3069
+ const t = toolName.toLowerCase();
3070
+ if (t === "bash" || t === "shell" || t === "run_shell_command" || t === "terminal.execute") {
3071
+ const a = args;
3072
+ const cmd = typeof a?.command === "string" ? a.command : typeof a?.cmd === "string" ? a.cmd : "";
3073
+ return /\b(curl|wget|scp|rsync|nc|ncat|netcat|ssh)\b/.test(cmd);
3074
+ }
3075
+ return false;
3076
+ }
2891
3077
  function notifyActivity(data) {
2892
3078
  return new Promise((resolve) => {
2893
3079
  try {
@@ -2917,7 +3103,7 @@ async function authorizeHeadless(toolName, args, meta, options) {
2917
3103
  id: actId,
2918
3104
  tool: toolName,
2919
3105
  ts: actTs,
2920
- status: result.approved ? "allow" : result.blockedByLabel?.includes("DLP") ? "dlp" : "block",
3106
+ status: result.approved ? "allow" : result.blockedByLabel?.includes("DLP") ? "dlp" : result.blockedByLabel?.includes("Taint") ? "taint" : "block",
2921
3107
  label: result.blockedByLabel
2922
3108
  });
2923
3109
  }
@@ -2931,6 +3117,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2931
3117
  if (pauseState.paused) return { approved: true, checkedBy: "paused" };
2932
3118
  const creds = getCredentials();
2933
3119
  const config = getConfig(options?.cwd);
3120
+ const hashAuditArgs = config.settings.auditHashArgs === true;
2934
3121
  const isTestEnv2 = !!(process.env.VITEST || process.env.NODE_ENV === "test" || process.env.CI || process.env.NODE9_TESTING === "1");
2935
3122
  const approvers = {
2936
3123
  ...config.settings.approvers || { native: true, browser: true, cloud: true, terminal: true }
@@ -2941,13 +3128,26 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2941
3128
  approvers.terminal = false;
2942
3129
  }
2943
3130
  if (config.settings.enableHookLogDebug && !isTestEnv2) {
2944
- appendHookDebug(toolName, args, meta);
3131
+ appendHookDebug(toolName, args, meta, hashAuditArgs);
2945
3132
  }
2946
3133
  const isManual = meta?.agent === "Terminal";
2947
3134
  let explainableLabel = "Local Config";
2948
3135
  let policyMatchedField;
2949
3136
  let policyMatchedWord;
2950
3137
  let riskMetadata;
3138
+ let taintWarning = null;
3139
+ if (isNetworkTool(toolName, args)) {
3140
+ const filePaths = extractFilePaths(toolName, args);
3141
+ if (filePaths.length > 0) {
3142
+ const taintResult = await checkTaint(filePaths);
3143
+ if (taintResult.tainted && taintResult.record) {
3144
+ const { path: taintedPath, source: taintSource } = taintResult.record;
3145
+ taintWarning = `\u26A0\uFE0F ${taintedPath} was flagged by ${taintSource} \u2014 this file may contain sensitive data`;
3146
+ } else if (taintResult.daemonUnavailable) {
3147
+ taintWarning = `\u26A0\uFE0F Taint service unavailable \u2014 cannot verify if ${filePaths.join(", ")} is clean`;
3148
+ }
3149
+ }
3150
+ }
2951
3151
  if (config.policy.dlp.enabled && (!isIgnoredTool(toolName) || config.policy.dlp.scanIgnoredTools)) {
2952
3152
  const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
2953
3153
  const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
@@ -2955,7 +3155,10 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2955
3155
  if (dlpMatch) {
2956
3156
  const dlpReason = `\u{1F6A8} DATA LOSS PREVENTION: ${dlpMatch.patternName} detected in field "${dlpMatch.fieldPath}" (${dlpMatch.redactedSample})`;
2957
3157
  if (dlpMatch.severity === "block") {
2958
- if (!isManual) appendLocalAudit(toolName, args, "deny", "dlp-block", meta);
3158
+ if (!isManual) appendLocalAudit(toolName, args, "deny", "dlp-block", meta, true);
3159
+ if (isWriteTool(toolName) && filePath) {
3160
+ await notifyTaint(filePath, `DLP:${dlpMatch.patternName}`);
3161
+ }
2959
3162
  return {
2960
3163
  approved: false,
2961
3164
  reason: dlpReason,
@@ -2963,7 +3166,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2963
3166
  blockedByLabel: "\u{1F6A8} Node9 DLP (Secret Detected)"
2964
3167
  };
2965
3168
  }
2966
- if (!isManual) appendLocalAudit(toolName, args, "allow", "dlp-review-flagged", meta);
3169
+ if (!isManual)
3170
+ appendLocalAudit(toolName, args, "allow", "dlp-review-flagged", meta, hashAuditArgs);
2967
3171
  explainableLabel = "\u{1F6A8} Node9 DLP (Credential Review)";
2968
3172
  }
2969
3173
  }
@@ -2971,7 +3175,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2971
3175
  if (!isIgnoredTool(toolName)) {
2972
3176
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent, options?.cwd);
2973
3177
  if (policyResult.decision === "review") {
2974
- appendLocalAudit(toolName, args, "allow", "audit-mode", meta);
3178
+ appendLocalAudit(toolName, args, "allow", "audit-mode", meta, hashAuditArgs);
2975
3179
  if (approvers.cloud && creds?.apiKey) {
2976
3180
  await auditLocalAllow(toolName, args, "audit-mode", creds, meta);
2977
3181
  }
@@ -2979,22 +3183,23 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
2979
3183
  }
2980
3184
  return { approved: true, checkedBy: "audit" };
2981
3185
  }
2982
- if (!isIgnoredTool(toolName)) {
3186
+ if (!taintWarning && !isIgnoredTool(toolName)) {
2983
3187
  if (getActiveTrustSession(toolName)) {
2984
3188
  if (approvers.cloud && creds?.apiKey)
2985
3189
  await auditLocalAllow(toolName, args, "trust", creds, meta);
2986
- if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta);
3190
+ if (!isManual) appendLocalAudit(toolName, args, "allow", "trust", meta, hashAuditArgs);
2987
3191
  return { approved: true, checkedBy: "trust" };
2988
3192
  }
2989
3193
  const policyResult = await evaluatePolicy(toolName, args, meta?.agent);
2990
3194
  if (policyResult.decision === "allow") {
2991
3195
  if (approvers.cloud && creds?.apiKey)
2992
3196
  auditLocalAllow(toolName, args, "local-policy", creds, meta);
2993
- if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta);
3197
+ if (!isManual) appendLocalAudit(toolName, args, "allow", "local-policy", meta, hashAuditArgs);
2994
3198
  return { approved: true, checkedBy: "local-policy" };
2995
3199
  }
2996
3200
  if (policyResult.decision === "block") {
2997
- if (!isManual) appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta);
3201
+ if (!isManual)
3202
+ appendLocalAudit(toolName, args, "deny", "smart-rule-block", meta, hashAuditArgs);
2998
3203
  return {
2999
3204
  approved: false,
3000
3205
  reason: policyResult.reason ?? "Action explicitly blocked by Smart Policy.",
@@ -3013,15 +3218,16 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
3013
3218
  policyMatchedWord,
3014
3219
  policyResult.ruleName
3015
3220
  );
3016
- const persistent = getPersistentDecision(toolName);
3221
+ const persistent = policyResult.ruleName ? null : getPersistentDecision(toolName);
3017
3222
  if (persistent === "allow") {
3018
3223
  if (approvers.cloud && creds?.apiKey)
3019
3224
  await auditLocalAllow(toolName, args, "persistent", creds, meta);
3020
- if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta);
3225
+ if (!isManual) appendLocalAudit(toolName, args, "allow", "persistent", meta, hashAuditArgs);
3021
3226
  return { approved: true, checkedBy: "persistent" };
3022
3227
  }
3023
3228
  if (persistent === "deny") {
3024
- if (!isManual) appendLocalAudit(toolName, args, "deny", "persistent-deny", meta);
3229
+ if (!isManual)
3230
+ appendLocalAudit(toolName, args, "deny", "persistent-deny", meta, hashAuditArgs);
3025
3231
  return {
3026
3232
  approved: false,
3027
3233
  reason: `This tool ("${toolName}") is explicitly listed in your 'Always Deny' list.`,
@@ -3029,10 +3235,21 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
3029
3235
  blockedByLabel: "Persistent User Rule"
3030
3236
  };
3031
3237
  }
3032
- } else {
3033
- if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta);
3238
+ } else if (!taintWarning) {
3239
+ if (!isManual) appendLocalAudit(toolName, args, "allow", "ignored", meta, hashAuditArgs);
3034
3240
  return { approved: true };
3035
3241
  }
3242
+ if (taintWarning) {
3243
+ explainableLabel = "\u{1F534} Node9 Taint (Exfiltration Prevention)";
3244
+ riskMetadata = computeRiskMetadata(
3245
+ args,
3246
+ 7,
3247
+ explainableLabel,
3248
+ void 0,
3249
+ void 0,
3250
+ taintWarning
3251
+ );
3252
+ }
3036
3253
  let cloudRequestId = null;
3037
3254
  const cloudEnforced = approvers.cloud && !!creds?.apiKey;
3038
3255
  if (cloudEnforced) {
@@ -3051,7 +3268,7 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
3051
3268
  };
3052
3269
  }
3053
3270
  cloudRequestId = initResult.requestId || null;
3054
- explainableLabel = "Organization Policy (SaaS)";
3271
+ if (!taintWarning) explainableLabel = "Organization Policy (SaaS)";
3055
3272
  } catch {
3056
3273
  }
3057
3274
  }
@@ -3080,13 +3297,16 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
3080
3297
  let viewerId = null;
3081
3298
  const internalToken = getInternalToken();
3082
3299
  let daemonEntryId = null;
3300
+ let daemonAllowCount = 1;
3083
3301
  if ((approvers.browser || approvers.terminal) && isDaemonRunning() && !options?.calledFromDaemon) {
3084
3302
  if (cloudEnforced && cloudRequestId) {
3085
- viewerId = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(() => null);
3303
+ const viewer = await notifyDaemonViewer(toolName, args, meta, riskMetadata).catch(() => null);
3304
+ viewerId = viewer?.id ?? null;
3086
3305
  daemonEntryId = viewerId;
3306
+ if (viewer) daemonAllowCount = viewer.allowCount;
3087
3307
  } else {
3088
3308
  try {
3089
- daemonEntryId = await registerDaemonEntry(
3309
+ const entry = await registerDaemonEntry(
3090
3310
  toolName,
3091
3311
  args,
3092
3312
  meta,
@@ -3094,6 +3314,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
3094
3314
  options?.activityId,
3095
3315
  options?.cwd
3096
3316
  );
3317
+ daemonEntryId = entry.id;
3318
+ daemonAllowCount = entry.allowCount;
3097
3319
  } catch {
3098
3320
  }
3099
3321
  }
@@ -3129,7 +3351,8 @@ async function _authorizeHeadlessCore(toolName, args, meta, options) {
3129
3351
  false,
3130
3352
  signal,
3131
3353
  policyMatchedField,
3132
- policyMatchedWord
3354
+ policyMatchedWord,
3355
+ daemonAllowCount
3133
3356
  );
3134
3357
  if (decision === "always_allow") {
3135
3358
  writeTrustSession(toolName, 36e5);
@@ -3187,10 +3410,13 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
3187
3410
  if (!resolved) {
3188
3411
  resolved = true;
3189
3412
  abortController.abort();
3190
- if (viewerId && internalToken) {
3191
- resolveViaDaemon(viewerId, res.approved ? "allow" : "deny", internalToken).catch(
3192
- () => null
3193
- );
3413
+ if (daemonEntryId && internalToken) {
3414
+ resolveViaDaemon(
3415
+ daemonEntryId,
3416
+ res.approved ? "allow" : "deny",
3417
+ internalToken,
3418
+ res.decisionSource
3419
+ ).catch(() => null);
3194
3420
  }
3195
3421
  resolve(res);
3196
3422
  }
@@ -3229,12 +3455,13 @@ REASON: Action blocked because no approval channels are available. (Native/Brows
3229
3455
  args,
3230
3456
  finalResult.approved ? "allow" : "deny",
3231
3457
  finalResult.checkedBy || finalResult.blockedBy || "unknown",
3232
- meta
3458
+ meta,
3459
+ hashAuditArgs
3233
3460
  );
3234
3461
  }
3235
3462
  return finalResult;
3236
3463
  }
3237
- var ACTIVITY_SOCKET_PATH;
3464
+ var WRITE_TOOLS, ACTIVITY_SOCKET_PATH;
3238
3465
  var init_orchestrator = __esm({
3239
3466
  "src/auth/orchestrator.ts"() {
3240
3467
  "use strict";
@@ -3247,6 +3474,17 @@ var init_orchestrator = __esm({
3247
3474
  init_state();
3248
3475
  init_daemon();
3249
3476
  init_cloud();
3477
+ WRITE_TOOLS = /* @__PURE__ */ new Set([
3478
+ "write",
3479
+ "write_file",
3480
+ "create_file",
3481
+ "edit",
3482
+ "multiedit",
3483
+ "str_replace_based_edit_tool",
3484
+ "replace",
3485
+ "notebook_edit",
3486
+ "notebookedit"
3487
+ ]);
3250
3488
  ACTIVITY_SOCKET_PATH = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path13.join(os10.tmpdir(), "node9-activity.sock");
3251
3489
  }
3252
3490
  });
@@ -3543,6 +3781,15 @@ var init_ui = __esm({
3543
3781
  padding: 5px 10px;
3544
3782
  margin-bottom: 14px;
3545
3783
  }
3784
+ .insight-hint {
3785
+ font-size: 12px;
3786
+ color: #f0c040;
3787
+ background: rgba(240, 192, 64, 0.08);
3788
+ border: 1px solid rgba(240, 192, 64, 0.25);
3789
+ border-radius: 6px;
3790
+ padding: 6px 10px;
3791
+ margin-bottom: 12px;
3792
+ }
3546
3793
  pre {
3547
3794
  background: #0d1117;
3548
3795
  padding: 14px 16px;
@@ -4015,6 +4262,78 @@ var init_ui = __esm({
4015
4262
  color: var(--danger);
4016
4263
  }
4017
4264
 
4265
+ /* \u2500\u2500 Suggestion cards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
4266
+ .suggestion-card {
4267
+ background: rgba(82, 130, 255, 0.06);
4268
+ border: 1px solid rgba(82, 130, 255, 0.25);
4269
+ border-radius: 8px;
4270
+ padding: 10px 12px;
4271
+ margin-bottom: 8px;
4272
+ }
4273
+ .suggestion-card:last-child {
4274
+ margin-bottom: 0;
4275
+ }
4276
+ .suggestion-header {
4277
+ display: flex;
4278
+ align-items: center;
4279
+ gap: 8px;
4280
+ margin-bottom: 6px;
4281
+ }
4282
+ .suggestion-tool {
4283
+ font-family: 'Fira Code', monospace;
4284
+ font-size: 11px;
4285
+ color: var(--text-bright);
4286
+ flex: 1;
4287
+ word-break: break-all;
4288
+ }
4289
+ .suggestion-count {
4290
+ font-size: 10px;
4291
+ color: var(--muted);
4292
+ white-space: nowrap;
4293
+ }
4294
+ .suggestion-rule {
4295
+ font-family: 'Fira Code', monospace;
4296
+ font-size: 10px;
4297
+ color: #79c0ff;
4298
+ background: rgba(0, 0, 0, 0.25);
4299
+ border-radius: 4px;
4300
+ padding: 4px 8px;
4301
+ margin-bottom: 8px;
4302
+ word-break: break-all;
4303
+ white-space: pre-wrap;
4304
+ }
4305
+ .suggestion-actions {
4306
+ display: flex;
4307
+ gap: 6px;
4308
+ }
4309
+ .btn-apply {
4310
+ background: rgba(52, 125, 57, 0.2);
4311
+ border: 1px solid rgba(87, 171, 90, 0.4);
4312
+ color: #57ab5a;
4313
+ padding: 4px 10px;
4314
+ font-size: 11px;
4315
+ border-radius: 5px;
4316
+ font-family: inherit;
4317
+ cursor: pointer;
4318
+ }
4319
+ .btn-apply:hover {
4320
+ background: rgba(52, 125, 57, 0.35);
4321
+ }
4322
+ .btn-dismiss-suggestion {
4323
+ background: transparent;
4324
+ border: 1px solid var(--border);
4325
+ color: var(--muted);
4326
+ padding: 4px 10px;
4327
+ font-size: 11px;
4328
+ border-radius: 5px;
4329
+ font-family: inherit;
4330
+ cursor: pointer;
4331
+ }
4332
+ .btn-dismiss-suggestion:hover {
4333
+ border-color: var(--danger);
4334
+ color: var(--danger);
4335
+ }
4336
+
4018
4337
  .modal-overlay {
4019
4338
  display: none;
4020
4339
  position: fixed;
@@ -4196,6 +4515,11 @@ var init_ui = __esm({
4196
4515
  <div class="panel-title">\u{1F4CB} Persistent Decisions</div>
4197
4516
  <div id="decisionsList"><span class="decisions-empty">None yet.</span></div>
4198
4517
  </div>
4518
+
4519
+ <div class="panel" id="suggestionsPanel" style="display: none">
4520
+ <div class="panel-title">\u{1F4A1} Smart Rule Suggestions</div>
4521
+ <div id="suggestionsList"></div>
4522
+ </div>
4199
4523
  </div>
4200
4524
  </div>
4201
4525
  </div>
@@ -4345,12 +4669,15 @@ var init_ui = __esm({
4345
4669
  const badgeClass = isEdit ? 'sniper-badge-edit' : 'sniper-badge-exec';
4346
4670
  const badgeLabel = isEdit ? '\u{1F4DD} Code Edit' : '\u{1F6D1} Execution';
4347
4671
  const tierLabel = \`Tier \${rm.tier} \xB7 \${esc(rm.blockedByLabel)}\`;
4672
+ const isTaint = rm.blockedByLabel?.includes('Taint');
4348
4673
  const fileLine =
4349
- isEdit && rm.editFilePath
4350
- ? \`<div class="sniper-filepath">\u{1F4C2} \${esc(rm.editFilePath)}</div>\`
4351
- : !isEdit && rm.matchedWord
4352
- ? \`<div class="sniper-match">Matched: <code>\${esc(rm.matchedWord)}</code>\${rm.matchedField ? \` in <code>\${esc(rm.matchedField)}</code>\` : ''}</div>\`
4353
- : '';
4674
+ isTaint && rm.ruleName
4675
+ ? \`<div class="sniper-match">\u26A0\uFE0F \${esc(rm.ruleName)}</div>\`
4676
+ : isEdit && rm.editFilePath
4677
+ ? \`<div class="sniper-filepath">\u{1F4C2} \${esc(rm.editFilePath)}</div>\`
4678
+ : !isEdit && rm.matchedWord
4679
+ ? \`<div class="sniper-match">Matched: <code>\${esc(rm.matchedWord)}</code>\${rm.matchedField ? \` in <code>\${esc(rm.matchedField)}</code>\` : ''}</div>\`
4680
+ : '';
4354
4681
  const snippetHtml = rm.contextSnippet ? \`<pre>\${esc(rm.contextSnippet)}</pre>\` : '';
4355
4682
  return \`
4356
4683
  <div class="sniper-header">
@@ -4385,6 +4712,7 @@ var init_ui = __esm({
4385
4712
  </div>
4386
4713
  <div class="tool-chip">\${esc(req.toolName)}</div>
4387
4714
  \${isSlack ? '<div class="slack-indicator">\u26A1 Awaiting Cloud approval \u2014 view only</div>' : ''}
4715
+ \${req.allowCount >= 3 ? \`<div class="insight-hint">\u{1F4A1} Approved \${req.allowCount - 1}\xD7 before \u2014 "Always Allow" creates a permanent rule</div>\` : ''}
4388
4716
  \${renderPayload(req)}
4389
4717
  <div class="actions" id="act-\${req.id}">
4390
4718
  <button class="btn-allow" onclick="sendDecision('\${req.id}','allow',false)" \${dis}>\u2705 Allow this Action</button>
@@ -4451,6 +4779,14 @@ var init_ui = __esm({
4451
4779
  ev.addEventListener('shields-status', (e) => {
4452
4780
  renderShields(JSON.parse(e.data).shields);
4453
4781
  });
4782
+ ev.addEventListener('suggestion:new', (e) => {
4783
+ const s = JSON.parse(e.data);
4784
+ addSuggestionCard(s);
4785
+ });
4786
+ ev.addEventListener('suggestion:resolved', (e) => {
4787
+ const { id } = JSON.parse(e.data);
4788
+ removeSuggestionCard(id);
4789
+ });
4454
4790
 
4455
4791
  // \u2500\u2500 Flight Recorder \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
4456
4792
  ev.addEventListener('activity', (e) => {
@@ -4700,6 +5036,74 @@ var init_ui = __esm({
4700
5036
  .then((r) => r.json())
4701
5037
  .then(renderDecisions)
4702
5038
  .catch(() => {});
5039
+
5040
+ // \u2500\u2500 Smart Rule Suggestions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
5041
+ function rulePreview(suggestion) {
5042
+ const r = suggestion.suggestedRule;
5043
+ if (r.type === 'ignoredTool') return \`ignoredTool: "\${r.toolName}"\`;
5044
+ const cond = r.rule.conditions?.[0];
5045
+ const condStr = cond ? \` where \${cond.field} \${cond.op} "\${cond.value}"\` : '';
5046
+ return \`allow \${r.rule.tool}\${condStr}\`;
5047
+ }
5048
+
5049
+ function addSuggestionCard(s) {
5050
+ const panel = document.getElementById('suggestionsPanel');
5051
+ const list = document.getElementById('suggestionsList');
5052
+ panel.style.display = '';
5053
+
5054
+ const card = document.createElement('div');
5055
+ card.className = 'suggestion-card';
5056
+ card.id = 'sg-' + s.id;
5057
+ card.innerHTML = \`
5058
+ <div class="suggestion-header">
5059
+ <span class="suggestion-tool">\${esc(s.toolName)}</span>
5060
+ <span class="suggestion-count">allowed \${s.allowCount}\xD7</span>
5061
+ </div>
5062
+ <div class="suggestion-rule">\${esc(rulePreview(s))}</div>
5063
+ <div class="suggestion-actions">
5064
+ <button class="btn-apply" onclick="applySuggestion('\${esc(s.id)}')">Apply rule</button>
5065
+ <button class="btn-dismiss-suggestion" onclick="dismissSuggestion('\${esc(s.id)}')">Dismiss</button>
5066
+ </div>
5067
+ \`;
5068
+ list.appendChild(card);
5069
+ }
5070
+
5071
+ function removeSuggestionCard(id) {
5072
+ document.getElementById('sg-' + id)?.remove();
5073
+ const list = document.getElementById('suggestionsList');
5074
+ if (!list.querySelector('.suggestion-card')) {
5075
+ document.getElementById('suggestionsPanel').style.display = 'none';
5076
+ }
5077
+ }
5078
+
5079
+ function applySuggestion(id) {
5080
+ fetch('/suggestions/' + id + '/apply', {
5081
+ method: 'POST',
5082
+ headers: { 'Content-Type': 'application/json', 'X-Node9-Token': CSRF_TOKEN },
5083
+ body: JSON.stringify({}),
5084
+ })
5085
+ .then((r) => {
5086
+ if (r.ok) removeSuggestionCard(id);
5087
+ })
5088
+ .catch(() => {});
5089
+ }
5090
+
5091
+ function dismissSuggestion(id) {
5092
+ fetch('/suggestions/' + id + '/dismiss', {
5093
+ method: 'POST',
5094
+ headers: { 'X-Node9-Token': CSRF_TOKEN },
5095
+ })
5096
+ .then((r) => {
5097
+ if (r.ok) removeSuggestionCard(id);
5098
+ })
5099
+ .catch(() => {});
5100
+ }
5101
+
5102
+ // Load any suggestions that survived a page reload (daemon still running)
5103
+ fetch('/suggestions')
5104
+ .then((r) => r.json())
5105
+ .then((list) => list.filter((s) => s.status === 'pending').forEach(addSuggestionCard))
5106
+ .catch(() => {});
4703
5107
  </script>
4704
5108
  </body>
4705
5109
  </html>
@@ -4717,13 +5121,203 @@ var init_ui2 = __esm({
4717
5121
  }
4718
5122
  });
4719
5123
 
4720
- // src/daemon/state.ts
4721
- import net2 from "net";
5124
+ // src/daemon/suggestion-tracker.ts
5125
+ import { randomUUID as randomUUID2 } from "crypto";
5126
+ function extractPath(args) {
5127
+ if (!args || typeof args !== "object") return null;
5128
+ const a = args;
5129
+ for (const key of ["path", "file_path", "filename", "filepath", "dest", "destination"]) {
5130
+ if (typeof a[key] === "string" && a[key]) return a[key];
5131
+ }
5132
+ return null;
5133
+ }
5134
+ function commonPathPrefix(paths) {
5135
+ if (paths.length < 2) return null;
5136
+ const dirParts = paths.map((p) => {
5137
+ const lastSlash = p.lastIndexOf("/");
5138
+ return lastSlash > 0 ? p.slice(0, lastSlash + 1) : "/";
5139
+ });
5140
+ const first = dirParts[0].split("/");
5141
+ const common = [];
5142
+ for (let i = 0; i < first.length; i++) {
5143
+ if (dirParts.every((d) => d.split("/")[i] === first[i])) {
5144
+ common.push(first[i]);
5145
+ } else {
5146
+ break;
5147
+ }
5148
+ }
5149
+ const prefix = common.join("/").replace(/\/?$/, "/");
5150
+ return prefix.length > 1 ? prefix : null;
5151
+ }
5152
+ var SuggestionTracker;
5153
+ var init_suggestion_tracker = __esm({
5154
+ "src/daemon/suggestion-tracker.ts"() {
5155
+ "use strict";
5156
+ SuggestionTracker = class {
5157
+ events = /* @__PURE__ */ new Map();
5158
+ threshold;
5159
+ constructor(threshold = 3) {
5160
+ this.threshold = threshold;
5161
+ }
5162
+ /**
5163
+ * Record a human-allowed review for a tool.
5164
+ * Returns a Suggestion when the threshold is reached, null otherwise.
5165
+ */
5166
+ recordAllow(toolName, args) {
5167
+ const events = this.events.get(toolName) ?? [];
5168
+ events.push({ args, ts: Date.now() });
5169
+ this.events.set(toolName, events);
5170
+ if (events.length >= this.threshold) {
5171
+ this.events.delete(toolName);
5172
+ return this.generateSuggestion(toolName, events);
5173
+ }
5174
+ return null;
5175
+ }
5176
+ /**
5177
+ * Reset the counter for a tool (e.g. when the user clicks Deny —
5178
+ * don't suggest allowing something they just blocked).
5179
+ */
5180
+ resetTool(toolName) {
5181
+ this.events.delete(toolName);
5182
+ }
5183
+ /** Current allow count for a tool (for tests). */
5184
+ getCount(toolName) {
5185
+ return this.events.get(toolName)?.length ?? 0;
5186
+ }
5187
+ generateSuggestion(toolName, events) {
5188
+ const paths = events.map((e) => extractPath(e.args)).filter((p) => typeof p === "string" && p.length > 0);
5189
+ const prefix = commonPathPrefix(paths);
5190
+ const suggestedRule = prefix ? {
5191
+ type: "smartRule",
5192
+ rule: {
5193
+ name: `allow-${toolName}-${prefix.replace(/[^a-z0-9]/gi, "-").replace(/-+/g, "-").replace(/^-|-$/g, "")}`,
5194
+ tool: toolName,
5195
+ conditions: [{ field: "path", op: "matchesGlob", value: `${prefix}**` }],
5196
+ verdict: "allow",
5197
+ reason: `Auto-suggested: ${toolName} allowed ${events.length}\xD7 in ${prefix}`
5198
+ }
5199
+ } : { type: "ignoredTool", toolName };
5200
+ return {
5201
+ id: randomUUID2(),
5202
+ toolName,
5203
+ allowCount: events.length,
5204
+ suggestedRule,
5205
+ status: "pending",
5206
+ createdAt: Date.now(),
5207
+ exampleArgs: events.slice(0, 3).map((e) => e.args)
5208
+ };
5209
+ }
5210
+ };
5211
+ }
5212
+ });
5213
+
5214
+ // src/daemon/taint-store.ts
4722
5215
  import fs12 from "fs";
4723
5216
  import path15 from "path";
5217
+ var DEFAULT_TTL_MS, TaintStore;
5218
+ var init_taint_store = __esm({
5219
+ "src/daemon/taint-store.ts"() {
5220
+ "use strict";
5221
+ DEFAULT_TTL_MS = 60 * 60 * 1e3;
5222
+ TaintStore = class {
5223
+ records = /* @__PURE__ */ new Map();
5224
+ /** Add or refresh taint on an absolute path. */
5225
+ taint(filePath, source, ttlMs = DEFAULT_TTL_MS) {
5226
+ const resolved = this._resolve(filePath);
5227
+ const now = Date.now();
5228
+ this.records.set(resolved, {
5229
+ path: resolved,
5230
+ source,
5231
+ createdAt: now,
5232
+ expiresAt: now + ttlMs
5233
+ });
5234
+ }
5235
+ /**
5236
+ * Check whether a path is currently tainted.
5237
+ * Returns the TaintRecord if tainted (and not expired), null otherwise.
5238
+ * Expired records are pruned on access.
5239
+ */
5240
+ check(filePath) {
5241
+ const resolved = this._resolve(filePath);
5242
+ const record = this.records.get(resolved);
5243
+ if (!record) return null;
5244
+ if (Date.now() > record.expiresAt) {
5245
+ this.records.delete(resolved);
5246
+ return null;
5247
+ }
5248
+ return record;
5249
+ }
5250
+ /**
5251
+ * Propagate taint from sourcePath to destPath (e.g. cp, mv).
5252
+ * For mv semantics (clearSource=true) the source taint is removed.
5253
+ */
5254
+ propagate(sourcePath, destPath, clearSource = false) {
5255
+ const taintRecord = this.check(sourcePath);
5256
+ if (!taintRecord) return;
5257
+ const remainingMs = taintRecord.expiresAt - Date.now();
5258
+ if (remainingMs > 0) {
5259
+ const baseSource = taintRecord.source.replace(/^(propagated:)+/, "");
5260
+ this.taint(destPath, `propagated:${baseSource}`, remainingMs);
5261
+ }
5262
+ if (clearSource) {
5263
+ this.records.delete(this._resolve(sourcePath));
5264
+ }
5265
+ }
5266
+ /** Remove all expired records. Called periodically by the daemon. */
5267
+ prune() {
5268
+ const now = Date.now();
5269
+ for (const [key, record] of this.records) {
5270
+ if (now > record.expiresAt) this.records.delete(key);
5271
+ }
5272
+ }
5273
+ /** Return all non-expired taint records (for audit/debug). */
5274
+ list() {
5275
+ this.prune();
5276
+ return [...this.records.values()];
5277
+ }
5278
+ /** Remove all taint records atomically. Used by tests to reset state between runs. */
5279
+ clear() {
5280
+ this.records.clear();
5281
+ }
5282
+ /** Resolve to absolute path, falling back to path.resolve if file doesn't exist yet. */
5283
+ _resolve(filePath) {
5284
+ try {
5285
+ return fs12.realpathSync.native(path15.resolve(filePath));
5286
+ } catch {
5287
+ return path15.resolve(filePath);
5288
+ }
5289
+ }
5290
+ };
5291
+ }
5292
+ });
5293
+
5294
+ // src/daemon/state.ts
5295
+ import net2 from "net";
5296
+ import fs13 from "fs";
5297
+ import path16 from "path";
4724
5298
  import os12 from "os";
4725
5299
  import { spawn as spawn2 } from "child_process";
4726
- import { randomUUID as randomUUID2 } from "crypto";
5300
+ import { randomUUID as randomUUID3 } from "crypto";
5301
+ function loadInsightCounts() {
5302
+ try {
5303
+ if (!fs13.existsSync(INSIGHT_COUNTS_FILE)) return;
5304
+ const data = JSON.parse(fs13.readFileSync(INSIGHT_COUNTS_FILE, "utf-8"));
5305
+ for (const [tool, count] of Object.entries(data)) {
5306
+ if (typeof count === "number" && count > 0) insightCounts.set(tool, count);
5307
+ }
5308
+ } catch {
5309
+ }
5310
+ }
5311
+ function saveInsightCounts() {
5312
+ try {
5313
+ const data = {};
5314
+ insightCounts.forEach((count, tool) => {
5315
+ data[tool] = count;
5316
+ });
5317
+ atomicWriteSync2(INSIGHT_COUNTS_FILE, JSON.stringify(data, null, 2), { mode: 384 });
5318
+ } catch {
5319
+ }
5320
+ }
4727
5321
  function getAbandonTimer() {
4728
5322
  return _abandonTimer;
4729
5323
  }
@@ -4746,11 +5340,27 @@ function markRejectionHandlerRegistered() {
4746
5340
  daemonRejectionHandlerRegistered = true;
4747
5341
  }
4748
5342
  function atomicWriteSync2(filePath, data, options) {
4749
- const dir = path15.dirname(filePath);
4750
- if (!fs12.existsSync(dir)) fs12.mkdirSync(dir, { recursive: true });
4751
- const tmpPath = `${filePath}.${randomUUID2()}.tmp`;
4752
- fs12.writeFileSync(tmpPath, data, options);
4753
- fs12.renameSync(tmpPath, filePath);
5343
+ const dir = path16.dirname(filePath);
5344
+ if (!fs13.existsSync(dir)) fs13.mkdirSync(dir, { recursive: true });
5345
+ const tmpPath = `${filePath}.${randomUUID3()}.tmp`;
5346
+ try {
5347
+ fs13.writeFileSync(tmpPath, data, options);
5348
+ } catch (err) {
5349
+ try {
5350
+ fs13.unlinkSync(tmpPath);
5351
+ } catch {
5352
+ }
5353
+ throw err;
5354
+ }
5355
+ try {
5356
+ fs13.renameSync(tmpPath, filePath);
5357
+ } catch (err) {
5358
+ try {
5359
+ fs13.unlinkSync(tmpPath);
5360
+ } catch {
5361
+ }
5362
+ throw err;
5363
+ }
4754
5364
  }
4755
5365
  function redactArgs(value) {
4756
5366
  if (!value || typeof value !== "object") return value;
@@ -4770,16 +5380,16 @@ function appendAuditLog(data) {
4770
5380
  decision: data.decision,
4771
5381
  source: "daemon"
4772
5382
  };
4773
- const dir = path15.dirname(AUDIT_LOG_FILE);
4774
- if (!fs12.existsSync(dir)) fs12.mkdirSync(dir, { recursive: true });
4775
- fs12.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + "\n");
5383
+ const dir = path16.dirname(AUDIT_LOG_FILE);
5384
+ if (!fs13.existsSync(dir)) fs13.mkdirSync(dir, { recursive: true });
5385
+ fs13.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + "\n");
4776
5386
  } catch {
4777
5387
  }
4778
5388
  }
4779
5389
  function getAuditHistory(limit = 20) {
4780
5390
  try {
4781
- if (!fs12.existsSync(AUDIT_LOG_FILE)) return [];
4782
- const lines = fs12.readFileSync(AUDIT_LOG_FILE, "utf-8").trim().split("\n");
5391
+ if (!fs13.existsSync(AUDIT_LOG_FILE)) return [];
5392
+ const lines = fs13.readFileSync(AUDIT_LOG_FILE, "utf-8").trim().split("\n");
4783
5393
  if (lines.length === 1 && lines[0] === "") return [];
4784
5394
  return lines.slice(-limit).map((l) => JSON.parse(l)).reverse();
4785
5395
  } catch {
@@ -4788,19 +5398,19 @@ function getAuditHistory(limit = 20) {
4788
5398
  }
4789
5399
  function getOrgName() {
4790
5400
  try {
4791
- if (fs12.existsSync(CREDENTIALS_FILE)) return "Node9 Cloud";
5401
+ if (fs13.existsSync(CREDENTIALS_FILE)) return "Node9 Cloud";
4792
5402
  } catch {
4793
5403
  }
4794
5404
  return null;
4795
5405
  }
4796
5406
  function hasStoredSlackKey() {
4797
- return fs12.existsSync(CREDENTIALS_FILE);
5407
+ return fs13.existsSync(CREDENTIALS_FILE);
4798
5408
  }
4799
5409
  function writeGlobalSetting(key, value) {
4800
5410
  let config = {};
4801
5411
  try {
4802
- if (fs12.existsSync(GLOBAL_CONFIG_FILE)) {
4803
- config = JSON.parse(fs12.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
5412
+ if (fs13.existsSync(GLOBAL_CONFIG_FILE)) {
5413
+ config = JSON.parse(fs13.readFileSync(GLOBAL_CONFIG_FILE, "utf-8"));
4804
5414
  }
4805
5415
  } catch {
4806
5416
  }
@@ -4812,8 +5422,8 @@ function writeTrustEntry(toolName, durationMs) {
4812
5422
  try {
4813
5423
  let trust = { entries: [] };
4814
5424
  try {
4815
- if (fs12.existsSync(TRUST_FILE2))
4816
- trust = JSON.parse(fs12.readFileSync(TRUST_FILE2, "utf-8"));
5425
+ if (fs13.existsSync(TRUST_FILE2))
5426
+ trust = JSON.parse(fs13.readFileSync(TRUST_FILE2, "utf-8"));
4817
5427
  } catch {
4818
5428
  }
4819
5429
  trust.entries = trust.entries.filter((e) => e.tool !== toolName && e.expiry > Date.now());
@@ -4824,8 +5434,8 @@ function writeTrustEntry(toolName, durationMs) {
4824
5434
  }
4825
5435
  function readPersistentDecisions() {
4826
5436
  try {
4827
- if (fs12.existsSync(DECISIONS_FILE)) {
4828
- return JSON.parse(fs12.readFileSync(DECISIONS_FILE, "utf-8"));
5437
+ if (fs13.existsSync(DECISIONS_FILE)) {
5438
+ return JSON.parse(fs13.readFileSync(DECISIONS_FILE, "utf-8"));
4829
5439
  }
4830
5440
  } catch {
4831
5441
  }
@@ -4890,7 +5500,7 @@ function abandonPending() {
4890
5500
  });
4891
5501
  if (autoStarted) {
4892
5502
  try {
4893
- fs12.unlinkSync(DAEMON_PID_FILE);
5503
+ fs13.unlinkSync(DAEMON_PID_FILE);
4894
5504
  } catch {
4895
5505
  }
4896
5506
  setTimeout(() => {
@@ -4901,7 +5511,7 @@ function abandonPending() {
4901
5511
  }
4902
5512
  function startActivitySocket() {
4903
5513
  try {
4904
- fs12.unlinkSync(ACTIVITY_SOCKET_PATH2);
5514
+ fs13.unlinkSync(ACTIVITY_SOCKET_PATH2);
4905
5515
  } catch {
4906
5516
  }
4907
5517
  const ACTIVITY_MAX_BYTES = 1024 * 1024;
@@ -4943,25 +5553,32 @@ function startActivitySocket() {
4943
5553
  unixServer.listen(ACTIVITY_SOCKET_PATH2);
4944
5554
  process.on("exit", () => {
4945
5555
  try {
4946
- fs12.unlinkSync(ACTIVITY_SOCKET_PATH2);
5556
+ fs13.unlinkSync(ACTIVITY_SOCKET_PATH2);
4947
5557
  } catch {
4948
5558
  }
4949
5559
  });
4950
5560
  }
4951
- var homeDir, DAEMON_PID_FILE, DECISIONS_FILE, AUDIT_LOG_FILE, TRUST_FILE2, GLOBAL_CONFIG_FILE, CREDENTIALS_FILE, pending, sseClients, _abandonTimer, _hadBrowserClient, _daemonServer, daemonRejectionHandlerRegistered, AUTO_DENY_MS, TRUST_DURATIONS, autoStarted, ACTIVITY_SOCKET_PATH2, ACTIVITY_RING_SIZE, activityRing, SECRET_KEY_RE;
5561
+ var homeDir, DAEMON_PID_FILE, DECISIONS_FILE, AUDIT_LOG_FILE, TRUST_FILE2, GLOBAL_CONFIG_FILE, CREDENTIALS_FILE, INSIGHT_COUNTS_FILE, pending, sseClients, suggestionTracker, suggestions, taintStore, insightCounts, _abandonTimer, _hadBrowserClient, _daemonServer, daemonRejectionHandlerRegistered, AUTO_DENY_MS, TRUST_DURATIONS, autoStarted, ACTIVITY_SOCKET_PATH2, ACTIVITY_RING_SIZE, activityRing, SECRET_KEY_RE;
4952
5562
  var init_state2 = __esm({
4953
5563
  "src/daemon/state.ts"() {
4954
5564
  "use strict";
4955
5565
  init_daemon();
5566
+ init_suggestion_tracker();
5567
+ init_taint_store();
4956
5568
  homeDir = os12.homedir();
4957
- DAEMON_PID_FILE = path15.join(homeDir, ".node9", "daemon.pid");
4958
- DECISIONS_FILE = path15.join(homeDir, ".node9", "decisions.json");
4959
- AUDIT_LOG_FILE = path15.join(homeDir, ".node9", "audit.log");
4960
- TRUST_FILE2 = path15.join(homeDir, ".node9", "trust.json");
4961
- GLOBAL_CONFIG_FILE = path15.join(homeDir, ".node9", "config.json");
4962
- CREDENTIALS_FILE = path15.join(homeDir, ".node9", "credentials.json");
5569
+ DAEMON_PID_FILE = path16.join(homeDir, ".node9", "daemon.pid");
5570
+ DECISIONS_FILE = path16.join(homeDir, ".node9", "decisions.json");
5571
+ AUDIT_LOG_FILE = path16.join(homeDir, ".node9", "audit.log");
5572
+ TRUST_FILE2 = path16.join(homeDir, ".node9", "trust.json");
5573
+ GLOBAL_CONFIG_FILE = path16.join(homeDir, ".node9", "config.json");
5574
+ CREDENTIALS_FILE = path16.join(homeDir, ".node9", "credentials.json");
5575
+ INSIGHT_COUNTS_FILE = path16.join(homeDir, ".node9", "insight-counts.json");
4963
5576
  pending = /* @__PURE__ */ new Map();
4964
5577
  sseClients = /* @__PURE__ */ new Set();
5578
+ suggestionTracker = new SuggestionTracker(3);
5579
+ suggestions = /* @__PURE__ */ new Map();
5580
+ taintStore = new TaintStore();
5581
+ insightCounts = /* @__PURE__ */ new Map();
4965
5582
  _abandonTimer = null;
4966
5583
  _hadBrowserClient = false;
4967
5584
  _daemonServer = null;
@@ -4973,24 +5590,82 @@ var init_state2 = __esm({
4973
5590
  "2h": 2 * 60 * 6e4
4974
5591
  };
4975
5592
  autoStarted = process.env.NODE9_AUTO_STARTED === "1";
4976
- ACTIVITY_SOCKET_PATH2 = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path15.join(os12.tmpdir(), "node9-activity.sock");
5593
+ ACTIVITY_SOCKET_PATH2 = process.platform === "win32" ? "\\\\.\\pipe\\node9-activity" : path16.join(os12.tmpdir(), "node9-activity.sock");
4977
5594
  ACTIVITY_RING_SIZE = 100;
4978
5595
  activityRing = [];
4979
5596
  SECRET_KEY_RE = /password|secret|token|key|apikey|credential|auth/i;
4980
5597
  }
4981
5598
  });
4982
5599
 
4983
- // src/daemon/server.ts
4984
- import http from "http";
4985
- import fs13 from "fs";
4986
- import path16 from "path";
4987
- import { randomUUID as randomUUID3 } from "crypto";
4988
- import { spawnSync as spawnSync2 } from "child_process";
4989
- import chalk2 from "chalk";
4990
- function startDaemon() {
4991
- const csrfToken = randomUUID3();
4992
- const internalToken = randomUUID3();
4993
- const UI_HTML = UI_HTML_TEMPLATE.replace("{{CSRF_TOKEN}}", csrfToken);
5600
+ // src/config/patch.ts
5601
+ import fs14 from "fs";
5602
+ import path17 from "path";
5603
+ import os13 from "os";
5604
+ function patchConfig(configPath, patch) {
5605
+ let config = {};
5606
+ try {
5607
+ if (fs14.existsSync(configPath)) {
5608
+ config = JSON.parse(fs14.readFileSync(configPath, "utf8"));
5609
+ }
5610
+ } catch {
5611
+ throw new Error(`Cannot read config at ${configPath} \u2014 file may be corrupted`);
5612
+ }
5613
+ if (!config.policy || typeof config.policy !== "object") config.policy = {};
5614
+ const policy = config.policy;
5615
+ if (patch.type === "smartRule") {
5616
+ if (!Array.isArray(policy.smartRules)) policy.smartRules = [];
5617
+ const rules = policy.smartRules;
5618
+ if (patch.rule.name && rules.some((r) => r.name === patch.rule.name)) return;
5619
+ rules.push(patch.rule);
5620
+ } else {
5621
+ if (!Array.isArray(policy.ignoredTools)) policy.ignoredTools = [];
5622
+ const ignored = policy.ignoredTools;
5623
+ if (!ignored.includes(patch.toolName)) {
5624
+ ignored.push(patch.toolName);
5625
+ }
5626
+ }
5627
+ const dir = path17.dirname(configPath);
5628
+ fs14.mkdirSync(dir, { recursive: true });
5629
+ const tmp = configPath + ".node9-tmp";
5630
+ try {
5631
+ fs14.writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 384 });
5632
+ } catch (err) {
5633
+ try {
5634
+ fs14.unlinkSync(tmp);
5635
+ } catch {
5636
+ }
5637
+ throw err;
5638
+ }
5639
+ try {
5640
+ fs14.renameSync(tmp, configPath);
5641
+ } catch (err) {
5642
+ try {
5643
+ fs14.unlinkSync(tmp);
5644
+ } catch {
5645
+ }
5646
+ throw err;
5647
+ }
5648
+ }
5649
+ var GLOBAL_CONFIG_PATH;
5650
+ var init_patch = __esm({
5651
+ "src/config/patch.ts"() {
5652
+ "use strict";
5653
+ GLOBAL_CONFIG_PATH = path17.join(os13.homedir(), ".node9", "config.json");
5654
+ }
5655
+ });
5656
+
5657
+ // src/daemon/server.ts
5658
+ import http from "http";
5659
+ import fs15 from "fs";
5660
+ import path18 from "path";
5661
+ import { randomUUID as randomUUID4 } from "crypto";
5662
+ import { spawnSync as spawnSync2 } from "child_process";
5663
+ import chalk2 from "chalk";
5664
+ function startDaemon() {
5665
+ loadInsightCounts();
5666
+ const csrfToken = randomUUID4();
5667
+ const internalToken = randomUUID4();
5668
+ const UI_HTML = UI_HTML_TEMPLATE.replace("{{CSRF_TOKEN}}", csrfToken);
4994
5669
  const validToken = (req) => req.headers["x-node9-token"] === csrfToken;
4995
5670
  const IDLE_TIMEOUT_MS = 12 * 60 * 60 * 1e3;
4996
5671
  const watchMode = process.env.NODE9_WATCH_MODE === "1";
@@ -5002,7 +5677,7 @@ function startDaemon() {
5002
5677
  idleTimer = setTimeout(() => {
5003
5678
  if (autoStarted) {
5004
5679
  try {
5005
- fs13.unlinkSync(DAEMON_PID_FILE);
5680
+ fs15.unlinkSync(DAEMON_PID_FILE);
5006
5681
  } catch {
5007
5682
  }
5008
5683
  }
@@ -5011,8 +5686,14 @@ function startDaemon() {
5011
5686
  idleTimer.unref();
5012
5687
  }
5013
5688
  resetIdleTimer();
5689
+ const allowedHosts = /* @__PURE__ */ new Set([`127.0.0.1:${DAEMON_PORT}`, `localhost:${DAEMON_PORT}`]);
5014
5690
  const server = http.createServer(async (req, res) => {
5015
- const reqUrl = new URL(req.url || "/", `http://${req.headers.host}`);
5691
+ const host = req.headers.host ?? "";
5692
+ if (!allowedHosts.has(host)) {
5693
+ res.writeHead(421, { "Content-Type": "text/plain" });
5694
+ return res.end("Misdirected Request");
5695
+ }
5696
+ const reqUrl = new URL(req.url || "/", `http://${host}`);
5016
5697
  const { pathname } = reqUrl;
5017
5698
  if (req.method === "GET" && pathname === "/") {
5018
5699
  res.writeHead(200, { "Content-Type": "text/html" });
@@ -5045,7 +5726,8 @@ data: ${JSON.stringify({
5045
5726
  slackDelegated: e.slackDelegated,
5046
5727
  timestamp: e.timestamp,
5047
5728
  agent: e.agent,
5048
- mcpServer: e.mcpServer
5729
+ mcpServer: e.mcpServer,
5730
+ allowCount: (insightCounts.get(e.toolName) ?? 0) + 1
5049
5731
  })),
5050
5732
  orgName: getOrgName(),
5051
5733
  autoDenyMs: getConfig().settings.approvalTimeoutMs ?? AUTO_DENY_MS
@@ -5087,6 +5769,12 @@ data: ${JSON.stringify(item.data)}
5087
5769
  }
5088
5770
  });
5089
5771
  }
5772
+ if (req.method === "POST" && pathname === "/browser-opened") {
5773
+ if (req.headers["x-node9-internal"] !== internalToken) return res.writeHead(403).end();
5774
+ browserOpened = true;
5775
+ res.writeHead(200).end();
5776
+ return;
5777
+ }
5090
5778
  if (req.method === "POST" && pathname === "/check") {
5091
5779
  try {
5092
5780
  resetIdleTimer();
@@ -5104,7 +5792,7 @@ data: ${JSON.stringify(item.data)}
5104
5792
  activityId,
5105
5793
  cwd
5106
5794
  } = JSON.parse(body);
5107
- const id = fromCLI && typeof activityId === "string" && activityId || randomUUID3();
5795
+ const id = fromCLI && typeof activityId === "string" && activityId || randomUUID4();
5108
5796
  const entry = {
5109
5797
  id,
5110
5798
  toolName,
@@ -5130,7 +5818,7 @@ data: ${JSON.stringify(item.data)}
5130
5818
  e.earlyReason = "No response \u2014 auto-denied after timeout";
5131
5819
  }
5132
5820
  pending.delete(id);
5133
- broadcast("remove", { id });
5821
+ broadcast("remove", { id, decision: "deny" });
5134
5822
  }
5135
5823
  }, getConfig().settings.approvalTimeoutMs ?? AUTO_DENY_MS)
5136
5824
  };
@@ -5144,7 +5832,7 @@ data: ${JSON.stringify(item.data)}
5144
5832
  status: "pending"
5145
5833
  });
5146
5834
  }
5147
- const projectCwd = typeof cwd === "string" && path16.isAbsolute(cwd) ? cwd : void 0;
5835
+ const projectCwd = typeof cwd === "string" && path18.isAbsolute(cwd) ? cwd : void 0;
5148
5836
  const projectConfig = getConfig(projectCwd);
5149
5837
  const browserEnabled = projectConfig.settings.approvers?.browser !== false;
5150
5838
  const terminalEnabled = projectConfig.settings.approvers?.terminal !== false;
@@ -5157,7 +5845,10 @@ data: ${JSON.stringify(item.data)}
5157
5845
  slackDelegated: entry.slackDelegated,
5158
5846
  agent: entry.agent,
5159
5847
  mcpServer: entry.mcpServer,
5160
- interactive: terminalEnabled
5848
+ interactive: terminalEnabled,
5849
+ // allowCount = what this count will be if the user allows.
5850
+ // Terminal uses this to show the 💡 insight line on the Nth consecutive approval.
5851
+ allowCount: (insightCounts.get(toolName) ?? 0) + 1
5161
5852
  });
5162
5853
  const browserAlreadyOpened = process.env.NODE9_BROWSER_OPENED === "1";
5163
5854
  if (browserEnabled && !browserOpened && !browserAlreadyOpened) {
@@ -5166,7 +5857,7 @@ data: ${JSON.stringify(item.data)}
5166
5857
  }
5167
5858
  }
5168
5859
  res.writeHead(200, { "Content-Type": "application/json" });
5169
- res.end(JSON.stringify({ id }));
5860
+ res.end(JSON.stringify({ id, allowCount: (insightCounts.get(toolName) ?? 0) + 1 }));
5170
5861
  if (slackDelegated) return;
5171
5862
  authorizeHeadless(
5172
5863
  toolName,
@@ -5193,7 +5884,7 @@ data: ${JSON.stringify(item.data)}
5193
5884
  if (e.waiter) {
5194
5885
  e.waiter(decision, result.reason);
5195
5886
  pending.delete(id);
5196
- broadcast("remove", { id });
5887
+ broadcast("remove", { id, decision });
5197
5888
  } else {
5198
5889
  e.earlyDecision = decision;
5199
5890
  e.earlyReason = result.reason;
@@ -5209,7 +5900,7 @@ data: ${JSON.stringify(item.data)}
5209
5900
  e.earlyReason = reason;
5210
5901
  }
5211
5902
  pending.delete(id);
5212
- broadcast("remove", { id });
5903
+ broadcast("remove", { id, decision: "deny" });
5213
5904
  });
5214
5905
  return;
5215
5906
  } catch {
@@ -5240,12 +5931,14 @@ data: ${JSON.stringify(item.data)}
5240
5931
  res.end(JSON.stringify(body));
5241
5932
  };
5242
5933
  req.on("close", () => {
5243
- const e = pending.get(id);
5244
- if (e && e.waiter && e.earlyDecision === null) {
5245
- clearTimeout(e.timer);
5246
- pending.delete(id);
5247
- broadcast("remove", { id });
5248
- }
5934
+ setTimeout(() => {
5935
+ const e = pending.get(id);
5936
+ if (e && e.waiter && e.earlyDecision === null) {
5937
+ clearTimeout(e.timer);
5938
+ pending.delete(id);
5939
+ broadcast("remove", { id });
5940
+ }
5941
+ }, 200);
5249
5942
  });
5250
5943
  return;
5251
5944
  }
@@ -5274,10 +5967,10 @@ data: ${JSON.stringify(item.data)}
5274
5967
  if (entry.waiter) {
5275
5968
  entry.waiter("allow");
5276
5969
  pending.delete(id);
5277
- broadcast("remove", { id });
5970
+ broadcast("remove", { id, decision: "allow" });
5278
5971
  } else {
5279
5972
  entry.earlyDecision = "allow";
5280
- broadcast("remove", { id });
5973
+ broadcast("remove", { id, decision: "allow" });
5281
5974
  entry.timer = setTimeout(() => pending.delete(id), 3e4);
5282
5975
  }
5283
5976
  res.writeHead(200);
@@ -5291,16 +5984,29 @@ data: ${JSON.stringify(item.data)}
5291
5984
  decision: resolvedDecision
5292
5985
  });
5293
5986
  clearTimeout(entry.timer);
5987
+ if (resolvedDecision === "allow" && !persist) {
5988
+ insightCounts.set(entry.toolName, (insightCounts.get(entry.toolName) ?? 0) + 1);
5989
+ saveInsightCounts();
5990
+ const suggestion = suggestionTracker.recordAllow(entry.toolName, entry.args);
5991
+ if (suggestion) {
5992
+ suggestions.set(suggestion.id, suggestion);
5993
+ broadcast("suggestion:new", suggestion);
5994
+ }
5995
+ } else if (resolvedDecision === "deny") {
5996
+ insightCounts.delete(entry.toolName);
5997
+ saveInsightCounts();
5998
+ suggestionTracker.resetTool(entry.toolName);
5999
+ }
5294
6000
  const VALID_SOURCES = /* @__PURE__ */ new Set(["terminal", "browser", "native"]);
5295
6001
  if (source && VALID_SOURCES.has(source)) entry.decisionSource = source;
5296
6002
  if (entry.waiter) {
5297
6003
  entry.waiter(resolvedDecision, reason);
5298
6004
  pending.delete(id);
5299
- broadcast("remove", { id });
6005
+ broadcast("remove", { id, decision: resolvedDecision });
5300
6006
  } else {
5301
6007
  entry.earlyDecision = resolvedDecision;
5302
6008
  entry.earlyReason = reason;
5303
- broadcast("remove", { id });
6009
+ broadcast("remove", { id, decision: resolvedDecision });
5304
6010
  entry.timer = setTimeout(() => pending.delete(id), 3e4);
5305
6011
  }
5306
6012
  res.writeHead(200);
@@ -5388,13 +6094,38 @@ data: ${JSON.stringify(item.data)}
5388
6094
  const id = pathname.split("/").pop();
5389
6095
  const entry = pending.get(id);
5390
6096
  if (!entry) return res.writeHead(404).end();
5391
- const { decision } = JSON.parse(await readBody(req));
5392
- appendAuditLog({ toolName: entry.toolName, args: entry.args, decision });
6097
+ const { decision, source } = JSON.parse(await readBody(req));
6098
+ const resolvedResolveDecision = decision === "allow" ? "allow" : "deny";
6099
+ appendAuditLog({
6100
+ toolName: entry.toolName,
6101
+ args: entry.args,
6102
+ decision: resolvedResolveDecision
6103
+ });
5393
6104
  clearTimeout(entry.timer);
5394
- if (entry.waiter) entry.waiter(decision);
5395
- else entry.earlyDecision = decision;
6105
+ if (resolvedResolveDecision === "allow") {
6106
+ insightCounts.set(entry.toolName, (insightCounts.get(entry.toolName) ?? 0) + 1);
6107
+ saveInsightCounts();
6108
+ } else {
6109
+ insightCounts.delete(entry.toolName);
6110
+ saveInsightCounts();
6111
+ }
6112
+ if (!entry.slackDelegated) {
6113
+ if (resolvedResolveDecision === "allow") {
6114
+ const suggestion = suggestionTracker.recordAllow(entry.toolName, entry.args);
6115
+ if (suggestion) {
6116
+ suggestions.set(suggestion.id, suggestion);
6117
+ broadcast("suggestion:new", suggestion);
6118
+ }
6119
+ } else {
6120
+ suggestionTracker.resetTool(entry.toolName);
6121
+ }
6122
+ }
6123
+ const VALID_RESOLVE_SOURCES = /* @__PURE__ */ new Set(["terminal", "browser", "native"]);
6124
+ if (source && VALID_RESOLVE_SOURCES.has(source)) entry.decisionSource = source;
6125
+ if (entry.waiter) entry.waiter(resolvedResolveDecision);
6126
+ else entry.earlyDecision = resolvedResolveDecision;
5396
6127
  pending.delete(id);
5397
- broadcast("remove", { id });
6128
+ broadcast("remove", { id, decision: resolvedResolveDecision });
5398
6129
  res.writeHead(200);
5399
6130
  return res.end(JSON.stringify({ ok: true }));
5400
6131
  } catch {
@@ -5442,20 +6173,136 @@ data: ${JSON.stringify(item.data)}
5442
6173
  res.writeHead(400).end();
5443
6174
  }
5444
6175
  }
6176
+ if (req.method === "GET" && pathname === "/suggestions") {
6177
+ res.writeHead(200, { "Content-Type": "application/json" });
6178
+ return res.end(JSON.stringify([...suggestions.values()]));
6179
+ }
6180
+ if (req.method === "POST" && pathname.startsWith("/suggestions/") && pathname.endsWith("/apply")) {
6181
+ if (!validToken(req)) return res.writeHead(403).end();
6182
+ try {
6183
+ const body = await readBody(req);
6184
+ const data = body ? JSON.parse(body) : {};
6185
+ const configPath = data.configPath ?? GLOBAL_CONFIG_PATH;
6186
+ const node9Dir = path18.dirname(GLOBAL_CONFIG_PATH);
6187
+ if (!path18.resolve(configPath).startsWith(node9Dir + path18.sep)) {
6188
+ res.writeHead(400, { "Content-Type": "application/json" });
6189
+ return res.end(
6190
+ JSON.stringify({ error: "configPath must be within the node9 config directory" })
6191
+ );
6192
+ }
6193
+ const id = pathname.split("/")[2];
6194
+ const suggestion = suggestions.get(id);
6195
+ if (!suggestion) return res.writeHead(404).end();
6196
+ let patch;
6197
+ if (data.rule !== void 0) {
6198
+ const parsed = SmartRuleSchema.safeParse(data.rule);
6199
+ if (!parsed.success) {
6200
+ res.writeHead(400, { "Content-Type": "application/json" });
6201
+ return res.end(JSON.stringify({ error: parsed.error.message }));
6202
+ }
6203
+ patch = { type: "smartRule", rule: parsed.data };
6204
+ } else {
6205
+ patch = suggestion.suggestedRule;
6206
+ }
6207
+ patchConfig(configPath, patch);
6208
+ _resetConfigCache();
6209
+ insightCounts.delete(suggestion.toolName);
6210
+ saveInsightCounts();
6211
+ suggestion.status = "applied";
6212
+ broadcast("suggestion:resolved", { id, status: "applied" });
6213
+ res.writeHead(200, { "Content-Type": "application/json" });
6214
+ return res.end(JSON.stringify({ ok: true }));
6215
+ } catch (err) {
6216
+ console.error(chalk2.red("[node9 daemon] POST /suggestions/:id/apply failed:"), err);
6217
+ res.writeHead(500, { "Content-Type": "application/json" });
6218
+ return res.end(JSON.stringify({ error: String(err) }));
6219
+ }
6220
+ }
6221
+ if (req.method === "POST" && pathname.startsWith("/suggestions/") && pathname.endsWith("/dismiss")) {
6222
+ if (!validToken(req)) return res.writeHead(403).end();
6223
+ try {
6224
+ const id = pathname.split("/")[2];
6225
+ const suggestion = suggestions.get(id);
6226
+ if (!suggestion) return res.writeHead(404).end();
6227
+ suggestion.status = "dismissed";
6228
+ broadcast("suggestion:resolved", { id, status: "dismissed" });
6229
+ res.writeHead(200, { "Content-Type": "application/json" });
6230
+ return res.end(JSON.stringify({ ok: true }));
6231
+ } catch {
6232
+ res.writeHead(400).end();
6233
+ }
6234
+ }
6235
+ if (req.method === "POST" && pathname === "/taint") {
6236
+ try {
6237
+ const body = JSON.parse(await readBody(req));
6238
+ if (typeof body.path !== "string" || typeof body.source !== "string") {
6239
+ res.writeHead(400, { "Content-Type": "application/json" });
6240
+ return res.end(JSON.stringify({ error: "path and source are required strings" }));
6241
+ }
6242
+ const ttlMs = typeof body.ttlMs === "number" ? body.ttlMs : void 0;
6243
+ taintStore.taint(body.path, body.source, ttlMs);
6244
+ res.writeHead(200, { "Content-Type": "application/json" });
6245
+ return res.end(JSON.stringify({ ok: true }));
6246
+ } catch {
6247
+ res.writeHead(400).end();
6248
+ return;
6249
+ }
6250
+ }
6251
+ if (req.method === "POST" && pathname === "/taint/check") {
6252
+ try {
6253
+ const body = JSON.parse(await readBody(req));
6254
+ if (!Array.isArray(body.paths)) {
6255
+ res.writeHead(400, { "Content-Type": "application/json" });
6256
+ return res.end(JSON.stringify({ error: "paths must be an array" }));
6257
+ }
6258
+ if (body.paths.some((p) => typeof p !== "string")) {
6259
+ res.writeHead(400, { "Content-Type": "application/json" });
6260
+ return res.end(JSON.stringify({ error: "all paths must be strings" }));
6261
+ }
6262
+ for (const p of body.paths) {
6263
+ const record = taintStore.check(p);
6264
+ if (record) {
6265
+ res.writeHead(200, { "Content-Type": "application/json" });
6266
+ return res.end(JSON.stringify({ tainted: true, record }));
6267
+ }
6268
+ }
6269
+ res.writeHead(200, { "Content-Type": "application/json" });
6270
+ return res.end(JSON.stringify({ tainted: false }));
6271
+ } catch {
6272
+ res.writeHead(400).end();
6273
+ return;
6274
+ }
6275
+ }
6276
+ if (req.method === "POST" && pathname === "/taint/propagate") {
6277
+ try {
6278
+ const body = JSON.parse(await readBody(req));
6279
+ if (typeof body.src !== "string" || typeof body.dest !== "string") {
6280
+ res.writeHead(400, { "Content-Type": "application/json" });
6281
+ return res.end(JSON.stringify({ error: "src and dest are required strings" }));
6282
+ }
6283
+ const clearSource = body.clearSource === true;
6284
+ taintStore.propagate(body.src, body.dest, clearSource);
6285
+ res.writeHead(200, { "Content-Type": "application/json" });
6286
+ return res.end(JSON.stringify({ ok: true }));
6287
+ } catch {
6288
+ res.writeHead(400).end();
6289
+ return;
6290
+ }
6291
+ }
5445
6292
  res.writeHead(404).end();
5446
6293
  });
5447
6294
  setDaemonServer(server);
5448
6295
  server.on("error", (e) => {
5449
6296
  if (e.code === "EADDRINUSE") {
5450
6297
  try {
5451
- if (fs13.existsSync(DAEMON_PID_FILE)) {
5452
- const { pid } = JSON.parse(fs13.readFileSync(DAEMON_PID_FILE, "utf-8"));
6298
+ if (fs15.existsSync(DAEMON_PID_FILE)) {
6299
+ const { pid } = JSON.parse(fs15.readFileSync(DAEMON_PID_FILE, "utf-8"));
5453
6300
  process.kill(pid, 0);
5454
6301
  return process.exit(0);
5455
6302
  }
5456
6303
  } catch {
5457
6304
  try {
5458
- fs13.unlinkSync(DAEMON_PID_FILE);
6305
+ fs15.unlinkSync(DAEMON_PID_FILE);
5459
6306
  } catch {
5460
6307
  }
5461
6308
  server.listen(DAEMON_PORT, DAEMON_HOST);
@@ -5521,32 +6368,34 @@ var init_server = __esm({
5521
6368
  init_shields();
5522
6369
  init_ui2();
5523
6370
  init_state2();
6371
+ init_patch();
6372
+ init_config_schema();
5524
6373
  }
5525
6374
  });
5526
6375
 
5527
6376
  // src/daemon/index.ts
5528
- import fs14 from "fs";
6377
+ import fs16 from "fs";
5529
6378
  import chalk3 from "chalk";
5530
6379
  import { spawnSync as spawnSync3 } from "child_process";
5531
6380
  function stopDaemon() {
5532
- if (!fs14.existsSync(DAEMON_PID_FILE)) return console.log(chalk3.yellow("Not running."));
6381
+ if (!fs16.existsSync(DAEMON_PID_FILE)) return console.log(chalk3.yellow("Not running."));
5533
6382
  try {
5534
- const { pid } = JSON.parse(fs14.readFileSync(DAEMON_PID_FILE, "utf-8"));
6383
+ const { pid } = JSON.parse(fs16.readFileSync(DAEMON_PID_FILE, "utf-8"));
5535
6384
  process.kill(pid, "SIGTERM");
5536
6385
  console.log(chalk3.green("\u2705 Stopped."));
5537
6386
  } catch {
5538
6387
  console.log(chalk3.gray("Cleaned up stale PID file."));
5539
6388
  } finally {
5540
6389
  try {
5541
- fs14.unlinkSync(DAEMON_PID_FILE);
6390
+ fs16.unlinkSync(DAEMON_PID_FILE);
5542
6391
  } catch {
5543
6392
  }
5544
6393
  }
5545
6394
  }
5546
6395
  function daemonStatus() {
5547
- if (fs14.existsSync(DAEMON_PID_FILE)) {
6396
+ if (fs16.existsSync(DAEMON_PID_FILE)) {
5548
6397
  try {
5549
- const { pid } = JSON.parse(fs14.readFileSync(DAEMON_PID_FILE, "utf-8"));
6398
+ const { pid } = JSON.parse(fs16.readFileSync(DAEMON_PID_FILE, "utf-8"));
5550
6399
  process.kill(pid, 0);
5551
6400
  console.log(chalk3.green("Node9 daemon: running"));
5552
6401
  return;
@@ -5580,10 +6429,10 @@ __export(tail_exports, {
5580
6429
  startTail: () => startTail
5581
6430
  });
5582
6431
  import http2 from "http";
5583
- import chalk15 from "chalk";
5584
- import fs21 from "fs";
5585
- import os19 from "os";
5586
- import path23 from "path";
6432
+ import chalk16 from "chalk";
6433
+ import fs24 from "fs";
6434
+ import os21 from "os";
6435
+ import path26 from "path";
5587
6436
  import readline3 from "readline";
5588
6437
  import { spawn as spawn9, execSync as execSync3 } from "child_process";
5589
6438
  function getIcon(tool) {
@@ -5599,17 +6448,17 @@ function formatBase(activity) {
5599
6448
  const toolName = activity.tool.slice(0, 16).padEnd(16);
5600
6449
  const argsStr = JSON.stringify(activity.args ?? {}).replace(/\s+/g, " ");
5601
6450
  const argsPreview = argsStr.length > 70 ? argsStr.slice(0, 70) + "\u2026" : argsStr;
5602
- return `${chalk15.gray(time)} ${icon} ${chalk15.white.bold(toolName)} ${chalk15.dim(argsPreview)}`;
6451
+ return `${chalk16.gray(time)} ${icon} ${chalk16.white.bold(toolName)} ${chalk16.dim(argsPreview)}`;
5603
6452
  }
5604
6453
  function renderResult(activity, result) {
5605
6454
  const base = formatBase(activity);
5606
6455
  let status;
5607
6456
  if (result.status === "allow") {
5608
- status = chalk15.green("\u2713 ALLOW");
6457
+ status = chalk16.green("\u2713 ALLOW");
5609
6458
  } else if (result.status === "dlp") {
5610
- status = chalk15.bgRed.white.bold(" \u{1F6E1}\uFE0F DLP ");
6459
+ status = chalk16.bgRed.white.bold(" \u{1F6E1}\uFE0F DLP ");
5611
6460
  } else {
5612
- status = chalk15.red("\u2717 BLOCK");
6461
+ status = chalk16.red("\u2717 BLOCK");
5613
6462
  }
5614
6463
  if (process.stdout.isTTY) {
5615
6464
  readline3.clearLine(process.stdout, 0);
@@ -5619,16 +6468,16 @@ function renderResult(activity, result) {
5619
6468
  }
5620
6469
  function renderPending(activity) {
5621
6470
  if (!process.stdout.isTTY) return;
5622
- process.stdout.write(`${formatBase(activity)} ${chalk15.yellow("\u25CF \u2026")}\r`);
6471
+ process.stdout.write(`${formatBase(activity)} ${chalk16.yellow("\u25CF \u2026")}\r`);
5623
6472
  }
5624
6473
  async function ensureDaemon() {
5625
6474
  let pidPort = null;
5626
- if (fs21.existsSync(PID_FILE)) {
6475
+ if (fs24.existsSync(PID_FILE)) {
5627
6476
  try {
5628
- const { port } = JSON.parse(fs21.readFileSync(PID_FILE, "utf-8"));
6477
+ const { port } = JSON.parse(fs24.readFileSync(PID_FILE, "utf-8"));
5629
6478
  pidPort = port;
5630
6479
  } catch {
5631
- console.error(chalk15.dim("\u26A0\uFE0F Could not read PID file; falling back to default port."));
6480
+ console.error(chalk16.dim("\u26A0\uFE0F Could not read PID file; falling back to default port."));
5632
6481
  }
5633
6482
  }
5634
6483
  const checkPort = pidPort ?? DAEMON_PORT;
@@ -5639,7 +6488,7 @@ async function ensureDaemon() {
5639
6488
  if (res.ok) return checkPort;
5640
6489
  } catch {
5641
6490
  }
5642
- console.log(chalk15.dim("\u{1F6E1}\uFE0F Starting Node9 daemon..."));
6491
+ console.log(chalk16.dim("\u{1F6E1}\uFE0F Starting Node9 daemon..."));
5643
6492
  const child = spawn9(process.execPath, [process.argv[1], "daemon"], {
5644
6493
  detached: true,
5645
6494
  stdio: "ignore",
@@ -5656,12 +6505,15 @@ async function ensureDaemon() {
5656
6505
  } catch {
5657
6506
  }
5658
6507
  }
5659
- console.error(chalk15.red("\u274C Daemon failed to start. Try: node9 daemon start"));
6508
+ console.error(chalk16.red("\u274C Daemon failed to start. Try: node9 daemon start"));
5660
6509
  process.exit(1);
5661
6510
  }
5662
- function postDecisionHttp(id, decision, csrfToken, port) {
6511
+ function postDecisionHttp(id, decision, csrfToken, port, opts) {
5663
6512
  return new Promise((resolve, reject) => {
5664
- const body = JSON.stringify({ decision, source: "terminal" });
6513
+ const bodyObj = { decision, source: "terminal" };
6514
+ if (opts?.persist) bodyObj.persist = true;
6515
+ if (opts?.trustDuration) bodyObj.trustDuration = opts.trustDuration;
6516
+ const body = JSON.stringify(bodyObj);
5665
6517
  const req = http2.request(
5666
6518
  {
5667
6519
  hostname: "127.0.0.1",
@@ -5684,22 +6536,33 @@ function postDecisionHttp(id, decision, csrfToken, port) {
5684
6536
  req.end(body);
5685
6537
  });
5686
6538
  }
5687
- function buildCardLines(req) {
6539
+ function buildCardLines(req, localCount = 0) {
5688
6540
  const argsStr = JSON.stringify(req.args ?? {}).replace(/\s+/g, " ");
5689
6541
  const argsPreview = argsStr.length > 60 ? argsStr.slice(0, 60) + "\u2026" : argsStr;
5690
6542
  const tierLabel = req.riskMetadata?.tier != null ? req.riskMetadata.tier <= 2 ? `${YELLOW}\u26A0 Tier ${req.riskMetadata.tier}` : `${RED}\u{1F6D1} Tier ${req.riskMetadata.tier}` : `${YELLOW}\u26A0 Review`;
5691
6543
  const blockedBy = req.riskMetadata?.blockedByLabel ?? "Policy rule";
5692
- return [
6544
+ const lines = [
5693
6545
  ``,
5694
6546
  `${BOLD}${CYAN}\u2554\u2550\u2550 Node9 Approval Required \u2550\u2550\u2557${RESET}`,
5695
6547
  `${CYAN}\u2551${RESET} Tool: ${BOLD}${req.toolName}${RESET}`,
5696
- `${CYAN}\u2551${RESET} Reason: ${tierLabel} \u2014 ${blockedBy}${RESET}`,
5697
- `${CYAN}\u2551${RESET} Args: ${GRAY}${argsPreview}${RESET}`,
6548
+ `${CYAN}\u2551${RESET} Reason: ${tierLabel} \u2014 ${blockedBy}${RESET}`
6549
+ ];
6550
+ if (req.riskMetadata?.ruleName && blockedBy.includes("Taint")) {
6551
+ lines.push(`${CYAN}\u2551${RESET} ${YELLOW}\u26A0 ${req.riskMetadata.ruleName}${RESET}`);
6552
+ }
6553
+ lines.push(`${CYAN}\u2551${RESET} Args: ${GRAY}${argsPreview}${RESET}`);
6554
+ if (localCount >= 2) {
6555
+ lines.push(
6556
+ `${CYAN}\u2551${RESET} ${YELLOW}\u{1F4A1}${RESET} Approved ${localCount}\xD7 before \u2014 ${BOLD}[a]${RESET}${YELLOW} creates a permanent rule${RESET}`
6557
+ );
6558
+ }
6559
+ lines.push(
5698
6560
  `${CYAN}\u255A${RESET}`,
5699
6561
  ``,
5700
- ` ${BOLD}${GREEN}[A]${RESET} Allow ${BOLD}${RED}[D]${RESET} Deny`,
6562
+ ` ${BOLD}${GREEN}[\u21B5/y]${RESET} Allow ${BOLD}${RED}[n]${RESET} Deny ${BOLD}${YELLOW}[a]${RESET} Always Allow ${BOLD}${CYAN}[t]${RESET} Trust 30m`,
5701
6563
  ``
5702
- ];
6564
+ );
6565
+ return lines;
5703
6566
  }
5704
6567
  async function startTail(options = {}) {
5705
6568
  const port = await ensureDaemon();
@@ -5727,7 +6590,7 @@ async function startTail(options = {}) {
5727
6590
  req2.end();
5728
6591
  });
5729
6592
  if (result.ok) {
5730
- console.log(chalk15.green("\u2713 Flight Recorder buffer cleared."));
6593
+ console.log(chalk16.green("\u2713 Flight Recorder buffer cleared."));
5731
6594
  } else if (result.code === "ECONNREFUSED") {
5732
6595
  throw new Error("Daemon is not running. Start it with: node9 daemon start");
5733
6596
  } else if (result.code === "ETIMEDOUT") {
@@ -5744,17 +6607,22 @@ async function startTail(options = {}) {
5744
6607
  let cardActive = false;
5745
6608
  let cardLineCount = 0;
5746
6609
  let cancelActiveCard = null;
6610
+ const localAllowCounts = /* @__PURE__ */ new Map();
5747
6611
  const canApprove = process.stdout.isTTY && process.stdin.isTTY;
5748
6612
  if (canApprove) readline3.emitKeypressEvents(process.stdin);
5749
6613
  function clearCard() {
5750
6614
  if (cardLineCount > 0) {
5751
- process.stdout.write(RESTORE_CURSOR + ERASE_DOWN);
6615
+ readline3.moveCursor(process.stdout, 0, -cardLineCount);
6616
+ process.stdout.write(ERASE_DOWN);
5752
6617
  cardLineCount = 0;
5753
6618
  }
5754
6619
  }
5755
6620
  function printCard(req2) {
5756
- process.stdout.write(HIDE_CURSOR + SAVE_CURSOR);
5757
- const lines = buildCardLines(req2);
6621
+ process.stdout.write(HIDE_CURSOR);
6622
+ const daemonPrior = req2.allowCount !== void 0 ? req2.allowCount - 1 : 0;
6623
+ const localPrior = localAllowCounts.get(req2.toolName) ?? 0;
6624
+ const priorCount = Math.max(daemonPrior, localPrior);
6625
+ const lines = buildCardLines(req2, priorCount);
5758
6626
  for (const line of lines) process.stdout.write(line + "\n");
5759
6627
  cardLineCount = lines.length;
5760
6628
  }
@@ -5782,34 +6650,70 @@ async function startTail(options = {}) {
5782
6650
  process.stdin.pause();
5783
6651
  cancelActiveCard = null;
5784
6652
  };
5785
- const settle = (decision) => {
6653
+ const settle = (action) => {
5786
6654
  if (settled) return;
5787
6655
  settled = true;
5788
6656
  cleanup();
5789
6657
  clearCard();
6658
+ const stampedLines = buildCardLines(
6659
+ req2,
6660
+ Math.max(
6661
+ req2.allowCount !== void 0 ? req2.allowCount - 1 : 0,
6662
+ localAllowCounts.get(req2.toolName) ?? 0
6663
+ )
6664
+ );
6665
+ const decisionStamp = action === "always-allow" ? chalk16.yellow("\u2605 ALWAYS ALLOW") : action === "trust" ? chalk16.cyan("\u23F1 TRUST 30m") : action === "allow" ? chalk16.green("\u2713 ALLOWED") : chalk16.red("\u2717 DENIED");
6666
+ stampedLines.push(` ${BOLD}\u2192${RESET} ${decisionStamp} ${GRAY}(terminal)${RESET}`, ``);
6667
+ for (const line of stampedLines) process.stdout.write(line + "\n");
5790
6668
  process.stdout.write(SHOW_CURSOR);
5791
- postDecisionHttp(req2.id, decision, csrfToken, port).catch((err) => {
6669
+ cardLineCount = 0;
6670
+ if (action === "allow" || action === "always-allow" || action === "trust") {
6671
+ localAllowCounts.set(req2.toolName, (localAllowCounts.get(req2.toolName) ?? 0) + 1);
6672
+ } else if (action === "deny") {
6673
+ localAllowCounts.delete(req2.toolName);
6674
+ }
6675
+ let httpDecision;
6676
+ let httpOpts;
6677
+ if (action === "always-allow") {
6678
+ httpDecision = "allow";
6679
+ httpOpts = { persist: true };
6680
+ } else if (action === "trust") {
6681
+ httpDecision = "trust";
6682
+ httpOpts = { trustDuration: "30m" };
6683
+ } else {
6684
+ httpDecision = action;
6685
+ }
6686
+ postDecisionHttp(req2.id, httpDecision, csrfToken, port, httpOpts).catch((err) => {
5792
6687
  try {
5793
- fs21.appendFileSync(
5794
- path23.join(os19.homedir(), ".node9", "hook-debug.log"),
6688
+ fs24.appendFileSync(
6689
+ path26.join(os21.homedir(), ".node9", "hook-debug.log"),
5795
6690
  `[tail] POST /decision failed: ${String(err)}
5796
6691
  `
5797
6692
  );
5798
6693
  } catch {
5799
6694
  }
5800
6695
  });
5801
- const decisionLabel = decision === "allow" ? chalk15.green("\u2713 ALLOWED (terminal)") : chalk15.red("\u2717 DENIED (terminal)");
5802
- console.log(`${chalk15.cyan("\u25C6")} ${chalk15.bold(req2.toolName.padEnd(16))} ${decisionLabel}`);
5803
6696
  approvalQueue.shift();
5804
6697
  cardActive = false;
5805
6698
  showNextCard();
5806
6699
  };
5807
- cancelActiveCard = () => {
6700
+ cancelActiveCard = (externalDecision) => {
5808
6701
  if (settled) return;
5809
6702
  settled = true;
5810
6703
  cleanup();
5811
6704
  clearCard();
6705
+ const priorCount = Math.max(
6706
+ req2.allowCount !== void 0 ? req2.allowCount - 1 : 0,
6707
+ localAllowCounts.get(req2.toolName) ?? 0
6708
+ );
6709
+ const stampedLines = buildCardLines(req2, priorCount);
6710
+ if (externalDecision) {
6711
+ const source = externalDecision === "allow" ? chalk16.green("\u2713 ALLOWED") : chalk16.red("\u2717 DENIED");
6712
+ stampedLines.push(` ${BOLD}\u2192${RESET} ${source} ${GRAY}(external)${RESET}`, ``);
6713
+ }
6714
+ for (const line of stampedLines) process.stdout.write(line + "\n");
5812
6715
  process.stdout.write(SHOW_CURSOR);
6716
+ cardLineCount = 0;
5813
6717
  approvalQueue.shift();
5814
6718
  cardActive = false;
5815
6719
  showNextCard();
@@ -5817,10 +6721,14 @@ async function startTail(options = {}) {
5817
6721
  process.stdin.resume();
5818
6722
  onKeypress = (_str, key) => {
5819
6723
  const name = key?.name ?? "";
5820
- if (name === "a") {
6724
+ if (name === "y" || name === "return") {
5821
6725
  settle("allow");
5822
- } else if (name === "d" || name === "return" || name === "enter" || key?.ctrl && name === "c") {
6726
+ } else if (name === "n" || name === "d" || key?.ctrl && name === "c") {
5823
6727
  settle("deny");
6728
+ } else if (name === "a") {
6729
+ settle("always-allow");
6730
+ } else if (name === "t") {
6731
+ settle("trust");
5824
6732
  }
5825
6733
  };
5826
6734
  process.stdin.on("keypress", onKeypress);
@@ -5833,19 +6741,27 @@ async function startTail(options = {}) {
5833
6741
  else if (process.platform === "win32")
5834
6742
  execSync3(`cmd /c start "" "${dashboardUrl}"`, { stdio: "ignore" });
5835
6743
  else execSync3(`xdg-open "${dashboardUrl}"`, { stdio: "ignore" });
6744
+ const intToken = getInternalToken();
6745
+ fetch(`http://127.0.0.1:${port}/browser-opened`, {
6746
+ method: "POST",
6747
+ headers: intToken ? { "X-Node9-Internal": intToken } : {}
6748
+ }).catch(() => {
6749
+ });
5836
6750
  }
5837
6751
  } catch {
5838
6752
  }
5839
- console.log(chalk15.cyan.bold(`
5840
- \u{1F6F0}\uFE0F Node9 tail `) + chalk15.dim(`\u2192 ${dashboardUrl}`));
6753
+ console.log(chalk16.cyan.bold(`
6754
+ \u{1F6F0}\uFE0F Node9 tail `) + chalk16.dim(`\u2192 ${dashboardUrl}`));
5841
6755
  if (canApprove) {
5842
- console.log(chalk15.dim("Interactive approvals enabled. [A] Allow [D] Deny"));
6756
+ console.log(
6757
+ chalk16.dim("Interactive approvals: [\u21B5/y] Allow [n] Deny [a] Always Allow [t] Trust 30m")
6758
+ );
5843
6759
  }
5844
6760
  if (options.history) {
5845
- console.log(chalk15.dim("Showing history + live events. Press Ctrl+C to exit.\n"));
6761
+ console.log(chalk16.dim("Showing history + live events. Press Ctrl+C to exit.\n"));
5846
6762
  } else {
5847
6763
  console.log(
5848
- chalk15.dim("Showing live events only. Use --history to include past. Press Ctrl+C to exit.\n")
6764
+ chalk16.dim("Showing live events only. Use --history to include past. Press Ctrl+C to exit.\n")
5849
6765
  );
5850
6766
  }
5851
6767
  process.on("SIGINT", () => {
@@ -5855,13 +6771,13 @@ async function startTail(options = {}) {
5855
6771
  readline3.clearLine(process.stdout, 0);
5856
6772
  readline3.cursorTo(process.stdout, 0);
5857
6773
  }
5858
- console.log(chalk15.dim("\n\u{1F6F0}\uFE0F Disconnected."));
6774
+ console.log(chalk16.dim("\n\u{1F6F0}\uFE0F Disconnected."));
5859
6775
  process.exit(0);
5860
6776
  });
5861
6777
  const sseUrl = `http://127.0.0.1:${port}/events?capabilities=input`;
5862
6778
  const req = http2.get(sseUrl, (res) => {
5863
6779
  if (res.statusCode !== 200) {
5864
- console.error(chalk15.red(`Failed to connect: HTTP ${res.statusCode}`));
6780
+ console.error(chalk16.red(`Failed to connect: HTTP ${res.statusCode}`));
5865
6781
  process.exit(1);
5866
6782
  }
5867
6783
  let currentEvent = "";
@@ -5891,7 +6807,7 @@ async function startTail(options = {}) {
5891
6807
  readline3.clearLine(process.stdout, 0);
5892
6808
  readline3.cursorTo(process.stdout, 0);
5893
6809
  }
5894
- console.log(chalk15.red("\n\u274C Daemon disconnected."));
6810
+ console.log(chalk16.red("\n\u274C Daemon disconnected."));
5895
6811
  process.exit(1);
5896
6812
  });
5897
6813
  });
@@ -5932,11 +6848,17 @@ async function startTail(options = {}) {
5932
6848
  }
5933
6849
  if (event === "remove") {
5934
6850
  try {
5935
- const { id } = JSON.parse(rawData);
6851
+ const { id, decision } = JSON.parse(rawData);
5936
6852
  const idx = approvalQueue.findIndex((r) => r.id === id);
5937
6853
  if (idx !== -1) {
5938
6854
  if (idx === 0 && cardActive && cancelActiveCard) {
5939
- cancelActiveCard();
6855
+ const toolName = approvalQueue[0].toolName;
6856
+ if (decision === "allow") {
6857
+ localAllowCounts.set(toolName, (localAllowCounts.get(toolName) ?? 0) + 1);
6858
+ } else if (decision === "deny") {
6859
+ localAllowCounts.delete(toolName);
6860
+ }
6861
+ cancelActiveCard(decision);
5940
6862
  } else {
5941
6863
  approvalQueue.splice(idx, 1);
5942
6864
  }
@@ -5971,18 +6893,19 @@ async function startTail(options = {}) {
5971
6893
  }
5972
6894
  req.on("error", (err) => {
5973
6895
  const msg = err.code === "ECONNREFUSED" ? "Daemon is not running. Start it with: node9 daemon start" : err.message;
5974
- console.error(chalk15.red(`
6896
+ console.error(chalk16.red(`
5975
6897
  \u274C ${msg}`));
5976
6898
  process.exit(1);
5977
6899
  });
5978
6900
  }
5979
- var PID_FILE, ICONS, RESET, BOLD, RED, YELLOW, CYAN, GRAY, GREEN, HIDE_CURSOR, SHOW_CURSOR, ERASE_DOWN, SAVE_CURSOR, RESTORE_CURSOR;
6901
+ var PID_FILE, ICONS, RESET, BOLD, RED, YELLOW, CYAN, GRAY, GREEN, HIDE_CURSOR, SHOW_CURSOR, ERASE_DOWN;
5980
6902
  var init_tail = __esm({
5981
6903
  "src/tui/tail.ts"() {
5982
6904
  "use strict";
5983
6905
  init_daemon2();
6906
+ init_daemon();
5984
6907
  init_core();
5985
- PID_FILE = path23.join(os19.homedir(), ".node9", "daemon.pid");
6908
+ PID_FILE = path26.join(os21.homedir(), ".node9", "daemon.pid");
5986
6909
  ICONS = {
5987
6910
  bash: "\u{1F4BB}",
5988
6911
  shell: "\u{1F4BB}",
@@ -6010,8 +6933,6 @@ var init_tail = __esm({
6010
6933
  HIDE_CURSOR = "\x1B[?25l";
6011
6934
  SHOW_CURSOR = "\x1B[?25h";
6012
6935
  ERASE_DOWN = "\x1B[J";
6013
- SAVE_CURSOR = "\x1B7";
6014
- RESTORE_CURSOR = "\x1B8";
6015
6936
  }
6016
6937
  });
6017
6938
 
@@ -6330,6 +7251,25 @@ async function setupGemini() {
6330
7251
  printDaemonTip();
6331
7252
  }
6332
7253
  }
7254
+ function detectAgents(homeDir2 = os11.homedir()) {
7255
+ const exists = (p) => {
7256
+ try {
7257
+ return fs11.existsSync(p);
7258
+ } catch (err) {
7259
+ const code = err.code;
7260
+ if (code !== "ENOENT") {
7261
+ process.stderr.write(`[node9] detectAgents: cannot access ${p}: ${code ?? String(err)}
7262
+ `);
7263
+ }
7264
+ return false;
7265
+ }
7266
+ };
7267
+ return {
7268
+ claude: exists(path14.join(homeDir2, ".claude")) || exists(path14.join(homeDir2, ".claude.json")),
7269
+ gemini: exists(path14.join(homeDir2, ".gemini")),
7270
+ cursor: exists(path14.join(homeDir2, ".cursor"))
7271
+ };
7272
+ }
6333
7273
  async function setupCursor() {
6334
7274
  const homeDir2 = os11.homedir();
6335
7275
  const mcpPath = path14.join(homeDir2, ".cursor", "mcp.json");
@@ -6388,10 +7328,10 @@ async function setupCursor() {
6388
7328
 
6389
7329
  // src/cli.ts
6390
7330
  init_daemon2();
6391
- import chalk16 from "chalk";
6392
- import fs22 from "fs";
6393
- import path24 from "path";
6394
- import os20 from "os";
7331
+ import chalk17 from "chalk";
7332
+ import fs25 from "fs";
7333
+ import path27 from "path";
7334
+ import os22 from "os";
6395
7335
  import { confirm as confirm3 } from "@inquirer/prompts";
6396
7336
 
6397
7337
  // src/utils/duration.ts
@@ -6616,32 +7556,32 @@ init_daemon();
6616
7556
  init_config();
6617
7557
  init_policy();
6618
7558
  import chalk5 from "chalk";
6619
- import fs16 from "fs";
6620
- import path18 from "path";
6621
- import os14 from "os";
7559
+ import fs18 from "fs";
7560
+ import path20 from "path";
7561
+ import os15 from "os";
6622
7562
 
6623
7563
  // src/undo.ts
6624
7564
  import { spawnSync as spawnSync4, spawn as spawn5 } from "child_process";
6625
7565
  import crypto2 from "crypto";
6626
- import fs15 from "fs";
6627
- import path17 from "path";
6628
- import os13 from "os";
6629
- var SNAPSHOT_STACK_PATH = path17.join(os13.homedir(), ".node9", "snapshots.json");
6630
- var UNDO_LATEST_PATH = path17.join(os13.homedir(), ".node9", "undo_latest.txt");
7566
+ import fs17 from "fs";
7567
+ import path19 from "path";
7568
+ import os14 from "os";
7569
+ var SNAPSHOT_STACK_PATH = path19.join(os14.homedir(), ".node9", "snapshots.json");
7570
+ var UNDO_LATEST_PATH = path19.join(os14.homedir(), ".node9", "undo_latest.txt");
6631
7571
  var MAX_SNAPSHOTS = 10;
6632
7572
  var GIT_TIMEOUT = 15e3;
6633
7573
  function readStack() {
6634
7574
  try {
6635
- if (fs15.existsSync(SNAPSHOT_STACK_PATH))
6636
- return JSON.parse(fs15.readFileSync(SNAPSHOT_STACK_PATH, "utf-8"));
7575
+ if (fs17.existsSync(SNAPSHOT_STACK_PATH))
7576
+ return JSON.parse(fs17.readFileSync(SNAPSHOT_STACK_PATH, "utf-8"));
6637
7577
  } catch {
6638
7578
  }
6639
7579
  return [];
6640
7580
  }
6641
7581
  function writeStack(stack) {
6642
- const dir = path17.dirname(SNAPSHOT_STACK_PATH);
6643
- if (!fs15.existsSync(dir)) fs15.mkdirSync(dir, { recursive: true });
6644
- fs15.writeFileSync(SNAPSHOT_STACK_PATH, JSON.stringify(stack, null, 2));
7582
+ const dir = path19.dirname(SNAPSHOT_STACK_PATH);
7583
+ if (!fs17.existsSync(dir)) fs17.mkdirSync(dir, { recursive: true });
7584
+ fs17.writeFileSync(SNAPSHOT_STACK_PATH, JSON.stringify(stack, null, 2));
6645
7585
  }
6646
7586
  function buildArgsSummary(tool, args) {
6647
7587
  if (!args || typeof args !== "object") return "";
@@ -6657,7 +7597,7 @@ function buildArgsSummary(tool, args) {
6657
7597
  function normalizeCwdForHash(cwd) {
6658
7598
  let normalized;
6659
7599
  try {
6660
- normalized = fs15.realpathSync(cwd);
7600
+ normalized = fs17.realpathSync(cwd);
6661
7601
  } catch {
6662
7602
  normalized = cwd;
6663
7603
  }
@@ -6667,16 +7607,16 @@ function normalizeCwdForHash(cwd) {
6667
7607
  }
6668
7608
  function getShadowRepoDir(cwd) {
6669
7609
  const hash = crypto2.createHash("sha256").update(normalizeCwdForHash(cwd)).digest("hex").slice(0, 16);
6670
- return path17.join(os13.homedir(), ".node9", "snapshots", hash);
7610
+ return path19.join(os14.homedir(), ".node9", "snapshots", hash);
6671
7611
  }
6672
7612
  function cleanOrphanedIndexFiles(shadowDir) {
6673
7613
  try {
6674
7614
  const cutoff = Date.now() - 6e4;
6675
- for (const f of fs15.readdirSync(shadowDir)) {
7615
+ for (const f of fs17.readdirSync(shadowDir)) {
6676
7616
  if (f.startsWith("index_")) {
6677
- const fp = path17.join(shadowDir, f);
7617
+ const fp = path19.join(shadowDir, f);
6678
7618
  try {
6679
- if (fs15.statSync(fp).mtimeMs < cutoff) fs15.unlinkSync(fp);
7619
+ if (fs17.statSync(fp).mtimeMs < cutoff) fs17.unlinkSync(fp);
6680
7620
  } catch {
6681
7621
  }
6682
7622
  }
@@ -6688,7 +7628,7 @@ function writeShadowExcludes(shadowDir, ignorePaths) {
6688
7628
  const hardcoded = [".git", ".node9"];
6689
7629
  const lines = [...hardcoded, ...ignorePaths].join("\n");
6690
7630
  try {
6691
- fs15.writeFileSync(path17.join(shadowDir, "info", "exclude"), lines + "\n", "utf8");
7631
+ fs17.writeFileSync(path19.join(shadowDir, "info", "exclude"), lines + "\n", "utf8");
6692
7632
  } catch {
6693
7633
  }
6694
7634
  }
@@ -6701,25 +7641,25 @@ function ensureShadowRepo(shadowDir, cwd) {
6701
7641
  timeout: 3e3
6702
7642
  });
6703
7643
  if (check.status === 0) {
6704
- const ptPath = path17.join(shadowDir, "project-path.txt");
7644
+ const ptPath = path19.join(shadowDir, "project-path.txt");
6705
7645
  try {
6706
- const stored = fs15.readFileSync(ptPath, "utf8").trim();
7646
+ const stored = fs17.readFileSync(ptPath, "utf8").trim();
6707
7647
  if (stored === normalizedCwd) return true;
6708
7648
  if (process.env.NODE9_DEBUG === "1")
6709
7649
  console.error(
6710
7650
  `[Node9] Shadow repo path mismatch: stored="${stored}" expected="${normalizedCwd}" \u2014 reinitializing`
6711
7651
  );
6712
- fs15.rmSync(shadowDir, { recursive: true, force: true });
7652
+ fs17.rmSync(shadowDir, { recursive: true, force: true });
6713
7653
  } catch {
6714
7654
  try {
6715
- fs15.writeFileSync(ptPath, normalizedCwd, "utf8");
7655
+ fs17.writeFileSync(ptPath, normalizedCwd, "utf8");
6716
7656
  } catch {
6717
7657
  }
6718
7658
  return true;
6719
7659
  }
6720
7660
  }
6721
7661
  try {
6722
- fs15.mkdirSync(shadowDir, { recursive: true });
7662
+ fs17.mkdirSync(shadowDir, { recursive: true });
6723
7663
  } catch {
6724
7664
  }
6725
7665
  const init = spawnSync4("git", ["init", "--bare", shadowDir], { timeout: 5e3 });
@@ -6728,7 +7668,7 @@ function ensureShadowRepo(shadowDir, cwd) {
6728
7668
  console.error("[Node9] git init --bare failed:", init.stderr?.toString());
6729
7669
  return false;
6730
7670
  }
6731
- const configFile = path17.join(shadowDir, "config");
7671
+ const configFile = path19.join(shadowDir, "config");
6732
7672
  spawnSync4("git", ["config", "--file", configFile, "core.untrackedCache", "true"], {
6733
7673
  timeout: 3e3
6734
7674
  });
@@ -6736,7 +7676,7 @@ function ensureShadowRepo(shadowDir, cwd) {
6736
7676
  timeout: 3e3
6737
7677
  });
6738
7678
  try {
6739
- fs15.writeFileSync(path17.join(shadowDir, "project-path.txt"), normalizedCwd, "utf8");
7679
+ fs17.writeFileSync(path19.join(shadowDir, "project-path.txt"), normalizedCwd, "utf8");
6740
7680
  } catch {
6741
7681
  }
6742
7682
  return true;
@@ -6759,7 +7699,7 @@ async function createShadowSnapshot(tool = "unknown", args = {}, ignorePaths = [
6759
7699
  const shadowDir = getShadowRepoDir(cwd);
6760
7700
  if (!ensureShadowRepo(shadowDir, cwd)) return null;
6761
7701
  writeShadowExcludes(shadowDir, ignorePaths);
6762
- indexFile = path17.join(shadowDir, `index_${process.pid}_${Date.now()}`);
7702
+ indexFile = path19.join(shadowDir, `index_${process.pid}_${Date.now()}`);
6763
7703
  const shadowEnv = {
6764
7704
  ...process.env,
6765
7705
  GIT_DIR: shadowDir,
@@ -6788,7 +7728,7 @@ async function createShadowSnapshot(tool = "unknown", args = {}, ignorePaths = [
6788
7728
  const shouldGc = stack.length % 5 === 0;
6789
7729
  if (stack.length > MAX_SNAPSHOTS) stack.splice(0, stack.length - MAX_SNAPSHOTS);
6790
7730
  writeStack(stack);
6791
- fs15.writeFileSync(UNDO_LATEST_PATH, commitHash);
7731
+ fs17.writeFileSync(UNDO_LATEST_PATH, commitHash);
6792
7732
  if (shouldGc) {
6793
7733
  spawn5("git", ["gc", "--auto"], { env: shadowEnv, detached: true, stdio: "ignore" }).unref();
6794
7734
  }
@@ -6799,7 +7739,7 @@ async function createShadowSnapshot(tool = "unknown", args = {}, ignorePaths = [
6799
7739
  } finally {
6800
7740
  if (indexFile) {
6801
7741
  try {
6802
- fs15.unlinkSync(indexFile);
7742
+ fs17.unlinkSync(indexFile);
6803
7743
  } catch {
6804
7744
  }
6805
7745
  }
@@ -6868,9 +7808,9 @@ function applyUndo(hash, cwd) {
6868
7808
  timeout: GIT_TIMEOUT
6869
7809
  }).stdout?.toString().trim().split("\n").filter(Boolean) ?? [];
6870
7810
  for (const file of [...tracked, ...untracked]) {
6871
- const fullPath = path17.join(dir, file);
6872
- if (!snapshotFiles.has(file) && fs15.existsSync(fullPath)) {
6873
- fs15.unlinkSync(fullPath);
7811
+ const fullPath = path19.join(dir, file);
7812
+ if (!snapshotFiles.has(file) && fs17.existsSync(fullPath)) {
7813
+ fs17.unlinkSync(fullPath);
6874
7814
  }
6875
7815
  }
6876
7816
  return true;
@@ -6894,9 +7834,9 @@ function registerCheckCommand(program2) {
6894
7834
  } catch (err) {
6895
7835
  const tempConfig = getConfig();
6896
7836
  if (process.env.NODE9_DEBUG === "1" || tempConfig.settings.enableHookLogDebug) {
6897
- const logPath = path18.join(os14.homedir(), ".node9", "hook-debug.log");
7837
+ const logPath = path20.join(os15.homedir(), ".node9", "hook-debug.log");
6898
7838
  const errMsg = err instanceof Error ? err.message : String(err);
6899
- fs16.appendFileSync(
7839
+ fs18.appendFileSync(
6900
7840
  logPath,
6901
7841
  `[${(/* @__PURE__ */ new Date()).toISOString()}] JSON_PARSE_ERROR: ${errMsg}
6902
7842
  RAW: ${raw}
@@ -6907,10 +7847,10 @@ RAW: ${raw}
6907
7847
  }
6908
7848
  const config = getConfig(payload.cwd || void 0);
6909
7849
  if (process.env.NODE9_DEBUG === "1" || config.settings.enableHookLogDebug) {
6910
- const logPath = path18.join(os14.homedir(), ".node9", "hook-debug.log");
6911
- if (!fs16.existsSync(path18.dirname(logPath)))
6912
- fs16.mkdirSync(path18.dirname(logPath), { recursive: true });
6913
- fs16.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
7850
+ const logPath = path20.join(os15.homedir(), ".node9", "hook-debug.log");
7851
+ if (!fs18.existsSync(path20.dirname(logPath)))
7852
+ fs18.mkdirSync(path20.dirname(logPath), { recursive: true });
7853
+ fs18.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] STDIN: ${raw}
6914
7854
  `);
6915
7855
  }
6916
7856
  const toolName = sanitize2(payload.tool_name ?? payload.name ?? "");
@@ -6923,8 +7863,8 @@ RAW: ${raw}
6923
7863
  const isHumanDecision = blockedByContext.toLowerCase().includes("user") || blockedByContext.toLowerCase().includes("daemon") || blockedByContext.toLowerCase().includes("decision");
6924
7864
  let ttyFd = null;
6925
7865
  try {
6926
- ttyFd = fs16.openSync("/dev/tty", "w");
6927
- const writeTty = (line) => fs16.writeSync(ttyFd, line + "\n");
7866
+ ttyFd = fs18.openSync("/dev/tty", "w");
7867
+ const writeTty = (line) => fs18.writeSync(ttyFd, line + "\n");
6928
7868
  if (blockedByContext.includes("DLP") || blockedByContext.includes("Secret Detected") || blockedByContext.includes("Credential Review")) {
6929
7869
  writeTty(chalk5.bgRed.white.bold(`
6930
7870
  \u{1F6A8} NODE9 DLP ALERT \u2014 CREDENTIAL DETECTED `));
@@ -6940,7 +7880,7 @@ RAW: ${raw}
6940
7880
  } finally {
6941
7881
  if (ttyFd !== null)
6942
7882
  try {
6943
- fs16.closeSync(ttyFd);
7883
+ fs18.closeSync(ttyFd);
6944
7884
  } catch {
6945
7885
  }
6946
7886
  }
@@ -6971,7 +7911,7 @@ RAW: ${raw}
6971
7911
  if (shouldSnapshot(toolName, toolInput, config)) {
6972
7912
  await createShadowSnapshot(toolName, toolInput, config.policy.snapshot.ignorePaths);
6973
7913
  }
6974
- const safeCwdForAuth = typeof payload.cwd === "string" && path18.isAbsolute(payload.cwd) ? payload.cwd : void 0;
7914
+ const safeCwdForAuth = typeof payload.cwd === "string" && path20.isAbsolute(payload.cwd) ? payload.cwd : void 0;
6975
7915
  const result = await authorizeHeadless(toolName, toolInput, meta, {
6976
7916
  cwd: safeCwdForAuth
6977
7917
  });
@@ -6983,12 +7923,12 @@ RAW: ${raw}
6983
7923
  }
6984
7924
  if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && !process.stdout.isTTY && config.settings.autoStartDaemon) {
6985
7925
  try {
6986
- const tty = fs16.openSync("/dev/tty", "w");
6987
- fs16.writeSync(
7926
+ const tty = fs18.openSync("/dev/tty", "w");
7927
+ fs18.writeSync(
6988
7928
  tty,
6989
7929
  chalk5.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically...\n")
6990
7930
  );
6991
- fs16.closeSync(tty);
7931
+ fs18.closeSync(tty);
6992
7932
  } catch {
6993
7933
  }
6994
7934
  const daemonReady = await autoStartDaemonAndWait();
@@ -7015,9 +7955,9 @@ RAW: ${raw}
7015
7955
  });
7016
7956
  } catch (err) {
7017
7957
  if (process.env.NODE9_DEBUG === "1") {
7018
- const logPath = path18.join(os14.homedir(), ".node9", "hook-debug.log");
7958
+ const logPath = path20.join(os15.homedir(), ".node9", "hook-debug.log");
7019
7959
  const errMsg = err instanceof Error ? err.message : String(err);
7020
- fs16.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
7960
+ fs18.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] ERROR: ${errMsg}
7021
7961
  `);
7022
7962
  }
7023
7963
  process.exit(0);
@@ -7054,9 +7994,49 @@ RAW: ${raw}
7054
7994
  init_audit();
7055
7995
  init_config();
7056
7996
  init_policy();
7057
- import fs17 from "fs";
7058
- import path19 from "path";
7059
- import os15 from "os";
7997
+ import fs19 from "fs";
7998
+ import path21 from "path";
7999
+ import os16 from "os";
8000
+ init_daemon();
8001
+
8002
+ // src/utils/cp-mv-parser.ts
8003
+ function parseCpMvOp(command) {
8004
+ const trimmed = command.trim();
8005
+ const tokens = trimmed.split(/\s+/);
8006
+ if (tokens.length < 3) return null;
8007
+ const [cmd, ...rest] = tokens;
8008
+ const base = cmd.split("/").pop() ?? cmd;
8009
+ if (base !== "cp" && base !== "mv") return null;
8010
+ const args = [];
8011
+ for (const tok of rest) {
8012
+ if (tok === "--") {
8013
+ args.push(...rest.slice(rest.indexOf("--") + 1));
8014
+ break;
8015
+ }
8016
+ if (tok === "-t" || tok === "--target-directory") return null;
8017
+ if (tok.startsWith("--target-directory=")) return null;
8018
+ if (tok.startsWith("-") && !tok.startsWith("--")) {
8019
+ if (tok.includes("t")) return null;
8020
+ continue;
8021
+ }
8022
+ if (tok.startsWith("--")) {
8023
+ continue;
8024
+ }
8025
+ args.push(tok);
8026
+ }
8027
+ if (args.length !== 2) return null;
8028
+ const [src, dest] = args;
8029
+ if (!src || !dest) return null;
8030
+ if (containsShellMetachar(src) || containsShellMetachar(dest)) return null;
8031
+ return { src, dest, clearSource: base === "mv" };
8032
+ }
8033
+ function containsShellMetachar(token) {
8034
+ if (/[$`{;*?]/.test(token)) return true;
8035
+ if (token.includes("\0")) return true;
8036
+ return false;
8037
+ }
8038
+
8039
+ // src/cli/commands/log.ts
7060
8040
  function sanitize3(value) {
7061
8041
  return value.replace(/[\x00-\x1F\x7F]/g, "");
7062
8042
  }
@@ -7075,11 +8055,20 @@ function registerLogCommand(program2) {
7075
8055
  decision: "allowed",
7076
8056
  source: "post-hook"
7077
8057
  };
7078
- const logPath = path19.join(os15.homedir(), ".node9", "audit.log");
7079
- if (!fs17.existsSync(path19.dirname(logPath)))
7080
- fs17.mkdirSync(path19.dirname(logPath), { recursive: true });
7081
- fs17.appendFileSync(logPath, JSON.stringify(entry) + "\n");
7082
- const safeCwd = typeof payload.cwd === "string" && path19.isAbsolute(payload.cwd) ? payload.cwd : void 0;
8058
+ const logPath = path21.join(os16.homedir(), ".node9", "audit.log");
8059
+ if (!fs19.existsSync(path21.dirname(logPath)))
8060
+ fs19.mkdirSync(path21.dirname(logPath), { recursive: true });
8061
+ fs19.appendFileSync(logPath, JSON.stringify(entry) + "\n");
8062
+ if ((tool === "Bash" || tool === "bash") && isDaemonRunning()) {
8063
+ const command = typeof rawInput === "object" && rawInput !== null && "command" in rawInput && typeof rawInput.command === "string" ? rawInput.command : null;
8064
+ if (command) {
8065
+ const op = parseCpMvOp(command);
8066
+ if (op) {
8067
+ await notifyTaintPropagate(op.src, op.dest, op.clearSource);
8068
+ }
8069
+ }
8070
+ }
8071
+ const safeCwd = typeof payload.cwd === "string" && path21.isAbsolute(payload.cwd) ? payload.cwd : void 0;
7083
8072
  const config = getConfig(safeCwd);
7084
8073
  if (shouldSnapshot(tool, {}, config)) {
7085
8074
  await createShadowSnapshot("unknown", {}, config.policy.snapshot.ignorePaths);
@@ -7088,9 +8077,9 @@ function registerLogCommand(program2) {
7088
8077
  const msg = err instanceof Error ? err.message : String(err);
7089
8078
  process.stderr.write(`[Node9] audit log error: ${msg}
7090
8079
  `);
7091
- const debugPath = path19.join(os15.homedir(), ".node9", "hook-debug.log");
8080
+ const debugPath = path21.join(os16.homedir(), ".node9", "hook-debug.log");
7092
8081
  try {
7093
- fs17.appendFileSync(debugPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] LOG_ERROR: ${msg}
8082
+ fs19.appendFileSync(debugPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] LOG_ERROR: ${msg}
7094
8083
  `);
7095
8084
  } catch {
7096
8085
  }
@@ -7395,13 +8384,13 @@ function registerConfigShowCommand(program2) {
7395
8384
  // src/cli/commands/doctor.ts
7396
8385
  init_daemon();
7397
8386
  import chalk7 from "chalk";
7398
- import fs18 from "fs";
7399
- import path20 from "path";
7400
- import os16 from "os";
8387
+ import fs20 from "fs";
8388
+ import path22 from "path";
8389
+ import os17 from "os";
7401
8390
  import { execSync as execSync2 } from "child_process";
7402
8391
  function registerDoctorCommand(program2, version2) {
7403
8392
  program2.command("doctor").description("Check that Node9 is installed and configured correctly").action(() => {
7404
- const homeDir2 = os16.homedir();
8393
+ const homeDir2 = os17.homedir();
7405
8394
  let failures = 0;
7406
8395
  function pass(msg) {
7407
8396
  console.log(chalk7.green(" \u2705 ") + msg);
@@ -7450,10 +8439,10 @@ function registerDoctorCommand(program2, version2) {
7450
8439
  );
7451
8440
  }
7452
8441
  section("Configuration");
7453
- const globalConfigPath = path20.join(homeDir2, ".node9", "config.json");
7454
- if (fs18.existsSync(globalConfigPath)) {
8442
+ const globalConfigPath = path22.join(homeDir2, ".node9", "config.json");
8443
+ if (fs20.existsSync(globalConfigPath)) {
7455
8444
  try {
7456
- JSON.parse(fs18.readFileSync(globalConfigPath, "utf-8"));
8445
+ JSON.parse(fs20.readFileSync(globalConfigPath, "utf-8"));
7457
8446
  pass("~/.node9/config.json found and valid");
7458
8447
  } catch {
7459
8448
  fail("~/.node9/config.json is invalid JSON", "Run: node9 init --force");
@@ -7461,10 +8450,10 @@ function registerDoctorCommand(program2, version2) {
7461
8450
  } else {
7462
8451
  warn("~/.node9/config.json not found (using defaults)", "Run: node9 init");
7463
8452
  }
7464
- const projectConfigPath = path20.join(process.cwd(), "node9.config.json");
7465
- if (fs18.existsSync(projectConfigPath)) {
8453
+ const projectConfigPath = path22.join(process.cwd(), "node9.config.json");
8454
+ if (fs20.existsSync(projectConfigPath)) {
7466
8455
  try {
7467
- JSON.parse(fs18.readFileSync(projectConfigPath, "utf-8"));
8456
+ JSON.parse(fs20.readFileSync(projectConfigPath, "utf-8"));
7468
8457
  pass("node9.config.json found and valid (project)");
7469
8458
  } catch {
7470
8459
  fail(
@@ -7473,8 +8462,8 @@ function registerDoctorCommand(program2, version2) {
7473
8462
  );
7474
8463
  }
7475
8464
  }
7476
- const credsPath = path20.join(homeDir2, ".node9", "credentials.json");
7477
- if (fs18.existsSync(credsPath)) {
8465
+ const credsPath = path22.join(homeDir2, ".node9", "credentials.json");
8466
+ if (fs20.existsSync(credsPath)) {
7478
8467
  pass("Cloud credentials found (~/.node9/credentials.json)");
7479
8468
  } else {
7480
8469
  warn(
@@ -7483,10 +8472,10 @@ function registerDoctorCommand(program2, version2) {
7483
8472
  );
7484
8473
  }
7485
8474
  section("Agent Hooks");
7486
- const claudeSettingsPath = path20.join(homeDir2, ".claude", "settings.json");
7487
- if (fs18.existsSync(claudeSettingsPath)) {
8475
+ const claudeSettingsPath = path22.join(homeDir2, ".claude", "settings.json");
8476
+ if (fs20.existsSync(claudeSettingsPath)) {
7488
8477
  try {
7489
- const cs = JSON.parse(fs18.readFileSync(claudeSettingsPath, "utf-8"));
8478
+ const cs = JSON.parse(fs20.readFileSync(claudeSettingsPath, "utf-8"));
7490
8479
  const hasHook = cs.hooks?.PreToolUse?.some(
7491
8480
  (m) => m.hooks.some((h) => h.command?.includes("node9") || h.command?.includes("cli.js"))
7492
8481
  );
@@ -7502,10 +8491,10 @@ function registerDoctorCommand(program2, version2) {
7502
8491
  } else {
7503
8492
  warn("Claude Code \u2014 not configured", "Run: node9 setup claude");
7504
8493
  }
7505
- const geminiSettingsPath = path20.join(homeDir2, ".gemini", "settings.json");
7506
- if (fs18.existsSync(geminiSettingsPath)) {
8494
+ const geminiSettingsPath = path22.join(homeDir2, ".gemini", "settings.json");
8495
+ if (fs20.existsSync(geminiSettingsPath)) {
7507
8496
  try {
7508
- const gs = JSON.parse(fs18.readFileSync(geminiSettingsPath, "utf-8"));
8497
+ const gs = JSON.parse(fs20.readFileSync(geminiSettingsPath, "utf-8"));
7509
8498
  const hasHook = gs.hooks?.BeforeTool?.some(
7510
8499
  (m) => m.hooks.some((h) => h.command?.includes("node9") || h.command?.includes("cli.js"))
7511
8500
  );
@@ -7521,10 +8510,10 @@ function registerDoctorCommand(program2, version2) {
7521
8510
  } else {
7522
8511
  warn("Gemini CLI \u2014 not configured", "Run: node9 setup gemini (skip if not using Gemini)");
7523
8512
  }
7524
- const cursorHooksPath = path20.join(homeDir2, ".cursor", "hooks.json");
7525
- if (fs18.existsSync(cursorHooksPath)) {
8513
+ const cursorHooksPath = path22.join(homeDir2, ".cursor", "hooks.json");
8514
+ if (fs20.existsSync(cursorHooksPath)) {
7526
8515
  try {
7527
- const cur = JSON.parse(fs18.readFileSync(cursorHooksPath, "utf-8"));
8516
+ const cur = JSON.parse(fs20.readFileSync(cursorHooksPath, "utf-8"));
7528
8517
  const hasHook = cur.hooks?.preToolUse?.some(
7529
8518
  (h) => h.command?.includes("node9") || h.command?.includes("cli.js")
7530
8519
  );
@@ -7562,9 +8551,9 @@ function registerDoctorCommand(program2, version2) {
7562
8551
 
7563
8552
  // src/cli/commands/audit.ts
7564
8553
  import chalk8 from "chalk";
7565
- import fs19 from "fs";
7566
- import path21 from "path";
7567
- import os17 from "os";
8554
+ import fs21 from "fs";
8555
+ import path23 from "path";
8556
+ import os18 from "os";
7568
8557
  function formatRelativeTime(timestamp) {
7569
8558
  const diff = Date.now() - new Date(timestamp).getTime();
7570
8559
  const sec = Math.floor(diff / 1e3);
@@ -7577,14 +8566,14 @@ function formatRelativeTime(timestamp) {
7577
8566
  }
7578
8567
  function registerAuditCommand(program2) {
7579
8568
  program2.command("audit").description("View local execution audit log").option("--tail <n>", "Number of entries to show", "20").option("--tool <pattern>", "Filter by tool name (substring match)").option("--deny", "Show only denied actions").option("--json", "Output raw JSON").action((options) => {
7580
- const logPath = path21.join(os17.homedir(), ".node9", "audit.log");
7581
- if (!fs19.existsSync(logPath)) {
8569
+ const logPath = path23.join(os18.homedir(), ".node9", "audit.log");
8570
+ if (!fs21.existsSync(logPath)) {
7582
8571
  console.log(
7583
8572
  chalk8.yellow("No audit logs found. Run node9 with an agent to generate entries.")
7584
8573
  );
7585
8574
  return;
7586
8575
  }
7587
- const raw = fs19.readFileSync(logPath, "utf-8");
8576
+ const raw = fs21.readFileSync(logPath, "utf-8");
7588
8577
  const lines = raw.split("\n").filter((l) => l.trim() !== "");
7589
8578
  let entries = lines.flatMap((line) => {
7590
8579
  try {
@@ -7704,9 +8693,42 @@ function registerDaemonCommand(program2) {
7704
8693
  init_core();
7705
8694
  init_daemon();
7706
8695
  import chalk10 from "chalk";
7707
- import fs20 from "fs";
7708
- import path22 from "path";
7709
- import os18 from "os";
8696
+ import fs22 from "fs";
8697
+ import path24 from "path";
8698
+ import os19 from "os";
8699
+ function readJson2(filePath) {
8700
+ try {
8701
+ if (fs22.existsSync(filePath)) return JSON.parse(fs22.readFileSync(filePath, "utf-8"));
8702
+ } catch {
8703
+ }
8704
+ return null;
8705
+ }
8706
+ function isNode9Hook2(cmd) {
8707
+ if (!cmd) return false;
8708
+ return /(?:^|[\s/\\])node9 (?:check|log)/.test(cmd) || /(?:^|[\s/\\])cli\.js (?:check|log)/.test(cmd);
8709
+ }
8710
+ function wrappedMcpServers(servers) {
8711
+ if (!servers) return [];
8712
+ return Object.entries(servers).filter(([, s]) => s.command === "node9" && Array.isArray(s.args) && s.args.length > 0).map(([name, s]) => `${name} \u2192 ${s.args.join(" ")}`);
8713
+ }
8714
+ function printAgentSection(label, hookPairs, wrapped) {
8715
+ console.log(chalk10.bold(` ${label}`));
8716
+ for (const { name, present } of hookPairs) {
8717
+ if (present) {
8718
+ console.log(chalk10.green(` \u2713 ${name}`));
8719
+ } else {
8720
+ console.log(chalk10.red(` \u2717 ${name}`) + chalk10.gray(" (not wired)"));
8721
+ }
8722
+ }
8723
+ if (wrapped.length > 0) {
8724
+ console.log(chalk10.cyan(` MCP proxied:`));
8725
+ for (const entry of wrapped) {
8726
+ console.log(chalk10.gray(` \u2022 ${entry}`));
8727
+ }
8728
+ } else {
8729
+ console.log(chalk10.gray(` MCP proxied: none`));
8730
+ }
8731
+ }
7710
8732
  function registerStatusCommand(program2) {
7711
8733
  program2.command("status").description("Show current Node9 mode, policy source, and persistent decisions").action(() => {
7712
8734
  const creds = getCredentials();
@@ -7741,19 +8763,72 @@ function registerStatusCommand(program2) {
7741
8763
  console.log("");
7742
8764
  const modeLabel = settings.mode === "audit" ? chalk10.blue("audit") : settings.mode === "strict" ? chalk10.red("strict") : chalk10.white("standard");
7743
8765
  console.log(` Mode: ${modeLabel}`);
7744
- const projectConfig = path22.join(process.cwd(), "node9.config.json");
7745
- const globalConfig = path22.join(os18.homedir(), ".node9", "config.json");
8766
+ const projectConfig = path24.join(process.cwd(), "node9.config.json");
8767
+ const globalConfig = path24.join(os19.homedir(), ".node9", "config.json");
7746
8768
  console.log(
7747
- ` Local: ${fs20.existsSync(projectConfig) ? chalk10.green("Active (node9.config.json)") : chalk10.gray("Not present")}`
8769
+ ` Local: ${fs22.existsSync(projectConfig) ? chalk10.green("Active (node9.config.json)") : chalk10.gray("Not present")}`
7748
8770
  );
7749
8771
  console.log(
7750
- ` Global: ${fs20.existsSync(globalConfig) ? chalk10.green("Active (~/.node9/config.json)") : chalk10.gray("Not present")}`
8772
+ ` Global: ${fs22.existsSync(globalConfig) ? chalk10.green("Active (~/.node9/config.json)") : chalk10.gray("Not present")}`
7751
8773
  );
7752
8774
  if (mergedConfig.policy.sandboxPaths.length > 0) {
7753
8775
  console.log(
7754
8776
  ` Sandbox: ${chalk10.green(`${mergedConfig.policy.sandboxPaths.length} safe zones active`)}`
7755
8777
  );
7756
8778
  }
8779
+ const homeDir2 = os19.homedir();
8780
+ const claudeSettings = readJson2(
8781
+ path24.join(homeDir2, ".claude", "settings.json")
8782
+ );
8783
+ const claudeConfig = readJson2(path24.join(homeDir2, ".claude.json"));
8784
+ const geminiSettings = readJson2(
8785
+ path24.join(homeDir2, ".gemini", "settings.json")
8786
+ );
8787
+ const cursorConfig = readJson2(path24.join(homeDir2, ".cursor", "mcp.json"));
8788
+ const agentFound = claudeSettings || claudeConfig || geminiSettings || cursorConfig;
8789
+ if (agentFound) {
8790
+ console.log("");
8791
+ console.log(chalk10.bold(" Agent Wiring:"));
8792
+ console.log("");
8793
+ if (claudeSettings || claudeConfig) {
8794
+ const preHook = claudeSettings?.hooks?.PreToolUse?.some(
8795
+ (m) => m.hooks.some((h) => isNode9Hook2(h.command))
8796
+ ) ?? false;
8797
+ const postHook = claudeSettings?.hooks?.PostToolUse?.some(
8798
+ (m) => m.hooks.some((h) => isNode9Hook2(h.command))
8799
+ ) ?? false;
8800
+ printAgentSection(
8801
+ "Claude Code",
8802
+ [
8803
+ { name: "PreToolUse (node9 check)", present: preHook },
8804
+ { name: "PostToolUse (node9 log)", present: postHook }
8805
+ ],
8806
+ wrappedMcpServers(claudeConfig?.mcpServers)
8807
+ );
8808
+ console.log("");
8809
+ }
8810
+ if (geminiSettings) {
8811
+ const beforeHook = geminiSettings.hooks?.BeforeTool?.some(
8812
+ (m) => m.hooks.some((h) => isNode9Hook2(h.command))
8813
+ ) ?? false;
8814
+ const afterHook = geminiSettings.hooks?.AfterTool?.some(
8815
+ (m) => m.hooks.some((h) => isNode9Hook2(h.command))
8816
+ ) ?? false;
8817
+ printAgentSection(
8818
+ "Gemini CLI",
8819
+ [
8820
+ { name: "BeforeTool (node9 check)", present: beforeHook },
8821
+ { name: "AfterTool (node9 log)", present: afterHook }
8822
+ ],
8823
+ wrappedMcpServers(geminiSettings.mcpServers)
8824
+ );
8825
+ console.log("");
8826
+ }
8827
+ if (cursorConfig) {
8828
+ printAgentSection("Cursor", [], wrappedMcpServers(cursorConfig.mcpServers));
8829
+ console.log("");
8830
+ }
8831
+ }
7757
8832
  const pauseState = checkPause();
7758
8833
  if (pauseState.paused) {
7759
8834
  const expiresAt = pauseState.expiresAt ? new Date(pauseState.expiresAt).toLocaleTimeString() : "indefinitely";
@@ -7766,8 +8841,63 @@ function registerStatusCommand(program2) {
7766
8841
  });
7767
8842
  }
7768
8843
 
7769
- // src/cli/commands/undo.ts
8844
+ // src/cli/commands/init.ts
8845
+ init_core();
7770
8846
  import chalk11 from "chalk";
8847
+ import fs23 from "fs";
8848
+ import path25 from "path";
8849
+ import os20 from "os";
8850
+ function registerInitCommand(program2) {
8851
+ program2.command("init").description("Set up Node9: create config and wire all detected AI agents").option("--force", "Overwrite existing config").option("-m, --mode <mode>", "Set initial security mode (standard, strict, audit)", "standard").option("--skip-setup", "Only create config \u2014 do not wire AI agents").action(async (options) => {
8852
+ console.log(chalk11.cyan.bold("\n\u{1F6E1}\uFE0F Node9 Init\n"));
8853
+ const configPath = path25.join(os20.homedir(), ".node9", "config.json");
8854
+ if (fs23.existsSync(configPath) && !options.force) {
8855
+ console.log(chalk11.blue(`\u2139\uFE0F Config already exists: ${configPath}`));
8856
+ } else {
8857
+ const requestedMode = options.mode.toLowerCase();
8858
+ const safeMode = ["standard", "strict", "audit"].includes(requestedMode) ? requestedMode : DEFAULT_CONFIG.settings.mode;
8859
+ const configToSave = {
8860
+ ...DEFAULT_CONFIG,
8861
+ settings: { ...DEFAULT_CONFIG.settings, mode: safeMode }
8862
+ };
8863
+ const dir = path25.dirname(configPath);
8864
+ if (!fs23.existsSync(dir)) fs23.mkdirSync(dir, { recursive: true });
8865
+ fs23.writeFileSync(configPath, JSON.stringify(configToSave, null, 2));
8866
+ console.log(chalk11.green(`\u2705 Config created: ${configPath}`));
8867
+ console.log(chalk11.gray(` Mode: ${safeMode}`));
8868
+ }
8869
+ if (options.skipSetup) return;
8870
+ console.log("");
8871
+ const detected = detectAgents();
8872
+ const found = Object.keys(detected).filter(
8873
+ (k) => detected[k]
8874
+ );
8875
+ if (found.length === 0) {
8876
+ console.log(
8877
+ chalk11.gray("No AI agents detected. Install Claude Code, Gemini CLI, or Cursor")
8878
+ );
8879
+ console.log(chalk11.gray("then run: node9 addto <claude|gemini|cursor>"));
8880
+ return;
8881
+ }
8882
+ console.log(chalk11.bold("Detected agents:"));
8883
+ for (const agent of found) {
8884
+ console.log(chalk11.green(` \u2713 ${agent}`));
8885
+ }
8886
+ console.log("");
8887
+ for (const agent of found) {
8888
+ console.log(chalk11.bold(`Wiring ${agent}...`));
8889
+ if (agent === "claude") await setupClaude();
8890
+ else if (agent === "gemini") await setupGemini();
8891
+ else if (agent === "cursor") await setupCursor();
8892
+ console.log("");
8893
+ }
8894
+ console.log(chalk11.green.bold("\u{1F6E1}\uFE0F Node9 is ready!"));
8895
+ console.log(chalk11.gray(" Run: node9 daemon start"));
8896
+ });
8897
+ }
8898
+
8899
+ // src/cli/commands/undo.ts
8900
+ import chalk12 from "chalk";
7771
8901
  import { confirm as confirm2 } from "@inquirer/prompts";
7772
8902
  function registerUndoCommand(program2) {
7773
8903
  program2.command("undo").description(
@@ -7779,22 +8909,22 @@ function registerUndoCommand(program2) {
7779
8909
  if (history.length === 0) {
7780
8910
  if (!options.all && allHistory.length > 0) {
7781
8911
  console.log(
7782
- chalk11.yellow(
8912
+ chalk12.yellow(
7783
8913
  `
7784
8914
  \u2139\uFE0F No snapshots found for the current directory (${process.cwd()}).
7785
- Run ${chalk11.cyan("node9 undo --all")} to see snapshots from all projects.
8915
+ Run ${chalk12.cyan("node9 undo --all")} to see snapshots from all projects.
7786
8916
  `
7787
8917
  )
7788
8918
  );
7789
8919
  } else {
7790
- console.log(chalk11.yellow("\n\u2139\uFE0F No undo snapshots found.\n"));
8920
+ console.log(chalk12.yellow("\n\u2139\uFE0F No undo snapshots found.\n"));
7791
8921
  }
7792
8922
  return;
7793
8923
  }
7794
8924
  const idx = history.length - steps;
7795
8925
  if (idx < 0) {
7796
8926
  console.log(
7797
- chalk11.yellow(
8927
+ chalk12.yellow(
7798
8928
  `
7799
8929
  \u2139\uFE0F Only ${history.length} snapshot(s) available, cannot go back ${steps}.
7800
8930
  `
@@ -7806,19 +8936,19 @@ function registerUndoCommand(program2) {
7806
8936
  const age = Math.round((Date.now() - snapshot.timestamp) / 1e3);
7807
8937
  const ageStr = age < 60 ? `${age}s ago` : age < 3600 ? `${Math.round(age / 60)}m ago` : `${Math.round(age / 3600)}h ago`;
7808
8938
  console.log(
7809
- chalk11.magenta.bold(`
8939
+ chalk12.magenta.bold(`
7810
8940
  \u23EA Node9 Undo${steps > 1 ? ` (${steps} steps back)` : ""}`)
7811
8941
  );
7812
8942
  console.log(
7813
- chalk11.white(
7814
- ` Tool: ${chalk11.cyan(snapshot.tool)}${snapshot.argsSummary ? chalk11.gray(" \u2192 " + snapshot.argsSummary) : ""}`
8943
+ chalk12.white(
8944
+ ` Tool: ${chalk12.cyan(snapshot.tool)}${snapshot.argsSummary ? chalk12.gray(" \u2192 " + snapshot.argsSummary) : ""}`
7815
8945
  )
7816
8946
  );
7817
- console.log(chalk11.white(` When: ${chalk11.gray(ageStr)}`));
7818
- console.log(chalk11.white(` Dir: ${chalk11.gray(snapshot.cwd)}`));
8947
+ console.log(chalk12.white(` When: ${chalk12.gray(ageStr)}`));
8948
+ console.log(chalk12.white(` Dir: ${chalk12.gray(snapshot.cwd)}`));
7819
8949
  if (steps > 1)
7820
8950
  console.log(
7821
- chalk11.yellow(` Note: This will also undo the ${steps - 1} action(s) after it.`)
8951
+ chalk12.yellow(` Note: This will also undo the ${steps - 1} action(s) after it.`)
7822
8952
  );
7823
8953
  console.log("");
7824
8954
  const diff = computeUndoDiff(snapshot.hash, snapshot.cwd);
@@ -7826,21 +8956,21 @@ function registerUndoCommand(program2) {
7826
8956
  const lines = diff.split("\n");
7827
8957
  for (const line of lines) {
7828
8958
  if (line.startsWith("+++") || line.startsWith("---")) {
7829
- console.log(chalk11.bold(line));
8959
+ console.log(chalk12.bold(line));
7830
8960
  } else if (line.startsWith("+")) {
7831
- console.log(chalk11.green(line));
8961
+ console.log(chalk12.green(line));
7832
8962
  } else if (line.startsWith("-")) {
7833
- console.log(chalk11.red(line));
8963
+ console.log(chalk12.red(line));
7834
8964
  } else if (line.startsWith("@@")) {
7835
- console.log(chalk11.cyan(line));
8965
+ console.log(chalk12.cyan(line));
7836
8966
  } else {
7837
- console.log(chalk11.gray(line));
8967
+ console.log(chalk12.gray(line));
7838
8968
  }
7839
8969
  }
7840
8970
  console.log("");
7841
8971
  } else {
7842
8972
  console.log(
7843
- chalk11.gray(" (no diff available \u2014 working tree may already match snapshot)\n")
8973
+ chalk12.gray(" (no diff available \u2014 working tree may already match snapshot)\n")
7844
8974
  );
7845
8975
  }
7846
8976
  const proceed = await confirm2({
@@ -7849,19 +8979,19 @@ function registerUndoCommand(program2) {
7849
8979
  });
7850
8980
  if (proceed) {
7851
8981
  if (applyUndo(snapshot.hash, snapshot.cwd)) {
7852
- console.log(chalk11.green("\n\u2705 Reverted successfully.\n"));
8982
+ console.log(chalk12.green("\n\u2705 Reverted successfully.\n"));
7853
8983
  } else {
7854
- console.error(chalk11.red("\n\u274C Undo failed. Ensure you are in a Git repository.\n"));
8984
+ console.error(chalk12.red("\n\u274C Undo failed. Ensure you are in a Git repository.\n"));
7855
8985
  }
7856
8986
  } else {
7857
- console.log(chalk11.gray("\nCancelled.\n"));
8987
+ console.log(chalk12.gray("\nCancelled.\n"));
7858
8988
  }
7859
8989
  });
7860
8990
  }
7861
8991
 
7862
8992
  // src/cli/commands/watch.ts
7863
8993
  init_daemon();
7864
- import chalk12 from "chalk";
8994
+ import chalk13 from "chalk";
7865
8995
  import { spawn as spawn7, spawnSync as spawnSync5 } from "child_process";
7866
8996
  function registerWatchCommand(program2) {
7867
8997
  program2.command("watch").description("Run a command under Node9 watch mode (daemon stays alive for the session)").argument("<command>", "Command to run").argument("[args...]", "Arguments for the command").action(async (cmd, args) => {
@@ -7877,7 +9007,7 @@ function registerWatchCommand(program2) {
7877
9007
  throw new Error("not running");
7878
9008
  }
7879
9009
  } catch {
7880
- console.error(chalk12.dim("\u{1F6E1}\uFE0F Starting Node9 daemon (watch mode)..."));
9010
+ console.error(chalk13.dim("\u{1F6E1}\uFE0F Starting Node9 daemon (watch mode)..."));
7881
9011
  const child = spawn7(process.execPath, [process.argv[1], "daemon"], {
7882
9012
  detached: true,
7883
9013
  stdio: "ignore",
@@ -7899,12 +9029,12 @@ function registerWatchCommand(program2) {
7899
9029
  }
7900
9030
  }
7901
9031
  if (!ready) {
7902
- console.error(chalk12.red("\u274C Daemon failed to start. Try: node9 daemon start"));
9032
+ console.error(chalk13.red("\u274C Daemon failed to start. Try: node9 daemon start"));
7903
9033
  process.exit(1);
7904
9034
  }
7905
9035
  }
7906
9036
  console.error(
7907
- chalk12.cyan.bold("\u{1F6E1}\uFE0F Node9 watch") + chalk12.dim(` \u2192 localhost:${port}`) + chalk12.dim(
9037
+ chalk13.cyan.bold("\u{1F6E1}\uFE0F Node9 watch") + chalk13.dim(` \u2192 localhost:${port}`) + chalk13.dim(
7908
9038
  "\n Tip: run `node9 tail` in another terminal to review and approve AI actions.\n"
7909
9039
  )
7910
9040
  );
@@ -7913,7 +9043,7 @@ function registerWatchCommand(program2) {
7913
9043
  env: { ...process.env, NODE9_WATCH_MODE: "1" }
7914
9044
  });
7915
9045
  if (result.error) {
7916
- console.error(chalk12.red(`\u274C Failed to run command: ${result.error.message}`));
9046
+ console.error(chalk13.red(`\u274C Failed to run command: ${result.error.message}`));
7917
9047
  process.exit(1);
7918
9048
  }
7919
9049
  process.exit(result.status ?? 0);
@@ -7923,7 +9053,7 @@ function registerWatchCommand(program2) {
7923
9053
  // src/mcp-gateway/index.ts
7924
9054
  init_orchestrator();
7925
9055
  import readline2 from "readline";
7926
- import chalk13 from "chalk";
9056
+ import chalk14 from "chalk";
7927
9057
  import { spawn as spawn8 } from "child_process";
7928
9058
  import { execa as execa2 } from "execa";
7929
9059
  init_provenance();
@@ -7986,13 +9116,13 @@ async function runMcpGateway(upstreamCommand) {
7986
9116
  const prov = checkProvenance(executable);
7987
9117
  if (prov.trustLevel === "suspect") {
7988
9118
  console.error(
7989
- chalk13.red(
9119
+ chalk14.red(
7990
9120
  `\u26A0\uFE0F Node9: Upstream MCP server binary is suspect \u2014 ${prov.reason} (${prov.resolvedPath})`
7991
9121
  )
7992
9122
  );
7993
- console.error(chalk13.red(" Verify this binary is trusted before proceeding."));
9123
+ console.error(chalk14.red(" Verify this binary is trusted before proceeding."));
7994
9124
  }
7995
- console.error(chalk13.green(`\u{1F680} Node9 MCP Gateway: Monitoring [${upstreamCommand}]`));
9125
+ console.error(chalk14.green(`\u{1F680} Node9 MCP Gateway: Monitoring [${upstreamCommand}]`));
7996
9126
  const UPSTREAM_INJECTOR_VARS = /* @__PURE__ */ new Set([
7997
9127
  "NODE_OPTIONS",
7998
9128
  "NODE_PATH",
@@ -8056,10 +9186,10 @@ async function runMcpGateway(upstreamCommand) {
8056
9186
  mcpServer
8057
9187
  });
8058
9188
  if (!result.approved) {
8059
- console.error(chalk13.red(`
9189
+ console.error(chalk14.red(`
8060
9190
  \u{1F6D1} Node9 MCP Gateway: Action Blocked`));
8061
- console.error(chalk13.gray(` Tool: ${toolName}`));
8062
- console.error(chalk13.gray(` Reason: ${result.reason ?? "Security Policy"}
9191
+ console.error(chalk14.gray(` Tool: ${toolName}`));
9192
+ console.error(chalk14.gray(` Reason: ${result.reason ?? "Security Policy"}
8063
9193
  `));
8064
9194
  const blockedByLabel = result.blockedByLabel ?? result.reason ?? "Security Policy";
8065
9195
  const isHumanDecision = blockedByLabel.toLowerCase().includes("user") || blockedByLabel.toLowerCase().includes("daemon") || blockedByLabel.toLowerCase().includes("decision");
@@ -8133,7 +9263,7 @@ function registerMcpGatewayCommand(program2) {
8133
9263
 
8134
9264
  // src/cli/commands/trust.ts
8135
9265
  init_trusted_hosts();
8136
- import chalk14 from "chalk";
9266
+ import chalk15 from "chalk";
8137
9267
  function isValidHost(host) {
8138
9268
  return /^(\*\.)?[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/.test(host);
8139
9269
  }
@@ -8143,44 +9273,44 @@ function registerTrustCommand(program2) {
8143
9273
  const normalized = normalizeHost(host.trim());
8144
9274
  if (!isValidHost(normalized)) {
8145
9275
  console.error(
8146
- chalk14.red(`
9276
+ chalk15.red(`
8147
9277
  \u274C Invalid host: "${host}"
8148
- `) + chalk14.gray(" Use an FQDN like api.mycompany.com or *.mycompany.com\n")
9278
+ `) + chalk15.gray(" Use an FQDN like api.mycompany.com or *.mycompany.com\n")
8149
9279
  );
8150
9280
  process.exit(1);
8151
9281
  }
8152
9282
  addTrustedHost(normalized);
8153
- console.log(chalk14.green(`
9283
+ console.log(chalk15.green(`
8154
9284
  \u2705 ${normalized} added to trusted hosts.`));
8155
9285
  console.log(
8156
- chalk14.gray(" Pipe-chain blocks to this host: critical \u2192 review, high \u2192 allow\n")
9286
+ chalk15.gray(" Pipe-chain blocks to this host: critical \u2192 review, high \u2192 allow\n")
8157
9287
  );
8158
9288
  });
8159
9289
  trustCmd.command("remove <host>").description("Remove a trusted host").action((host) => {
8160
9290
  const normalized = normalizeHost(host.trim());
8161
9291
  const removed = removeTrustedHost(normalized);
8162
9292
  if (!removed) {
8163
- console.error(chalk14.yellow(`
9293
+ console.error(chalk15.yellow(`
8164
9294
  \u26A0\uFE0F "${normalized}" is not in the trusted hosts list.
8165
9295
  `));
8166
9296
  process.exit(1);
8167
9297
  }
8168
- console.log(chalk14.green(`
9298
+ console.log(chalk15.green(`
8169
9299
  \u2705 ${normalized} removed from trusted hosts.
8170
9300
  `));
8171
9301
  });
8172
9302
  trustCmd.command("list").description("Show all trusted hosts").action(() => {
8173
9303
  const hosts = readTrustedHosts();
8174
9304
  if (hosts.length === 0) {
8175
- console.log(chalk14.gray("\n No trusted hosts configured.\n"));
8176
- console.log(` Add one: ${chalk14.cyan("node9 trust add api.mycompany.com")}
9305
+ console.log(chalk15.gray("\n No trusted hosts configured.\n"));
9306
+ console.log(` Add one: ${chalk15.cyan("node9 trust add api.mycompany.com")}
8177
9307
  `);
8178
9308
  return;
8179
9309
  }
8180
- console.log(chalk14.bold("\n\u{1F513} Trusted Hosts\n"));
9310
+ console.log(chalk15.bold("\n\u{1F513} Trusted Hosts\n"));
8181
9311
  for (const entry of hosts) {
8182
9312
  const date = new Date(entry.addedAt).toLocaleDateString();
8183
- console.log(` ${chalk14.cyan(entry.host.padEnd(40))} ${chalk14.gray(`added ${date}`)}`);
9313
+ console.log(` ${chalk15.cyan(entry.host.padEnd(40))} ${chalk15.gray(`added ${date}`)}`);
8184
9314
  }
8185
9315
  console.log("");
8186
9316
  });
@@ -8188,20 +9318,20 @@ function registerTrustCommand(program2) {
8188
9318
 
8189
9319
  // src/cli.ts
8190
9320
  var { version } = JSON.parse(
8191
- fs22.readFileSync(path24.join(__dirname, "../package.json"), "utf-8")
9321
+ fs25.readFileSync(path27.join(__dirname, "../package.json"), "utf-8")
8192
9322
  );
8193
9323
  var program = new Command();
8194
9324
  program.name("node9").description("The Sudo Command for AI Agents").version(version);
8195
9325
  program.command("login").argument("<apiKey>").option("--local", "Save key for audit/logging only \u2014 local config still controls all decisions").option("--profile <name>", 'Save as a named profile (default: "default")').action((apiKey, options) => {
8196
9326
  const DEFAULT_API_URL = "https://api.node9.ai/api/v1/intercept";
8197
- const credPath = path24.join(os20.homedir(), ".node9", "credentials.json");
8198
- if (!fs22.existsSync(path24.dirname(credPath)))
8199
- fs22.mkdirSync(path24.dirname(credPath), { recursive: true });
9327
+ const credPath = path27.join(os22.homedir(), ".node9", "credentials.json");
9328
+ if (!fs25.existsSync(path27.dirname(credPath)))
9329
+ fs25.mkdirSync(path27.dirname(credPath), { recursive: true });
8200
9330
  const profileName = options.profile || "default";
8201
9331
  let existingCreds = {};
8202
9332
  try {
8203
- if (fs22.existsSync(credPath)) {
8204
- const raw = JSON.parse(fs22.readFileSync(credPath, "utf-8"));
9333
+ if (fs25.existsSync(credPath)) {
9334
+ const raw = JSON.parse(fs25.readFileSync(credPath, "utf-8"));
8205
9335
  if (raw.apiKey) {
8206
9336
  existingCreds = {
8207
9337
  default: { apiKey: raw.apiKey, apiUrl: raw.apiUrl || DEFAULT_API_URL }
@@ -8213,13 +9343,13 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
8213
9343
  } catch {
8214
9344
  }
8215
9345
  existingCreds[profileName] = { apiKey, apiUrl: DEFAULT_API_URL };
8216
- fs22.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
9346
+ fs25.writeFileSync(credPath, JSON.stringify(existingCreds, null, 2), { mode: 384 });
8217
9347
  if (profileName === "default") {
8218
- const configPath = path24.join(os20.homedir(), ".node9", "config.json");
9348
+ const configPath = path27.join(os22.homedir(), ".node9", "config.json");
8219
9349
  let config = {};
8220
9350
  try {
8221
- if (fs22.existsSync(configPath))
8222
- config = JSON.parse(fs22.readFileSync(configPath, "utf-8"));
9351
+ if (fs25.existsSync(configPath))
9352
+ config = JSON.parse(fs25.readFileSync(configPath, "utf-8"));
8223
9353
  } catch {
8224
9354
  }
8225
9355
  if (!config.settings || typeof config.settings !== "object") config.settings = {};
@@ -8234,36 +9364,36 @@ program.command("login").argument("<apiKey>").option("--local", "Save key for au
8234
9364
  approvers.cloud = false;
8235
9365
  }
8236
9366
  s.approvers = approvers;
8237
- if (!fs22.existsSync(path24.dirname(configPath)))
8238
- fs22.mkdirSync(path24.dirname(configPath), { recursive: true });
8239
- fs22.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
9367
+ if (!fs25.existsSync(path27.dirname(configPath)))
9368
+ fs25.mkdirSync(path27.dirname(configPath), { recursive: true });
9369
+ fs25.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 384 });
8240
9370
  }
8241
9371
  if (options.profile && profileName !== "default") {
8242
- console.log(chalk16.green(`\u2705 Profile "${profileName}" saved`));
8243
- console.log(chalk16.gray(` Switch to it per-session: NODE9_PROFILE=${profileName} claude`));
9372
+ console.log(chalk17.green(`\u2705 Profile "${profileName}" saved`));
9373
+ console.log(chalk17.gray(` Switch to it per-session: NODE9_PROFILE=${profileName} claude`));
8244
9374
  } else if (options.local) {
8245
- console.log(chalk16.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
8246
- console.log(chalk16.gray(` All decisions stay on this machine.`));
9375
+ console.log(chalk17.green(`\u2705 Privacy mode \u{1F6E1}\uFE0F`));
9376
+ console.log(chalk17.gray(` All decisions stay on this machine.`));
8247
9377
  } else {
8248
- console.log(chalk16.green(`\u2705 Logged in \u2014 agent mode`));
8249
- console.log(chalk16.gray(` Team policy enforced for all calls via Node9 cloud.`));
9378
+ console.log(chalk17.green(`\u2705 Logged in \u2014 agent mode`));
9379
+ console.log(chalk17.gray(` Team policy enforced for all calls via Node9 cloud.`));
8250
9380
  }
8251
9381
  });
8252
9382
  program.command("addto").description("Integrate Node9 with an AI agent").addHelpText("after", "\n Supported targets: claude gemini cursor").argument("<target>", "The agent to protect: claude | gemini | cursor").action(async (target) => {
8253
9383
  if (target === "gemini") return await setupGemini();
8254
9384
  if (target === "claude") return await setupClaude();
8255
9385
  if (target === "cursor") return await setupCursor();
8256
- console.error(chalk16.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
9386
+ console.error(chalk17.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
8257
9387
  process.exit(1);
8258
9388
  });
8259
9389
  program.command("setup").description('Alias for "addto" \u2014 integrate Node9 with an AI agent').addHelpText("after", "\n Supported targets: claude gemini cursor").argument("[target]", "The agent to protect: claude | gemini | cursor").action(async (target) => {
8260
9390
  if (!target) {
8261
- console.log(chalk16.cyan("\n\u{1F6E1}\uFE0F Node9 Setup \u2014 integrate with your AI agent\n"));
8262
- console.log(" Usage: " + chalk16.white("node9 setup <target>") + "\n");
9391
+ console.log(chalk17.cyan("\n\u{1F6E1}\uFE0F Node9 Setup \u2014 integrate with your AI agent\n"));
9392
+ console.log(" Usage: " + chalk17.white("node9 setup <target>") + "\n");
8263
9393
  console.log(" Targets:");
8264
- console.log(" " + chalk16.green("claude") + " \u2014 Claude Code (hook mode)");
8265
- console.log(" " + chalk16.green("gemini") + " \u2014 Gemini CLI (hook mode)");
8266
- console.log(" " + chalk16.green("cursor") + " \u2014 Cursor (hook mode)");
9394
+ console.log(" " + chalk17.green("claude") + " \u2014 Claude Code (hook mode)");
9395
+ console.log(" " + chalk17.green("gemini") + " \u2014 Gemini CLI (hook mode)");
9396
+ console.log(" " + chalk17.green("cursor") + " \u2014 Cursor (hook mode)");
8267
9397
  console.log("");
8268
9398
  return;
8269
9399
  }
@@ -8271,7 +9401,7 @@ program.command("setup").description('Alias for "addto" \u2014 integrate Node9 w
8271
9401
  if (t === "gemini") return await setupGemini();
8272
9402
  if (t === "claude") return await setupClaude();
8273
9403
  if (t === "cursor") return await setupCursor();
8274
- console.error(chalk16.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
9404
+ console.error(chalk17.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
8275
9405
  process.exit(1);
8276
9406
  });
8277
9407
  program.command("removefrom").description("Remove Node9 hooks from an AI agent configuration").addHelpText("after", "\n Supported targets: claude gemini cursor").argument("<target>", "The agent to remove from: claude | gemini | cursor").action((target) => {
@@ -8280,30 +9410,30 @@ program.command("removefrom").description("Remove Node9 hooks from an AI agent c
8280
9410
  else if (target === "gemini") fn = teardownGemini;
8281
9411
  else if (target === "cursor") fn = teardownCursor;
8282
9412
  else {
8283
- console.error(chalk16.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
9413
+ console.error(chalk17.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
8284
9414
  process.exit(1);
8285
9415
  }
8286
- console.log(chalk16.cyan(`
9416
+ console.log(chalk17.cyan(`
8287
9417
  \u{1F6E1}\uFE0F Node9: removing hooks from ${target}...
8288
9418
  `));
8289
9419
  try {
8290
9420
  fn();
8291
9421
  } catch (err) {
8292
- console.error(chalk16.red(` \u26A0\uFE0F Failed: ${err instanceof Error ? err.message : String(err)}`));
9422
+ console.error(chalk17.red(` \u26A0\uFE0F Failed: ${err instanceof Error ? err.message : String(err)}`));
8293
9423
  process.exit(1);
8294
9424
  }
8295
- console.log(chalk16.gray("\n Restart the agent for changes to take effect."));
9425
+ console.log(chalk17.gray("\n Restart the agent for changes to take effect."));
8296
9426
  });
8297
9427
  program.command("uninstall").description("Remove all Node9 hooks and optionally delete config files").option("--purge", "Also delete ~/.node9/ directory (config, audit log, credentials)").action(async (options) => {
8298
- console.log(chalk16.cyan("\n\u{1F6E1}\uFE0F Node9 Uninstall\n"));
8299
- console.log(chalk16.bold("Stopping daemon..."));
9428
+ console.log(chalk17.cyan("\n\u{1F6E1}\uFE0F Node9 Uninstall\n"));
9429
+ console.log(chalk17.bold("Stopping daemon..."));
8300
9430
  try {
8301
9431
  stopDaemon();
8302
- console.log(chalk16.green(" \u2705 Daemon stopped"));
9432
+ console.log(chalk17.green(" \u2705 Daemon stopped"));
8303
9433
  } catch {
8304
- console.log(chalk16.blue(" \u2139\uFE0F Daemon was not running"));
9434
+ console.log(chalk17.blue(" \u2139\uFE0F Daemon was not running"));
8305
9435
  }
8306
- console.log(chalk16.bold("\nRemoving hooks..."));
9436
+ console.log(chalk17.bold("\nRemoving hooks..."));
8307
9437
  let teardownFailed = false;
8308
9438
  for (const [label, fn] of [
8309
9439
  ["Claude", teardownClaude],
@@ -8315,45 +9445,45 @@ program.command("uninstall").description("Remove all Node9 hooks and optionally
8315
9445
  } catch (err) {
8316
9446
  teardownFailed = true;
8317
9447
  console.error(
8318
- chalk16.red(
9448
+ chalk17.red(
8319
9449
  ` \u26A0\uFE0F Failed to remove ${label} hooks: ${err instanceof Error ? err.message : String(err)}`
8320
9450
  )
8321
9451
  );
8322
9452
  }
8323
9453
  }
8324
9454
  if (options.purge) {
8325
- const node9Dir = path24.join(os20.homedir(), ".node9");
8326
- if (fs22.existsSync(node9Dir)) {
9455
+ const node9Dir = path27.join(os22.homedir(), ".node9");
9456
+ if (fs25.existsSync(node9Dir)) {
8327
9457
  const confirmed = await confirm3({
8328
9458
  message: `Permanently delete ${node9Dir} (config, audit log, credentials)?`,
8329
9459
  default: false
8330
9460
  });
8331
9461
  if (confirmed) {
8332
- fs22.rmSync(node9Dir, { recursive: true });
8333
- if (fs22.existsSync(node9Dir)) {
9462
+ fs25.rmSync(node9Dir, { recursive: true });
9463
+ if (fs25.existsSync(node9Dir)) {
8334
9464
  console.error(
8335
- chalk16.red("\n \u26A0\uFE0F ~/.node9/ could not be fully deleted \u2014 remove it manually.")
9465
+ chalk17.red("\n \u26A0\uFE0F ~/.node9/ could not be fully deleted \u2014 remove it manually.")
8336
9466
  );
8337
9467
  } else {
8338
- console.log(chalk16.green("\n \u2705 Deleted ~/.node9/ (config, audit log, credentials)"));
9468
+ console.log(chalk17.green("\n \u2705 Deleted ~/.node9/ (config, audit log, credentials)"));
8339
9469
  }
8340
9470
  } else {
8341
- console.log(chalk16.yellow("\n Skipped \u2014 ~/.node9/ was not deleted."));
9471
+ console.log(chalk17.yellow("\n Skipped \u2014 ~/.node9/ was not deleted."));
8342
9472
  }
8343
9473
  } else {
8344
- console.log(chalk16.blue("\n \u2139\uFE0F ~/.node9/ not found \u2014 nothing to delete"));
9474
+ console.log(chalk17.blue("\n \u2139\uFE0F ~/.node9/ not found \u2014 nothing to delete"));
8345
9475
  }
8346
9476
  } else {
8347
9477
  console.log(
8348
- chalk16.gray("\n ~/.node9/ kept \u2014 run with --purge to delete config and audit log")
9478
+ chalk17.gray("\n ~/.node9/ kept \u2014 run with --purge to delete config and audit log")
8349
9479
  );
8350
9480
  }
8351
9481
  if (teardownFailed) {
8352
- console.error(chalk16.red("\n \u26A0\uFE0F Some hooks could not be removed \u2014 see errors above."));
9482
+ console.error(chalk17.red("\n \u26A0\uFE0F Some hooks could not be removed \u2014 see errors above."));
8353
9483
  process.exit(1);
8354
9484
  }
8355
- console.log(chalk16.green.bold("\n\u{1F6E1}\uFE0F Node9 removed. Run: npm uninstall -g @node9/proxy"));
8356
- console.log(chalk16.gray(" Restart any open AI agent sessions for changes to take effect.\n"));
9485
+ console.log(chalk17.green.bold("\n\u{1F6E1}\uFE0F Node9 removed. Run: npm uninstall -g @node9/proxy"));
9486
+ console.log(chalk17.gray(" Restart any open AI agent sessions for changes to take effect.\n"));
8357
9487
  });
8358
9488
  registerDoctorCommand(program, version);
8359
9489
  program.command("explain").description(
@@ -8366,7 +9496,7 @@ program.command("explain").description(
8366
9496
  try {
8367
9497
  args = JSON.parse(trimmed);
8368
9498
  } catch {
8369
- console.error(chalk16.red(`
9499
+ console.error(chalk17.red(`
8370
9500
  \u274C Invalid JSON: ${trimmed}
8371
9501
  `));
8372
9502
  process.exit(1);
@@ -8377,83 +9507,59 @@ program.command("explain").description(
8377
9507
  }
8378
9508
  const result = await explainPolicy(tool, args);
8379
9509
  console.log("");
8380
- console.log(chalk16.cyan.bold("\u{1F6E1}\uFE0F Node9 Explain"));
9510
+ console.log(chalk17.cyan.bold("\u{1F6E1}\uFE0F Node9 Explain"));
8381
9511
  console.log("");
8382
- console.log(` ${chalk16.bold("Tool:")} ${chalk16.white(result.tool)}`);
9512
+ console.log(` ${chalk17.bold("Tool:")} ${chalk17.white(result.tool)}`);
8383
9513
  if (argsRaw) {
8384
9514
  const preview = argsRaw.length > 80 ? argsRaw.slice(0, 77) + "\u2026" : argsRaw;
8385
- console.log(` ${chalk16.bold("Input:")} ${chalk16.gray(preview)}`);
9515
+ console.log(` ${chalk17.bold("Input:")} ${chalk17.gray(preview)}`);
8386
9516
  }
8387
9517
  console.log("");
8388
- console.log(chalk16.bold("Config Sources (Waterfall):"));
9518
+ console.log(chalk17.bold("Config Sources (Waterfall):"));
8389
9519
  for (const tier of result.waterfall) {
8390
- const num = chalk16.gray(` ${tier.tier}.`);
9520
+ const num = chalk17.gray(` ${tier.tier}.`);
8391
9521
  const label = tier.label.padEnd(16);
8392
9522
  let statusStr;
8393
9523
  if (tier.tier === 1) {
8394
- statusStr = chalk16.gray(tier.note ?? "");
9524
+ statusStr = chalk17.gray(tier.note ?? "");
8395
9525
  } else if (tier.status === "active") {
8396
- const loc = tier.path ? chalk16.gray(tier.path) : "";
8397
- const note = tier.note ? chalk16.gray(`(${tier.note})`) : "";
8398
- statusStr = chalk16.green("\u2713 active") + (loc ? " " + loc : "") + (note ? " " + note : "");
9526
+ const loc = tier.path ? chalk17.gray(tier.path) : "";
9527
+ const note = tier.note ? chalk17.gray(`(${tier.note})`) : "";
9528
+ statusStr = chalk17.green("\u2713 active") + (loc ? " " + loc : "") + (note ? " " + note : "");
8399
9529
  } else {
8400
- statusStr = chalk16.gray("\u25CB " + (tier.note ?? "not found"));
9530
+ statusStr = chalk17.gray("\u25CB " + (tier.note ?? "not found"));
8401
9531
  }
8402
- console.log(`${num} ${chalk16.white(label)} ${statusStr}`);
9532
+ console.log(`${num} ${chalk17.white(label)} ${statusStr}`);
8403
9533
  }
8404
9534
  console.log("");
8405
- console.log(chalk16.bold("Policy Evaluation:"));
9535
+ console.log(chalk17.bold("Policy Evaluation:"));
8406
9536
  for (const step of result.steps) {
8407
9537
  const isFinal = step.isFinal;
8408
9538
  let icon;
8409
- if (step.outcome === "allow") icon = chalk16.green(" \u2705");
8410
- else if (step.outcome === "review") icon = chalk16.red(" \u{1F534}");
8411
- else if (step.outcome === "skip") icon = chalk16.gray(" \u2500 ");
8412
- else icon = chalk16.gray(" \u25CB ");
9539
+ if (step.outcome === "allow") icon = chalk17.green(" \u2705");
9540
+ else if (step.outcome === "review") icon = chalk17.red(" \u{1F534}");
9541
+ else if (step.outcome === "skip") icon = chalk17.gray(" \u2500 ");
9542
+ else icon = chalk17.gray(" \u25CB ");
8413
9543
  const name = step.name.padEnd(18);
8414
- const nameStr = isFinal ? chalk16.white.bold(name) : chalk16.white(name);
8415
- const detail = isFinal ? chalk16.white(step.detail) : chalk16.gray(step.detail);
8416
- const arrow = isFinal ? chalk16.yellow(" \u2190 STOP") : "";
9544
+ const nameStr = isFinal ? chalk17.white.bold(name) : chalk17.white(name);
9545
+ const detail = isFinal ? chalk17.white(step.detail) : chalk17.gray(step.detail);
9546
+ const arrow = isFinal ? chalk17.yellow(" \u2190 STOP") : "";
8417
9547
  console.log(`${icon} ${nameStr} ${detail}${arrow}`);
8418
9548
  }
8419
9549
  console.log("");
8420
9550
  if (result.decision === "allow") {
8421
- console.log(chalk16.green.bold(" Decision: \u2705 ALLOW") + chalk16.gray(" \u2014 no approval needed"));
9551
+ console.log(chalk17.green.bold(" Decision: \u2705 ALLOW") + chalk17.gray(" \u2014 no approval needed"));
8422
9552
  } else {
8423
9553
  console.log(
8424
- chalk16.red.bold(" Decision: \u{1F534} REVIEW") + chalk16.gray(" \u2014 human approval required")
9554
+ chalk17.red.bold(" Decision: \u{1F534} REVIEW") + chalk17.gray(" \u2014 human approval required")
8425
9555
  );
8426
9556
  if (result.blockedByLabel) {
8427
- console.log(chalk16.gray(` Reason: ${result.blockedByLabel}`));
9557
+ console.log(chalk17.gray(` Reason: ${result.blockedByLabel}`));
8428
9558
  }
8429
9559
  }
8430
9560
  console.log("");
8431
9561
  });
8432
- program.command("init").description("Create ~/.node9/config.json with default policy (safe to run multiple times)").option("--force", "Overwrite existing config").option("-m, --mode <mode>", "Set initial security mode (standard, strict, audit)", "standard").action((options) => {
8433
- const configPath = path24.join(os20.homedir(), ".node9", "config.json");
8434
- if (fs22.existsSync(configPath) && !options.force) {
8435
- console.log(chalk16.yellow(`\u2139\uFE0F Global config already exists: ${configPath}`));
8436
- console.log(chalk16.gray(` Run with --force to overwrite.`));
8437
- return;
8438
- }
8439
- const requestedMode = options.mode.toLowerCase();
8440
- const safeMode = ["standard", "strict", "audit"].includes(requestedMode) ? requestedMode : DEFAULT_CONFIG.settings.mode;
8441
- const configToSave = {
8442
- ...DEFAULT_CONFIG,
8443
- settings: {
8444
- ...DEFAULT_CONFIG.settings,
8445
- mode: safeMode
8446
- }
8447
- };
8448
- const dir = path24.dirname(configPath);
8449
- if (!fs22.existsSync(dir)) fs22.mkdirSync(dir, { recursive: true });
8450
- fs22.writeFileSync(configPath, JSON.stringify(configToSave, null, 2));
8451
- console.log(chalk16.green(`\u2705 Global config created: ${configPath}`));
8452
- console.log(chalk16.cyan(` Mode set to: ${safeMode}`));
8453
- console.log(
8454
- chalk16.gray(` Undo Engine is ENABLED by default. Use 'node9 undo' to revert AI changes.`)
8455
- );
8456
- });
9562
+ registerInitCommand(program);
8457
9563
  registerAuditCommand(program);
8458
9564
  registerStatusCommand(program);
8459
9565
  registerDaemonCommand(program);
@@ -8462,7 +9568,7 @@ program.command("tail").description("Stream live agent activity to the terminal"
8462
9568
  try {
8463
9569
  await startTail2(options);
8464
9570
  } catch (err) {
8465
- console.error(chalk16.red(`\u274C ${err instanceof Error ? err.message : String(err)}`));
9571
+ console.error(chalk17.red(`\u274C ${err instanceof Error ? err.message : String(err)}`));
8466
9572
  process.exit(1);
8467
9573
  }
8468
9574
  });
@@ -8474,7 +9580,7 @@ program.command("pause").description("Temporarily disable Node9 protection for a
8474
9580
  const ms = parseDuration(options.duration);
8475
9581
  if (ms === null) {
8476
9582
  console.error(
8477
- chalk16.red(`
9583
+ chalk17.red(`
8478
9584
  \u274C Invalid duration: "${options.duration}". Use format like 15m, 1h, 30s.
8479
9585
  `)
8480
9586
  );
@@ -8482,20 +9588,20 @@ program.command("pause").description("Temporarily disable Node9 protection for a
8482
9588
  }
8483
9589
  pauseNode9(ms, options.duration);
8484
9590
  const expiresAt = new Date(Date.now() + ms).toLocaleTimeString();
8485
- console.log(chalk16.yellow(`
9591
+ console.log(chalk17.yellow(`
8486
9592
  \u23F8 Node9 paused until ${expiresAt}`));
8487
- console.log(chalk16.gray(` All tool calls will be allowed without review.`));
8488
- console.log(chalk16.gray(` Run "node9 resume" to re-enable early.
9593
+ console.log(chalk17.gray(` All tool calls will be allowed without review.`));
9594
+ console.log(chalk17.gray(` Run "node9 resume" to re-enable early.
8489
9595
  `));
8490
9596
  });
8491
9597
  program.command("resume").description("Re-enable Node9 protection immediately").action(() => {
8492
9598
  const { paused } = checkPause();
8493
9599
  if (!paused) {
8494
- console.log(chalk16.gray("\nNode9 is already active \u2014 nothing to resume.\n"));
9600
+ console.log(chalk17.gray("\nNode9 is already active \u2014 nothing to resume.\n"));
8495
9601
  return;
8496
9602
  }
8497
9603
  resumeNode9();
8498
- console.log(chalk16.green("\n\u25B6 Node9 resumed \u2014 protection is active.\n"));
9604
+ console.log(chalk17.green("\n\u25B6 Node9 resumed \u2014 protection is active.\n"));
8499
9605
  });
8500
9606
  var HOOK_BASED_AGENTS = {
8501
9607
  claude: "claude",
@@ -8508,15 +9614,15 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
8508
9614
  if (HOOK_BASED_AGENTS[firstArg2] !== void 0) {
8509
9615
  const target = HOOK_BASED_AGENTS[firstArg2];
8510
9616
  console.error(
8511
- chalk16.yellow(`
9617
+ chalk17.yellow(`
8512
9618
  \u26A0\uFE0F Node9 proxy mode does not support "${target}" directly.`)
8513
9619
  );
8514
- console.error(chalk16.white(`
9620
+ console.error(chalk17.white(`
8515
9621
  "${target}" uses its own hook system. Use:`));
8516
9622
  console.error(
8517
- chalk16.green(` node9 addto ${target} `) + chalk16.gray("# one-time setup")
9623
+ chalk17.green(` node9 addto ${target} `) + chalk17.gray("# one-time setup")
8518
9624
  );
8519
- console.error(chalk16.green(` ${target} `) + chalk16.gray("# run normally"));
9625
+ console.error(chalk17.green(` ${target} `) + chalk17.gray("# run normally"));
8520
9626
  process.exit(1);
8521
9627
  }
8522
9628
  const runArgs = firstArg2 === "shell" ? commandArgs.slice(1) : commandArgs;
@@ -8533,7 +9639,7 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
8533
9639
  }
8534
9640
  );
8535
9641
  if (result.noApprovalMechanism && !isDaemonRunning() && !process.env.NODE9_NO_AUTO_DAEMON && getConfig().settings.autoStartDaemon) {
8536
- console.error(chalk16.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
9642
+ console.error(chalk17.cyan("\n\u{1F6E1}\uFE0F Node9: Starting approval daemon automatically..."));
8537
9643
  const daemonReady = await autoStartDaemonAndWait();
8538
9644
  if (daemonReady) result = await authorizeHeadless("shell", { command: fullCommand });
8539
9645
  }
@@ -8546,12 +9652,12 @@ program.argument("[command...]", "The agent command to run (e.g., gemini)").acti
8546
9652
  }
8547
9653
  if (!result.approved) {
8548
9654
  console.error(
8549
- chalk16.red(`
9655
+ chalk17.red(`
8550
9656
  \u274C Node9 Blocked: ${result.reason || "Dangerous command detected."}`)
8551
9657
  );
8552
9658
  process.exit(1);
8553
9659
  }
8554
- console.error(chalk16.green("\n\u2705 Approved \u2014 running command...\n"));
9660
+ console.error(chalk17.green("\n\u2705 Approved \u2014 running command...\n"));
8555
9661
  await runProxy(fullCommand);
8556
9662
  } else {
8557
9663
  program.help();
@@ -8566,9 +9672,9 @@ if (process.argv[2] !== "daemon") {
8566
9672
  const isCheckHook = process.argv[2] === "check";
8567
9673
  if (isCheckHook) {
8568
9674
  if (process.env.NODE9_DEBUG === "1" || getConfig().settings.enableHookLogDebug) {
8569
- const logPath = path24.join(os20.homedir(), ".node9", "hook-debug.log");
9675
+ const logPath = path27.join(os22.homedir(), ".node9", "hook-debug.log");
8570
9676
  const msg = reason instanceof Error ? reason.message : String(reason);
8571
- fs22.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
9677
+ fs25.appendFileSync(logPath, `[${(/* @__PURE__ */ new Date()).toISOString()}] UNHANDLED: ${msg}
8572
9678
  `);
8573
9679
  }
8574
9680
  process.exit(0);