Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions packages/core/src/clients/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] }],
Expand All @@ -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',
Expand All @@ -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<ReturnType<typeof configLoader.loadConfig>>);
Expand All @@ -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')
);
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/clients/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
29 changes: 24 additions & 5 deletions packages/core/src/clients/codex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] }],
Expand All @@ -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',
Expand All @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/clients/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading