Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions apps/desktop/src-tauri/src/core/built_in_tools/bash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,32 @@ async fn try_rtk_rewrite(command: &str) -> Option<String> {
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(());
}
Comment on lines +107 to +109

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Keep the allowlist authoritative at the native boundary.

Line 107 accepts an empty list, but the list is supplied by the same IPC request being validated. A caller can omit it or provide ['C:\\'], bypassing the configured scope. Resolve the policy from trusted native-owned configuration (or a validated opaque policy identifier), rather than trusting a renderer-provided directory list.

  • apps/desktop/src-tauri/src/core/built_in_tools/bash.rs#L107-L109: validate against native-owned policy, not request data.
  • apps/desktop/src/services/NativeService/types.ts#L18: remove the raw allowlist from the public execution request.
  • apps/desktop/src-tauri/src/core/built_in_tools/types.rs#L17-L18: do not deserialize caller-provided authorization policy.
  • apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts#L162: stop forwarding renderer-owned allowlist values as native authorization input.
📍 Affects 4 files
  • apps/desktop/src-tauri/src/core/built_in_tools/bash.rs#L107-L109 (this comment)
  • apps/desktop/src/services/NativeService/types.ts#L18-L18
  • apps/desktop/src-tauri/src/core/built_in_tools/types.rs#L17-L18
  • apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts#L162-L162
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/desktop/src-tauri/src/core/built_in_tools/bash.rs` around lines 107 -
109, Make the native bash authorization path authoritative: in
apps/desktop/src-tauri/src/core/built_in_tools/bash.rs#L107-L109, resolve and
validate the allowlist from native-owned configuration or a validated opaque
policy identifier instead of accepting an empty or renderer-supplied list; in
apps/desktop/src/services/NativeService/types.ts#L18, remove the raw allowlist
from the public execution request; in
apps/desktop/src-tauri/src/core/built_in_tools/types.rs#L17-L18, stop
deserializing caller-provided authorization policy; and in
apps/desktop/src/services/BuiltInToolService/tools/bash/index.ts#L162, stop
forwarding renderer-owned allowlist values as native authorization input.


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,
Expand All @@ -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) =
Expand Down Expand Up @@ -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"));
}
}
2 changes: 2 additions & 0 deletions apps/desktop/src-tauri/src/core/built_in_tools/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ pub struct BuiltInBashExecutionRequest {
pub command: String,
/// 可选工作目录。留空时沿用当前进程工作目录。
pub working_directory: Option<String>,
#[serde(default)]
pub allowed_working_directories: Vec<String>,
/// 可选超时,单位毫秒。
pub timeout_ms: Option<u64>,
/// 是否启用输出压缩:执行前自动为命令添加压缩前缀以精简输出。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ async function executeCancelableBash(
executionId: string;
command: string;
workingDirectory?: string | null;
allowedWorkingDirectories?: string[];
timeoutMs?: number | null;
compactOutput?: boolean;
rawOutput?: boolean;
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/services/NativeService/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface BuiltInBashExecutionRequest {
executionId: string;
command: string;
workingDirectory?: string | null;
allowedWorkingDirectories?: string[];
timeoutMs?: number | null;
compactOutput?: boolean;
rawOutput?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ describe('executeBashTool request construction', () => {
const config = {
...DEFAULT_BASH_TOOL_CONFIG,
defaultWorkingDirectory: 'D:/project',
allowedWorkingDirectories: ['D:/other'],
compactOutput: true,
timeoutMs: 20000,
};
Expand All @@ -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,
Expand Down
Loading