Skip to content

Commit a1c8071

Browse files
fix(anthropic): wire buildGuardedModelFetch into the GitHub Copilot createClient branch
This brings the GitHub Copilot Anthropic SDK client to guarded-fetch parity with sibling providers. The change is 2 lines (+1 import, +1 `fetch:` option) plus an inline loopback proof test. This is one of five split PRs (Cloudflare / Copilot / Foundry / OAuth / API-key) that supersede #97868. Each PR is one constructor's blast radius — separate from the others. Land any combination without affecting the rest.
1 parent 54b0958 commit a1c8071

3 files changed

Lines changed: 224 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Anthropic GitHub Copilot constructor guard-specific proof: SSRF-blocks a
2+
// private IP before the SDK's default global fetch is ever reached, then
3+
// streams Anthropic SSE end-to-end through buildGuardedModelFetch against
4+
// a real local HTTP server.
5+
//
6+
// Unlike anthropic.test.ts (which mocks the Anthropic SDK to verify
7+
// constructor options), this test does NOT mock the SDK for the SSRF
8+
// block or loopback proof — it stubs globalThis.fetch to COUNT calls and
9+
// proves the guard intercepts the request before the final fetch hop.
10+
// Behavior only buildGuardedModelFetch can produce.
11+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
12+
import type { AddressInfo } from "node:net";
13+
import { afterEach, describe, expect, it, vi } from "vitest";
14+
import type { Context, Model } from "../types.js";
15+
16+
// A private-link-local IP that the SSRF guard blocks.
17+
const SSRF_BLOCKED_COPILOT_MODEL = {
18+
id: "claude-sonnet-4-6",
19+
name: "Claude Sonnet 4.6",
20+
api: "anthropic-messages",
21+
provider: "github-copilot",
22+
baseUrl: "https://api.githubcopilot.com",
23+
reasoning: true,
24+
input: ["text"],
25+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
26+
contextWindow: 200_000,
27+
maxTokens: 4096,
28+
} satisfies Model<"anthropic-messages">;
29+
30+
const context = {
31+
messages: [{ role: "user", content: "hi", timestamp: 1 }],
32+
} satisfies Context;
33+
34+
describe("Anthropic GitHub Copilot guard-specific SSRF blocking proof", () => {
35+
afterEach(() => {
36+
vi.unstubAllGlobals();
37+
});
38+
39+
it("blocks a private-IP request before globalThis.fetch is called (guard-specific behavior)", async () => {
40+
let globalFetchCalled = 0;
41+
vi.stubGlobal(
42+
"fetch",
43+
vi.fn(async () => {
44+
globalFetchCalled++;
45+
return new Response(null, { status: 500 });
46+
}),
47+
);
48+
49+
// Override the model baseUrl to a private link-local IP that the guard blocks.
50+
const blockedModel = {
51+
...SSRF_BLOCKED_COPILOT_MODEL,
52+
baseUrl: "http://169.254.169.254/v1",
53+
} satisfies Model<"anthropic-messages">;
54+
55+
const { streamAnthropic } = await import("./anthropic.js");
56+
const stream = streamAnthropic(blockedModel, context, { apiKey: "sk-ant-test" });
57+
const result = await stream.result();
58+
59+
expect(result.stopReason).toBe("error");
60+
expect(result.errorMessage).toBeTruthy();
61+
62+
// Guard-specific: SSRF blocked the private-IP request before
63+
// globalThis.fetch was ever called.
64+
expect(globalFetchCalled).toBe(0);
65+
});
66+
});
67+
68+
describe("Anthropic GitHub Copilot guard real wire loopback proof (http.createServer)", () => {
69+
it("streams Anthropic SSE end-to-end through buildGuardedModelFetch against a real loopback server", async () => {
70+
const recorded: Array<{
71+
method: string;
72+
url: string;
73+
bodyBytes: number;
74+
authorization: string;
75+
contentType: string;
76+
}> = [];
77+
const sseEvents: Array<Record<string, unknown>> = [
78+
{
79+
type: "message_start",
80+
message: {
81+
id: "msg_loopback",
82+
type: "message",
83+
role: "assistant",
84+
model: "claude-sonnet-4-6",
85+
content: [],
86+
stop_reason: null,
87+
usage: { input_tokens: 9, output_tokens: 1 },
88+
},
89+
},
90+
{
91+
type: "content_block_start",
92+
index: 0,
93+
content_block: { type: "text", text: "" },
94+
},
95+
{
96+
type: "content_block_delta",
97+
index: 0,
98+
delta: { type: "text_delta", text: "Hello from local Copilot Anthropic loopback." },
99+
},
100+
{
101+
type: "content_block_stop",
102+
index: 0,
103+
},
104+
{
105+
type: "message_delta",
106+
delta: { stop_reason: "end_turn" },
107+
usage: { output_tokens: 7 },
108+
},
109+
{
110+
type: "message_stop",
111+
},
112+
];
113+
const sseBody = sseEvents
114+
.map((e) => `event: ${String(e["type"])}\ndata: ${JSON.stringify(e)}\n\n`)
115+
.join("");
116+
117+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
118+
const chunks: Buffer[] = [];
119+
req.on("data", (c: Buffer) => chunks.push(c));
120+
req.on("end", () => {
121+
const body = Buffer.concat(chunks);
122+
recorded.push({
123+
method: req.method ?? "?",
124+
url: req.url ?? "?",
125+
bodyBytes: body.byteLength,
126+
authorization: (req.headers.authorization as string) ?? "<absent>",
127+
contentType: (req.headers["content-type"] as string) ?? "<absent>",
128+
});
129+
res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8" });
130+
res.end(sseBody);
131+
});
132+
});
133+
134+
await new Promise<void>((resolve) => {
135+
server.listen(0, "127.0.0.1", () => resolve());
136+
});
137+
const port = (server.address() as AddressInfo).port;
138+
139+
try {
140+
const { attachModelProviderRequestTransport } =
141+
await import("../../agents/provider-request-config.js");
142+
const loopbackModel = attachModelProviderRequestTransport(
143+
{
144+
...SSRF_BLOCKED_COPILOT_MODEL,
145+
baseUrl: `http://127.0.0.1:${port}`,
146+
},
147+
{ allowPrivateNetwork: true },
148+
);
149+
150+
const { streamAnthropic } = await import("./anthropic.js");
151+
const stream = streamAnthropic(loopbackModel, context, {
152+
apiKey: "sk-ant-loopback-proof",
153+
});
154+
155+
let textBytes = 0;
156+
for await (const event of stream) {
157+
if (event.type === "text_delta") {
158+
textBytes += event.delta.length;
159+
}
160+
}
161+
const result = await stream.result();
162+
163+
const hit = recorded[0];
164+
expect(recorded.length).toBeGreaterThanOrEqual(1);
165+
expect(hit.method).toBe("POST");
166+
// Anthropic SDK appends "/v1/messages" to baseUrl, so with
167+
// baseUrl "http://127.0.0.1:PORT" the wire URL is "/v1/messages".
168+
expect(hit.url).toBe("/v1/messages");
169+
expect(hit.bodyBytes).toBeGreaterThan(0);
170+
// Copilot constructor sets apiKey:null and uses authToken, which the
171+
// SDK sends as the `Authorization: Bearer` header.
172+
expect(hit.authorization).toBe("Bearer sk-ant-loopback-proof");
173+
expect(hit.contentType).toBe("application/json");
174+
175+
expect(textBytes).toBeGreaterThan(0);
176+
expect(result.stopReason).toBe("stop");
177+
expect(result.errorMessage).toBeUndefined();
178+
179+
console.log(
180+
`[layer3 loopback proof] server_hits=${recorded.length} ` +
181+
`request_url=${hit.url} request_bytes=${hit.bodyBytes} ` +
182+
`content_type=${hit.contentType} ` +
183+
`text_bytes=${textBytes} ` +
184+
`stop_reason=${result.stopReason}`,
185+
);
186+
} finally {
187+
await new Promise<void>((resolve) => {
188+
server.close(() => resolve());
189+
});
190+
}
191+
});
192+
});

src/llm/providers/anthropic.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,36 @@ describe("Anthropic provider", () => {
139139
expect(config.authToken).toBeNull();
140140
});
141141

142+
it("wires buildGuardedModelFetch into the GitHub Copilot Anthropic SDK fetch option", async () => {
143+
const model = makeAnthropicModel({
144+
provider: "github-copilot",
145+
baseUrl: "https://api.githubcopilot.com",
146+
});
147+
const context = {
148+
messages: [{ role: "user", content: "hello", timestamp: 1 }],
149+
} satisfies Context;
150+
151+
streamAnthropic(model, context, {
152+
apiKey: "copilot-token",
153+
});
154+
155+
await vi.waitFor(() => expect(anthropicMockState.configs).toHaveLength(1));
156+
const config = anthropicMockState.configs[0] as {
157+
apiKey?: string | null;
158+
authToken?: string | null;
159+
baseURL?: string;
160+
fetch?: unknown;
161+
};
162+
163+
expect(config.apiKey).toBeNull();
164+
expect(config.authToken).toBe("copilot-token");
165+
expect(config.baseURL).toBe("https://api.githubcopilot.com");
166+
// Bounded-read contract: a custom `fetch` is wired through the SDK. The
167+
// cap itself is exercised in `provider-transport-fetch.test.ts`; this
168+
// test only proves the cap is in scope on this code path.
169+
expect(typeof config.fetch).toBe("function");
170+
});
171+
142172
it("preserves provider-signed Anthropic thinking and drops reasoning_content placeholders", async () => {
143173
const highSurrogate = String.fromCharCode(0xd83d);
144174
const signedThinking = `keep${highSurrogate}signed`;

src/llm/providers/anthropic.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type AnthropicProjectedToolChoice,
1616
type AnthropicToolProjection,
1717
} from "../../agents/anthropic-tool-projection.js";
18+
import { buildGuardedModelFetch } from "../../agents/provider-transport-fetch.js";
1819
import {
1920
splitSystemPromptCacheBoundary,
2021
stripSystemPromptCacheBoundary,
@@ -941,6 +942,7 @@ function createClient(
941942
dynamicHeaders,
942943
optionsHeaders,
943944
),
945+
fetch: buildGuardedModelFetch(model),
944946
});
945947

946948
return { client, isOAuthToken: false };

0 commit comments

Comments
 (0)