From a0f0881220ed6dba62db80be7f913cd020ea75df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Mar 2026 02:18:29 +0000 Subject: [PATCH] feat(bin): add HTTP proxy server for Claude Code sandbox mode Deno-based proxy that controls which domains are accessible, designed for use with Claude Code's sandbox network settings. Supports CONNECT tunneling for HTTPS, regular HTTP proxying, domain allowlists via config file and CLI args, and wildcard domain patterns. https://claude.ai/code/session_01SLwXju59WZmk97fSJbrW5S --- bin/claude-proxy | 468 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 468 insertions(+) create mode 100755 bin/claude-proxy diff --git a/bin/claude-proxy b/bin/claude-proxy new file mode 100755 index 0000000..6ec30bc --- /dev/null +++ b/bin/claude-proxy @@ -0,0 +1,468 @@ +#!/usr/bin/env -S deno run --allow-net --allow-read --allow-env + +import { parseArgs } from "jsr:@std/cli@1/parse-args"; +import { join } from "jsr:@std/path@1/join"; + +// --- Types --- + +interface ProxyConfig { + allowedDomains: string[]; + port: number; + logLevel: "quiet" | "normal" | "verbose"; +} + +interface ConfigFile { + allowedDomains?: string[]; + port?: number; + logLevel?: "quiet" | "normal" | "verbose"; +} + +// --- Logging --- + +type LogLevel = ProxyConfig["logLevel"]; + +const LOG_PRIORITY: Record = { + quiet: 0, + normal: 1, + verbose: 2, +}; + +let currentLogLevel: LogLevel = "normal"; + +function log(level: LogLevel, ...args: unknown[]): void { + if (LOG_PRIORITY[level] <= LOG_PRIORITY[currentLogLevel]) { + const timestamp = new Date().toISOString(); + console.log(`[${timestamp}]`, ...args); + } +} + +// --- Configuration --- + +const DEFAULT_CONFIG_PATH = join( + Deno.env.get("HOME") ?? ".", + ".config", + "claude-proxy", + "config.json", +); + +function loadConfigFile(path: string): ConfigFile { + try { + const content = Deno.readTextFileSync(path); + return JSON.parse(content) as ConfigFile; + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + log("verbose", `Config file not found: ${path}`); + return {}; + } + throw new Error(`Failed to read config file ${path}: ${err}`); + } +} + +function buildConfig(args: string[]): ProxyConfig { + const parsed = parseArgs(args, { + string: ["config", "port", "log-level"], + collect: ["allow"], + boolean: ["help"], + alias: { c: "config", p: "port", a: "allow", h: "help" }, + default: { + config: DEFAULT_CONFIG_PATH, + port: "8080", + }, + }); + + if (parsed.help) { + printUsage(); + Deno.exit(0); + } + + const configFile = loadConfigFile(parsed.config as string); + + const cliDomains = (parsed.allow as string[] | undefined) ?? []; + const fileDomains = configFile.allowedDomains ?? []; + const mergedDomains = [...new Set([...fileDomains, ...cliDomains])]; + + const logLevel = + (parsed["log-level"] as LogLevel | undefined) ?? + configFile.logLevel ?? + "normal"; + + const port = parseInt(parsed.port as string, 10) || configFile.port || 8080; + + return { + allowedDomains: mergedDomains, + port, + logLevel, + }; +} + +function printUsage(): void { + console.log(`claude-proxy - HTTP proxy server for Claude Code sandbox mode + +Usage: claude-proxy [options] + +Options: + -a, --allow Add an allowed domain (can be repeated) + -c, --config Path to config file + (default: ~/.config/claude-proxy/config.json) + -p, --port Port to listen on (default: 8080) + --log-level Log level: quiet, normal, verbose (default: normal) + -h, --help Show this help message + +Config file format (JSON): + { + "allowedDomains": ["registry.npmjs.org", "github.com", "*.deno.land"], + "port": 8080, + "logLevel": "normal" + } + +Domain patterns: + - Exact match: "github.com" matches only github.com + - Wildcard: "*.github.com" matches any subdomain of github.com + - Both: Use "github.com" and "*.github.com" to match both + +CLI --allow domains are merged with config file domains. + +Claude Code sandbox settings (settings.json): + { + "sandbox": { + "network": { + "httpProxyPort": 8080 + } + } + }`); +} + +// --- Domain matching --- + +function isDomainAllowed(hostname: string, allowedDomains: string[]): boolean { + if (allowedDomains.length === 0) { + return true; // no restrictions when no domains configured + } + + const normalizedHost = hostname.toLowerCase(); + + for (const pattern of allowedDomains) { + const normalizedPattern = pattern.toLowerCase(); + + if (normalizedPattern === normalizedHost) { + return true; + } + + // Wildcard: *.example.com matches sub.example.com + if (normalizedPattern.startsWith("*.")) { + const suffix = normalizedPattern.slice(1); // ".example.com" + if (normalizedHost.endsWith(suffix)) { + return true; + } + } + } + + return false; +} + +function extractHostPort( + authority: string, + defaultPort: number, +): { hostname: string; port: number } { + // Handle [IPv6]:port + const bracketMatch = authority.match(/^\[(.+)\]:(\d+)$/); + if (bracketMatch) { + return { hostname: bracketMatch[1], port: parseInt(bracketMatch[2], 10) }; + } + + // Handle host:port + const colonIdx = authority.lastIndexOf(":"); + if (colonIdx > 0) { + const maybePort = parseInt(authority.slice(colonIdx + 1), 10); + if (!isNaN(maybePort)) { + return { hostname: authority.slice(0, colonIdx), port: maybePort }; + } + } + + return { hostname: authority, port: defaultPort }; +} + +// --- HTTP CONNECT handling --- + +async function handleConnect( + conn: Deno.Conn, + connectLine: string, + allowedDomains: string[], +): Promise { + // CONNECT host:port HTTP/1.x + const parts = connectLine.split(" "); + if (parts.length < 2) { + await writeResponse(conn, 400, "Bad Request"); + return; + } + + const { hostname, port } = extractHostPort(parts[1], 443); + + if (!isDomainAllowed(hostname, allowedDomains)) { + log("normal", `BLOCKED CONNECT ${hostname}:${port}`); + await writeResponse(conn, 403, "Forbidden"); + return; + } + + log("normal", `CONNECT ${hostname}:${port}`); + + let upstream: Deno.Conn; + try { + upstream = await Deno.connect({ hostname, port }); + } catch (err) { + log("normal", `Failed to connect to ${hostname}:${port}: ${err}`); + await writeResponse(conn, 502, "Bad Gateway"); + return; + } + + // Send 200 Connection Established + const established = new TextEncoder().encode( + "HTTP/1.1 200 Connection Established\r\n\r\n", + ); + try { + await writeAll(conn, established); + } catch { + upstream.close(); + return; + } + + // Bidirectional piping + await Promise.allSettled([ + pipe(conn, upstream), + pipe(upstream, conn), + ]); + + try { + upstream.close(); + } catch { /* already closed */ } +} + +// --- Regular HTTP proxy handling --- + +async function handleHttpRequest( + conn: Deno.Conn, + requestLine: string, + headerLines: string[], + allowedDomains: string[], +): Promise { + const parts = requestLine.split(" "); + if (parts.length < 3) { + await writeResponse(conn, 400, "Bad Request"); + return; + } + + const [method, url, httpVersion] = parts; + + let targetUrl: URL; + try { + targetUrl = new URL(url); + } catch { + await writeResponse(conn, 400, "Bad Request: invalid URL"); + return; + } + + if (!isDomainAllowed(targetUrl.hostname, allowedDomains)) { + log("normal", `BLOCKED ${method} ${targetUrl.hostname}`); + await writeResponse(conn, 403, "Forbidden"); + return; + } + + log("normal", `${method} ${targetUrl.href}`); + + // Rebuild headers, removing proxy-specific ones + const filteredHeaders: string[] = []; + let contentLength = 0; + + for (const line of headerLines) { + const lowerLine = line.toLowerCase(); + if ( + lowerLine.startsWith("proxy-authorization:") || + lowerLine.startsWith("proxy-connection:") + ) { + continue; + } + if (lowerLine.startsWith("content-length:")) { + contentLength = parseInt(line.split(":")[1].trim(), 10); + } + filteredHeaders.push(line); + } + + // Read request body if present + let body: Uint8Array | undefined; + if (contentLength > 0) { + body = new Uint8Array(contentLength); + let bytesRead = 0; + while (bytesRead < contentLength) { + const n = await conn.read(body.subarray(bytesRead)); + if (n === null) break; + bytesRead += n; + } + } + + // Build path-only request line + const pathAndQuery = targetUrl.pathname + targetUrl.search; + const { hostname, port } = extractHostPort( + targetUrl.host, + targetUrl.protocol === "https:" ? 443 : 80, + ); + + let upstream: Deno.Conn; + try { + upstream = await Deno.connect({ hostname, port }); + } catch (err) { + log("normal", `Failed to connect to ${hostname}:${port}: ${err}`); + await writeResponse(conn, 502, "Bad Gateway"); + return; + } + + // Send request to upstream + const requestHead = + `${method} ${pathAndQuery} ${httpVersion}\r\n${filteredHeaders.join("\r\n")}\r\n\r\n`; + try { + await writeAll(upstream, new TextEncoder().encode(requestHead)); + if (body) { + await writeAll(upstream, body); + } + } catch (err) { + log("normal", `Failed to send to upstream: ${err}`); + upstream.close(); + await writeResponse(conn, 502, "Bad Gateway"); + return; + } + + // Pipe response back to client + await pipe(upstream, conn); + + try { + upstream.close(); + } catch { /* already closed */ } +} + +// --- I/O utilities --- + +async function writeAll(conn: Deno.Conn, data: Uint8Array): Promise { + let written = 0; + while (written < data.length) { + const n = await conn.write(data.subarray(written)); + written += n; + } +} + +async function writeResponse( + conn: Deno.Conn, + status: number, + statusText: string, +): Promise { + const body = `${status} ${statusText}\n`; + const response = + `HTTP/1.1 ${status} ${statusText}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`; + try { + await writeAll(conn, new TextEncoder().encode(response)); + } catch { /* client disconnected */ } +} + +async function pipe(src: Deno.Conn, dst: Deno.Conn): Promise { + const buf = new Uint8Array(16384); + try { + while (true) { + const n = await src.read(buf); + if (n === null) break; + await writeAll(dst, buf.subarray(0, n)); + } + } catch { + // Connection closed or errored - expected during teardown + } +} + +// --- Request parsing --- + +async function readRequestHead( + conn: Deno.Conn, +): Promise<{ requestLine: string; headerLines: string[] } | null> { + const buf = new Uint8Array(8192); + let accumulated = new Uint8Array(0); + const decoder = new TextDecoder(); + + while (true) { + const n = await conn.read(buf); + if (n === null) return null; + + const combined = new Uint8Array(accumulated.length + n); + combined.set(accumulated); + combined.set(buf.subarray(0, n), accumulated.length); + accumulated = combined; + + const text = decoder.decode(accumulated); + const headerEnd = text.indexOf("\r\n\r\n"); + if (headerEnd !== -1) { + const headerSection = text.slice(0, headerEnd); + const lines = headerSection.split("\r\n"); + return { + requestLine: lines[0], + headerLines: lines.slice(1), + }; + } + + if (accumulated.length > 65536) { + return null; // headers too large + } + } +} + +// --- Connection handler --- + +async function handleConnection( + conn: Deno.Conn, + allowedDomains: string[], +): Promise { + try { + const head = await readRequestHead(conn); + if (!head) { + conn.close(); + return; + } + + const { requestLine, headerLines } = head; + log("verbose", `Request: ${requestLine}`); + + if (requestLine.toUpperCase().startsWith("CONNECT ")) { + await handleConnect(conn, requestLine, allowedDomains); + } else { + await handleHttpRequest(conn, requestLine, headerLines, allowedDomains); + } + } catch (err) { + log("verbose", `Connection error: ${err}`); + } finally { + try { + conn.close(); + } catch { /* already closed */ } + } +} + +// --- Main --- + +async function main(): Promise { + const config = buildConfig(Deno.args); + currentLogLevel = config.logLevel; + + if (config.allowedDomains.length === 0) { + log( + "normal", + "WARNING: No allowed domains configured. All domains will be permitted.", + ); + log("normal", "Use --allow or a config file to restrict access."); + } else { + log("normal", `Allowed domains: ${config.allowedDomains.join(", ")}`); + } + + const listener = Deno.listen({ port: config.port }); + log("normal", `Proxy server listening on port ${config.port}`); + + for await (const conn of listener) { + handleConnection(conn, config.allowedDomains).catch((err) => { + log("verbose", `Unhandled connection error: ${err}`); + }); + } +} + +main();