diff --git a/design/src/auth.ts b/design/src/auth.ts index c3d8d7e5e..1202500a9 100644 --- a/design/src/auth.ts +++ b/design/src/auth.ts @@ -111,7 +111,10 @@ export function describeApiKeySource(resolution: ApiKeyResolution): string { export function saveApiKey(key: string): void { const dir = path.dirname(configPath()); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(configPath(), JSON.stringify({ api_key: key }, null, 2)); + // Create the file owner-only up front so the API key is never briefly + // world/group-readable in the window between write and chmod. The trailing + // chmodSync is kept as a backstop to tighten a pre-existing loose file. + fs.writeFileSync(configPath(), JSON.stringify({ api_key: key }, null, 2), { mode: 0o600 }); fs.chmodSync(configPath(), 0o600); } diff --git a/design/test/auth.test.ts b/design/test/auth.test.ts index 4cb1058f1..0c0c60b23 100644 --- a/design/test/auth.test.ts +++ b/design/test/auth.test.ts @@ -111,6 +111,27 @@ describe("resolveApiKeyInfo", () => { }); }); +describe("saveApiKey", () => { + test("stores the key file owner-only, even under a permissive umask", () => { + // The OpenAI key file must never be group/other-readable. saveApiKey now + // creates it with mode 0600 up front (matching session.ts / #859) instead + // of writing at the default umask and tightening afterwards, so the key is + // not briefly world-readable in the write-then-chmod window (CWE-377/367). + const prevUmask = process.umask(0o000); + try { + saveApiKey("sk-secret-value"); + } finally { + process.umask(prevUmask); + } + + const keyPath = path.join(tmpHome, ".gstack", "openai.json"); + const mode = fs.statSync(keyPath).mode & 0o777; + expect(mode).toBe(0o600); + // No group/other read/write/exec bits. + expect(mode & 0o077).toBe(0); + }); +}); + describe("requireApiKey", () => { test("prints source disclosure without leaking the key", () => { process.env.OPENAI_API_KEY = "sk-secret-value";