diff --git a/packages/core/src/clients/claude.test.ts b/packages/core/src/clients/claude.test.ts index fd99746e03..fd79d16280 100644 --- a/packages/core/src/clients/claude.test.ts +++ b/packages/core/src/clients/claude.test.ts @@ -978,7 +978,12 @@ describe('ClaudeClient', () => { spyScan.mockRestore(); }); - test('throws EnvLeakError when .env contains sensitive keys and codebase has no consent', async () => { + test('throws EnvLeakError when .env contains sensitive keys and registered codebase has no consent', async () => { + spyFindByDefaultCwd.mockResolvedValueOnce({ + id: 'codebase-1', + allow_env_keys: false, + default_cwd: '/workspace', + }); spyScan.mockReturnValueOnce({ path: '/workspace', findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], @@ -991,6 +996,24 @@ describe('ClaudeClient', () => { }).toThrow('Cannot run workflow'); }); + test('skips scan entirely when cwd is not a registered codebase', async () => { + // Both lookups return null (default from beforeEach) → unregistered cwd. + // Even if sensitive keys would be present, the pre-spawn check must not run + // because the canonical gate is registerRepoAtPath, not sendQuery. + spyScan.mockReturnValue({ + path: '/workspace', + findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], + }); + + const chunks = []; + for await (const chunk of client.sendQuery('test', '/workspace')) { + chunks.push(chunk); + } + + expect(spyScan).not.toHaveBeenCalled(); + expect(chunks).toHaveLength(1); + }); + test('skips scan when codebase has allow_env_keys: true', async () => { spyFindByDefaultCwd.mockResolvedValueOnce({ id: 'codebase-1', @@ -1007,17 +1030,23 @@ describe('ClaudeClient', () => { expect(chunks).toHaveLength(1); }); - test('proceeds when cwd has no registered codebase and no sensitive keys', async () => { + test('proceeds without scanning when cwd has no registered codebase', async () => { + // Unregistered cwd — the pre-spawn safety net is out of scope. const chunks = []; for await (const chunk of client.sendQuery('test', '/workspace')) { chunks.push(chunk); } - expect(spyScan).toHaveBeenCalledTimes(1); + expect(spyScan).not.toHaveBeenCalled(); expect(chunks).toHaveLength(1); }); test('skips scan when allowTargetRepoKeys is true in merged config', async () => { + spyFindByDefaultCwd.mockResolvedValueOnce({ + id: 'codebase-1', + allow_env_keys: false, + default_cwd: '/workspace', + }); const spyLoadConfig = spyOn(configLoader, 'loadConfig').mockResolvedValueOnce({ allowTargetRepoKeys: true, } as Awaited>); @@ -1038,6 +1067,11 @@ describe('ClaudeClient', () => { }); test('falls back to scanner when loadConfig throws (fail-closed)', async () => { + spyFindByDefaultCwd.mockResolvedValueOnce({ + id: 'codebase-1', + allow_env_keys: false, + default_cwd: '/workspace', + }); const spyLoadConfig = spyOn(configLoader, 'loadConfig').mockRejectedValueOnce( new Error('YAML parse error') ); diff --git a/packages/core/src/clients/claude.ts b/packages/core/src/clients/claude.ts index f05bc98ea7..1d2bd664b3 100644 --- a/packages/core/src/clients/claude.ts +++ b/packages/core/src/clients/claude.ts @@ -276,7 +276,7 @@ export class ClaudeClient implements IAssistantClient { const codebase = (await codebaseDb.findCodebaseByDefaultCwd(cwd)) ?? (await codebaseDb.findCodebaseByPathPrefix(cwd)); - if (!codebase?.allow_env_keys) { + if (codebase && !codebase.allow_env_keys) { // Fail-closed: a config load failure (corrupt YAML, permission denied) // must NOT silently bypass the gate. Catch, log, and treat as // `allowTargetRepoKeys = false` so the scanner still runs. diff --git a/packages/core/src/clients/codex.test.ts b/packages/core/src/clients/codex.test.ts index e29002cd0a..cfa329e7c1 100644 --- a/packages/core/src/clients/codex.test.ts +++ b/packages/core/src/clients/codex.test.ts @@ -1029,9 +1029,12 @@ describe('CodexClient', () => { spyScan.mockRestore(); }); - test('throws EnvLeakError when .env contains sensitive keys and codebase has no consent', async () => { - spyFindByDefaultCwd.mockResolvedValueOnce(null); - spyFindByPathPrefix.mockResolvedValueOnce(null); + test('throws EnvLeakError when .env contains sensitive keys and registered codebase has no consent', async () => { + spyFindByDefaultCwd.mockResolvedValueOnce({ + id: 'codebase-1', + allow_env_keys: false, + default_cwd: '/workspace', + }); spyScan.mockReturnValueOnce({ path: '/workspace', findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], @@ -1046,6 +1049,22 @@ describe('CodexClient', () => { await expect(consumeGenerator()).rejects.toThrow('Cannot run workflow'); }); + test('skips scan entirely when cwd is not a registered codebase', async () => { + // Both lookups return null (default from beforeEach). Pre-spawn safety net + // is only for registered codebases; unregistered paths go through registerRepoAtPath. + spyScan.mockReturnValue({ + path: '/workspace', + findings: [{ file: '.env', keys: ['ANTHROPIC_API_KEY'] }], + }); + + const chunks = []; + for await (const chunk of client.sendQuery('test', '/workspace')) { + chunks.push(chunk); + } + + expect(spyScan).not.toHaveBeenCalled(); + }); + test('skips scan when codebase has allow_env_keys: true', async () => { spyFindByDefaultCwd.mockResolvedValueOnce({ id: 'codebase-1', @@ -1061,13 +1080,13 @@ describe('CodexClient', () => { expect(spyScan).not.toHaveBeenCalled(); }); - test('proceeds when cwd has no registered codebase and no sensitive keys', async () => { + test('proceeds without scanning when cwd has no registered codebase', async () => { const chunks = []; for await (const chunk of client.sendQuery('test', '/workspace')) { chunks.push(chunk); } - expect(spyScan).toHaveBeenCalledTimes(1); + expect(spyScan).not.toHaveBeenCalled(); }); test('uses prefix lookup for worktree paths when exact match returns null', async () => { diff --git a/packages/core/src/clients/codex.ts b/packages/core/src/clients/codex.ts index a7c52731e1..110f35d2b2 100644 --- a/packages/core/src/clients/codex.ts +++ b/packages/core/src/clients/codex.ts @@ -163,7 +163,7 @@ export class CodexClient implements IAssistantClient { const codebase = (await codebaseDb.findCodebaseByDefaultCwd(cwd)) ?? (await codebaseDb.findCodebaseByPathPrefix(cwd)); - if (!codebase?.allow_env_keys) { + if (codebase && !codebase.allow_env_keys) { // Fail-closed: a config load failure must NOT silently bypass the gate. let allowTargetRepoKeys = false; try {