@node9/proxy 1.26.3 → 1.27.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/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  Node9 sits between your AI agent and the tools it can use — **discover** what it's already been doing, **protect** against risky actions in real time, and **review** what happened over any time window.
12
12
 
13
- Works with **Claude Code · Codex CLI · Gemini CLI · Cursor · Windsurf · any MCP server**.
13
+ Works with **Claude Code · Codex CLI · Gemini CLI · Cursor · Windsurf · VSCode · Claude Desktop · Opencode · Pi · any MCP server**.
14
14
 
15
15
  ## What Node9 does
16
16
 
@@ -66,7 +66,7 @@ npm install -g node9-ai
66
66
  ```
67
67
 
68
68
  ```bash
69
- node9 init # auto-wires Claude Code, Gemini CLI, Cursor, Codex, MCP servers
69
+ node9 init # auto-wires all detected agents + MCP servers
70
70
  node9 doctor # verify everything is wired correctly
71
71
  ```
72
72
 
@@ -195,7 +195,7 @@ def run_command(cmd: str) -> str:
195
195
  ## Under the hood
196
196
 
197
197
  - **Scan** reads raw agent history from `~/.claude/projects/`, `~/.gemini/tmp/`, `~/.codex/sessions/` — no API calls, fully offline
198
- - **Runtime** wires PreToolUse hooks into Claude Code, Gemini CLI, and Codex hooks write to `~/.node9/audit.log` atomically
198
+ - **Runtime** intercepts tool calls via pre-execution hooks (Claude Code, Codex, Gemini CLI, Opencode, Pi) or via the MCP gateway (Cursor, Windsurf, VSCode, Claude Desktop). All decisions land in `~/.node9/audit.log` atomically.
199
199
  - **MCP gateway** is a stdio proxy; intercepts `tools/list` + `tools/call` JSON-RPC, forwards the rest
200
200
  - **Policy engine** uses [mvdan-sh](https://github.com/mvdan/sh) for bash AST analysis — defeats obfuscation via backslash escaping, variable substitution, eval of remote download
201
201
  - **Shadow repo** for auto-undo lives at `~/.node9/snapshots/<hash16>/` — never touches your `.git`
package/dist/cli.js CHANGED
@@ -6647,6 +6647,200 @@ var init_setup_opencode_shim = __esm({
6647
6647
  }
6648
6648
  });
6649
6649
 
6650
+ // src/setup-pi-shim.ts
6651
+ function renderPiShim(input) {
6652
+ const { node9Argv, version: version2 } = input;
6653
+ return `// Auto-generated by \`node9 init\`. Do not edit \u2014 re-run init to upgrade.
6654
+ // NODE9_SHIM_VERSION = "${version2}"
6655
+ //
6656
+ // node9 protection shim for Pi (https://pi.dev). Wires four hooks
6657
+ // against the agent's extension API and shells out to the node9 CLI
6658
+ // for verdicts.
6659
+ //
6660
+ // Contract (verified against opensources/pi-main):
6661
+ // - tool_call \u2192 return { block: true, reason } to block (NOT throw)
6662
+ // - tool_result \u2192 audit fire-and-forget
6663
+ // - input \u2192 return { action: "handled" } to block, { action: "continue" } to allow
6664
+ // - user_bash \u2192 return synthetic BashResult { output, exitCode: 1, ... } to block
6665
+ // (UserBashEventResult has no block field by design)
6666
+
6667
+ const { spawnSync } = require("node:child_process");
6668
+ const fs = require("node:fs");
6669
+ const path = require("node:path");
6670
+ const os = require("node:os");
6671
+
6672
+ function debugLog(entry) {
6673
+ // Best-effort write to ~/.node9/hook-debug.log so audit-log failures
6674
+ // in the tool_result catch leave a breadcrumb. Pi has no equivalent
6675
+ // of node9's own hook-debug.log path, and silent swallow makes
6676
+ // misconfiguration invisible until block-rate dashboards catch on.
6677
+ // Wrapped in try because this hook NEVER throws \u2014 see catch comment.
6678
+ try {
6679
+ const logPath = path.join(os.homedir(), ".node9", "hook-debug.log");
6680
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
6681
+ fs.appendFileSync(logPath, JSON.stringify({
6682
+ ts: new Date().toISOString(),
6683
+ source: "pi-shim",
6684
+ ...entry,
6685
+ }) + "\\n");
6686
+ } catch (_) {
6687
+ // ignore \u2014 last-resort breadcrumb is best-effort
6688
+ }
6689
+ }
6690
+
6691
+ // argv prefix for invoking the node9 CLI. argv[0] is the executable
6692
+ // (either the npm-installed wrapper script or the node binary); the
6693
+ // remaining entries (if any) are passed as the leading args before
6694
+ // the subcommand name (e.g. ["/path/to/dist/cli.js"] for dev mode).
6695
+ const NODE9_ARGV = ${JSON.stringify(node9Argv)};
6696
+ const HOOK_TIMEOUT_MS = 30000;
6697
+ const LOG_TIMEOUT_MS = 5000;
6698
+
6699
+ // Pi tool names \u2192 Claude tool names. Policy rules in ~/.node9/config.yaml
6700
+ // match against Claude's PascalCase names (Bash/Read/Edit/Write/...).
6701
+ // Without this mapping every DLP and project-jail rule silently no-ops
6702
+ // for pi payloads (design R5).
6703
+ const TOOL_NAME_MAP = {
6704
+ bash: "Bash",
6705
+ read: "Read",
6706
+ edit: "Edit",
6707
+ write: "Write",
6708
+ grep: "Grep",
6709
+ find: "Glob",
6710
+ ls: "LS",
6711
+ };
6712
+
6713
+ function normalizeToolName(piName) {
6714
+ return TOOL_NAME_MAP[piName] || piName;
6715
+ }
6716
+
6717
+ function parseReason(stdout) {
6718
+ // node9 check emits {decision, reason, message} JSON on stdout when
6719
+ // blocking. Fall back to a generic string if anything goes wrong.
6720
+ try {
6721
+ const v = JSON.parse(stdout || "");
6722
+ return v && (v.reason || v.message);
6723
+ } catch (e) {
6724
+ return null;
6725
+ }
6726
+ }
6727
+
6728
+ function runCheck(payload, timeoutMs) {
6729
+ return spawnSync(NODE9_ARGV[0], [...NODE9_ARGV.slice(1), "check"], {
6730
+ input: JSON.stringify(payload),
6731
+ encoding: "utf-8",
6732
+ timeout: timeoutMs,
6733
+ });
6734
+ }
6735
+
6736
+ module.exports = function (pi) {
6737
+ pi.on("tool_call", async (event, ctx) => {
6738
+ const payload = {
6739
+ hook_event_name: "PreToolUse",
6740
+ tool_name: normalizeToolName(event.toolName),
6741
+ tool_input: event.input,
6742
+ cwd: ctx.cwd,
6743
+ meta: { agent: "Pi" },
6744
+ };
6745
+ const r = runCheck(payload, HOOK_TIMEOUT_MS);
6746
+ if (r.status === 0) return undefined;
6747
+ const reason = parseReason(r.stdout) || "blocked by node9";
6748
+ return { block: true, reason: "[node9] " + reason };
6749
+ });
6750
+
6751
+ pi.on("tool_result", async (event, ctx) => {
6752
+ // Fire-and-forget audit log. Failures here must NEVER throw or
6753
+ // return an error result \u2014 we'd retroactively "fail" a tool call
6754
+ // that already completed.
6755
+ const payload = {
6756
+ hook_event_name: "PostToolUse",
6757
+ tool_name: normalizeToolName(event.toolName),
6758
+ tool_input: event.input,
6759
+ cwd: ctx.cwd,
6760
+ meta: { agent: "Pi" },
6761
+ };
6762
+ try {
6763
+ spawnSync(NODE9_ARGV[0], [...NODE9_ARGV.slice(1), "log"], {
6764
+ input: JSON.stringify(payload),
6765
+ encoding: "utf-8",
6766
+ timeout: LOG_TIMEOUT_MS,
6767
+ });
6768
+ } catch (e) {
6769
+ // Swallow + breadcrumb. Audit log gaps are preferable to crashing
6770
+ // the agent, but a persistent failure here (e.g. NODE9_ARGV[0]
6771
+ // no longer exists after a node-version bump) used to be invisible
6772
+ // because pi has no hook-debug surface. Write a one-line entry to
6773
+ // ~/.node9/hook-debug.log so dashboards can catch silent drift.
6774
+ debugLog({
6775
+ event: "tool_result-spawn-failed",
6776
+ tool: payload.tool_name,
6777
+ agent: "Pi",
6778
+ error: e && e.message ? e.message : String(e),
6779
+ });
6780
+ }
6781
+ return undefined;
6782
+ });
6783
+
6784
+ pi.on("input", async (event, ctx) => {
6785
+ // Pre-prompt DLP. event.text is the user's typed/pasted prompt.
6786
+ // Block via { action: "handled" }; allow via { action: "continue" }.
6787
+ // We do NOT use { action: "transform" } in v1 \u2014 block-only mirrors
6788
+ // Claude/Codex/Opencode behavior. Redact-mode is a future option.
6789
+ const payload = {
6790
+ hook_event_name: "UserPromptSubmit",
6791
+ prompt: event.text,
6792
+ cwd: ctx.cwd,
6793
+ meta: { agent: "Pi" },
6794
+ };
6795
+ const r = runCheck(payload, HOOK_TIMEOUT_MS);
6796
+ if (r.status === 0) return { action: "continue" };
6797
+ const reason = parseReason(r.stdout) || "prompt blocked";
6798
+ if (ctx.hasUI) {
6799
+ ctx.ui.notify("[node9] " + reason, "error");
6800
+ }
6801
+ return { action: "handled" };
6802
+ });
6803
+
6804
+ pi.on("user_bash", async (event, ctx) => {
6805
+ // The !/!! prompt-escape side channel (design R4). Synthesize a
6806
+ // Bash-shaped PreToolUse payload so the same dangerous-words / DLP
6807
+ // rules that gate tool_call(bash) also gate user_bash.
6808
+ //
6809
+ // UserBashEventResult has no { block } field \u2014 by design, since
6810
+ // pi.on("user_bash") was meant for execution-replacement. We block
6811
+ // by returning a synthetic BashResult that looks like the command
6812
+ // failed with our reason as the output (exitCode: 1).
6813
+ const payload = {
6814
+ hook_event_name: "PreToolUse",
6815
+ tool_name: "Bash",
6816
+ tool_input: { command: event.command },
6817
+ cwd: event.cwd,
6818
+ meta: { agent: "Pi" },
6819
+ };
6820
+ const r = runCheck(payload, HOOK_TIMEOUT_MS);
6821
+ if (r.status === 0) return undefined;
6822
+ const reason = parseReason(r.stdout) || "blocked by node9";
6823
+ if (ctx.hasUI) {
6824
+ ctx.ui.notify("[node9] " + reason, "error");
6825
+ }
6826
+ return {
6827
+ result: {
6828
+ output: "[node9] blocked: " + reason,
6829
+ exitCode: 1,
6830
+ cancelled: false,
6831
+ truncated: false,
6832
+ },
6833
+ };
6834
+ });
6835
+ };
6836
+ `;
6837
+ }
6838
+ var init_setup_pi_shim = __esm({
6839
+ "src/setup-pi-shim.ts"() {
6840
+ "use strict";
6841
+ }
6842
+ });
6843
+
6650
6844
  // src/setup.ts
6651
6845
  function hasNode9McpServer(servers) {
6652
6846
  const entry = servers["node9"];
@@ -7139,7 +7333,14 @@ function detectAgents(homeDir2 = import_os12.default.homedir()) {
7139
7333
  claudeDesktop: desktopPath !== null && exists(import_path15.default.dirname(desktopPath)),
7140
7334
  // Opencode creates ~/.config/opencode lazily on first launch — fall back
7141
7335
  // to a PATH lookup so installed-but-never-launched CLIs are still wired.
7142
- opencode: exists(import_path15.default.join(homeDir2, ".config", "opencode")) || binaryInPath("opencode")
7336
+ opencode: exists(import_path15.default.join(homeDir2, ".config", "opencode")) || binaryInPath("opencode"),
7337
+ // Pi (https://pi.dev): config dir is ~/.pi/agent (CONFIG_DIR_NAME=".pi"
7338
+ // + agentDir, verified against opensources/pi-main/packages/coding-agent/
7339
+ // src/config.ts:449,475). The Bun-compiled binary path may create this
7340
+ // dir lazily on first launch — same class of bug as opencode's #186
7341
+ // (design R6) — so fall back to PATH lookup for installed-but-never-
7342
+ // launched pi.
7343
+ pi: exists(import_path15.default.join(homeDir2, ".pi", "agent")) || binaryInPath("pi")
7143
7344
  };
7144
7345
  }
7145
7346
  async function setupCursor() {
@@ -7893,6 +8094,59 @@ function teardownOpencode() {
7893
8094
  console.log(import_chalk.default.blue(" \u2139\uFE0F No node9 entries found in ~/.config/opencode/opencode.json"));
7894
8095
  }
7895
8096
  }
8097
+ async function setupPi() {
8098
+ seedMcpPinsIfMissing();
8099
+ const homeDir2 = import_os12.default.homedir();
8100
+ const extensionsDir = import_path15.default.join(homeDir2, ".pi", "agent", "extensions");
8101
+ const extensionPath = import_path15.default.join(extensionsDir, PI_EXTENSION_NAME);
8102
+ try {
8103
+ import_fs13.default.mkdirSync(extensionsDir, { recursive: true });
8104
+ } catch (err2) {
8105
+ const code = err2.code;
8106
+ console.log(import_chalk.default.yellow(` \u26A0\uFE0F Could not create ${extensionsDir}: ${code ?? String(err2)}`));
8107
+ return;
8108
+ }
8109
+ const shimContent = renderPiShim({
8110
+ node9Argv: node9ArgvForShim(),
8111
+ version: node9Version()
8112
+ });
8113
+ const existingShim = (() => {
8114
+ try {
8115
+ return import_fs13.default.readFileSync(extensionPath, "utf-8");
8116
+ } catch {
8117
+ return null;
8118
+ }
8119
+ })();
8120
+ if (existingShim === shimContent) {
8121
+ console.log(import_chalk.default.blue(" \u2139\uFE0F Node9 is already fully configured for Pi."));
8122
+ return;
8123
+ }
8124
+ import_fs13.default.writeFileSync(extensionPath, shimContent);
8125
+ if (existingShim) {
8126
+ console.log(import_chalk.default.yellow(" \u{1F527} Pi extension shim updated to current version"));
8127
+ } else {
8128
+ console.log(
8129
+ import_chalk.default.green(" \u2705 Pi extension installed \u2192 tool_call / tool_result / input / user_bash")
8130
+ );
8131
+ }
8132
+ console.log(import_chalk.default.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Pi!"));
8133
+ console.log(import_chalk.default.gray(" Restart Pi for changes to take effect."));
8134
+ printDaemonTip();
8135
+ }
8136
+ function teardownPi() {
8137
+ const homeDir2 = import_os12.default.homedir();
8138
+ const extensionPath = import_path15.default.join(homeDir2, ".pi", "agent", "extensions", PI_EXTENSION_NAME);
8139
+ try {
8140
+ if (import_fs13.default.existsSync(extensionPath)) {
8141
+ import_fs13.default.unlinkSync(extensionPath);
8142
+ console.log(import_chalk.default.green(" \u2705 Removed node9 extension from ~/.pi/agent/extensions/"));
8143
+ } else {
8144
+ console.log(import_chalk.default.blue(" \u2139\uFE0F No Pi extension installed \u2014 nothing to remove"));
8145
+ }
8146
+ } catch (err2) {
8147
+ console.log(import_chalk.default.yellow(` \u26A0\uFE0F Could not remove ${extensionPath}: ${String(err2)}`));
8148
+ }
8149
+ }
7896
8150
  function getAgentsStatus(homeDir2 = import_os12.default.homedir()) {
7897
8151
  const detected = detectAgents(homeDir2);
7898
8152
  const claudeWired = (() => {
@@ -7995,10 +8249,19 @@ function getAgentsStatus(homeDir2 = import_os12.default.homedir()) {
7995
8249
  return !!cfg?.mcp?.["node9"];
7996
8250
  })(),
7997
8251
  mode: detected.opencode ? "hooks" : null
8252
+ },
8253
+ {
8254
+ name: "pi",
8255
+ label: "Pi",
8256
+ installed: detected.pi,
8257
+ // Pi has no MCP path — only the extension file. "wired" is a
8258
+ // simple existence check on the canonical install location.
8259
+ wired: import_fs13.default.existsSync(import_path15.default.join(homeDir2, ".pi", "agent", "extensions", PI_EXTENSION_NAME)),
8260
+ mode: detected.pi ? "hooks" : null
7998
8261
  }
7999
8262
  ];
8000
8263
  }
8001
- var import_fs13, import_path15, import_os12, import_chalk, import_prompts, import_smol_toml, NODE9_MCP_SERVER_ENTRY, CODEX_PRE_TOOL_MATCHERS, OPENCODE_PLUGIN_NAME;
8264
+ var import_fs13, import_path15, import_os12, import_chalk, import_prompts, import_smol_toml, NODE9_MCP_SERVER_ENTRY, CODEX_PRE_TOOL_MATCHERS, OPENCODE_PLUGIN_NAME, PI_EXTENSION_NAME;
8002
8265
  var init_setup = __esm({
8003
8266
  "src/setup.ts"() {
8004
8267
  "use strict";
@@ -8010,9 +8273,11 @@ var init_setup = __esm({
8010
8273
  import_smol_toml = require("smol-toml");
8011
8274
  init_mcp_pin();
8012
8275
  init_setup_opencode_shim();
8276
+ init_setup_pi_shim();
8013
8277
  NODE9_MCP_SERVER_ENTRY = { command: "node9", args: ["mcp-server"] };
8014
8278
  CODEX_PRE_TOOL_MATCHERS = ["^Bash$", "^apply_patch$", "^mcp__.*"];
8015
8279
  OPENCODE_PLUGIN_NAME = "node9.js";
8280
+ PI_EXTENSION_NAME = "node9.js";
8016
8281
  }
8017
8282
  });
8018
8283
 
@@ -16812,7 +17077,15 @@ function registerLogCommand(program2) {
16812
17077
  const payload = JSON.parse(raw);
16813
17078
  const tool = sanitize3(payload.tool_name ?? payload.name ?? "unknown");
16814
17079
  const rawInput = payload.tool_input ?? payload.args ?? {};
16815
- const agent = payload.turn_id !== void 0 ? "Codex" : payload.hook_event_name === "PreToolUse" || payload.hook_event_name === "PostToolUse" || payload.tool_use_id !== void 0 || payload.permission_mode !== void 0 ? "Claude Code" : payload.hook_event_name === "BeforeTool" || payload.hook_event_name === "AfterTool" || payload.timestamp !== void 0 ? "Gemini CLI" : void 0;
17080
+ const metaTag = (() => {
17081
+ const m = payload.meta;
17082
+ if (m && typeof m === "object") {
17083
+ const tagged = m.agent;
17084
+ if (typeof tagged === "string" && tagged.length > 0) return tagged;
17085
+ }
17086
+ return void 0;
17087
+ })();
17088
+ const agent = metaTag !== void 0 ? metaTag : payload.turn_id !== void 0 ? "Codex" : payload.hook_event_name === "PreToolUse" || payload.hook_event_name === "PostToolUse" || payload.tool_use_id !== void 0 || payload.permission_mode !== void 0 ? "Claude Code" : payload.hook_event_name === "BeforeTool" || payload.hook_event_name === "AfterTool" || payload.timestamp !== void 0 ? "Gemini CLI" : void 0;
16816
17089
  const entry = {
16817
17090
  ts: (/* @__PURE__ */ new Date()).toISOString(),
16818
17091
  tool,
@@ -18946,11 +19219,13 @@ function registerInitCommand(program2) {
18946
19219
  if (found.length === 0) {
18947
19220
  console.log(
18948
19221
  import_chalk16.default.gray(
18949
- "No AI agents detected. Install Claude Code, Gemini CLI, Cursor, Windsurf, VSCode, or Codex"
19222
+ "No AI agents detected. Install one of the supported agents (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, VSCode, Claude Desktop, Opencode, or Pi)."
18950
19223
  )
18951
19224
  );
18952
19225
  console.log(
18953
- import_chalk16.default.gray("then run: node9 agents add <claude|gemini|cursor|windsurf|vscode|codex>")
19226
+ import_chalk16.default.gray(
19227
+ "then run: node9 agents add <claude|codex|gemini|cursor|windsurf|vscode|claudeDesktop|opencode|pi>"
19228
+ )
18954
19229
  );
18955
19230
  return;
18956
19231
  }
@@ -18969,6 +19244,7 @@ function registerInitCommand(program2) {
18969
19244
  else if (agent === "vscode") await setupVSCode();
18970
19245
  else if (agent === "claudeDesktop") await setupClaudeDesktop();
18971
19246
  else if (agent === "opencode") await setupOpencode();
19247
+ else if (agent === "pi") await setupPi();
18972
19248
  console.log("");
18973
19249
  }
18974
19250
  if ((process.platform === "darwin" || process.platform === "linux") && process.stdout.isTTY) {
@@ -20729,7 +21005,8 @@ var SETUP_FN = {
20729
21005
  windsurf: setupWindsurf,
20730
21006
  vscode: setupVSCode,
20731
21007
  claudeDesktop: setupClaudeDesktop,
20732
- opencode: setupOpencode
21008
+ opencode: setupOpencode,
21009
+ pi: setupPi
20733
21010
  };
20734
21011
  var TEARDOWN_FN = {
20735
21012
  claude: teardownClaude,
@@ -20739,7 +21016,8 @@ var TEARDOWN_FN = {
20739
21016
  windsurf: teardownWindsurf,
20740
21017
  vscode: teardownVSCode,
20741
21018
  claudeDesktop: teardownClaudeDesktop,
20742
- opencode: teardownOpencode
21019
+ opencode: teardownOpencode,
21020
+ pi: teardownPi
20743
21021
  };
20744
21022
  var AGENT_NAMES = Object.keys(SETUP_FN);
20745
21023
  function registerAgentsCommand(program2) {
package/dist/cli.mjs CHANGED
@@ -6622,6 +6622,200 @@ var init_setup_opencode_shim = __esm({
6622
6622
  }
6623
6623
  });
6624
6624
 
6625
+ // src/setup-pi-shim.ts
6626
+ function renderPiShim(input) {
6627
+ const { node9Argv, version: version2 } = input;
6628
+ return `// Auto-generated by \`node9 init\`. Do not edit \u2014 re-run init to upgrade.
6629
+ // NODE9_SHIM_VERSION = "${version2}"
6630
+ //
6631
+ // node9 protection shim for Pi (https://pi.dev). Wires four hooks
6632
+ // against the agent's extension API and shells out to the node9 CLI
6633
+ // for verdicts.
6634
+ //
6635
+ // Contract (verified against opensources/pi-main):
6636
+ // - tool_call \u2192 return { block: true, reason } to block (NOT throw)
6637
+ // - tool_result \u2192 audit fire-and-forget
6638
+ // - input \u2192 return { action: "handled" } to block, { action: "continue" } to allow
6639
+ // - user_bash \u2192 return synthetic BashResult { output, exitCode: 1, ... } to block
6640
+ // (UserBashEventResult has no block field by design)
6641
+
6642
+ const { spawnSync } = require("node:child_process");
6643
+ const fs = require("node:fs");
6644
+ const path = require("node:path");
6645
+ const os = require("node:os");
6646
+
6647
+ function debugLog(entry) {
6648
+ // Best-effort write to ~/.node9/hook-debug.log so audit-log failures
6649
+ // in the tool_result catch leave a breadcrumb. Pi has no equivalent
6650
+ // of node9's own hook-debug.log path, and silent swallow makes
6651
+ // misconfiguration invisible until block-rate dashboards catch on.
6652
+ // Wrapped in try because this hook NEVER throws \u2014 see catch comment.
6653
+ try {
6654
+ const logPath = path.join(os.homedir(), ".node9", "hook-debug.log");
6655
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
6656
+ fs.appendFileSync(logPath, JSON.stringify({
6657
+ ts: new Date().toISOString(),
6658
+ source: "pi-shim",
6659
+ ...entry,
6660
+ }) + "\\n");
6661
+ } catch (_) {
6662
+ // ignore \u2014 last-resort breadcrumb is best-effort
6663
+ }
6664
+ }
6665
+
6666
+ // argv prefix for invoking the node9 CLI. argv[0] is the executable
6667
+ // (either the npm-installed wrapper script or the node binary); the
6668
+ // remaining entries (if any) are passed as the leading args before
6669
+ // the subcommand name (e.g. ["/path/to/dist/cli.js"] for dev mode).
6670
+ const NODE9_ARGV = ${JSON.stringify(node9Argv)};
6671
+ const HOOK_TIMEOUT_MS = 30000;
6672
+ const LOG_TIMEOUT_MS = 5000;
6673
+
6674
+ // Pi tool names \u2192 Claude tool names. Policy rules in ~/.node9/config.yaml
6675
+ // match against Claude's PascalCase names (Bash/Read/Edit/Write/...).
6676
+ // Without this mapping every DLP and project-jail rule silently no-ops
6677
+ // for pi payloads (design R5).
6678
+ const TOOL_NAME_MAP = {
6679
+ bash: "Bash",
6680
+ read: "Read",
6681
+ edit: "Edit",
6682
+ write: "Write",
6683
+ grep: "Grep",
6684
+ find: "Glob",
6685
+ ls: "LS",
6686
+ };
6687
+
6688
+ function normalizeToolName(piName) {
6689
+ return TOOL_NAME_MAP[piName] || piName;
6690
+ }
6691
+
6692
+ function parseReason(stdout) {
6693
+ // node9 check emits {decision, reason, message} JSON on stdout when
6694
+ // blocking. Fall back to a generic string if anything goes wrong.
6695
+ try {
6696
+ const v = JSON.parse(stdout || "");
6697
+ return v && (v.reason || v.message);
6698
+ } catch (e) {
6699
+ return null;
6700
+ }
6701
+ }
6702
+
6703
+ function runCheck(payload, timeoutMs) {
6704
+ return spawnSync(NODE9_ARGV[0], [...NODE9_ARGV.slice(1), "check"], {
6705
+ input: JSON.stringify(payload),
6706
+ encoding: "utf-8",
6707
+ timeout: timeoutMs,
6708
+ });
6709
+ }
6710
+
6711
+ module.exports = function (pi) {
6712
+ pi.on("tool_call", async (event, ctx) => {
6713
+ const payload = {
6714
+ hook_event_name: "PreToolUse",
6715
+ tool_name: normalizeToolName(event.toolName),
6716
+ tool_input: event.input,
6717
+ cwd: ctx.cwd,
6718
+ meta: { agent: "Pi" },
6719
+ };
6720
+ const r = runCheck(payload, HOOK_TIMEOUT_MS);
6721
+ if (r.status === 0) return undefined;
6722
+ const reason = parseReason(r.stdout) || "blocked by node9";
6723
+ return { block: true, reason: "[node9] " + reason };
6724
+ });
6725
+
6726
+ pi.on("tool_result", async (event, ctx) => {
6727
+ // Fire-and-forget audit log. Failures here must NEVER throw or
6728
+ // return an error result \u2014 we'd retroactively "fail" a tool call
6729
+ // that already completed.
6730
+ const payload = {
6731
+ hook_event_name: "PostToolUse",
6732
+ tool_name: normalizeToolName(event.toolName),
6733
+ tool_input: event.input,
6734
+ cwd: ctx.cwd,
6735
+ meta: { agent: "Pi" },
6736
+ };
6737
+ try {
6738
+ spawnSync(NODE9_ARGV[0], [...NODE9_ARGV.slice(1), "log"], {
6739
+ input: JSON.stringify(payload),
6740
+ encoding: "utf-8",
6741
+ timeout: LOG_TIMEOUT_MS,
6742
+ });
6743
+ } catch (e) {
6744
+ // Swallow + breadcrumb. Audit log gaps are preferable to crashing
6745
+ // the agent, but a persistent failure here (e.g. NODE9_ARGV[0]
6746
+ // no longer exists after a node-version bump) used to be invisible
6747
+ // because pi has no hook-debug surface. Write a one-line entry to
6748
+ // ~/.node9/hook-debug.log so dashboards can catch silent drift.
6749
+ debugLog({
6750
+ event: "tool_result-spawn-failed",
6751
+ tool: payload.tool_name,
6752
+ agent: "Pi",
6753
+ error: e && e.message ? e.message : String(e),
6754
+ });
6755
+ }
6756
+ return undefined;
6757
+ });
6758
+
6759
+ pi.on("input", async (event, ctx) => {
6760
+ // Pre-prompt DLP. event.text is the user's typed/pasted prompt.
6761
+ // Block via { action: "handled" }; allow via { action: "continue" }.
6762
+ // We do NOT use { action: "transform" } in v1 \u2014 block-only mirrors
6763
+ // Claude/Codex/Opencode behavior. Redact-mode is a future option.
6764
+ const payload = {
6765
+ hook_event_name: "UserPromptSubmit",
6766
+ prompt: event.text,
6767
+ cwd: ctx.cwd,
6768
+ meta: { agent: "Pi" },
6769
+ };
6770
+ const r = runCheck(payload, HOOK_TIMEOUT_MS);
6771
+ if (r.status === 0) return { action: "continue" };
6772
+ const reason = parseReason(r.stdout) || "prompt blocked";
6773
+ if (ctx.hasUI) {
6774
+ ctx.ui.notify("[node9] " + reason, "error");
6775
+ }
6776
+ return { action: "handled" };
6777
+ });
6778
+
6779
+ pi.on("user_bash", async (event, ctx) => {
6780
+ // The !/!! prompt-escape side channel (design R4). Synthesize a
6781
+ // Bash-shaped PreToolUse payload so the same dangerous-words / DLP
6782
+ // rules that gate tool_call(bash) also gate user_bash.
6783
+ //
6784
+ // UserBashEventResult has no { block } field \u2014 by design, since
6785
+ // pi.on("user_bash") was meant for execution-replacement. We block
6786
+ // by returning a synthetic BashResult that looks like the command
6787
+ // failed with our reason as the output (exitCode: 1).
6788
+ const payload = {
6789
+ hook_event_name: "PreToolUse",
6790
+ tool_name: "Bash",
6791
+ tool_input: { command: event.command },
6792
+ cwd: event.cwd,
6793
+ meta: { agent: "Pi" },
6794
+ };
6795
+ const r = runCheck(payload, HOOK_TIMEOUT_MS);
6796
+ if (r.status === 0) return undefined;
6797
+ const reason = parseReason(r.stdout) || "blocked by node9";
6798
+ if (ctx.hasUI) {
6799
+ ctx.ui.notify("[node9] " + reason, "error");
6800
+ }
6801
+ return {
6802
+ result: {
6803
+ output: "[node9] blocked: " + reason,
6804
+ exitCode: 1,
6805
+ cancelled: false,
6806
+ truncated: false,
6807
+ },
6808
+ };
6809
+ });
6810
+ };
6811
+ `;
6812
+ }
6813
+ var init_setup_pi_shim = __esm({
6814
+ "src/setup-pi-shim.ts"() {
6815
+ "use strict";
6816
+ }
6817
+ });
6818
+
6625
6819
  // src/setup.ts
6626
6820
  import fs13 from "fs";
6627
6821
  import path15 from "path";
@@ -7120,7 +7314,14 @@ function detectAgents(homeDir2 = os12.homedir()) {
7120
7314
  claudeDesktop: desktopPath !== null && exists(path15.dirname(desktopPath)),
7121
7315
  // Opencode creates ~/.config/opencode lazily on first launch — fall back
7122
7316
  // to a PATH lookup so installed-but-never-launched CLIs are still wired.
7123
- opencode: exists(path15.join(homeDir2, ".config", "opencode")) || binaryInPath("opencode")
7317
+ opencode: exists(path15.join(homeDir2, ".config", "opencode")) || binaryInPath("opencode"),
7318
+ // Pi (https://pi.dev): config dir is ~/.pi/agent (CONFIG_DIR_NAME=".pi"
7319
+ // + agentDir, verified against opensources/pi-main/packages/coding-agent/
7320
+ // src/config.ts:449,475). The Bun-compiled binary path may create this
7321
+ // dir lazily on first launch — same class of bug as opencode's #186
7322
+ // (design R6) — so fall back to PATH lookup for installed-but-never-
7323
+ // launched pi.
7324
+ pi: exists(path15.join(homeDir2, ".pi", "agent")) || binaryInPath("pi")
7124
7325
  };
7125
7326
  }
7126
7327
  async function setupCursor() {
@@ -7874,6 +8075,59 @@ function teardownOpencode() {
7874
8075
  console.log(chalk.blue(" \u2139\uFE0F No node9 entries found in ~/.config/opencode/opencode.json"));
7875
8076
  }
7876
8077
  }
8078
+ async function setupPi() {
8079
+ seedMcpPinsIfMissing();
8080
+ const homeDir2 = os12.homedir();
8081
+ const extensionsDir = path15.join(homeDir2, ".pi", "agent", "extensions");
8082
+ const extensionPath = path15.join(extensionsDir, PI_EXTENSION_NAME);
8083
+ try {
8084
+ fs13.mkdirSync(extensionsDir, { recursive: true });
8085
+ } catch (err2) {
8086
+ const code = err2.code;
8087
+ console.log(chalk.yellow(` \u26A0\uFE0F Could not create ${extensionsDir}: ${code ?? String(err2)}`));
8088
+ return;
8089
+ }
8090
+ const shimContent = renderPiShim({
8091
+ node9Argv: node9ArgvForShim(),
8092
+ version: node9Version()
8093
+ });
8094
+ const existingShim = (() => {
8095
+ try {
8096
+ return fs13.readFileSync(extensionPath, "utf-8");
8097
+ } catch {
8098
+ return null;
8099
+ }
8100
+ })();
8101
+ if (existingShim === shimContent) {
8102
+ console.log(chalk.blue(" \u2139\uFE0F Node9 is already fully configured for Pi."));
8103
+ return;
8104
+ }
8105
+ fs13.writeFileSync(extensionPath, shimContent);
8106
+ if (existingShim) {
8107
+ console.log(chalk.yellow(" \u{1F527} Pi extension shim updated to current version"));
8108
+ } else {
8109
+ console.log(
8110
+ chalk.green(" \u2705 Pi extension installed \u2192 tool_call / tool_result / input / user_bash")
8111
+ );
8112
+ }
8113
+ console.log(chalk.green.bold("\u{1F6E1}\uFE0F Node9 is now protecting Pi!"));
8114
+ console.log(chalk.gray(" Restart Pi for changes to take effect."));
8115
+ printDaemonTip();
8116
+ }
8117
+ function teardownPi() {
8118
+ const homeDir2 = os12.homedir();
8119
+ const extensionPath = path15.join(homeDir2, ".pi", "agent", "extensions", PI_EXTENSION_NAME);
8120
+ try {
8121
+ if (fs13.existsSync(extensionPath)) {
8122
+ fs13.unlinkSync(extensionPath);
8123
+ console.log(chalk.green(" \u2705 Removed node9 extension from ~/.pi/agent/extensions/"));
8124
+ } else {
8125
+ console.log(chalk.blue(" \u2139\uFE0F No Pi extension installed \u2014 nothing to remove"));
8126
+ }
8127
+ } catch (err2) {
8128
+ console.log(chalk.yellow(` \u26A0\uFE0F Could not remove ${extensionPath}: ${String(err2)}`));
8129
+ }
8130
+ }
7877
8131
  function getAgentsStatus(homeDir2 = os12.homedir()) {
7878
8132
  const detected = detectAgents(homeDir2);
7879
8133
  const claudeWired = (() => {
@@ -7976,18 +8230,29 @@ function getAgentsStatus(homeDir2 = os12.homedir()) {
7976
8230
  return !!cfg?.mcp?.["node9"];
7977
8231
  })(),
7978
8232
  mode: detected.opencode ? "hooks" : null
8233
+ },
8234
+ {
8235
+ name: "pi",
8236
+ label: "Pi",
8237
+ installed: detected.pi,
8238
+ // Pi has no MCP path — only the extension file. "wired" is a
8239
+ // simple existence check on the canonical install location.
8240
+ wired: fs13.existsSync(path15.join(homeDir2, ".pi", "agent", "extensions", PI_EXTENSION_NAME)),
8241
+ mode: detected.pi ? "hooks" : null
7979
8242
  }
7980
8243
  ];
7981
8244
  }
7982
- var NODE9_MCP_SERVER_ENTRY, CODEX_PRE_TOOL_MATCHERS, OPENCODE_PLUGIN_NAME;
8245
+ var NODE9_MCP_SERVER_ENTRY, CODEX_PRE_TOOL_MATCHERS, OPENCODE_PLUGIN_NAME, PI_EXTENSION_NAME;
7983
8246
  var init_setup = __esm({
7984
8247
  "src/setup.ts"() {
7985
8248
  "use strict";
7986
8249
  init_mcp_pin();
7987
8250
  init_setup_opencode_shim();
8251
+ init_setup_pi_shim();
7988
8252
  NODE9_MCP_SERVER_ENTRY = { command: "node9", args: ["mcp-server"] };
7989
8253
  CODEX_PRE_TOOL_MATCHERS = ["^Bash$", "^apply_patch$", "^mcp__.*"];
7990
8254
  OPENCODE_PLUGIN_NAME = "node9.js";
8255
+ PI_EXTENSION_NAME = "node9.js";
7991
8256
  }
7992
8257
  });
7993
8258
 
@@ -16784,7 +17049,15 @@ function registerLogCommand(program2) {
16784
17049
  const payload = JSON.parse(raw);
16785
17050
  const tool = sanitize3(payload.tool_name ?? payload.name ?? "unknown");
16786
17051
  const rawInput = payload.tool_input ?? payload.args ?? {};
16787
- const agent = payload.turn_id !== void 0 ? "Codex" : payload.hook_event_name === "PreToolUse" || payload.hook_event_name === "PostToolUse" || payload.tool_use_id !== void 0 || payload.permission_mode !== void 0 ? "Claude Code" : payload.hook_event_name === "BeforeTool" || payload.hook_event_name === "AfterTool" || payload.timestamp !== void 0 ? "Gemini CLI" : void 0;
17052
+ const metaTag = (() => {
17053
+ const m = payload.meta;
17054
+ if (m && typeof m === "object") {
17055
+ const tagged = m.agent;
17056
+ if (typeof tagged === "string" && tagged.length > 0) return tagged;
17057
+ }
17058
+ return void 0;
17059
+ })();
17060
+ const agent = metaTag !== void 0 ? metaTag : payload.turn_id !== void 0 ? "Codex" : payload.hook_event_name === "PreToolUse" || payload.hook_event_name === "PostToolUse" || payload.tool_use_id !== void 0 || payload.permission_mode !== void 0 ? "Claude Code" : payload.hook_event_name === "BeforeTool" || payload.hook_event_name === "AfterTool" || payload.timestamp !== void 0 ? "Gemini CLI" : void 0;
16788
17061
  const entry = {
16789
17062
  ts: (/* @__PURE__ */ new Date()).toISOString(),
16790
17063
  tool,
@@ -18918,11 +19191,13 @@ function registerInitCommand(program2) {
18918
19191
  if (found.length === 0) {
18919
19192
  console.log(
18920
19193
  chalk16.gray(
18921
- "No AI agents detected. Install Claude Code, Gemini CLI, Cursor, Windsurf, VSCode, or Codex"
19194
+ "No AI agents detected. Install one of the supported agents (Claude Code, Codex, Gemini CLI, Cursor, Windsurf, VSCode, Claude Desktop, Opencode, or Pi)."
18922
19195
  )
18923
19196
  );
18924
19197
  console.log(
18925
- chalk16.gray("then run: node9 agents add <claude|gemini|cursor|windsurf|vscode|codex>")
19198
+ chalk16.gray(
19199
+ "then run: node9 agents add <claude|codex|gemini|cursor|windsurf|vscode|claudeDesktop|opencode|pi>"
19200
+ )
18926
19201
  );
18927
19202
  return;
18928
19203
  }
@@ -18941,6 +19216,7 @@ function registerInitCommand(program2) {
18941
19216
  else if (agent === "vscode") await setupVSCode();
18942
19217
  else if (agent === "claudeDesktop") await setupClaudeDesktop();
18943
19218
  else if (agent === "opencode") await setupOpencode();
19219
+ else if (agent === "pi") await setupPi();
18944
19220
  console.log("");
18945
19221
  }
18946
19222
  if ((process.platform === "darwin" || process.platform === "linux") && process.stdout.isTTY) {
@@ -20701,7 +20977,8 @@ var SETUP_FN = {
20701
20977
  windsurf: setupWindsurf,
20702
20978
  vscode: setupVSCode,
20703
20979
  claudeDesktop: setupClaudeDesktop,
20704
- opencode: setupOpencode
20980
+ opencode: setupOpencode,
20981
+ pi: setupPi
20705
20982
  };
20706
20983
  var TEARDOWN_FN = {
20707
20984
  claude: teardownClaude,
@@ -20711,7 +20988,8 @@ var TEARDOWN_FN = {
20711
20988
  windsurf: teardownWindsurf,
20712
20989
  vscode: teardownVSCode,
20713
20990
  claudeDesktop: teardownClaudeDesktop,
20714
- opencode: teardownOpencode
20991
+ opencode: teardownOpencode,
20992
+ pi: teardownPi
20715
20993
  };
20716
20994
  var AGENT_NAMES = Object.keys(SETUP_FN);
20717
20995
  function registerAgentsCommand(program2) {
@@ -3383,6 +3383,13 @@ var init_setup_opencode_shim = __esm({
3383
3383
  }
3384
3384
  });
3385
3385
 
3386
+ // src/setup-pi-shim.ts
3387
+ var init_setup_pi_shim = __esm({
3388
+ "src/setup-pi-shim.ts"() {
3389
+ "use strict";
3390
+ }
3391
+ });
3392
+
3386
3393
  // src/setup.ts
3387
3394
  import chalk2 from "chalk";
3388
3395
  import { confirm } from "@inquirer/prompts";
@@ -3392,6 +3399,7 @@ var init_setup = __esm({
3392
3399
  "use strict";
3393
3400
  init_mcp_pin();
3394
3401
  init_setup_opencode_shim();
3402
+ init_setup_pi_shim();
3395
3403
  }
3396
3404
  });
3397
3405
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@node9/proxy",
3
- "version": "1.26.3",
4
- "description": "The Sudo Command for AI Agents. Execution Security for Claude Code & MCP.",
3
+ "version": "1.27.1",
4
+ "description": "The Sudo Command for AI Agents. Execution Security for Claude Code, Codex, Gemini, Cursor, Opencode, Pi, and any MCP server.",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
7
7
  "types": "./dist/index.d.ts",
@@ -31,13 +31,19 @@
31
31
  "homepage": "https://github.com/node9-ai/node9-proxy#readme",
32
32
  "keywords": [
33
33
  "ai-security",
34
+ "agent-security",
35
+ "agentic-ai",
34
36
  "mcp",
35
37
  "mcp-proxy",
36
38
  "claude-code",
39
+ "claude-desktop",
40
+ "codex",
37
41
  "gemini-cli",
38
42
  "cursor",
39
- "agentic-ai",
40
- "agent-security",
43
+ "windsurf",
44
+ "vscode",
45
+ "opencode",
46
+ "pi-agent",
41
47
  "sudo",
42
48
  "security-proxy",
43
49
  "human-in-the-loop",