@khalilgharbaoui/opencode-claude-code-plugin 0.13.0 → 0.13.2

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
@@ -182,6 +182,7 @@ The account model IDs are internally suffixed, for example `claude-sonnet-4-6@wo
182
182
  | `skipPermissions` | boolean | `true` | Pass `--dangerously-skip-permissions` to `claude`. Ignored when `proxyTools` is set — the proxy handles permissions through opencode instead. |
183
183
  | `permissionMode` | `acceptEdits` \| `auto` \| `bypassPermissions` \| `default` \| `dontAsk` \| `plan` | – | Forwarded to `claude --permission-mode`. |
184
184
  | `proxyTools` | string[] | `["Bash", "Edit", "Write", "WebFetch", "Task"]` | Claude built-in tools to route through opencode's executor + permission UI. Opt-in extras: `"Question"`, `"Compress"`. See [Selective tool proxy](#selective-tool-proxy). |
185
+ | `extraDisallowedTools` | string[] | – | Extra Claude built-ins to switch off with `--disallowedTools`, on top of what `proxyTools` implies. Claude's names, e.g. `["NotebookEdit"]`. See [Closing a tool with no proxy](#closing-a-tool-with-no-proxy). |
185
186
  | `proxyToolTimeoutMs` | `Record<string, number>` | – | Per-tool proxy call deadline in ms, keyed by proxy tool name (`bash`, `task`, …). Defaults: 10 min flat, `task` → 60 min. For `bash`, the call's own `input.timeout` is honoured on top (`max(resolved, input.timeout)`). See [Selective tool proxy](#selective-tool-proxy). |
186
187
  | `planModeQuestion` | boolean | `false` | Route `ExitPlanMode` approval through opencode's native `question` tool instead of a text "(yes/no)" prompt. Off because opencode's question form is currently broken upstream. See [Plan mode](#plan-mode). |
187
188
  | `controlRequestBehavior` | `allow` \| `deny` | `allow` | Default response when `skipPermissions: false` and Claude sends a `can_use_tool` control request. |
@@ -298,6 +299,28 @@ recovery step for harnesses that defer MCP tool schemas. Both apply per Claude
298
299
  process at spawn, and provider options are read once at opencode startup, so
299
300
  `proxyTools` changes need a full opencode restart.
300
301
 
302
+ ### Proxy endpoint security
303
+
304
+ The proxy is a small HTTP MCP server on an ephemeral loopback port, and calling it runs Bash, Edit and Write through opencode's executor. Since 0.13.2 it requires a 256-bit bearer token, generated per server and handed to Claude in the `headers` block of the `0600` MCP config file the plugin writes. Requests are also rejected unless the `Host` header matches the bound `127.0.0.1:<port>` authority, no `Origin` header is present, and the content type is `application/json`.
305
+
306
+ **Upgrade if you are on 0.13.1 or earlier.** Before this, any local process could post to that port and execute commands as you, and a web page you visited could do the same blind, without reading the response. Reported by @willmcginnis in [#28](https://github.com/khalilgharbaoui/opencode-claude-code-plugin/pull/28).
307
+
308
+ Nothing to configure. If proxied tools ever stop working after a Claude Code upgrade, check the plugin log for `proxy-mcp rejected a request`, which names which guard failed.
309
+
310
+ ### Closing a tool with no proxy
311
+
312
+ `proxyTools` only reaches built-ins the plugin can replace. A built-in with no opencode equivalent, `NotebookEdit` today and whatever Claude Code ships next, stays enabled and unmediated no matter what you put in that list. `extraDisallowedTools` names them directly:
313
+
314
+ ```json
315
+ "options": {
316
+ "extraDisallowedTools": ["NotebookEdit"]
317
+ }
318
+ ```
319
+
320
+ These go straight to `claude --disallowedTools`, so use Claude's tool names rather than opencode's. There is no replacement: the capability goes away rather than being routed through opencode, which is the point, but the model then has to work without it.
321
+
322
+ Unknown entries in `proxyTools` are logged as a warning at spawn rather than passing silently, so a typo shows up as "ignoring unknown proxyTools entries" in the plugin log instead of quietly leaving the matching built-in unmediated.
323
+
301
324
  ### Context compression
302
325
 
303
326
  `"Compress"` is off by default. Add it when you run a harness that expects the model to manage its own context (opencode-dcp injects exactly those instructions), and the plugin exposes `mcp__opencode_proxy__compress`:
package/dist/index.d.ts CHANGED
@@ -31,8 +31,8 @@ type OpenCodeModel = {
31
31
  video: boolean;
32
32
  pdf: boolean;
33
33
  };
34
- interleaved: boolean | {
35
- field: "reasoning_content" | "reasoning_details";
34
+ interleaved: boolean | string | {
35
+ field: "reasoning" | "reasoning_content" | "reasoning_text" | string;
36
36
  };
37
37
  };
38
38
  cost: {
@@ -161,6 +161,7 @@ interface ClaudeCodeConfig {
161
161
  controlRequestToolBehaviors?: Record<string, ControlRequestBehavior>;
162
162
  controlRequestDenyMessage?: string;
163
163
  proxyTools?: string[];
164
+ extraDisallowedTools?: string[];
164
165
  proxyToolTimeoutMs?: Record<string, number>;
165
166
  /**
166
167
  * Route `ExitPlanMode` through opencode's native `question` tool so plan
@@ -283,6 +284,20 @@ interface ClaudeCodeProviderSettings {
283
284
  * entry, in which case the deny/markdown fallback applies.
284
285
  */
285
286
  proxyTools?: string[];
287
+ /**
288
+ * Extra Claude Code built-ins to switch off with `--disallowedTools`,
289
+ * on top of the ones implied by `proxyTools`.
290
+ *
291
+ * `proxyTools` can only disable built-ins the plugin knows how to
292
+ * replace, so a built-in with no proxy equivalent (`NotebookEdit`, and
293
+ * anything Claude Code adds after this release) has no off switch
294
+ * otherwise. Names are Claude's, not opencode's: `["NotebookEdit"]`.
295
+ *
296
+ * Disabling a tool with no replacement removes the capability rather
297
+ * than routing it through opencode — that is the point, but it does mean
298
+ * the model has to work without it.
299
+ */
300
+ extraDisallowedTools?: string[];
286
301
  /**
287
302
  * Per-tool proxy call timeouts in milliseconds, keyed by the proxy tool
288
303
  * name (`bash`, `edit`, `write`, `webfetch`, `task`, `question` —
package/dist/index.js CHANGED
@@ -294,6 +294,9 @@ var CLAUDE_INTERNAL_TOOLS = /* @__PURE__ */ new Set([
294
294
  "TaskGet",
295
295
  "TaskStop"
296
296
  ]);
297
+ function singleQuoteForShell(value) {
298
+ return `'${value.replace(/'/g, `'\\''`)}'`;
299
+ }
297
300
  function emitTodoWrite(todos) {
298
301
  return {
299
302
  name: "todowrite",
@@ -348,7 +351,7 @@ function mapTool(name, input, opts) {
348
351
  return {
349
352
  name: "bash",
350
353
  input: {
351
- command: `echo "TASK OUTPUT: ${String(output).replace(/"/g, '\\"')}"`,
354
+ command: `printf '%s\\n' ${singleQuoteForShell(`TASK OUTPUT: ${String(output)}`)}`,
352
355
  description: "Displaying task output"
353
356
  },
354
357
  executed: false
@@ -2538,10 +2541,50 @@ var DEFAULT_PROXY_TOOLS = [
2538
2541
  async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverrides, interceptors) {
2539
2542
  const calls = new EventEmitter3();
2540
2543
  const pending = /* @__PURE__ */ new Map();
2544
+ const authToken = crypto2.randomBytes(32).toString("hex");
2545
+ const expectedAuth = Buffer.from(`Bearer ${authToken}`);
2546
+ let boundAuthority = "";
2547
+ function authOk(req) {
2548
+ const got = req.headers.authorization;
2549
+ if (typeof got !== "string") return false;
2550
+ const candidate = Buffer.from(got);
2551
+ if (candidate.length !== expectedAuth.length) return false;
2552
+ return crypto2.timingSafeEqual(candidate, expectedAuth);
2553
+ }
2554
+ function reject(req, res, statusCode, reason) {
2555
+ log.notice("proxy-mcp rejected a request", {
2556
+ statusCode,
2557
+ reason,
2558
+ method: req.method,
2559
+ hasAuthorization: typeof req.headers.authorization === "string"
2560
+ });
2561
+ res.statusCode = statusCode;
2562
+ res.setHeader("Connection", "close");
2563
+ res.on("finish", () => {
2564
+ req.socket?.destroy();
2565
+ });
2566
+ res.end();
2567
+ }
2541
2568
  const server2 = createServer(async (req, res) => {
2542
2569
  if (req.method !== "POST" || !req.url?.startsWith("/mcp")) {
2543
- res.statusCode = 404;
2544
- res.end();
2570
+ reject(req, res, 404, "not a POST to /mcp");
2571
+ return;
2572
+ }
2573
+ if (req.headers.host !== boundAuthority) {
2574
+ reject(req, res, 403, "host header is not the bound authority");
2575
+ return;
2576
+ }
2577
+ if (req.headers.origin !== void 0) {
2578
+ reject(req, res, 403, "origin header present");
2579
+ return;
2580
+ }
2581
+ const contentType = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
2582
+ if (contentType !== "application/json") {
2583
+ reject(req, res, 415, "content-type is not application/json");
2584
+ return;
2585
+ }
2586
+ if (!authOk(req)) {
2587
+ reject(req, res, 401, "missing or invalid bearer token");
2545
2588
  return;
2546
2589
  }
2547
2590
  let requestId = null;
@@ -2633,13 +2676,13 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
2633
2676
  });
2634
2677
  let timer = null;
2635
2678
  const result = await new Promise(
2636
- (resolve4, reject) => {
2679
+ (resolve4, reject2) => {
2637
2680
  const entry = {
2638
2681
  id: callId,
2639
2682
  toolName,
2640
2683
  input,
2641
2684
  resolve: resolve4,
2642
- reject
2685
+ reject: reject2
2643
2686
  };
2644
2687
  pending.set(callId, entry);
2645
2688
  const deadlineMs = resolveProxyCallTimeoutMs(
@@ -2655,7 +2698,7 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
2655
2698
  toolName,
2656
2699
  deadlineMs
2657
2700
  });
2658
- reject(buildProxyTimeoutError(toolName, deadlineMs));
2701
+ reject2(buildProxyTimeoutError(toolName, deadlineMs));
2659
2702
  }, deadlineMs);
2660
2703
  calls.emit("call", entry);
2661
2704
  }
@@ -2714,10 +2757,10 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
2714
2757
  }
2715
2758
  }
2716
2759
  });
2717
- await new Promise((resolve4, reject) => {
2718
- server2.once("error", reject);
2760
+ await new Promise((resolve4, reject2) => {
2761
+ server2.once("error", reject2);
2719
2762
  server2.listen(0, "127.0.0.1", () => {
2720
- server2.off("error", reject);
2763
+ server2.off("error", reject2);
2721
2764
  resolve4();
2722
2765
  });
2723
2766
  });
@@ -2726,7 +2769,8 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
2726
2769
  server2.close();
2727
2770
  throw new Error("Failed to bind proxy MCP server");
2728
2771
  }
2729
- const url = `http://127.0.0.1:${addr.port}/mcp`;
2772
+ boundAuthority = `127.0.0.1:${addr.port}`;
2773
+ const url = `http://${boundAuthority}/mcp`;
2730
2774
  log.info("proxy-mcp server started", {
2731
2775
  url,
2732
2776
  tools: tools.map((t) => t.name)
@@ -2736,6 +2780,7 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
2736
2780
  url,
2737
2781
  serverName: SERVER_NAME,
2738
2782
  tools,
2783
+ authToken,
2739
2784
  calls,
2740
2785
  configPath() {
2741
2786
  if (configFilePath) return configFilePath;
@@ -2745,6 +2790,10 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
2745
2790
  [SERVER_NAME]: {
2746
2791
  type: "http",
2747
2792
  url,
2793
+ // Claude CLI replays these headers on every request to this
2794
+ // server, which is what lets the handler above reject anyone
2795
+ // who did not read this 0600 file.
2796
+ headers: { Authorization: `Bearer ${authToken}` },
2748
2797
  timeout: resolveProxyClientCeilingMs(timeoutOverrides)
2749
2798
  }
2750
2799
  }
@@ -2810,6 +2859,20 @@ function disallowedToolFlags(tools) {
2810
2859
  }
2811
2860
  return out;
2812
2861
  }
2862
+ function resolveDisallowedTools(options) {
2863
+ const out = [];
2864
+ const seen = /* @__PURE__ */ new Set();
2865
+ const push = (name) => {
2866
+ const trimmed = name.trim();
2867
+ if (!trimmed || seen.has(trimmed)) return;
2868
+ seen.add(trimmed);
2869
+ out.push(trimmed);
2870
+ };
2871
+ for (const name of disallowedToolFlags(options.proxyTools ?? [])) push(name);
2872
+ for (const name of options.extraDisallowedTools ?? []) push(String(name));
2873
+ if (options.disableWebSearch) push("WebSearch");
2874
+ return out;
2875
+ }
2813
2876
  function readBody(req) {
2814
2877
  return new Promise((resolve4, reject) => {
2815
2878
  const chunks = [];
@@ -3353,9 +3416,22 @@ var ClaudeCodeLanguageModel = class {
3353
3416
  DEFAULT_PROXY_TOOLS.map((t) => [t.name.toLowerCase(), t])
3354
3417
  );
3355
3418
  const picked = [];
3419
+ const unknown = [];
3356
3420
  for (const n of names) {
3357
3421
  const def = defsByName.get(String(n).toLowerCase());
3358
3422
  if (def) picked.push(def);
3423
+ else unknown.push(String(n));
3424
+ }
3425
+ if (unknown.length > 0) {
3426
+ const known = [...defsByName.keys()].join(", ");
3427
+ if (picked.length === 0) {
3428
+ log.warn(
3429
+ "no proxyTools entry was recognised; nothing will be proxied this turn",
3430
+ { unknown, known }
3431
+ );
3432
+ } else {
3433
+ log.warn("ignoring unknown proxyTools entries", { unknown, known });
3434
+ }
3359
3435
  }
3360
3436
  return picked.length > 0 ? picked : null;
3361
3437
  }
@@ -4453,10 +4529,11 @@ ${plan}
4453
4529
  proxyServer = await self.ensureProxyServer(combinedProxyTools, sk);
4454
4530
  }
4455
4531
  const questionProxyActive = enrichedProxy?.some((t) => t.name === "question") ?? false;
4456
- const proxyDisallowed = enrichedProxy ? disallowedToolFlags(enrichedProxy) : [];
4457
- const extraDisallowed = [];
4458
- if (self.config.webSearch === "disabled") extraDisallowed.push("WebSearch");
4459
- const allDisallowed = [...proxyDisallowed, ...extraDisallowed];
4532
+ const allDisallowed = resolveDisallowedTools({
4533
+ proxyTools: enrichedProxy,
4534
+ extraDisallowedTools: self.config.extraDisallowedTools,
4535
+ disableWebSearch: self.config.webSearch === "disabled"
4536
+ });
4460
4537
  const mcp = self.effectiveMcpConfig(
4461
4538
  cwd,
4462
4539
  proxyServer?.configPath(),
@@ -5707,11 +5784,11 @@ function defineModel(opts) {
5707
5784
  variants: opts.reasoning ? reasoningVariants : void 0
5708
5785
  };
5709
5786
  }
5710
- var haikuCost = { input: 1e-6, output: 5e-6, cacheRead: 1e-7, cacheWrite: 125e-8 };
5711
- var sonnetCost = { input: 3e-6, output: 15e-6, cacheRead: 3e-7, cacheWrite: 375e-8 };
5712
- var sonnet5Cost = { input: 2e-6, output: 1e-5, cacheRead: 2e-7, cacheWrite: 25e-7 };
5713
- var opusCost = { input: 5e-6, output: 25e-6, cacheRead: 5e-7, cacheWrite: 625e-8 };
5714
- var fableCost = { input: 1e-5, output: 5e-5, cacheRead: 1e-6, cacheWrite: 125e-7 };
5787
+ var haikuCost = { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 };
5788
+ var sonnetCost = { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 };
5789
+ var sonnet5Cost = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
5790
+ var opusCost = { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 };
5791
+ var fableCost = { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 };
5715
5792
  function toConfigModel(model) {
5716
5793
  const inputMods = [];
5717
5794
  const outputMods = [];
@@ -6310,6 +6387,7 @@ function createClaudeCode(settings = {}) {
6310
6387
  controlRequestToolBehaviors: settings.controlRequestToolBehaviors,
6311
6388
  controlRequestDenyMessage: settings.controlRequestDenyMessage,
6312
6389
  proxyTools,
6390
+ extraDisallowedTools: settings.extraDisallowedTools,
6313
6391
  proxyToolTimeoutMs: settings.proxyToolTimeoutMs,
6314
6392
  planModeQuestion: settings.planModeQuestion ?? false,
6315
6393
  webSearch: settings.webSearch,