Repository Public
1/**2 * No REST payload ever contains a configured model API key, whatever the journal holds.3 * The harness configures fake `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` values; this file plants them4 * wherever a careless tool, provider error or owner could have put them, then reads EVERY route5 * of the live route table and greps the serialised responses and the logs.6 * (WebSocket frames: `modules/events-ws.test.ts`.)7 */8import { afterAll, beforeAll, describe, expect, it } from 'vitest';9import { Client, FAKE_ANTHROPIC_KEY, FAKE_OPENAI_KEY, createHarness, seedMission, setupAdmin, type Harness } from './testkit/harness.js';1011let h: Harness;12let admin: Client;13const KEYS = [FAKE_ANTHROPIC_KEY, FAKE_OPENAI_KEY];14const both = `A=${FAKE_ANTHROPIC_KEY} O=${FAKE_OPENAI_KEY}`;15const clean = (label: string, text: string): void => {16 for (const k of KEYS) expect(text.includes(k), `${label} leaks a model API key`).toBe(false);17};1819beforeAll(async () => {20 h = await createHarness();21 ({ client: admin } = await setupAdmin(h));22});23afterAll(async () => {24 await h.close();25});2627describe('model API keys never leave the server', () => {28 it('the configuration holds them as booleans; the values are not enumerable anywhere on it', () => {29 expect(h.config.providerKeys).toEqual({ anthropic: true, openai: true });30 clean('config', JSON.stringify(h.config));31 clean('config (inspect)', String(Object.entries(h.config)));32 });3334 it('every GET route of the route table, with real ids, over a journal polluted with the keys', async () => {35 const { projectId, missionId, runId } = await seedMission(h, admin, { mission: { title: `t ${both}`.slice(0, 160), objective: `objective ${both}`, doneCriteria: [`criterion ${both}`] } });36 await admin.req('PATCH', `/projects/${projectId}`, { description: both, instructions: both });37 await h.kernel.seedStep(runId, 0, { kind: 'model_call', name: 'claude-opus-5-5', effect: 'model_call', request: { system: both, transcript: [{ role: 'user', content: both }] }, result: { blocks: [{ type: 'text', text: both }], native: { k: both } }, usage: { usd: 0.01 } });38 await h.kernel.seedStep(runId, 1, { name: 'shell', effect: 'read', request: { name: 'shell', input: { command: `curl -H "x-api-key: ${FAKE_ANTHROPIC_KEY}"` } }, result: { ok: true, output: `env:\nANTHROPIC_API_KEY=${FAKE_ANTHROPIC_KEY}\nOPENAI_API_KEY=${FAKE_OPENAI_KEY}` } });39 await h.kernel.seedStep(runId, 2, { name: 'shell', effect: 'read', status: 'failed', request: {}, error: { message: `401 ${both}` } });40 // …also beyond the truncation point of a long body41 await h.kernel.seedStep(runId, 3, { name: 'read_file', effect: 'read', request: {}, result: { output: `${'y'.repeat(19_990)}${FAKE_OPENAI_KEY}` } });42 const waitId = await h.kernel.seedWait(runId, 4, 'approval', { tool: 'shell', input: { command: `export K=${FAKE_OPENAI_KEY}` }, detail: both, diff: `+${both}` });43 await h.db.q('UPDATE runs SET result = $2 WHERE id = $1', [runId, JSON.stringify({ status: 'failed', summary: both, payload: { k: both }, artifacts: [], evidence: [{ kind: 'note', detail: both }], openQuestions: [both] })]);44 await h.db.q("UPDATE missions SET done_criteria = $2 WHERE id = $1", [missionId, JSON.stringify([{ id: 'c1', text: 't', passes: true, evidence: both }])]);45 const rule = (await admin.post('/policy/rules', { projectId, decision: 'deny', tool: 'shell', match: { commandPrefix: both }, note: both })).json<{ id: string }>();46 await h.server.ctx.notifier.notify((await h.db.one<{ id: string }>('SELECT id FROM users'))?.id as string, 'system', `title ${both}`, `subject ${both}`, null);47 await h.server.ctx.audit.record({ actor: 'admin', action: 'probe', detail: { note: both } });48 const sha = await h.kernel.seedArtifact(runId, `name-${FAKE_OPENAI_KEY}.txt`, 'plain content');49 h.providers.fail.anthropic = true; // its error message quotes a key (see FakeProviders)5051 const ids: Record<string, string> = { '/projects/:id': projectId, '/missions/:id': missionId, '/runs/:id': runId, '/waits/:id': waitId, '/policy/rules/:id': rule.id, '/artifacts/:sha256': sha };52 const routes = h.server.ctx.routes.filter((r) => r.url.startsWith('/api/v1') && r.method === 'GET' && !r.websocket);53 expect(routes.length).toBeGreaterThan(18);54 const seen: string[] = [];55 for (const r of routes) {56 let url = r.url.slice('/api/v1'.length);57 for (const [prefix, id] of Object.entries(ids)) if (url.startsWith(prefix.replace(/:[A-Za-z0-9]+$/, ''))) url = url.replace(/:[A-Za-z0-9]+/, id);58 expect(url, r.url).not.toMatch(/:[A-Za-z]/);59 for (const q of ['', '?status=all&kind=all&limit=500']) {60 const res = await admin.get(`${url}${q}`);61 if (q === '') expect([r.url, res.statusCode]).toEqual([r.url, 200]);62 clean(`GET ${r.url}${q} body`, res.body);63 clean(`GET ${r.url}${q} headers`, JSON.stringify(res.headers));64 }65 seen.push(r.url.slice('/api/v1'.length));66 }67 // the polluted rows really were served (redacted), not skipped68 for (const must of ['/missions', '/missions/:id', '/missions/:id/runs', '/runs/:id', '/runs/:id/steps', '/runs/:id/artifacts', '/artifacts/:sha256', '/waits', '/policy/rules', '/projects', '/projects/:id', '/models', '/spend', '/system/status', '/system/audit', '/notifications', '/settings', '/openapi.json']) {69 expect(seen, must).toContain(must);70 }71 const steps = (await admin.get(`/runs/${runId}/steps`)).body;72 expect(steps).toContain('ANTHROPIC_API_KEY=[redacted]');73 // a key straddling the 20 000-char cut does not survive as a prefix either74 expect(steps).not.toContain(FAKE_OPENAI_KEY.slice(0, 12));75 expect(steps).not.toContain(FAKE_ANTHROPIC_KEY.slice(0, 12));76 expect((await admin.get(`/missions/${missionId}`)).json().objective).toBe('objective A=[redacted] O=[redacted]');77 expect((await admin.get('/waits')).body).toContain('export K=[redacted]');78 expect(String((await admin.get(`/artifacts/${sha}`)).headers['content-disposition'])).toContain('name-[redacted].txt');79 });8081 it('mutation responses and error bodies are scrubbed too', async () => {82 const p = await admin.post('/projects', { name: `n ${FAKE_OPENAI_KEY}`.slice(0, 80), mode: 'create', path: 'leak-proj', description: both });83 expect(p.statusCode).toBe(201);84 clean('POST /projects', p.body);85 const m = await admin.post('/missions', { projectId: p.json().id, objective: both });86 expect(m.statusCode).toBe(201);87 clean('POST /missions', m.body);88 const msg = await admin.post(`/runs/${m.json().rootRunId}/message`, { text: both });89 clean('POST /runs/:id/message', msg.body);90 const bad = await admin.post('/missions', { projectId: p.json().id, objective: 'o', model: FAKE_ANTHROPIC_KEY });91 expect(bad.statusCode).toBe(400);92 clean('validation error', bad.body);93 const nf = await admin.get(`/artifacts/${FAKE_ANTHROPIC_KEY}`);94 clean('bad request', nf.body);95 clean('cancel', (await admin.post(`/missions/${m.json().id}/cancel`)).body);96 });9798 it('the logs of all of the above contain no key', async () => {99 h.server.app.log.info({ env: { ANTHROPIC_API_KEY: FAKE_ANTHROPIC_KEY }, apiKey: FAKE_OPENAI_KEY, body: { apiKey: FAKE_OPENAI_KEY } }, 'probe-log');100 await new Promise((r) => setTimeout(r, 50));101 const all = h.logs.join('');102 expect(all).toContain('probe-log');103 clean('logs', all);104 });105});106