diff --git a/sdk/README.md b/sdk/README.md index e9a26e214f..ab4c698539 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -180,6 +180,13 @@ gitignore. This built-in policy is scoped to SDK-mediated agent file reads (including `read_files`); it does not add terminal-command or internal-edit restrictions. +Ignore-rule blocks on `read_files` return `[BLOCKED]` followed by a trailing +reason. `.codebuffignore` — a project-level ignore file with `.gitignore` +syntax, checked alongside `.gitignore` — is the escape hatch: adjust or +negate the matching rule there to allow tool reads of a specific file (a +file-level negation cannot re-include a path whose parent directory is itself +excluded). + Custom `overrideTools.read_files` implementations must preserve project gitignore behavior for env templates. The built-in CLI, Desktop, Web, and Cloud bridges already do this. diff --git a/sdk/src/__tests__/read-files.test.ts b/sdk/src/__tests__/read-files.test.ts index 9448c0f00b..608e7cc7d9 100644 --- a/sdk/src/__tests__/read-files.test.ts +++ b/sdk/src/__tests__/read-files.test.ts @@ -393,11 +393,74 @@ describe('getFiles', () => { fs: mockFs, }) - expect(result['node_modules/package/index.js']).toBe( - FILE_READ_STATUS.IGNORED, + expect( + result['node_modules/package/index.js']!.startsWith( + FILE_READ_STATUS.IGNORED, + ), + ).toBe(true) + expect(result['node_modules/package/index.js']).toContain( + 'excluded by ignore rules', ) }) + test('ignore-rule block explains reason and unblock path; env-policy block stays opaque', async () => { + isFileIgnoredSpy.mockResolvedValue(true) + + const mockFs = createMockFs({ + files: { + '/project/AGENTS.local.md': { content: 'private notes' }, + }, + }) + + const result = await getFiles({ + filePaths: ['AGENTS.local.md'], + cwd: '/project', + fs: mockFs, + }) + + expect( + result['AGENTS.local.md']!.startsWith(FILE_READ_STATUS.IGNORED), + ).toBe(true) + expect(result['AGENTS.local.md']).toContain('excluded by ignore rules') + expect(result['AGENTS.local.md']).toContain('cannot re-include') + + // Ignored-and-deleted: the block must not assert the file exists. + const goneFs = createMockFs({ files: {} }) + const goneResult = await getFiles({ + filePaths: ['AGENTS.local.md'], + cwd: '/project', + fs: goneFs, + }) + expect( + goneResult['AGENTS.local.md']!.startsWith(FILE_READ_STATUS.IGNORED), + ).toBe(true) + expect(goneResult['AGENTS.local.md']).not.toContain('exists on disk') + + // Internal-edit path (no env policy): a secret blocked by built-in + // ignore defaults must stay opaque too. + const editFs = createMockFs({ + files: { '/project/.env': { content: 'SECRET=value' } }, + }) + const editResult = await getFiles({ + filePaths: ['.env'], + cwd: '/project', + fs: editFs, + enforceEnvPolicy: false, + }) + expect(editResult['.env']).toBe(FILE_READ_STATUS.IGNORED) + + // The env-policy block must NOT leak a reason or an unblock hint. + const envFs = createMockFs({ + files: { '/project/.env': { content: 'SECRET=value' } }, + }) + const envResult = await getFiles({ + filePaths: ['.env'], + cwd: '/project', + fs: envFs, + }) + expect(envResult['.env']).toBe(FILE_READ_STATUS.IGNORED) + }) + test('should call isFileIgnored with correct parameters', async () => { const mockFs = createMockFs({ files: { @@ -436,7 +499,11 @@ describe('getFiles', () => { }) expect(result['src/index.ts']).toBe('main code') - expect(result['node_modules/pkg/index.js']).toBe(FILE_READ_STATUS.IGNORED) + expect( + result['node_modules/pkg/index.js']!.startsWith( + FILE_READ_STATUS.IGNORED, + ), + ).toBe(true) }) }) @@ -454,10 +521,13 @@ describe('getFiles', () => { filePaths: ['node_modules/pkg/index.js'], cwd: '/project', fs: mockFs, - // No fileFilter provided - SDK applies default gitignore checking }) - expect(result['node_modules/pkg/index.js']).toBe(FILE_READ_STATUS.IGNORED) + expect( + result['node_modules/pkg/index.js']!.startsWith( + FILE_READ_STATUS.IGNORED, + ), + ).toBe(true) expect(isFileIgnoredSpy).toHaveBeenCalled() }) @@ -615,8 +685,12 @@ describe('getFiles', () => { fileFilter: () => ({ status: 'allow' }), }) - expect(result['.env.example']).toBe(FILE_READ_STATUS.IGNORED) - expect(result['.ENV.SAMPLE']).toBe(FILE_READ_STATUS.IGNORED) + expect(result['.env.example']!.startsWith(FILE_READ_STATUS.IGNORED)).toBe( + true, + ) + expect(result['.ENV.SAMPLE']!.startsWith(FILE_READ_STATUS.IGNORED)).toBe( + true, + ) expect(isFileIgnoredSpy).toHaveBeenCalledTimes(2) }) diff --git a/sdk/src/__tests__/run-file-filter.test.ts b/sdk/src/__tests__/run-file-filter.test.ts index 501df88d56..0c7d247c59 100644 --- a/sdk/src/__tests__/run-file-filter.test.ts +++ b/sdk/src/__tests__/run-file-filter.test.ts @@ -275,7 +275,9 @@ describe('CodebuffClientOptions fileFilter', () => { }) expect(result.output.type).toBe('lastMessage') - expect(requestedFiles['.env.example']).toBe(FILE_READ_STATUS.IGNORED) + expect( + requestedFiles['.env.example']!.startsWith(FILE_READ_STATUS.IGNORED), + ).toBe(true) }) it('should pass fileFilter to requestOptionalFile as well', async () => { diff --git a/sdk/src/tools/read-files.ts b/sdk/src/tools/read-files.ts index 1a9c217919..12e9ef43ff 100644 --- a/sdk/src/tools/read-files.ts +++ b/sdk/src/tools/read-files.ts @@ -99,7 +99,27 @@ export async function getFiles(params: { ...(isEnvTemplate ? { allowEnvTemplate: true } : {}), }) if (ignored) { - result[relativePath] = FILE_READ_STATUS.IGNORED + // The internal-edit path (enforceEnvPolicy: false) skips the env + // gate above, so secrets can reach this branch via built-in ignore + // defaults. Never explain or hint unblocking for them. + if (isSensitiveEnvFilePath(relativePath)) { + result[relativePath] = FILE_READ_STATUS.IGNORED + continue + } + // Keep the sentinel as the prefix (consumers match with startsWith) + // and append the reason, following the FILE_TOO_LARGE precedent. + // The ignore check never touches the file itself, so only claim + // existence when a stat confirms it. + let exists = false + try { + await fs.stat(fullPath) + exists = true + } catch { + // missing or unreadable: omit the existence claim + } + result[relativePath] = + FILE_READ_STATUS.IGNORED + + `: ${isEnvTemplate ? 'blocked by ignore-rule checking' : 'excluded by ignore rules'} (.gitignore, .codebuffignore, or built-in defaults), not an OS permission issue.${exists ? ' The file exists on disk;' : ''} glob and code_search omit it for the same reason. To allow tool reads, adjust or negate the matching rule in .codebuffignore (a file-level negation cannot re-include a path under an excluded directory).` continue } }