@node9/proxy 1.0.17 → 1.0.19

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/README.md CHANGED
@@ -5,11 +5,14 @@
5
5
  [![NPM Version](https://img.shields.io/npm/v/@node9/proxy.svg)](https://www.npmjs.com/package/@node9/proxy)
6
6
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
7
7
  [![Open in HF Spaces](https://huggingface.co/datasets/huggingface/badges/resolve/main/open-in-hf-spaces-sm.svg)](https://huggingface.co/spaces/Node9ai/node9-security-demo)
8
+ [![Documentation](https://img.shields.io/badge/docs-node9.ai%2Fdocs-blue)](https://node9.ai/docs)
8
9
 
9
10
  **Node9** is the execution security layer for the Agentic Era. It encases autonomous AI Agents (Claude Code, Gemini CLI, Cursor, MCP Servers) in a deterministic security wrapper, intercepting dangerous shell commands and tool calls before they execute.
10
11
 
11
12
  While others try to _guess_ if a prompt is malicious (Semantic Security), Node9 _governs_ the actual action (Execution Security).
12
13
 
14
+ 📖 **[Full Documentation →](https://node9.ai/docs)**
15
+
13
16
  ---
14
17
 
15
18
  ## 💎 The "Aha!" Moment
package/dist/cli.js CHANGED
@@ -2404,7 +2404,7 @@ var init_core = __esm({
2404
2404
  {
2405
2405
  field: "command",
2406
2406
  op: "matches",
2407
- value: "git push.*(--force|--force-with-lease|-f\\b)",
2407
+ value: "^\\s*git\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
2408
2408
  flags: "i"
2409
2409
  }
2410
2410
  ],
@@ -2415,7 +2415,14 @@ var init_core = __esm({
2415
2415
  {
2416
2416
  name: "review-git-push",
2417
2417
  tool: "bash",
2418
- conditions: [{ field: "command", op: "matches", value: "^\\s*git\\s+push\\b", flags: "i" }],
2418
+ conditions: [
2419
+ {
2420
+ field: "command",
2421
+ op: "matches",
2422
+ value: "^\\s*git\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
2423
+ flags: "i"
2424
+ }
2425
+ ],
2419
2426
  conditionMode: "all",
2420
2427
  verdict: "review",
2421
2428
  reason: "git push sends changes to a shared remote"
@@ -2427,7 +2434,7 @@ var init_core = __esm({
2427
2434
  {
2428
2435
  field: "command",
2429
2436
  op: "matches",
2430
- value: "git\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase|tag\\s+-d|branch\\s+-[dD])",
2437
+ value: "^\\s*git\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
2431
2438
  flags: "i"
2432
2439
  }
2433
2440
  ],
@@ -4730,18 +4737,21 @@ function renderPending(activity) {
4730
4737
  process.stdout.write(`${formatBase(activity)} ${import_chalk5.default.yellow("\u25CF \u2026")}\r`);
4731
4738
  }
4732
4739
  async function ensureDaemon() {
4740
+ let pidPort = null;
4733
4741
  if (import_fs6.default.existsSync(PID_FILE)) {
4734
4742
  try {
4735
4743
  const { port } = JSON.parse(import_fs6.default.readFileSync(PID_FILE, "utf-8"));
4736
- return port;
4744
+ pidPort = port;
4737
4745
  } catch {
4746
+ console.error(import_chalk5.default.dim("\u26A0\uFE0F Could not read PID file; falling back to default port."));
4738
4747
  }
4739
4748
  }
4749
+ const checkPort = pidPort ?? DAEMON_PORT2;
4740
4750
  try {
4741
- const res = await fetch(`http://127.0.0.1:${DAEMON_PORT2}/settings`, {
4751
+ const res = await fetch(`http://127.0.0.1:${checkPort}/settings`, {
4742
4752
  signal: AbortSignal.timeout(500)
4743
4753
  });
4744
- if (res.ok) return DAEMON_PORT2;
4754
+ if (res.ok) return checkPort;
4745
4755
  } catch {
4746
4756
  }
4747
4757
  console.log(import_chalk5.default.dim("\u{1F6E1}\uFE0F Starting Node9 daemon..."));
@@ -4767,25 +4777,44 @@ async function ensureDaemon() {
4767
4777
  async function startTail(options = {}) {
4768
4778
  const port = await ensureDaemon();
4769
4779
  if (options.clear) {
4770
- await new Promise((resolve) => {
4780
+ const result = await new Promise((resolve) => {
4771
4781
  const req2 = import_http2.default.request(
4772
4782
  { method: "POST", hostname: "127.0.0.1", port, path: "/events/clear" },
4773
4783
  (res) => {
4784
+ const status = res.statusCode ?? 0;
4785
+ res.on(
4786
+ "end",
4787
+ () => resolve({
4788
+ ok: status >= 200 && status < 300,
4789
+ code: status >= 200 && status < 300 ? void 0 : `HTTP ${status}`
4790
+ })
4791
+ );
4774
4792
  res.resume();
4775
- res.on("end", resolve);
4776
4793
  }
4777
4794
  );
4778
- req2.on("error", resolve);
4795
+ req2.once("error", (err) => resolve({ ok: false, code: err.code }));
4796
+ req2.setTimeout(2e3, () => {
4797
+ resolve({ ok: false, code: "ETIMEDOUT" });
4798
+ req2.destroy();
4799
+ });
4779
4800
  req2.end();
4780
4801
  });
4802
+ if (result.ok) {
4803
+ console.log(import_chalk5.default.green("\u2713 Flight Recorder buffer cleared."));
4804
+ } else if (result.code === "ECONNREFUSED") {
4805
+ throw new Error("Daemon is not running. Start it with: node9 daemon start");
4806
+ } else if (result.code === "ETIMEDOUT") {
4807
+ throw new Error("Daemon did not respond in time. Try: node9 daemon restart");
4808
+ } else {
4809
+ throw new Error(`Failed to clear buffer (${result.code ?? "unknown error"})`);
4810
+ }
4811
+ return;
4781
4812
  }
4782
4813
  const connectionTime = Date.now();
4783
4814
  const pending2 = /* @__PURE__ */ new Map();
4784
4815
  console.log(import_chalk5.default.cyan.bold(`
4785
4816
  \u{1F6F0}\uFE0F Node9 tail `) + import_chalk5.default.dim(`\u2192 localhost:${port}`));
4786
- if (options.clear) {
4787
- console.log(import_chalk5.default.dim("History cleared. Showing live events. Press Ctrl+C to exit.\n"));
4788
- } else if (options.history) {
4817
+ if (options.history) {
4789
4818
  console.log(import_chalk5.default.dim("Showing history + live events. Press Ctrl+C to exit.\n"));
4790
4819
  } else {
4791
4820
  console.log(
@@ -4918,6 +4947,7 @@ function fullPathCommand(subcommand) {
4918
4947
  if (process.env.NODE9_TESTING === "1") return `node9 ${subcommand}`;
4919
4948
  const nodeExec = process.execPath;
4920
4949
  const cliScript = process.argv[1];
4950
+ if (!cliScript.endsWith(".js")) return `${cliScript} ${subcommand}`;
4921
4951
  return `${nodeExec} ${cliScript} ${subcommand}`;
4922
4952
  }
4923
4953
  function readJson(filePath) {
@@ -4934,6 +4964,126 @@ function writeJson(filePath, data) {
4934
4964
  if (!import_fs3.default.existsSync(dir)) import_fs3.default.mkdirSync(dir, { recursive: true });
4935
4965
  import_fs3.default.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
4936
4966
  }
4967
+ function isNode9Hook(cmd) {
4968
+ if (!cmd) return false;
4969
+ return /(?:^|[\s/\\])node9 (?:check|log)/.test(cmd) || /(?:^|[\s/\\])cli\.js (?:check|log)/.test(cmd);
4970
+ }
4971
+ function teardownClaude() {
4972
+ const homeDir2 = import_os3.default.homedir();
4973
+ const hooksPath = import_path5.default.join(homeDir2, ".claude", "settings.json");
4974
+ const mcpPath = import_path5.default.join(homeDir2, ".claude.json");
4975
+ let changed = false;
4976
+ const settings = readJson(hooksPath);
4977
+ if (settings?.hooks) {
4978
+ for (const event of ["PreToolUse", "PostToolUse"]) {
4979
+ const before = settings.hooks[event]?.length ?? 0;
4980
+ settings.hooks[event] = settings.hooks[event]?.filter(
4981
+ (m) => !m.hooks.some((h) => isNode9Hook(h.command))
4982
+ );
4983
+ if ((settings.hooks[event]?.length ?? 0) < before) changed = true;
4984
+ if (settings.hooks[event]?.length === 0) delete settings.hooks[event];
4985
+ }
4986
+ if (changed) {
4987
+ writeJson(hooksPath, settings);
4988
+ console.log(
4989
+ import_chalk3.default.green(" \u2705 Removed PreToolUse / PostToolUse hooks from ~/.claude/settings.json")
4990
+ );
4991
+ } else {
4992
+ console.log(import_chalk3.default.blue(" \u2139\uFE0F No Node9 hooks found in ~/.claude/settings.json"));
4993
+ }
4994
+ }
4995
+ const claudeConfig = readJson(mcpPath);
4996
+ if (claudeConfig?.mcpServers) {
4997
+ let mcpChanged = false;
4998
+ for (const [name, server] of Object.entries(claudeConfig.mcpServers)) {
4999
+ if (server.command === "node9" && Array.isArray(server.args) && server.args.length > 0) {
5000
+ const [originalCmd, ...originalArgs] = server.args;
5001
+ claudeConfig.mcpServers[name] = {
5002
+ ...server,
5003
+ command: originalCmd,
5004
+ args: originalArgs.length ? originalArgs : void 0
5005
+ };
5006
+ mcpChanged = true;
5007
+ } else if (server.command === "node9") {
5008
+ console.warn(
5009
+ import_chalk3.default.yellow(
5010
+ ` \u26A0\uFE0F Cannot unwrap MCP server "${name}" in ~/.claude.json \u2014 args is empty. Remove it manually.`
5011
+ )
5012
+ );
5013
+ }
5014
+ }
5015
+ if (mcpChanged) {
5016
+ writeJson(mcpPath, claudeConfig);
5017
+ console.log(import_chalk3.default.green(" \u2705 Unwrapped MCP servers in ~/.claude.json"));
5018
+ }
5019
+ }
5020
+ }
5021
+ function teardownGemini() {
5022
+ const homeDir2 = import_os3.default.homedir();
5023
+ const settingsPath = import_path5.default.join(homeDir2, ".gemini", "settings.json");
5024
+ const settings = readJson(settingsPath);
5025
+ if (!settings) {
5026
+ console.log(import_chalk3.default.blue(" \u2139\uFE0F ~/.gemini/settings.json not found \u2014 nothing to remove"));
5027
+ return;
5028
+ }
5029
+ let changed = false;
5030
+ for (const event of ["BeforeTool", "AfterTool"]) {
5031
+ const before = settings.hooks?.[event]?.length ?? 0;
5032
+ if (settings.hooks?.[event]) {
5033
+ settings.hooks[event] = settings.hooks[event].filter(
5034
+ (m) => !m.hooks.some((h) => isNode9Hook(h.command))
5035
+ );
5036
+ if ((settings.hooks[event]?.length ?? 0) < before) changed = true;
5037
+ if (settings.hooks[event]?.length === 0) delete settings.hooks[event];
5038
+ }
5039
+ }
5040
+ if (settings.mcpServers) {
5041
+ for (const [name, server] of Object.entries(settings.mcpServers)) {
5042
+ if (server.command === "node9" && Array.isArray(server.args) && server.args.length > 0) {
5043
+ const [originalCmd, ...originalArgs] = server.args;
5044
+ settings.mcpServers[name] = {
5045
+ ...server,
5046
+ command: originalCmd,
5047
+ args: originalArgs.length ? originalArgs : void 0
5048
+ };
5049
+ changed = true;
5050
+ }
5051
+ }
5052
+ }
5053
+ if (changed) {
5054
+ writeJson(settingsPath, settings);
5055
+ console.log(import_chalk3.default.green(" \u2705 Removed Node9 hooks from ~/.gemini/settings.json"));
5056
+ } else {
5057
+ console.log(import_chalk3.default.blue(" \u2139\uFE0F No Node9 hooks found in ~/.gemini/settings.json"));
5058
+ }
5059
+ }
5060
+ function teardownCursor() {
5061
+ const homeDir2 = import_os3.default.homedir();
5062
+ const mcpPath = import_path5.default.join(homeDir2, ".cursor", "mcp.json");
5063
+ const mcpConfig = readJson(mcpPath);
5064
+ if (!mcpConfig?.mcpServers) {
5065
+ console.log(import_chalk3.default.blue(" \u2139\uFE0F ~/.cursor/mcp.json not found \u2014 nothing to remove"));
5066
+ return;
5067
+ }
5068
+ let changed = false;
5069
+ for (const [name, server] of Object.entries(mcpConfig.mcpServers)) {
5070
+ if (server.command === "node9" && Array.isArray(server.args) && server.args.length > 0) {
5071
+ const [originalCmd, ...originalArgs] = server.args;
5072
+ mcpConfig.mcpServers[name] = {
5073
+ ...server,
5074
+ command: originalCmd,
5075
+ args: originalArgs.length ? originalArgs : void 0
5076
+ };
5077
+ changed = true;
5078
+ }
5079
+ }
5080
+ if (changed) {
5081
+ writeJson(mcpPath, mcpConfig);
5082
+ console.log(import_chalk3.default.green(" \u2705 Unwrapped MCP servers in ~/.cursor/mcp.json"));
5083
+ } else {
5084
+ console.log(import_chalk3.default.blue(" \u2139\uFE0F No Node9-wrapped MCP servers found in ~/.cursor/mcp.json"));
5085
+ }
5086
+ }
4937
5087
  async function setupClaude() {
4938
5088
  const homeDir2 = import_os3.default.homedir();
4939
5089
  const mcpPath = import_path5.default.join(homeDir2, ".claude.json");
@@ -5557,6 +5707,87 @@ program.command("setup").description('Alias for "addto" \u2014 integrate Node9 w
5557
5707
  console.error(import_chalk6.default.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
5558
5708
  process.exit(1);
5559
5709
  });
5710
+ 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) => {
5711
+ let fn;
5712
+ if (target === "claude") fn = teardownClaude;
5713
+ else if (target === "gemini") fn = teardownGemini;
5714
+ else if (target === "cursor") fn = teardownCursor;
5715
+ else {
5716
+ console.error(import_chalk6.default.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
5717
+ process.exit(1);
5718
+ }
5719
+ console.log(import_chalk6.default.cyan(`
5720
+ \u{1F6E1}\uFE0F Node9: removing hooks from ${target}...
5721
+ `));
5722
+ try {
5723
+ fn();
5724
+ } catch (err) {
5725
+ console.error(import_chalk6.default.red(` \u26A0\uFE0F Failed: ${err instanceof Error ? err.message : String(err)}`));
5726
+ process.exit(1);
5727
+ }
5728
+ console.log(import_chalk6.default.gray("\n Restart the agent for changes to take effect."));
5729
+ });
5730
+ 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) => {
5731
+ console.log(import_chalk6.default.cyan("\n\u{1F6E1}\uFE0F Node9 Uninstall\n"));
5732
+ console.log(import_chalk6.default.bold("Stopping daemon..."));
5733
+ try {
5734
+ stopDaemon();
5735
+ console.log(import_chalk6.default.green(" \u2705 Daemon stopped"));
5736
+ } catch {
5737
+ console.log(import_chalk6.default.blue(" \u2139\uFE0F Daemon was not running"));
5738
+ }
5739
+ console.log(import_chalk6.default.bold("\nRemoving hooks..."));
5740
+ let teardownFailed = false;
5741
+ for (const [label, fn] of [
5742
+ ["Claude", teardownClaude],
5743
+ ["Gemini", teardownGemini],
5744
+ ["Cursor", teardownCursor]
5745
+ ]) {
5746
+ try {
5747
+ fn();
5748
+ } catch (err) {
5749
+ teardownFailed = true;
5750
+ console.error(
5751
+ import_chalk6.default.red(
5752
+ ` \u26A0\uFE0F Failed to remove ${label} hooks: ${err instanceof Error ? err.message : String(err)}`
5753
+ )
5754
+ );
5755
+ }
5756
+ }
5757
+ if (options.purge) {
5758
+ const node9Dir = import_path9.default.join(import_os7.default.homedir(), ".node9");
5759
+ if (import_fs7.default.existsSync(node9Dir)) {
5760
+ const confirmed = await (0, import_prompts3.confirm)({
5761
+ message: `Permanently delete ${node9Dir} (config, audit log, credentials)?`,
5762
+ default: false
5763
+ });
5764
+ if (confirmed) {
5765
+ import_fs7.default.rmSync(node9Dir, { recursive: true });
5766
+ if (import_fs7.default.existsSync(node9Dir)) {
5767
+ console.error(
5768
+ import_chalk6.default.red("\n \u26A0\uFE0F ~/.node9/ could not be fully deleted \u2014 remove it manually.")
5769
+ );
5770
+ } else {
5771
+ console.log(import_chalk6.default.green("\n \u2705 Deleted ~/.node9/ (config, audit log, credentials)"));
5772
+ }
5773
+ } else {
5774
+ console.log(import_chalk6.default.yellow("\n Skipped \u2014 ~/.node9/ was not deleted."));
5775
+ }
5776
+ } else {
5777
+ console.log(import_chalk6.default.blue("\n \u2139\uFE0F ~/.node9/ not found \u2014 nothing to delete"));
5778
+ }
5779
+ } else {
5780
+ console.log(
5781
+ import_chalk6.default.gray("\n ~/.node9/ kept \u2014 run with --purge to delete config and audit log")
5782
+ );
5783
+ }
5784
+ if (teardownFailed) {
5785
+ console.error(import_chalk6.default.red("\n \u26A0\uFE0F Some hooks could not be removed \u2014 see errors above."));
5786
+ process.exit(1);
5787
+ }
5788
+ console.log(import_chalk6.default.green.bold("\n\u{1F6E1}\uFE0F Node9 removed. Run: npm uninstall -g @node9/proxy"));
5789
+ console.log(import_chalk6.default.gray(" Restart any open AI agent sessions for changes to take effect.\n"));
5790
+ });
5560
5791
  program.command("doctor").description("Check that Node9 is installed and configured correctly").action(() => {
5561
5792
  const homeDir2 = import_os7.default.homedir();
5562
5793
  let failures = 0;
@@ -5967,9 +6198,14 @@ program.command("daemon").description("Run the local approval server").argument(
5967
6198
  startDaemon();
5968
6199
  }
5969
6200
  );
5970
- program.command("tail").description("Stream live agent activity to the terminal").option("--history", "Include recent history on connect", false).option("--clear", "Clear history buffer and stream live events fresh", false).action(async (options) => {
6201
+ program.command("tail").description("Stream live agent activity to the terminal").option("--history", "Replay recent history then continue live", false).option("--clear", "Clear the history buffer and exit (does not stream)", false).action(async (options) => {
5971
6202
  const { startTail: startTail2 } = await Promise.resolve().then(() => (init_tail(), tail_exports));
5972
- await startTail2(options);
6203
+ try {
6204
+ await startTail2(options);
6205
+ } catch (err) {
6206
+ console.error(import_chalk6.default.red(`\u274C ${err instanceof Error ? err.message : String(err)}`));
6207
+ process.exit(1);
6208
+ }
5973
6209
  });
5974
6210
  program.command("check").description("Hook handler \u2014 evaluates a tool call before execution").argument("[data]", "JSON string of the tool call").action(async (data) => {
5975
6211
  const processPayload = async (raw) => {
@@ -6052,7 +6288,7 @@ RAW: ${raw}
6052
6288
  }
6053
6289
  const result = await authorizeHeadless(toolName, toolInput, false, meta);
6054
6290
  if (result.approved) {
6055
- if (result.checkedBy)
6291
+ if (result.checkedBy && process.env.NODE9_DEBUG === "1")
6056
6292
  process.stderr.write(`\u2713 node9 [${result.checkedBy}]: "${toolName}" allowed
6057
6293
  `);
6058
6294
  process.exit(0);
@@ -6063,7 +6299,7 @@ RAW: ${raw}
6063
6299
  if (daemonReady) {
6064
6300
  const retry = await authorizeHeadless(toolName, toolInput, false, meta);
6065
6301
  if (retry.approved) {
6066
- if (retry.checkedBy)
6302
+ if (retry.checkedBy && process.env.NODE9_DEBUG === "1")
6067
6303
  process.stderr.write(`\u2713 node9 [${retry.checkedBy}]: "${toolName}" allowed
6068
6304
  `);
6069
6305
  process.exit(0);
package/dist/cli.mjs CHANGED
@@ -2383,7 +2383,7 @@ var init_core = __esm({
2383
2383
  {
2384
2384
  field: "command",
2385
2385
  op: "matches",
2386
- value: "git push.*(--force|--force-with-lease|-f\\b)",
2386
+ value: "^\\s*git\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
2387
2387
  flags: "i"
2388
2388
  }
2389
2389
  ],
@@ -2394,7 +2394,14 @@ var init_core = __esm({
2394
2394
  {
2395
2395
  name: "review-git-push",
2396
2396
  tool: "bash",
2397
- conditions: [{ field: "command", op: "matches", value: "^\\s*git\\s+push\\b", flags: "i" }],
2397
+ conditions: [
2398
+ {
2399
+ field: "command",
2400
+ op: "matches",
2401
+ value: "^\\s*git\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
2402
+ flags: "i"
2403
+ }
2404
+ ],
2398
2405
  conditionMode: "all",
2399
2406
  verdict: "review",
2400
2407
  reason: "git push sends changes to a shared remote"
@@ -2406,7 +2413,7 @@ var init_core = __esm({
2406
2413
  {
2407
2414
  field: "command",
2408
2415
  op: "matches",
2409
- value: "git\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase|tag\\s+-d|branch\\s+-[dD])",
2416
+ value: "^\\s*git\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
2410
2417
  flags: "i"
2411
2418
  }
2412
2419
  ],
@@ -4716,18 +4723,21 @@ function renderPending(activity) {
4716
4723
  process.stdout.write(`${formatBase(activity)} ${chalk5.yellow("\u25CF \u2026")}\r`);
4717
4724
  }
4718
4725
  async function ensureDaemon() {
4726
+ let pidPort = null;
4719
4727
  if (fs6.existsSync(PID_FILE)) {
4720
4728
  try {
4721
4729
  const { port } = JSON.parse(fs6.readFileSync(PID_FILE, "utf-8"));
4722
- return port;
4730
+ pidPort = port;
4723
4731
  } catch {
4732
+ console.error(chalk5.dim("\u26A0\uFE0F Could not read PID file; falling back to default port."));
4724
4733
  }
4725
4734
  }
4735
+ const checkPort = pidPort ?? DAEMON_PORT2;
4726
4736
  try {
4727
- const res = await fetch(`http://127.0.0.1:${DAEMON_PORT2}/settings`, {
4737
+ const res = await fetch(`http://127.0.0.1:${checkPort}/settings`, {
4728
4738
  signal: AbortSignal.timeout(500)
4729
4739
  });
4730
- if (res.ok) return DAEMON_PORT2;
4740
+ if (res.ok) return checkPort;
4731
4741
  } catch {
4732
4742
  }
4733
4743
  console.log(chalk5.dim("\u{1F6E1}\uFE0F Starting Node9 daemon..."));
@@ -4753,25 +4763,44 @@ async function ensureDaemon() {
4753
4763
  async function startTail(options = {}) {
4754
4764
  const port = await ensureDaemon();
4755
4765
  if (options.clear) {
4756
- await new Promise((resolve) => {
4766
+ const result = await new Promise((resolve) => {
4757
4767
  const req2 = http2.request(
4758
4768
  { method: "POST", hostname: "127.0.0.1", port, path: "/events/clear" },
4759
4769
  (res) => {
4770
+ const status = res.statusCode ?? 0;
4771
+ res.on(
4772
+ "end",
4773
+ () => resolve({
4774
+ ok: status >= 200 && status < 300,
4775
+ code: status >= 200 && status < 300 ? void 0 : `HTTP ${status}`
4776
+ })
4777
+ );
4760
4778
  res.resume();
4761
- res.on("end", resolve);
4762
4779
  }
4763
4780
  );
4764
- req2.on("error", resolve);
4781
+ req2.once("error", (err) => resolve({ ok: false, code: err.code }));
4782
+ req2.setTimeout(2e3, () => {
4783
+ resolve({ ok: false, code: "ETIMEDOUT" });
4784
+ req2.destroy();
4785
+ });
4765
4786
  req2.end();
4766
4787
  });
4788
+ if (result.ok) {
4789
+ console.log(chalk5.green("\u2713 Flight Recorder buffer cleared."));
4790
+ } else if (result.code === "ECONNREFUSED") {
4791
+ throw new Error("Daemon is not running. Start it with: node9 daemon start");
4792
+ } else if (result.code === "ETIMEDOUT") {
4793
+ throw new Error("Daemon did not respond in time. Try: node9 daemon restart");
4794
+ } else {
4795
+ throw new Error(`Failed to clear buffer (${result.code ?? "unknown error"})`);
4796
+ }
4797
+ return;
4767
4798
  }
4768
4799
  const connectionTime = Date.now();
4769
4800
  const pending2 = /* @__PURE__ */ new Map();
4770
4801
  console.log(chalk5.cyan.bold(`
4771
4802
  \u{1F6F0}\uFE0F Node9 tail `) + chalk5.dim(`\u2192 localhost:${port}`));
4772
- if (options.clear) {
4773
- console.log(chalk5.dim("History cleared. Showing live events. Press Ctrl+C to exit.\n"));
4774
- } else if (options.history) {
4803
+ if (options.history) {
4775
4804
  console.log(chalk5.dim("Showing history + live events. Press Ctrl+C to exit.\n"));
4776
4805
  } else {
4777
4806
  console.log(
@@ -4897,6 +4926,7 @@ function fullPathCommand(subcommand) {
4897
4926
  if (process.env.NODE9_TESTING === "1") return `node9 ${subcommand}`;
4898
4927
  const nodeExec = process.execPath;
4899
4928
  const cliScript = process.argv[1];
4929
+ if (!cliScript.endsWith(".js")) return `${cliScript} ${subcommand}`;
4900
4930
  return `${nodeExec} ${cliScript} ${subcommand}`;
4901
4931
  }
4902
4932
  function readJson(filePath) {
@@ -4913,6 +4943,126 @@ function writeJson(filePath, data) {
4913
4943
  if (!fs3.existsSync(dir)) fs3.mkdirSync(dir, { recursive: true });
4914
4944
  fs3.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n");
4915
4945
  }
4946
+ function isNode9Hook(cmd) {
4947
+ if (!cmd) return false;
4948
+ return /(?:^|[\s/\\])node9 (?:check|log)/.test(cmd) || /(?:^|[\s/\\])cli\.js (?:check|log)/.test(cmd);
4949
+ }
4950
+ function teardownClaude() {
4951
+ const homeDir2 = os3.homedir();
4952
+ const hooksPath = path5.join(homeDir2, ".claude", "settings.json");
4953
+ const mcpPath = path5.join(homeDir2, ".claude.json");
4954
+ let changed = false;
4955
+ const settings = readJson(hooksPath);
4956
+ if (settings?.hooks) {
4957
+ for (const event of ["PreToolUse", "PostToolUse"]) {
4958
+ const before = settings.hooks[event]?.length ?? 0;
4959
+ settings.hooks[event] = settings.hooks[event]?.filter(
4960
+ (m) => !m.hooks.some((h) => isNode9Hook(h.command))
4961
+ );
4962
+ if ((settings.hooks[event]?.length ?? 0) < before) changed = true;
4963
+ if (settings.hooks[event]?.length === 0) delete settings.hooks[event];
4964
+ }
4965
+ if (changed) {
4966
+ writeJson(hooksPath, settings);
4967
+ console.log(
4968
+ chalk3.green(" \u2705 Removed PreToolUse / PostToolUse hooks from ~/.claude/settings.json")
4969
+ );
4970
+ } else {
4971
+ console.log(chalk3.blue(" \u2139\uFE0F No Node9 hooks found in ~/.claude/settings.json"));
4972
+ }
4973
+ }
4974
+ const claudeConfig = readJson(mcpPath);
4975
+ if (claudeConfig?.mcpServers) {
4976
+ let mcpChanged = false;
4977
+ for (const [name, server] of Object.entries(claudeConfig.mcpServers)) {
4978
+ if (server.command === "node9" && Array.isArray(server.args) && server.args.length > 0) {
4979
+ const [originalCmd, ...originalArgs] = server.args;
4980
+ claudeConfig.mcpServers[name] = {
4981
+ ...server,
4982
+ command: originalCmd,
4983
+ args: originalArgs.length ? originalArgs : void 0
4984
+ };
4985
+ mcpChanged = true;
4986
+ } else if (server.command === "node9") {
4987
+ console.warn(
4988
+ chalk3.yellow(
4989
+ ` \u26A0\uFE0F Cannot unwrap MCP server "${name}" in ~/.claude.json \u2014 args is empty. Remove it manually.`
4990
+ )
4991
+ );
4992
+ }
4993
+ }
4994
+ if (mcpChanged) {
4995
+ writeJson(mcpPath, claudeConfig);
4996
+ console.log(chalk3.green(" \u2705 Unwrapped MCP servers in ~/.claude.json"));
4997
+ }
4998
+ }
4999
+ }
5000
+ function teardownGemini() {
5001
+ const homeDir2 = os3.homedir();
5002
+ const settingsPath = path5.join(homeDir2, ".gemini", "settings.json");
5003
+ const settings = readJson(settingsPath);
5004
+ if (!settings) {
5005
+ console.log(chalk3.blue(" \u2139\uFE0F ~/.gemini/settings.json not found \u2014 nothing to remove"));
5006
+ return;
5007
+ }
5008
+ let changed = false;
5009
+ for (const event of ["BeforeTool", "AfterTool"]) {
5010
+ const before = settings.hooks?.[event]?.length ?? 0;
5011
+ if (settings.hooks?.[event]) {
5012
+ settings.hooks[event] = settings.hooks[event].filter(
5013
+ (m) => !m.hooks.some((h) => isNode9Hook(h.command))
5014
+ );
5015
+ if ((settings.hooks[event]?.length ?? 0) < before) changed = true;
5016
+ if (settings.hooks[event]?.length === 0) delete settings.hooks[event];
5017
+ }
5018
+ }
5019
+ if (settings.mcpServers) {
5020
+ for (const [name, server] of Object.entries(settings.mcpServers)) {
5021
+ if (server.command === "node9" && Array.isArray(server.args) && server.args.length > 0) {
5022
+ const [originalCmd, ...originalArgs] = server.args;
5023
+ settings.mcpServers[name] = {
5024
+ ...server,
5025
+ command: originalCmd,
5026
+ args: originalArgs.length ? originalArgs : void 0
5027
+ };
5028
+ changed = true;
5029
+ }
5030
+ }
5031
+ }
5032
+ if (changed) {
5033
+ writeJson(settingsPath, settings);
5034
+ console.log(chalk3.green(" \u2705 Removed Node9 hooks from ~/.gemini/settings.json"));
5035
+ } else {
5036
+ console.log(chalk3.blue(" \u2139\uFE0F No Node9 hooks found in ~/.gemini/settings.json"));
5037
+ }
5038
+ }
5039
+ function teardownCursor() {
5040
+ const homeDir2 = os3.homedir();
5041
+ const mcpPath = path5.join(homeDir2, ".cursor", "mcp.json");
5042
+ const mcpConfig = readJson(mcpPath);
5043
+ if (!mcpConfig?.mcpServers) {
5044
+ console.log(chalk3.blue(" \u2139\uFE0F ~/.cursor/mcp.json not found \u2014 nothing to remove"));
5045
+ return;
5046
+ }
5047
+ let changed = false;
5048
+ for (const [name, server] of Object.entries(mcpConfig.mcpServers)) {
5049
+ if (server.command === "node9" && Array.isArray(server.args) && server.args.length > 0) {
5050
+ const [originalCmd, ...originalArgs] = server.args;
5051
+ mcpConfig.mcpServers[name] = {
5052
+ ...server,
5053
+ command: originalCmd,
5054
+ args: originalArgs.length ? originalArgs : void 0
5055
+ };
5056
+ changed = true;
5057
+ }
5058
+ }
5059
+ if (changed) {
5060
+ writeJson(mcpPath, mcpConfig);
5061
+ console.log(chalk3.green(" \u2705 Unwrapped MCP servers in ~/.cursor/mcp.json"));
5062
+ } else {
5063
+ console.log(chalk3.blue(" \u2139\uFE0F No Node9-wrapped MCP servers found in ~/.cursor/mcp.json"));
5064
+ }
5065
+ }
4916
5066
  async function setupClaude() {
4917
5067
  const homeDir2 = os3.homedir();
4918
5068
  const mcpPath = path5.join(homeDir2, ".claude.json");
@@ -5536,6 +5686,87 @@ program.command("setup").description('Alias for "addto" \u2014 integrate Node9 w
5536
5686
  console.error(chalk6.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
5537
5687
  process.exit(1);
5538
5688
  });
5689
+ 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) => {
5690
+ let fn;
5691
+ if (target === "claude") fn = teardownClaude;
5692
+ else if (target === "gemini") fn = teardownGemini;
5693
+ else if (target === "cursor") fn = teardownCursor;
5694
+ else {
5695
+ console.error(chalk6.red(`Unknown target: "${target}". Supported: claude, gemini, cursor`));
5696
+ process.exit(1);
5697
+ }
5698
+ console.log(chalk6.cyan(`
5699
+ \u{1F6E1}\uFE0F Node9: removing hooks from ${target}...
5700
+ `));
5701
+ try {
5702
+ fn();
5703
+ } catch (err) {
5704
+ console.error(chalk6.red(` \u26A0\uFE0F Failed: ${err instanceof Error ? err.message : String(err)}`));
5705
+ process.exit(1);
5706
+ }
5707
+ console.log(chalk6.gray("\n Restart the agent for changes to take effect."));
5708
+ });
5709
+ 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) => {
5710
+ console.log(chalk6.cyan("\n\u{1F6E1}\uFE0F Node9 Uninstall\n"));
5711
+ console.log(chalk6.bold("Stopping daemon..."));
5712
+ try {
5713
+ stopDaemon();
5714
+ console.log(chalk6.green(" \u2705 Daemon stopped"));
5715
+ } catch {
5716
+ console.log(chalk6.blue(" \u2139\uFE0F Daemon was not running"));
5717
+ }
5718
+ console.log(chalk6.bold("\nRemoving hooks..."));
5719
+ let teardownFailed = false;
5720
+ for (const [label, fn] of [
5721
+ ["Claude", teardownClaude],
5722
+ ["Gemini", teardownGemini],
5723
+ ["Cursor", teardownCursor]
5724
+ ]) {
5725
+ try {
5726
+ fn();
5727
+ } catch (err) {
5728
+ teardownFailed = true;
5729
+ console.error(
5730
+ chalk6.red(
5731
+ ` \u26A0\uFE0F Failed to remove ${label} hooks: ${err instanceof Error ? err.message : String(err)}`
5732
+ )
5733
+ );
5734
+ }
5735
+ }
5736
+ if (options.purge) {
5737
+ const node9Dir = path9.join(os7.homedir(), ".node9");
5738
+ if (fs7.existsSync(node9Dir)) {
5739
+ const confirmed = await confirm3({
5740
+ message: `Permanently delete ${node9Dir} (config, audit log, credentials)?`,
5741
+ default: false
5742
+ });
5743
+ if (confirmed) {
5744
+ fs7.rmSync(node9Dir, { recursive: true });
5745
+ if (fs7.existsSync(node9Dir)) {
5746
+ console.error(
5747
+ chalk6.red("\n \u26A0\uFE0F ~/.node9/ could not be fully deleted \u2014 remove it manually.")
5748
+ );
5749
+ } else {
5750
+ console.log(chalk6.green("\n \u2705 Deleted ~/.node9/ (config, audit log, credentials)"));
5751
+ }
5752
+ } else {
5753
+ console.log(chalk6.yellow("\n Skipped \u2014 ~/.node9/ was not deleted."));
5754
+ }
5755
+ } else {
5756
+ console.log(chalk6.blue("\n \u2139\uFE0F ~/.node9/ not found \u2014 nothing to delete"));
5757
+ }
5758
+ } else {
5759
+ console.log(
5760
+ chalk6.gray("\n ~/.node9/ kept \u2014 run with --purge to delete config and audit log")
5761
+ );
5762
+ }
5763
+ if (teardownFailed) {
5764
+ console.error(chalk6.red("\n \u26A0\uFE0F Some hooks could not be removed \u2014 see errors above."));
5765
+ process.exit(1);
5766
+ }
5767
+ console.log(chalk6.green.bold("\n\u{1F6E1}\uFE0F Node9 removed. Run: npm uninstall -g @node9/proxy"));
5768
+ console.log(chalk6.gray(" Restart any open AI agent sessions for changes to take effect.\n"));
5769
+ });
5539
5770
  program.command("doctor").description("Check that Node9 is installed and configured correctly").action(() => {
5540
5771
  const homeDir2 = os7.homedir();
5541
5772
  let failures = 0;
@@ -5946,9 +6177,14 @@ program.command("daemon").description("Run the local approval server").argument(
5946
6177
  startDaemon();
5947
6178
  }
5948
6179
  );
5949
- program.command("tail").description("Stream live agent activity to the terminal").option("--history", "Include recent history on connect", false).option("--clear", "Clear history buffer and stream live events fresh", false).action(async (options) => {
6180
+ program.command("tail").description("Stream live agent activity to the terminal").option("--history", "Replay recent history then continue live", false).option("--clear", "Clear the history buffer and exit (does not stream)", false).action(async (options) => {
5950
6181
  const { startTail: startTail2 } = await Promise.resolve().then(() => (init_tail(), tail_exports));
5951
- await startTail2(options);
6182
+ try {
6183
+ await startTail2(options);
6184
+ } catch (err) {
6185
+ console.error(chalk6.red(`\u274C ${err instanceof Error ? err.message : String(err)}`));
6186
+ process.exit(1);
6187
+ }
5952
6188
  });
5953
6189
  program.command("check").description("Hook handler \u2014 evaluates a tool call before execution").argument("[data]", "JSON string of the tool call").action(async (data) => {
5954
6190
  const processPayload = async (raw) => {
@@ -6031,7 +6267,7 @@ RAW: ${raw}
6031
6267
  }
6032
6268
  const result = await authorizeHeadless(toolName, toolInput, false, meta);
6033
6269
  if (result.approved) {
6034
- if (result.checkedBy)
6270
+ if (result.checkedBy && process.env.NODE9_DEBUG === "1")
6035
6271
  process.stderr.write(`\u2713 node9 [${result.checkedBy}]: "${toolName}" allowed
6036
6272
  `);
6037
6273
  process.exit(0);
@@ -6042,7 +6278,7 @@ RAW: ${raw}
6042
6278
  if (daemonReady) {
6043
6279
  const retry = await authorizeHeadless(toolName, toolInput, false, meta);
6044
6280
  if (retry.approved) {
6045
- if (retry.checkedBy)
6281
+ if (retry.checkedBy && process.env.NODE9_DEBUG === "1")
6046
6282
  process.stderr.write(`\u2713 node9 [${retry.checkedBy}]: "${toolName}" allowed
6047
6283
  `);
6048
6284
  process.exit(0);
package/dist/index.js CHANGED
@@ -1115,7 +1115,7 @@ var DEFAULT_CONFIG = {
1115
1115
  {
1116
1116
  field: "command",
1117
1117
  op: "matches",
1118
- value: "git push.*(--force|--force-with-lease|-f\\b)",
1118
+ value: "^\\s*git\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
1119
1119
  flags: "i"
1120
1120
  }
1121
1121
  ],
@@ -1126,7 +1126,14 @@ var DEFAULT_CONFIG = {
1126
1126
  {
1127
1127
  name: "review-git-push",
1128
1128
  tool: "bash",
1129
- conditions: [{ field: "command", op: "matches", value: "^\\s*git\\s+push\\b", flags: "i" }],
1129
+ conditions: [
1130
+ {
1131
+ field: "command",
1132
+ op: "matches",
1133
+ value: "^\\s*git\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
1134
+ flags: "i"
1135
+ }
1136
+ ],
1130
1137
  conditionMode: "all",
1131
1138
  verdict: "review",
1132
1139
  reason: "git push sends changes to a shared remote"
@@ -1138,7 +1145,7 @@ var DEFAULT_CONFIG = {
1138
1145
  {
1139
1146
  field: "command",
1140
1147
  op: "matches",
1141
- value: "git\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase|tag\\s+-d|branch\\s+-[dD])",
1148
+ value: "^\\s*git\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
1142
1149
  flags: "i"
1143
1150
  }
1144
1151
  ],
package/dist/index.mjs CHANGED
@@ -1079,7 +1079,7 @@ var DEFAULT_CONFIG = {
1079
1079
  {
1080
1080
  field: "command",
1081
1081
  op: "matches",
1082
- value: "git push.*(--force|--force-with-lease|-f\\b)",
1082
+ value: "^\\s*git\\b.*\\bpush\\b.*(--force|--force-with-lease|-f\\b)",
1083
1083
  flags: "i"
1084
1084
  }
1085
1085
  ],
@@ -1090,7 +1090,14 @@ var DEFAULT_CONFIG = {
1090
1090
  {
1091
1091
  name: "review-git-push",
1092
1092
  tool: "bash",
1093
- conditions: [{ field: "command", op: "matches", value: "^\\s*git\\s+push\\b", flags: "i" }],
1093
+ conditions: [
1094
+ {
1095
+ field: "command",
1096
+ op: "matches",
1097
+ value: "^\\s*git\\b.*\\bpush\\b(?!.*(-f\\b|--force|--force-with-lease))",
1098
+ flags: "i"
1099
+ }
1100
+ ],
1094
1101
  conditionMode: "all",
1095
1102
  verdict: "review",
1096
1103
  reason: "git push sends changes to a shared remote"
@@ -1102,7 +1109,7 @@ var DEFAULT_CONFIG = {
1102
1109
  {
1103
1110
  field: "command",
1104
1111
  op: "matches",
1105
- value: "git\\s+(reset\\s+--hard|clean\\s+-[fdxX]|rebase|tag\\s+-d|branch\\s+-[dD])",
1112
+ value: "^\\s*git\\b.*(reset\\s+--hard|clean\\s+-[fdxX]|\\brebase\\b|tag\\s+-d|branch\\s+-[dD])",
1106
1113
  flags: "i"
1107
1114
  }
1108
1115
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "The Sudo Command for AI Agents. Execution Security for Claude Code & MCP.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
@@ -59,6 +59,7 @@
59
59
  "fix": "npm run format && npm run lint:fix",
60
60
  "validate": "npm run format && npm run lint && npm run typecheck && npm run test && npm run test:e2e && npm run build",
61
61
  "test:e2e": "NODE9_TESTING=1 bash scripts/e2e.sh",
62
+ "preuninstall": "node9 uninstall || echo 'node9 uninstall failed — remove hooks manually from ~/.claude/settings.json'",
62
63
  "prepublishOnly": "npm run validate",
63
64
  "test": "NODE_ENV=test vitest --run",
64
65
  "test:watch": "NODE_ENV=test vitest",