From 20c2381cd2d00f78dea7cd903298b39985267457 Mon Sep 17 00:00:00 2001 From: nordicnode <128633122+nordicnode@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:49:59 -0700 Subject: [PATCH 1/3] Explain why read_files blocks gitignored files --- sdk/src/__tests__/read-files.test.ts | 75 ++++++++++++++++++++--- sdk/src/__tests__/run-file-filter.test.ts | 4 +- sdk/src/tools/read-files.ts | 15 ++++- 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/sdk/src/__tests__/read-files.test.ts b/sdk/src/__tests__/read-files.test.ts index 9448c0f00b..b891b140ba 100644 --- a/sdk/src/__tests__/read-files.test.ts +++ b/sdk/src/__tests__/read-files.test.ts @@ -393,11 +393,61 @@ 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') + + // 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 +486,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 +508,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 +672,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..b920ea44d6 100644 --- a/sdk/src/tools/read-files.ts +++ b/sdk/src/tools/read-files.ts @@ -99,7 +99,20 @@ export async function getFiles(params: { ...(isEnvTemplate ? { allowEnvTemplate: true } : {}), }) if (ignored) { - result[relativePath] = FILE_READ_STATUS.IGNORED + // 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 } } From 9ab8522953492e9014ced1cefbbb4d29540fbf50 Mon Sep 17 00:00:00 2001 From: nordicnode <128633122+nordicnode@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:55:38 -0700 Subject: [PATCH 2/3] Keep env secrets opaque on internal-edit path; document codebuffignore --- sdk/README.md | 5 +++++ sdk/src/__tests__/read-files.test.ts | 13 +++++++++++++ sdk/src/tools/read-files.ts | 7 +++++++ 3 files changed, 25 insertions(+) diff --git a/sdk/README.md b/sdk/README.md index e9a26e214f..2ea486efc9 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -180,6 +180,11 @@ 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. + 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 b891b140ba..608e7cc7d9 100644 --- a/sdk/src/__tests__/read-files.test.ts +++ b/sdk/src/__tests__/read-files.test.ts @@ -436,6 +436,19 @@ describe('getFiles', () => { ).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' } }, diff --git a/sdk/src/tools/read-files.ts b/sdk/src/tools/read-files.ts index b920ea44d6..12e9ef43ff 100644 --- a/sdk/src/tools/read-files.ts +++ b/sdk/src/tools/read-files.ts @@ -99,6 +99,13 @@ export async function getFiles(params: { ...(isEnvTemplate ? { allowEnvTemplate: true } : {}), }) if (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 From ed92e6e6bfae5e7bacfaf905295794afa51df108 Mon Sep 17 00:00:00 2001 From: nordicnode <128633122+nordicnode@users.noreply.github.com> Date: Tue, 1 Sep 2026 15:57:19 -0700 Subject: [PATCH 3/3] Note directory-negation limit in codebuffignore docs --- sdk/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/sdk/README.md b/sdk/README.md index 2ea486efc9..ab4c698539 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -183,7 +183,9 @@ 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. +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