@khalilgharbaoui/opencode-claude-code-plugin 0.13.1 → 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 +8 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +55 -9
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -299,6 +299,14 @@ recovery step for harnesses that defer MCP tool schemas. Both apply per Claude
|
|
|
299
299
|
process at spawn, and provider options are read once at opencode startup, so
|
|
300
300
|
`proxyTools` changes need a full opencode restart.
|
|
301
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
|
+
|
|
302
310
|
### Closing a tool with no proxy
|
|
303
311
|
|
|
304
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:
|
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" | "
|
|
34
|
+
interleaved: boolean | string | {
|
|
35
|
+
field: "reasoning" | "reasoning_content" | "reasoning_text" | string;
|
|
36
36
|
};
|
|
37
37
|
};
|
|
38
38
|
cost: {
|
package/dist/index.js
CHANGED
|
@@ -2541,10 +2541,50 @@ var DEFAULT_PROXY_TOOLS = [
|
|
|
2541
2541
|
async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverrides, interceptors) {
|
|
2542
2542
|
const calls = new EventEmitter3();
|
|
2543
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
|
+
}
|
|
2544
2568
|
const server2 = createServer(async (req, res) => {
|
|
2545
2569
|
if (req.method !== "POST" || !req.url?.startsWith("/mcp")) {
|
|
2546
|
-
res
|
|
2547
|
-
|
|
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");
|
|
2548
2588
|
return;
|
|
2549
2589
|
}
|
|
2550
2590
|
let requestId = null;
|
|
@@ -2636,13 +2676,13 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2636
2676
|
});
|
|
2637
2677
|
let timer = null;
|
|
2638
2678
|
const result = await new Promise(
|
|
2639
|
-
(resolve4,
|
|
2679
|
+
(resolve4, reject2) => {
|
|
2640
2680
|
const entry = {
|
|
2641
2681
|
id: callId,
|
|
2642
2682
|
toolName,
|
|
2643
2683
|
input,
|
|
2644
2684
|
resolve: resolve4,
|
|
2645
|
-
reject
|
|
2685
|
+
reject: reject2
|
|
2646
2686
|
};
|
|
2647
2687
|
pending.set(callId, entry);
|
|
2648
2688
|
const deadlineMs = resolveProxyCallTimeoutMs(
|
|
@@ -2658,7 +2698,7 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2658
2698
|
toolName,
|
|
2659
2699
|
deadlineMs
|
|
2660
2700
|
});
|
|
2661
|
-
|
|
2701
|
+
reject2(buildProxyTimeoutError(toolName, deadlineMs));
|
|
2662
2702
|
}, deadlineMs);
|
|
2663
2703
|
calls.emit("call", entry);
|
|
2664
2704
|
}
|
|
@@ -2717,10 +2757,10 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2717
2757
|
}
|
|
2718
2758
|
}
|
|
2719
2759
|
});
|
|
2720
|
-
await new Promise((resolve4,
|
|
2721
|
-
server2.once("error",
|
|
2760
|
+
await new Promise((resolve4, reject2) => {
|
|
2761
|
+
server2.once("error", reject2);
|
|
2722
2762
|
server2.listen(0, "127.0.0.1", () => {
|
|
2723
|
-
server2.off("error",
|
|
2763
|
+
server2.off("error", reject2);
|
|
2724
2764
|
resolve4();
|
|
2725
2765
|
});
|
|
2726
2766
|
});
|
|
@@ -2729,7 +2769,8 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2729
2769
|
server2.close();
|
|
2730
2770
|
throw new Error("Failed to bind proxy MCP server");
|
|
2731
2771
|
}
|
|
2732
|
-
|
|
2772
|
+
boundAuthority = `127.0.0.1:${addr.port}`;
|
|
2773
|
+
const url = `http://${boundAuthority}/mcp`;
|
|
2733
2774
|
log.info("proxy-mcp server started", {
|
|
2734
2775
|
url,
|
|
2735
2776
|
tools: tools.map((t) => t.name)
|
|
@@ -2739,6 +2780,7 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2739
2780
|
url,
|
|
2740
2781
|
serverName: SERVER_NAME,
|
|
2741
2782
|
tools,
|
|
2783
|
+
authToken,
|
|
2742
2784
|
calls,
|
|
2743
2785
|
configPath() {
|
|
2744
2786
|
if (configFilePath) return configFilePath;
|
|
@@ -2748,6 +2790,10 @@ async function createProxyMcpServer(tools = DEFAULT_PROXY_TOOLS, timeoutOverride
|
|
|
2748
2790
|
[SERVER_NAME]: {
|
|
2749
2791
|
type: "http",
|
|
2750
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}` },
|
|
2751
2797
|
timeout: resolveProxyClientCeilingMs(timeoutOverrides)
|
|
2752
2798
|
}
|
|
2753
2799
|
}
|