SPB Git

Repository Public

leak.test.ts 7.3 KB 106 lines typescript

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
Files main
Menu

Theme