diff --git a/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts b/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts index 81cf4e6d..2eb8cb77 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts @@ -16,7 +16,49 @@ import { } from './constants'; function normalizeDirectoryPath(path: string): string { - return path.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); + const normalizedSeparators = path.trim().replace(/\//g, '\\').replace(/\\+$/, ''); + const uncMatch = /^\\\\([^\\]+)\\([^\\]+)(?:\\(.*))?$/.exec(normalizedSeparators); + const driveMatch = /^([a-zA-Z]:)(?:\\(.*))?$/.exec(normalizedSeparators); + const hasRoot = normalizedSeparators.startsWith('\\'); + const prefix = + uncMatch && uncMatch[1] && uncMatch[2] + ? `\\\\${uncMatch[1].toLowerCase()}\\${uncMatch[2].toLowerCase()}` + : (driveMatch?.[1]?.toLowerCase() ?? (hasRoot ? '\\' : '')); + const body = uncMatch + ? (uncMatch[3] ?? '') + : driveMatch + ? (driveMatch[2] ?? '') + : normalizedSeparators.replace(/^\\+/, ''); + const resolvedSegments: string[] = []; + + for (const segment of body.split(/\\+/)) { + if (!segment || segment === '.') { + continue; + } + + if (segment === '..') { + const lastSegment = resolvedSegments[resolvedSegments.length - 1]; + if (lastSegment && lastSegment !== '..') { + resolvedSegments.pop(); + } else if (!prefix) { + resolvedSegments.push(segment); + } + continue; + } + + resolvedSegments.push(segment.toLowerCase()); + } + + const normalizedBody = resolvedSegments.join('\\'); + if (!prefix) { + return normalizedBody; + } + + if (!normalizedBody) { + return prefix; + } + + return prefix.endsWith('\\') ? `${prefix}${normalizedBody}` : `${prefix}\\${normalizedBody}`; } function isWithinAllowedDirectory(path: string, allowlist: string[]): boolean { diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/bash/helper.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/bash/helper.test.ts index 15741d99..f9b5c29c 100644 --- a/apps/desktop/tests/services/BuiltInToolService/tools/bash/helper.test.ts +++ b/apps/desktop/tests/services/BuiltInToolService/tools/bash/helper.test.ts @@ -94,6 +94,48 @@ describe('resolveCommandContext', () => { ).rejects.toThrow('Working directory is outside the allowed scope'); }); + it('rejects working directories that escape the allowlist with parent segments', async () => { + setLocale('en-US'); + const config = { + ...baseConfig, + allowedWorkingDirectories: ['D:/allowed'], + }; + + await expect( + resolveCommandContext( + { command: 'dir', workingDirectory: 'D:/allowed/../other' }, + config + ) + ).rejects.toThrow('Working directory is outside the allowed scope'); + }); + + it('rejects relative working directories that escape with consecutive parent segments', async () => { + setLocale('en-US'); + const config = { + ...baseConfig, + allowedWorkingDirectories: ['foo'], + }; + + await expect( + resolveCommandContext({ command: 'dir', workingDirectory: '../../foo' }, config) + ).rejects.toThrow('Working directory is outside the allowed scope'); + }); + + it('rejects UNC paths that escape to a sibling share before re-entering the allowlist', async () => { + setLocale('en-US'); + const config = { + ...baseConfig, + allowedWorkingDirectories: ['\\\\server\\share'], + }; + + await expect( + resolveCommandContext( + { command: 'dir', workingDirectory: '\\\\server\\other\\..\\share\\secret' }, + config + ) + ).rejects.toThrow('Working directory is outside the allowed scope'); + }); + it('accepts command inside allowed directories', async () => { const config = { ...baseConfig,