Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/claude/intercept/connect-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface ConnectProxyOptions {
authToken?: string | (() => string | null);
/** Hostnames (lowercase) whose 443 tunnels are spliced onto `interceptPort`. */
interceptHosts?: readonly string[];
/** Optional exact CONNECT authorities (host:port); empty denies all, absent keeps blind relay. */
allowedTargets?: readonly string[];
/** Per-connection override, consulted before interceptHosts; null keeps the default. */
selectTunnel?: (host: string, port: number, request: ConnectRequestInfo) => TunnelDecision | null | Promise<TunnelDecision | null>;
/** Test seam: dial the real destination for a blind tunnel. */
Expand Down Expand Up @@ -67,7 +69,8 @@ function connectRequestInfo(head: string): ConnectRequestInfo {
}

type ResolvedConnectProxyOptions = Required<Pick<ConnectProxyOptions, "interceptPort" | "interceptHosts" | "dialUpstream">>
& Pick<ConnectProxyOptions, "selectTunnel" | "authToken">;
& Pick<ConnectProxyOptions, "selectTunnel" | "authToken">
& { allowedTargets?: ReadonlySet<string> };

export interface ConnectProxyHandle {
port: number;
Expand Down Expand Up @@ -186,6 +189,10 @@ function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions):
respond(socket, 403, "Forbidden");
return;
}
if (options.allowedTargets && !options.allowedTargets.has(`${target.host}:${target.port}`)) {
respond(socket, 403, "Forbidden");
return;
}
const dialFor = (selected: TunnelDecision | null): void => {
if (socket.destroyed) return;
const choice = selected ?? (target.port === 443 && options.interceptHosts.includes(target.host)
Expand Down Expand Up @@ -258,10 +265,19 @@ function handleConnection(socket: Socket, options: ResolvedConnectProxyOptions):

/** Bind the CONNECT proxy on 127.0.0.1. Rejects when the port is unavailable. */
export function startConnectProxy(port: number, options: ConnectProxyOptions): Promise<ConnectProxyHandle> {
// Snapshot caller-owned policy before listening. Reuse the request parser's
// host normalization; malformed policy must not silently disable restrictions.
const allowedTargets = options.allowedTargets === undefined ? undefined : new Set(options.allowedTargets.map(authority => {
const target = typeof authority === "string" && !/[\s\r\n]/.test(authority)
? parseConnectRequestLine(`CONNECT ${authority} HTTP/1.1`) : null;
if (!target || /[*/\\?#@%]/.test(target.host)) throw new Error("Invalid CONNECT allowed target");
return `${target.host}:${target.port}`;
}));
const resolved: ResolvedConnectProxyOptions = {
interceptPort: options.interceptPort,
authToken: options.authToken,
interceptHosts: options.interceptHosts ?? CLAUDE_INTERCEPT_HOSTS,
allowedTargets,
selectTunnel: options.selectTunnel,
dialUpstream: options.dialUpstream ?? ((host: string, targetPort: number) => connect({ host, port: targetPort })),
};
Expand Down
8 changes: 7 additions & 1 deletion src/claude/intercept/local-ca.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,20 +193,26 @@ export interface LocalInterceptCa extends PemKeyPair {

export interface AuthorityOptions {
commonName: string;
/** Optional whole-day lifetime for short-lived authorities; defaults to the existing 3650 days. */
validityDays?: number;
permittedDnsNames?: readonly string[];
/** With permittedDnsNames: also exclude every IP address (default true). */
excludeAllIpAddresses?: boolean;
}

export function createCertificateAuthority(options: AuthorityOptions): LocalInterceptCa {
const validityDays = options.validityDays ?? CA_VALIDITY_DAYS;
if (!Number.isInteger(validityDays) || validityDays < 1 || validityDays > CA_VALIDITY_DAYS) {
throw new Error("CA validityDays must be an integer between 1 and 3650");
}
const { publicKey, privateKey } = generateKeyPairSync("ec", { namedCurve: "prime256v1" });
const name = distinguishedName(options.commonName);
const der = issueCertificate({
subject: name,
issuer: name,
subjectKey: publicKey,
signingKey: privateKey,
validityDays: CA_VALIDITY_DAYS,
validityDays,
extensions: [
extension(OID.basicConstraints, true, sequence(boolean(true), tlv(0x02, Uint8Array.of(0)))),
// keyCertSign | cRLSign
Expand Down
6 changes: 6 additions & 0 deletions structure/clients/claude-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ event loop. Injected probes may return a state or a promise, so isolated callers

### Picker mode: the Desktop egress proxy

The shared CONNECT primitive accepts optional `allowedTargets` authorities. It snapshots and
normalizes that list at startup; an empty list denies all, and other host/port pairs receive 403
before tunnel selection or dialing. Authentication and loopback refusal remain in force.
Existing Claude consumers omit this option and retain blind forwarding; it enables no new integration or certificate trust.
The authority primitive accepts `validityDays` from 1 through 3650 for short-lived callers; omitted values preserve the existing 3650-day CA lifetime. This parameter does not install trust or rotate an existing authority.

When the lifecycle passes `loadPickerRoutes` (the server always does), `startClaudeIntercept` also
wires Claude Desktop picker mode: a second loopback CONNECT proxy on the dedicated picker proxy
port (`getClaudeInterceptState()?.pickerProxyPort`), used as Desktop's pinned egress proxy. Desktop
Expand Down
2 changes: 1 addition & 1 deletion structure/runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ still believes it talks to Anthropic. The proxy splices `CONNECT api.anthropic.c
listener, relays every other CONNECT target blind, and refuses unauthenticated clients, plain proxied HTTP, and loopback targets. The per-install proxy token is stored owner-only under `<OPENCODEX_HOME>/claude-intercept/` (0600 plus a real per-user NTFS ACL on Windows, via `src/lib/windows-secret-acl.ts`, and re-pinned on every read-through `ensure`), and the settings file carrying it is written through the same hardened atomic writer. Every start runs `migrateClaudeInterceptSettings` (`src/claude/intercept/settings.ts`), which rewrites an owned env that no longer matches — e.g. a pre-auth URL left by an upgrade — while never creating an absent env or touching a foreign one, so a service restart cannot strand clients on 407s. Status/inspection reads the token without minting it; only apply and intercept startup create it.
The TLS listener rewrites `POST /v1/messages` and `POST /v1/messages/count_tokens` to a loopback origin and dispatches them under the `claude-intercept` ingress; other paths relay to the configured upstream.
The pair is on by default on a hub (`claudeCode.intercept.enabled`); its proxy port defaults to public port + 100 (`claudeCode.intercept.port`). Bind failure warns, and stop joins both sockets. With an ephemeral public port (`startServer(0)`), an explicit intercept port is required.
This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode adds a second Desktop CONNECT proxy on the next port; see [Claude Desktop](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy).
This ingress honours first-party model bindings (`claudeCode.intercept.modelMap`); see [Claude Desktop](clients/claude-desktop.md#first-party-model-bindings). Picker mode and the primitive's optional `allowedTargets` restriction are described under [Desktop egress proxy](clients/claude-desktop.md#picker-mode-the-desktop-egress-proxy).

`src/claude/intercept/client-class.ts` classifies each request by its Claude Code `User-Agent` entrypoint: `claude-desktop`, `claude-desktop-3p`, and `local-agent` are Desktop; other well-formed `claude-cli/<v> (external, <entrypoint>)` values are CLI; absent or malformed values are unknown.
Only a client with its own first-party intent enabled (Desktop mode or `claudeCode.cliFirstParty`) enters the router for Messages paths; other paths use the configured upstream relay.
Expand Down
13 changes: 13 additions & 0 deletions tests/claude-integration/claude-intercept-local-ca.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
claudeInterceptCaCertPath,
claudeInterceptStateDir,
createLocalInterceptCa,
createCertificateAuthority,
ensureLocalInterceptCa,
ensureLocalInterceptCaForStartup,
issueLocalInterceptLeaf,
Expand All @@ -30,6 +31,18 @@ test("CA certificate is a self-signed X.509 v3 authority", () => {
expect(new Date(cert.validTo).getTime()).toBeGreaterThan(Date.now() + 365 * 24 * 3600 * 1000);
});

test("temporary authorities can shorten validity without changing the default", () => {
const short = createCertificateAuthority({ commonName: "temporary fixture", validityDays: 1, permittedDnsNames: ["example.test"] });
const cert = new X509Certificate(short.certPem);
expect(Date.parse(cert.validTo) - Date.parse(cert.validFrom)).toBe(86_400_000);
expect(cert.verify(short.publicKey)).toBe(true);
const normal = new X509Certificate(createLocalInterceptCa().certPem);
expect(Date.parse(normal.validTo) - Date.parse(normal.validFrom)).toBe(3650 * 86_400_000);
for (const validityDays of [0, -1, 0.5, NaN, Infinity, 3651]) {
expect(() => createCertificateAuthority({ commonName: "invalid fixture", validityDays })).toThrow("validityDays");
}
});

test("leaf is issued by the CA and names every requested host in SAN", () => {
const ca = createLocalInterceptCa();
const leaf = issueLocalInterceptLeaf(ca, ["api.anthropic.com", "example.test"]);
Expand Down
51 changes: 51 additions & 0 deletions tests/claude-integration/claude-intercept-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,57 @@ test("invalid request and loopback are refused before consulting tunnel choice",
expect(consulted).toBe(0);
});

test("restricted CONNECT admits only exact normalized authorities before tunnel selection", async () => {
const echo = await startEchoUpstream();
cleanups.push(echo.close);
const selected: string[] = [];
let blindDials = 0;
const allowedTargets = ["CHATGPT.COM.:443"];
const proxy = await startConnectProxy(0, {
interceptPort: echo.port,
interceptHosts: ["chatgpt.com"],
allowedTargets,
selectTunnel: (host, port) => { selected.push(`${host}:${port}`); return null; },
dialUpstream: () => { blindDials++; return connect({ host: "127.0.0.1", port: echo.port }); },
});
cleanups.push(proxy.close);
// A caller mutating its original array cannot broaden a running listener.
allowedTargets.push("other.example:443");
expect(await tunnelPayload(proxy.port, "ChatGPT.Com.")).toContain("echo:hello");
for (const authority of ["other.example:443", "chatgpt.com:8443", "child.chatgpt.com:443", "chatgpt.com.evil.example:443"]) {
expect(await rawRequest(proxy.port, `CONNECT ${authority} HTTP/1.1\r\nUser-Agent: Mozilla/test\r\n\r\npipelined`)).toStartWith("HTTP/1.1 403");
}
expect(selected).toEqual(["chatgpt.com:443"]);
expect(blindDials).toBe(0);
});

test("empty CONNECT allowlist denies all and cannot enter a failing selector", async () => {
let called = false;
const proxy = await startConnectProxy(0, {
interceptPort: 1,
allowedTargets: [],
selectTunnel: () => { called = true; throw new Error("must not run"); },
});
cleanups.push(proxy.close);
expect(await rawRequest(proxy.port, "CONNECT api.anthropic.com:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 403");
expect(called).toBe(false);
});

test("destination restriction does not replace authentication or loopback refusal", async () => {
const proxy = await startConnectProxy(0, {
interceptPort: 1, authToken: AUTH_TOKEN, allowedTargets: ["chatgpt.com:443", "127.0.0.1:443"],
});
cleanups.push(proxy.close);
expect(await rawRequest(proxy.port, "CONNECT chatgpt.com:443 HTTP/1.1\r\n\r\n")).toStartWith("HTTP/1.1 407");
expect(await rawRequest(proxy.port, `CONNECT 127.0.0.1:443 HTTP/1.1\r\n${AUTH_HEADER}\r\n`)).toStartWith("HTTP/1.1 403");
});

test("malformed destination restrictions fail before a listener starts", () => {
for (const authority of ["", "*.example.com:443", "example.com", "example.com:0", "example.com:65536", "example.com:443\r\nX: value", "https://example.com:443"]) {
expect(() => startConnectProxy(0, { interceptPort: 1, allowedTargets: [authority] })).toThrow("Invalid CONNECT allowed target");
}
});

test("plain proxied HTTP, loopback targets and oversized heads are refused", async () => {
const { proxy } = await startPair();
expect(await rawRequest(proxy.port, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n")).toStartWith("HTTP/1.1 405");
Expand Down
Loading