diff --git a/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts b/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts index 81cf4e6d..f103ce9d 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/bash/helper.ts @@ -16,7 +16,37 @@ import { } from './constants'; function normalizeDirectoryPath(path: string): string { - return path.replace(/\//g, '\\').replace(/\\+$/, '').toLowerCase(); + const normalizedSeparators = path.trim().replace(/\//g, '\\').replace(/\\+$/, ''); + const driveMatch = /^([a-zA-Z]:)(?:\\(.*))?$/.exec(normalizedSeparators); + const hasRoot = normalizedSeparators.startsWith('\\'); + const prefix = driveMatch?.[1]?.toLowerCase() ?? (hasRoot ? '\\' : ''); + const body = 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; + } + + return normalizedBody ? `${prefix}\\${normalizedBody}` : prefix; } 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..b14c4eb3 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,33 @@ 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('accepts command inside allowed directories', async () => { const config = { ...baseConfig,