diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts index fb8a14eddf..d80a8bfd80 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.test.ts @@ -14,6 +14,7 @@ import { type Mock, } from 'vitest'; import { + checkPermissions, handleAtCommand, escapeAtSymbols, unescapeLiteralAt, @@ -35,6 +36,7 @@ import { import * as core from '@google/gemini-cli-core'; import * as os from 'node:os'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import * as fs from 'node:fs'; import * as fsPromises from 'node:fs/promises'; import * as path from 'node:path'; @@ -1541,3 +1543,57 @@ describe('unescapeLiteralAt', () => { expect(unescapeLiteralAt(escapeAtSymbols(input))).toBe(input); }); }); + +describe('checkPermissions', () => { + let testRootDir: string; + let mockConfig: Config; + + beforeEach(async () => { + vi.restoreAllMocks(); + testRootDir = await fsPromises.mkdtemp( + path.join(os.tmpdir(), 'check-permissions-test-'), + ); + + mockConfig = { + getTargetDir: () => testRootDir, + getAgentRegistry: () => ({ + getDefinition: () => undefined, + }), + getResourceRegistry: () => ({ + findResourceByUri: () => undefined, + getAllResources: () => [], + }), + validatePathAccess: () => null, + } as unknown as Config; + }); + + afterEach(async () => { + await fsPromises.rm(testRootDir, { recursive: true, force: true }); + }); + + // Regression for #22029 (and related #25910 / #25923): when a user pastes + // a JSON-like blob after an @, the @-command regex greedily captures it. + // The resolved string is longer than NAME_MAX, so fs.realpathSync throws + // ENAMETOOLONG. Previously this bubbled up as an unhandled rejection and + // crashed the CLI. + it('skips @-mentions whose path is too long to be a real filesystem entry', async () => { + const longSegment = 'a'.repeat(8192); + const query = `@${longSegment}`; + await expect(checkPermissions(query, mockConfig)).resolves.toEqual([]); + }); + + it('still surfaces real @-mentioned files when a sibling @-mention is unresolvable', async () => { + // A real file alongside a giant pasted-blob mention: the bogus mention + // should be skipped, the real one should still appear in the result. + const realFile = path.join(testRootDir, 'real.txt'); + await fsPromises.writeFile(realFile, 'hello'); + const resolvedRealFile = fs.realpathSync(realFile); + mockConfig.validatePathAccess = () => + 'permission required' as unknown as null; + const longSegment = 'b'.repeat(8192); + const query = `@real.txt and @${longSegment}`; + await expect(checkPermissions(query, mockConfig)).resolves.toEqual([ + resolvedRealFile, + ]); + }); +}); diff --git a/packages/cli/src/ui/hooks/atCommandProcessor.ts b/packages/cli/src/ui/hooks/atCommandProcessor.ts index 512fe952ba..e23d70a60d 100644 --- a/packages/cli/src/ui/hooks/atCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/atCommandProcessor.ts @@ -188,9 +188,15 @@ export async function checkPermissions( const pathName = part.content.substring(1); if (!pathName) continue; - const resolvedPathName = resolveToRealPath( - path.resolve(config.getTargetDir(), pathName), - ); + let resolvedPathName: string; + try { + resolvedPathName = resolveToRealPath( + path.resolve(config.getTargetDir(), pathName), + ); + } catch { + // skip if resolveToRealPath errors out + continue; + } if (config.validatePathAccess(resolvedPathName, 'read')) { if (await fileExists(resolvedPathName)) {