diff --git a/apps/desktop/src-tauri/src/core/built_in_tools/bash.rs b/apps/desktop/src-tauri/src/core/built_in_tools/bash.rs index a467b154..32cba66c 100644 --- a/apps/desktop/src-tauri/src/core/built_in_tools/bash.rs +++ b/apps/desktop/src-tauri/src/core/built_in_tools/bash.rs @@ -96,6 +96,32 @@ async fn try_rtk_rewrite(command: &str) -> Option { None } +#[cfg(target_os = "windows")] +fn validate_working_directory( + working_directory: Option<&str>, + allowed_directories: &[String], +) -> Result<(), String> { + let Some(working_directory) = working_directory else { + return Ok(()); + }; + if allowed_directories.is_empty() { + return Ok(()); + } + + let canonical_working_directory = std::fs::canonicalize(working_directory) + .map_err(|error| format!("Failed to resolve working directory: {error}"))?; + let is_allowed = allowed_directories.iter().any(|allowed_directory| { + std::fs::canonicalize(allowed_directory) + .map(|canonical_allowed| canonical_working_directory.starts_with(canonical_allowed)) + .unwrap_or(false) + }); + if is_allowed { + Ok(()) + } else { + Err("Working directory is outside the allowed scope".to_string()) + } +} + #[cfg(target_os = "windows")] async fn execute_bash_windows( request: BuiltInBashExecutionRequest, @@ -106,6 +132,11 @@ async fn execute_bash_windows( return Err("Command cannot be empty".to_string()); } + validate_working_directory( + request.working_directory.as_deref(), + &request.allowed_working_directories, + )?; + // 当输出压缩启用且模型未请求原始输出时,通过 rtk rewrite 判断命令是否可重写, // 支持的命令自动替换为 rtk 等价形式,不支持的原样执行。 let (effective_command, compressed) = @@ -257,3 +288,27 @@ fn build_powershell_command_script(command: &str) -> String { format!("{}\n{}\n{}", UTF8_POWERSHELL_PRELUDE, path_prelude, command) } } + +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::validate_working_directory; + use std::fs; + + #[test] + fn rejects_canonical_working_directory_outside_allowlist() { + let root = tempfile::tempdir().expect("tempdir"); + let allowed = root.path().join("allowed"); + let outside = root.path().join("outside"); + fs::create_dir_all(&allowed).expect("allowed directory"); + fs::create_dir_all(&outside).expect("outside directory"); + let escaped = allowed.join("..").join("outside"); + + let error = validate_working_directory( + Some(escaped.to_string_lossy().as_ref()), + &[allowed.to_string_lossy().into_owned()], + ) + .expect_err("escaped working directory must be rejected"); + + assert!(error.contains("outside the allowed scope")); + } +} diff --git a/apps/desktop/src-tauri/src/core/built_in_tools/types.rs b/apps/desktop/src-tauri/src/core/built_in_tools/types.rs index 8159b1ea..939a5d1f 100644 --- a/apps/desktop/src-tauri/src/core/built_in_tools/types.rs +++ b/apps/desktop/src-tauri/src/core/built_in_tools/types.rs @@ -14,6 +14,8 @@ pub struct BuiltInBashExecutionRequest { pub command: String, /// 可选工作目录。留空时沿用当前进程工作目录。 pub working_directory: Option, + #[serde(default)] + pub allowed_working_directories: Vec, /// 可选超时,单位毫秒。 pub timeout_ms: Option, /// 是否启用输出压缩:执行前自动为命令添加压缩前缀以精简输出。 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/src/services/BuiltInToolService/tools/bash/index.ts b/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts index 8eae42d3..7c23291a 100644 --- a/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts +++ b/apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts @@ -52,6 +52,7 @@ async function executeCancelableBash( executionId: string; command: string; workingDirectory?: string | null; + allowedWorkingDirectories?: string[]; timeoutMs?: number | null; compactOutput?: boolean; rawOutput?: boolean; @@ -158,6 +159,7 @@ export async function executeBashTool( executionId: context.callId, command: commandContext.command, workingDirectory: commandContext.workingDirectory, + allowedWorkingDirectories: config.allowedWorkingDirectories, timeoutMs: config.timeoutMs, compactOutput: config.compactOutput, rawOutput: commandContext.rawOutput, diff --git a/apps/desktop/src/services/NativeService/types.ts b/apps/desktop/src/services/NativeService/types.ts index a3a52379..0a4921f8 100644 --- a/apps/desktop/src/services/NativeService/types.ts +++ b/apps/desktop/src/services/NativeService/types.ts @@ -15,6 +15,7 @@ export interface BuiltInBashExecutionRequest { executionId: string; command: string; workingDirectory?: string | null; + allowedWorkingDirectories?: string[]; timeoutMs?: number | null; compactOutput?: boolean; rawOutput?: 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, diff --git a/apps/desktop/tests/services/BuiltInToolService/tools/bash/index.test.ts b/apps/desktop/tests/services/BuiltInToolService/tools/bash/index.test.ts index af8b8484..c6024610 100644 --- a/apps/desktop/tests/services/BuiltInToolService/tools/bash/index.test.ts +++ b/apps/desktop/tests/services/BuiltInToolService/tools/bash/index.test.ts @@ -128,6 +128,7 @@ describe('executeBashTool request construction', () => { const config = { ...DEFAULT_BASH_TOOL_CONFIG, defaultWorkingDirectory: 'D:/project', + allowedWorkingDirectories: ['D:/other'], compactOutput: true, timeoutMs: 20000, }; @@ -143,6 +144,7 @@ describe('executeBashTool request construction', () => { executionId: 'call-1', command: 'git diff', workingDirectory: 'D:/other', + allowedWorkingDirectories: ['D:/other'], timeoutMs: 20000, compactOutput: true, rawOutput: true,