fix(backend): harden provider credential isolation
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { test, expect } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { chmodSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import path from "node:path";
|
||||
@@ -82,13 +82,16 @@ test("production model-list spawn preserves PATH and passes the portable data ro
|
||||
}
|
||||
});
|
||||
|
||||
test("model-list spawn omits ambient THT_DATA_ROOT when config does not provide one", async () => {
|
||||
test("model-list spawn scrubs ambient provider credentials and generic secret metadata", async () => {
|
||||
const script = scriptWith([]);
|
||||
const calls: any[][] = [];
|
||||
const previousDataRoot = process.env.THT_DATA_ROOT;
|
||||
const previousCredential = process.env.PI_PROVIDER_API_KEY;
|
||||
process.env.THT_DATA_ROOT = "/ambient-must-not-leak";
|
||||
process.env.PI_PROVIDER_API_KEY = "still-inherited";
|
||||
process.env.PI_PROVIDER_API_KEY = "must-not-leak";
|
||||
process.env.OPENAI_API_KEY = "must-not-leak";
|
||||
process.env.AWS_SECRET_ACCESS_KEY = "must-not-leak";
|
||||
process.env.CLOUDFLARE_ACCOUNT_ID = "must-not-leak";
|
||||
try {
|
||||
const lister = createPiModelLister(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), {
|
||||
spawnFn: (...args: any[]) => {
|
||||
@@ -98,12 +101,43 @@ test("model-list spawn omits ambient THT_DATA_ROOT when config does not provide
|
||||
});
|
||||
await lister();
|
||||
expect(calls[0][2].env).not.toHaveProperty("THT_DATA_ROOT");
|
||||
expect(calls[0][2].env.PI_PROVIDER_API_KEY).toBe("still-inherited");
|
||||
expect(calls[0][2].env).not.toHaveProperty("PI_PROVIDER_API_KEY");
|
||||
expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY");
|
||||
expect(calls[0][2].env).not.toHaveProperty("AWS_SECRET_ACCESS_KEY");
|
||||
expect(calls[0][2].env).not.toHaveProperty("CLOUDFLARE_ACCOUNT_ID");
|
||||
} finally {
|
||||
if (previousDataRoot === undefined) delete process.env.THT_DATA_ROOT;
|
||||
else process.env.THT_DATA_ROOT = previousDataRoot;
|
||||
if (previousCredential === undefined) delete process.env.PI_PROVIDER_API_KEY;
|
||||
else process.env.PI_PROVIDER_API_KEY = previousCredential;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
delete process.env.AWS_SECRET_ACCESS_KEY;
|
||||
delete process.env.CLOUDFLARE_ACCOUNT_ID;
|
||||
rmSync(path.dirname(script), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("model-list spawn loads only the selected canonical provider credential", async () => {
|
||||
const script = scriptWith([]);
|
||||
const secret = join(path.dirname(script), "model-key");
|
||||
writeFileSync(secret, "selected-secret", { mode: 0o600 });
|
||||
chmodSync(secret, 0o600);
|
||||
const calls: any[][] = [];
|
||||
const lister = createPiModelLister(loadConfig({
|
||||
PI_PROVIDER: "Gemini",
|
||||
THT_MODEL_API_KEY_FILE: secret,
|
||||
}), {
|
||||
spawnFn: (...args: any[]) => {
|
||||
calls.push(args);
|
||||
return spawn("node", [FAKE, script]) as any;
|
||||
},
|
||||
});
|
||||
try {
|
||||
await lister();
|
||||
expect(calls[0][2].env.GEMINI_API_KEY).toBe("selected-secret");
|
||||
expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY_FILE");
|
||||
expect(JSON.stringify(calls[0].slice(0, 2))).not.toContain("selected-secret");
|
||||
} finally {
|
||||
rmSync(path.dirname(script), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -212,6 +212,35 @@ test.each([
|
||||
}
|
||||
});
|
||||
|
||||
test.each([["OpenAI", "openai"], ["gemini", "google"]])(
|
||||
"set_model uses canonical packaged provider ID for %s", async (provider, canonical) => {
|
||||
const secret = path.resolve(__dirname, `.canonical-key-${process.pid}-${provider}`);
|
||||
writeFileSync(secret, "provider-secret", { mode: 0o600 });
|
||||
const child = recordingChild();
|
||||
child.stderr.resume = () => {};
|
||||
child.stdin.write = (data: unknown) => {
|
||||
const request = JSON.parse(String(data));
|
||||
child._writes.push(String(data));
|
||||
if (request.id) {
|
||||
queueMicrotask(() => child.stdout.emit("data", `${JSON.stringify({
|
||||
type: "response", id: request.id, success: true,
|
||||
})}\n`));
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const mgr = new PiProcessManager(loadConfig({ THT_MODEL_API_KEY_FILE: secret }), {
|
||||
spawnFn: () => child as any,
|
||||
});
|
||||
try {
|
||||
await mgr.spawnFor("canonical-provider", { provider, model: "model-id" });
|
||||
expect(child._writes.join("")).toContain(`\"provider\":\"${canonical}\"`);
|
||||
} finally {
|
||||
mgr.teardown("canonical-provider");
|
||||
await import("node:fs/promises").then((fs) => fs.unlink(secret));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("local providers spawn without a model key and scrub ambient generic credentials", async () => {
|
||||
vi.stubEnv("PI_PROVIDER_API_KEY", "ambient-secret");
|
||||
vi.stubEnv("THT_MODEL_API_KEY_FILE", "/ambient/secret-path");
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
PI_0803_CREDENTIAL_ENV_NAMES,
|
||||
buildPiChildEnv,
|
||||
canonicalPiProvider,
|
||||
} from "../src/pi/provider-credentials.js";
|
||||
|
||||
test("canonical provider aliases resolve to packaged Pi 0.80.3 IDs", () => {
|
||||
expect(canonicalPiProvider(" OpenAI ")).toBe("openai");
|
||||
expect(canonicalPiProvider("gemini")).toBe("google");
|
||||
expect(canonicalPiProvider("Google")).toBe("google");
|
||||
expect(() => buildPiChildEnv({
|
||||
ambient: {}, provider: "cohere", credentialFile: "/unused",
|
||||
})).toThrow("model provider credential is unavailable");
|
||||
});
|
||||
|
||||
test("credential scrub list matches the audited Pi AI 0.80.3 provider definitions", () => {
|
||||
const lock = JSON.parse(readFileSync(path.resolve("../docker/pi-runtime/package-lock.json"), "utf8"));
|
||||
expect(lock.packages["node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai"].version)
|
||||
.toBe("0.80.3");
|
||||
expect(PI_0803_CREDENTIAL_ENV_NAMES).toEqual([
|
||||
"AI_GATEWAY_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANT_LING_API_KEY",
|
||||
"AWS_ACCESS_KEY_ID", "AWS_BEARER_TOKEN_BEDROCK", "AWS_CONFIG_FILE",
|
||||
"AWS_CONTAINER_AUTHORIZATION_TOKEN", "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
|
||||
"AWS_CONTAINER_CREDENTIALS_FULL_URI", "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "AWS_PROFILE",
|
||||
"AWS_ROLE_ARN", "AWS_ROLE_SESSION_NAME", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN",
|
||||
"AWS_SHARED_CREDENTIALS_FILE", "AWS_WEB_IDENTITY_TOKEN_FILE", "AZURE_OPENAI_API_KEY",
|
||||
"CEREBRAS_API_KEY", "CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY",
|
||||
"CLOUDFLARE_GATEWAY_ID", "COPILOT_GITHUB_TOKEN", "DEEPSEEK_API_KEY", "FIREWORKS_API_KEY",
|
||||
"GCLOUD_PROJECT", "GEMINI_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_API_KEY",
|
||||
"GOOGLE_CLOUD_LOCATION", "GOOGLE_CLOUD_PROJECT", "GROQ_API_KEY", "HF_TOKEN",
|
||||
"KIMI_API_KEY", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "MISTRAL_API_KEY",
|
||||
"MOONSHOT_API_KEY", "NVIDIA_API_KEY", "OPENCODE_API_KEY", "OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY", "TOGETHER_API_KEY", "XAI_API_KEY", "XIAOMI_API_KEY",
|
||||
"XIAOMI_TOKEN_PLAN_AMS_API_KEY", "XIAOMI_TOKEN_PLAN_CN_API_KEY",
|
||||
"XIAOMI_TOKEN_PLAN_SGP_API_KEY", "ZAI_API_KEY", "ZAI_CODING_CN_API_KEY",
|
||||
]);
|
||||
expect(PI_0803_CREDENTIAL_ENV_NAMES).not.toContain("COHERE_API_KEY");
|
||||
});
|
||||
|
||||
test("credential file replacement between lstat and open is rejected before read", () => {
|
||||
let reads = 0;
|
||||
const stat = (ino: number) => ({
|
||||
dev: 7, ino, uid: process.getuid?.() ?? 0, mode: 0o100600, nlink: 1, size: 6,
|
||||
isFile: () => true, isDirectory: () => false, isSymbolicLink: () => false,
|
||||
});
|
||||
expect(() => buildPiChildEnv({
|
||||
ambient: {}, provider: "openai", credentialFile: "/safe/key",
|
||||
fsOps: {
|
||||
lstat: () => stat(1) as any,
|
||||
open: () => 9,
|
||||
fstat: () => stat(2) as any,
|
||||
read: () => { reads += 1; return "secret"; },
|
||||
close: () => undefined,
|
||||
},
|
||||
})).toThrow("model provider credential is unavailable");
|
||||
expect(reads).toBe(0);
|
||||
});
|
||||
|
||||
test("Docker secrets require a secure root-owned /run/secrets parent and 0444 file", () => {
|
||||
const stat = (kind: "parent" | "file") => ({
|
||||
dev: 7, ino: 1, uid: kind === "parent" ? 1000 : 0,
|
||||
mode: kind === "parent" ? 0o40755 : 0o100444, nlink: 1, size: 6,
|
||||
isFile: () => kind === "file", isDirectory: () => kind === "parent",
|
||||
isSymbolicLink: () => false,
|
||||
});
|
||||
expect(() => buildPiChildEnv({
|
||||
ambient: {}, provider: "openai", credentialFile: "/run/secrets/model-key",
|
||||
fsOps: {
|
||||
lstat: (file) => stat(file === "/run/secrets" ? "parent" : "file") as any,
|
||||
open: () => 9, fstat: () => stat("file") as any, read: () => "secret", close: () => undefined,
|
||||
},
|
||||
})).toThrow("model provider credential is unavailable");
|
||||
});
|
||||
Reference in New Issue
Block a user