Compare commits

...

4 Commits

8 changed files with 55 additions and 27 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.15", "version": "0.0.19",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.15", "version": "0.0.19",
"dependencies": { "dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/claude-agent-sdk": "^0.3.202",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@paradigm/hub", "name": "@paradigm/hub",
"version": "0.0.15", "version": "0.0.19",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
+4 -6
View File
@@ -8,6 +8,7 @@ const PROVIDER_ENV_KEYS = new Set([
"ANTHROPIC_BASE_URL", "ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY",
"ANTHROPIC_CUSTOM_HEADERS",
]); ]);
const SAFE_HOST_ENV_KEYS = [ const SAFE_HOST_ENV_KEYS = [
@@ -23,11 +24,6 @@ const SAFE_HOST_ENV_KEYS = [
"CPH_BIN", "CPH_BIN",
] as const; ] as const;
const SANDBOX_HIDDEN_ENV_KEYS = [
"ANTHROPIC_AUTH_TOKEN",
"ANTHROPIC_API_KEY",
] as const;
// Linux sockaddr_un.sun_path is 108 bytes including the terminator. Claude's // Linux sockaddr_un.sun_path is 108 bytes including the terminator. Claude's
// sandbox appends its own user directory and randomized bridge socket names, // sandbox appends its own user directory and randomized bridge socket names,
// so keep our prefix well below that hard limit. // so keep our prefix well below that hard limit.
@@ -156,7 +152,9 @@ export async function createAgentSecurityPolicy(input: AgentSecurityInput): Prom
}, },
credentials: { credentials: {
files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })), files: sensitiveReadPaths.map((path) => ({ path, mode: "deny" as const })),
envVars: SANDBOX_HIDDEN_ENV_KEYS.map((name) => ({ name, mode: "deny" as const })), // These values are short-lived loopback capabilities, not Organization
// provider secrets. Denying them also strips Claude's own request auth.
envVars: [],
}, },
}, },
}; };
+34 -5
View File
@@ -20,6 +20,7 @@ const REPLACED_REQUEST_HEADERS = new Set([
"content-length", "content-length",
"host", "host",
"x-api-key", "x-api-key",
"x-cph-run-capability",
]); ]);
export interface ProviderUpstreamCredential { export interface ProviderUpstreamCredential {
@@ -41,6 +42,12 @@ export interface ProviderProxyDiagnostic {
| "provider_proxy_redirect_refused" | "provider_proxy_redirect_refused"
| "provider_proxy_upstream_failed"; | "provider_proxy_upstream_failed";
readonly category: NetworkFailureCategory | "authorization" | "request_limit" | "redirect"; readonly category: NetworkFailureCategory | "authorization" | "request_limit" | "redirect";
readonly authShape?: {
readonly bearerLength: number;
readonly apiKeyLength: number;
readonly customCapabilityLength: number;
readonly expectedLength: number;
};
} }
export interface ProviderProxyOptions { export interface ProviderProxyOptions {
@@ -81,7 +88,8 @@ export async function openProviderProxyLease(
sdkEnv: { sdkEnv: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`, ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`,
ANTHROPIC_AUTH_TOKEN: capability, ANTHROPIC_AUTH_TOKEN: capability,
ANTHROPIC_API_KEY: "", ANTHROPIC_API_KEY: capability,
ANTHROPIC_CUSTOM_HEADERS: `X-CPH-Run-Capability: ${capability}`,
}, },
sensitiveValues: [capability], sensitiveValues: [capability],
async close(): Promise<void> { async close(): Promise<void> {
@@ -102,8 +110,24 @@ async function forwardProviderRequest(
upstream: ProviderUpstreamCredential, upstream: ProviderUpstreamCredential,
options: ProviderProxyOptions, options: ProviderProxyOptions,
): Promise<void> { ): Promise<void> {
if (!authorized(request.headers.authorization, header(request.headers["x-api-key"]), capability)) { if (!authorized(
options.onDiagnostic?.({ code: "provider_proxy_unauthorized", category: "authorization" }); request.headers.authorization,
header(request.headers["x-api-key"]),
header(request.headers["x-cph-run-capability"]),
capability,
)) {
options.onDiagnostic?.({
code: "provider_proxy_unauthorized",
category: "authorization",
authShape: {
bearerLength: request.headers.authorization?.startsWith("Bearer ")
? request.headers.authorization.length - "Bearer ".length
: 0,
apiKeyLength: header(request.headers["x-api-key"])?.length ?? 0,
customCapabilityLength: header(request.headers["x-cph-run-capability"])?.length ?? 0,
expectedLength: capability.length,
},
});
response.writeHead(401, { "content-type": "text/plain; charset=utf-8" }); response.writeHead(401, { "content-type": "text/plain; charset=utf-8" });
response.end("unauthorized"); response.end("unauthorized");
return; return;
@@ -168,11 +192,16 @@ async function forwardProviderRequest(
function authorized( function authorized(
authorization: string | undefined, authorization: string | undefined,
apiKey: string | undefined, apiKey: string | undefined,
customCapability: string | undefined,
capability: string, capability: string,
): boolean { ): boolean {
const value = authorization?.startsWith("Bearer ") const bearer = authorization?.startsWith("Bearer ")
? authorization.slice("Bearer ".length) ? authorization.slice("Bearer ".length)
: apiKey; : undefined;
return [bearer, apiKey, customCapability].some((value) => matchesCapability(value, capability));
}
function matchesCapability(value: string | undefined, capability: string): boolean {
if (value === undefined) return false; if (value === undefined) return false;
const supplied = Buffer.from(value); const supplied = Buffer.from(value);
const expected = Buffer.from(capability); const expected = Buffer.from(capability);
+1
View File
@@ -73,6 +73,7 @@ export async function startHub(): Promise<void> {
providerId: context.providerId, providerId: context.providerId,
errorCode: diagnostic.code, errorCode: diagnostic.code,
failureCategory: diagnostic.category, failureCategory: diagnostic.category,
authShape: diagnostic.authShape,
}, "provider proxy diagnostic"); }, "provider proxy diagnostic");
}, },
}), }),
+1 -4
View File
@@ -66,10 +66,7 @@ describe("agent subprocess security policy", () => {
allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]), allowRead: expect.arrayContaining([canonicalWorkspace, "/usr/bin"]),
}, },
credentials: { credentials: {
envVars: expect.arrayContaining([ envVars: [],
{ name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" },
{ name: "ANTHROPIC_API_KEY", mode: "deny" },
]),
}, },
}); });
}); });
+11 -5
View File
@@ -53,13 +53,15 @@ describe("run-scoped provider proxy", () => {
expect(lease.sdkEnv).toMatchObject({ expect(lease.sdkEnv).toMatchObject({
ANTHROPIC_BASE_URL: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/), ANTHROPIC_BASE_URL: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/),
ANTHROPIC_AUTH_TOKEN: expect.any(String), ANTHROPIC_AUTH_TOKEN: expect.any(String),
ANTHROPIC_API_KEY: "", ANTHROPIC_API_KEY: expect.any(String),
ANTHROPIC_CUSTOM_HEADERS: expect.stringMatching(/^X-CPH-Run-Capability: /),
}); });
expect(lease.sdkEnv.ANTHROPIC_AUTH_TOKEN).toBe(lease.sdkEnv.ANTHROPIC_API_KEY);
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, { const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
method: "POST", method: "POST",
headers: { headers: {
authorization: `Bearer ${lease.sdkEnv.ANTHROPIC_AUTH_TOKEN}`, "x-api-key": lease.sdkEnv.ANTHROPIC_API_KEY ?? "",
"content-type": "application/json", "content-type": "application/json",
"anthropic-version": "2023-06-01", "anthropic-version": "2023-06-01",
}, },
@@ -104,7 +106,11 @@ describe("run-scoped provider proxy", () => {
expect(response.status).toBe(401); expect(response.status).toBe(401);
expect(upstreamRequests).toBe(0); expect(upstreamRequests).toBe(0);
expect(diagnostics).toEqual([{ code: "provider_proxy_unauthorized", category: "authorization" }]); expect(diagnostics).toEqual([{
code: "provider_proxy_unauthorized",
category: "authorization",
authShape: { bearerLength: 0, apiKeyLength: 0, customCapabilityLength: 0, expectedLength: 43 },
}]);
}); });
it("accepts the run capability in the Anthropic x-api-key header", async () => { it("accepts the run capability in the Anthropic x-api-key header", async () => {
@@ -127,7 +133,7 @@ describe("run-scoped provider proxy", () => {
leases.push(lease); leases.push(lease);
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, { const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
headers: { "x-api-key": lease.sdkEnv.ANTHROPIC_AUTH_TOKEN ?? "" }, headers: { "x-api-key": lease.sdkEnv.ANTHROPIC_API_KEY ?? "" },
}); });
expect(response.status).toBe(200); expect(response.status).toBe(200);
@@ -167,7 +173,7 @@ describe("run-scoped provider proxy", () => {
leases.push(lease); leases.push(lease);
const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, { const response = await fetch(`${lease.sdkEnv.ANTHROPIC_BASE_URL}/v1/messages`, {
headers: { authorization: `Bearer ${lease.sdkEnv.ANTHROPIC_AUTH_TOKEN}` }, headers: { "x-api-key": lease.sdkEnv.ANTHROPIC_API_KEY ?? "" },
redirect: "manual", redirect: "manual",
}); });
+1 -4
View File
@@ -288,10 +288,7 @@ describe("runAgent", () => {
}, },
sandbox: { sandbox: {
credentials: { credentials: {
envVars: expect.arrayContaining([ envVars: [],
{ name: "ANTHROPIC_AUTH_TOKEN", mode: "deny" },
{ name: "ANTHROPIC_API_KEY", mode: "deny" },
]),
}, },
}, },
}, },