Skip to content

Commit fd2cb0b

Browse files
committed
Fix Windows paths, harden tilde expansion, cap MCP images
1 parent 0627f6a commit fd2cb0b

8 files changed

Lines changed: 443 additions & 38 deletions

File tree

src/tools/bashexec.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
use crate::error::{Result, SofosError};
22
use crate::tools::permissions::{CommandPermission, PermissionManager};
3-
use crate::tools::utils::{MAX_TOOL_OUTPUT_TOKENS, TruncationKind, truncate_for_context};
3+
use crate::tools::utils::{
4+
MAX_TOOL_OUTPUT_TOKENS, TruncationKind, is_absolute_path, truncate_for_context,
5+
};
46
use std::collections::HashSet;
57
use std::path::PathBuf;
68
use std::process::Command;
@@ -334,11 +336,18 @@ impl BashExecutor {
334336
cleaned
335337
};
336338

337-
if path_candidate.starts_with('/') {
338-
self.check_bash_external_path(path_candidate, permission_manager)?;
339-
} else if path_candidate.starts_with("~/") || path_candidate == "~" {
339+
// Check tilde before absolute so `~` / `~/foo` get expanded
340+
// first. `is_absolute_path` catches Unix (`/foo`) and
341+
// Windows (`C:\foo`, `\\server\share`) shapes on every
342+
// platform — `Path::is_absolute` alone would miss Unix
343+
// paths on Windows, letting a bash command referencing
344+
// `/etc/passwd` bypass the external-path prompt when the
345+
// binary runs there.
346+
if path_candidate.starts_with("~/") || path_candidate == "~" {
340347
let expanded = PermissionManager::expand_tilde_pub(path_candidate);
341348
self.check_bash_external_path(&expanded, permission_manager)?;
349+
} else if is_absolute_path(path_candidate) {
350+
self.check_bash_external_path(path_candidate, permission_manager)?;
342351
}
343352
}
344353

src/tools/codesearch.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,15 @@ impl CodeSearchTool {
179179
}
180180
}
181181

182-
/// List available file types supported by ripgrep
183-
pub fn _list_file_types() -> Result<String> {
184-
let output = Command::new("rg")
182+
/// List available file types supported by ripgrep.
183+
///
184+
/// Uses the resolved `rg_path` from the constructor so it honours
185+
/// `SOFOS_RG_PATH` and the Homebrew / Linux fallback list. The
186+
/// earlier implementation hard-coded `Command::new("rg")` which
187+
/// silently fell back to PATH and broke wherever ripgrep wasn't
188+
/// on PATH but had been found via the fallbacks.
189+
pub fn _list_file_types(&self) -> Result<String> {
190+
let output = Command::new(&self.rg_path)
185191
.arg("--type-list")
186192
.output()
187193
.map_err(|e| SofosError::ToolExecution(format!("Failed to list file types: {}", e)))?;

src/tools/filesystem.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::error::{Result, SofosError};
22
use crate::error_ext::ResultExt;
3+
use crate::tools::utils::is_absolute_path;
34
use std::fs;
45
use std::path::{Path, PathBuf};
56

@@ -104,7 +105,12 @@ impl FileSystemTool {
104105
/// Validate and resolve a path relative to the workspace
105106
/// Returns an error if the path attempts to escape the workspace
106107
fn validate_path(&self, path: &str) -> Result<PathBuf> {
107-
if path.starts_with('/') {
108+
// `is_absolute_path` catches both Unix (`/foo`) and Windows
109+
// (`C:\foo`, UNC `\\server\share`) shapes. Using
110+
// `Path::is_absolute` directly would miss Unix-style paths
111+
// when running on Windows — a regression the helper
112+
// specifically guards against.
113+
if is_absolute_path(path) {
108114
return Err(SofosError::PathViolation(
109115
"Absolute paths are not allowed".to_string(),
110116
));

src/tools/image.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::error::{Result, SofosError};
22
use crate::error_ext::ResultExt;
33
use crate::tools::permissions::{CommandPermission, PermissionManager};
4+
use crate::tools::utils::is_absolute_or_tilde;
45
use base64::{Engine, engine::general_purpose::STANDARD};
56
use std::path::PathBuf;
67

@@ -119,7 +120,7 @@ impl ImageLoader {
119120
}
120121

121122
pub fn load_local_image(&self, path: &str) -> Result<ImageSource> {
122-
let full_path = if path.starts_with('/') || path.starts_with('~') {
123+
let full_path = if is_absolute_or_tilde(path) {
123124
PathBuf::from(PermissionManager::expand_tilde_pub(path))
124125
} else {
125126
self.workspace.join(path)

src/tools/mod.rs

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ use tool_name::ToolName;
2222

2323
use crate::tools::types::get_read_only_tools;
2424
use crate::tools::utils::{
25-
MAX_DIFF_TOKENS, MAX_MCP_OUTPUT_TOKENS, MAX_PATH_LIST_TOKENS, MAX_TOOL_OUTPUT_TOKENS,
26-
TruncationKind, confirm_destructive, truncate_for_context,
25+
MAX_DIFF_TOKENS, MAX_MCP_IMAGE_BYTES, MAX_MCP_IMAGE_COUNT, MAX_MCP_OUTPUT_TOKENS,
26+
MAX_PATH_LIST_TOKENS, MAX_TOOL_OUTPUT_TOKENS, TruncationKind, confirm_destructive,
27+
is_absolute_or_tilde, truncate_for_context,
2728
};
2829
pub use types::{add_code_search_tool, get_all_tools, get_all_tools_with_morph};
2930

@@ -111,6 +112,56 @@ pub struct ToolExecutor {
111112
const MORPH_STUB_ORIGINAL_MIN: usize = 500;
112113
const MORPH_STUB_FLOOR_BYTES: usize = 50;
113114

115+
/// Apply both MCP-response caps (image count/bytes and text tokens) in
116+
/// the order the dispatcher needs. The drop note for images has to land
117+
/// AFTER text truncation so the model always sees it — otherwise a
118+
/// ~1 MB text response could push the note out past the truncation
119+
/// boundary and the model would silently lose the "images dropped"
120+
/// signal. The overage this adds to the text field is ~100 bytes,
121+
/// immaterial compared with the 10 MB API ceiling this cap is really
122+
/// protecting against. Factored out from the `execute` dispatcher so
123+
/// the ordering can be pinned in unit tests.
124+
fn cap_mcp_response(result: &mut McpToolResult) {
125+
let dropped = cap_mcp_images(result);
126+
result.text = truncate_for_context(
127+
&result.text,
128+
MAX_MCP_OUTPUT_TOKENS,
129+
TruncationKind::McpOutput,
130+
);
131+
if dropped > 0 {
132+
result.text.push_str(&format!(
133+
"\n\n[{} image attachment(s) dropped: MCP image cap is {} images or ~{} MB total]",
134+
dropped,
135+
MAX_MCP_IMAGE_COUNT,
136+
MAX_MCP_IMAGE_BYTES / (1024 * 1024)
137+
));
138+
}
139+
}
140+
141+
/// Drop image attachments from an MCP result until both the per-call
142+
/// count cap and the total-bytes cap are satisfied. Returns the number
143+
/// of images that were dropped. Walks the list in order and keeps each
144+
/// image that still fits under both caps — so a single oversized image
145+
/// in the middle of the response is skipped without blocking smaller
146+
/// images that come after it. Kept images retain their original order.
147+
fn cap_mcp_images(result: &mut McpToolResult) -> usize {
148+
let original = result.images.len();
149+
let mut kept = Vec::with_capacity(result.images.len().min(MAX_MCP_IMAGE_COUNT));
150+
let mut total_bytes: usize = 0;
151+
for img in std::mem::take(&mut result.images) {
152+
let size = img.base64_data.len();
153+
if kept.len() >= MAX_MCP_IMAGE_COUNT
154+
|| total_bytes.saturating_add(size) > MAX_MCP_IMAGE_BYTES
155+
{
156+
continue;
157+
}
158+
total_bytes += size;
159+
kept.push(img);
160+
}
161+
result.images = kept;
162+
original - result.images.len()
163+
}
164+
114165
/// Ensure `path`'s parent directory exists, creating it (and any missing
115166
/// intermediates) if not. Used by move/copy when the destination is
116167
/// outside the workspace — inside-workspace writes go through
@@ -257,7 +308,7 @@ impl ToolExecutor {
257308
/// doesn't exist or canonicalize fails. Individual dispatchers can
258309
/// still customise the error via `.map_err(...)?`.
259310
fn resolve_existing(&self, caller_path: &str) -> Result<ResolvedPath> {
260-
let full_path = if caller_path.starts_with('/') || caller_path.starts_with('~') {
311+
let full_path = if is_absolute_or_tilde(caller_path) {
261312
std::path::PathBuf::from(permissions::PermissionManager::expand_tilde_pub(
262313
caller_path,
263314
))
@@ -283,7 +334,7 @@ impl ToolExecutor {
283334
/// lossy conversion if the parent is also missing (nested mkdir)
284335
/// or the path has no parent (filesystem root).
285336
fn resolve_for_write(&self, caller_path: &str) -> Result<ResolvedPath> {
286-
let full_path = if caller_path.starts_with('/') || caller_path.starts_with('~') {
337+
let full_path = if is_absolute_or_tilde(caller_path) {
287338
std::path::PathBuf::from(permissions::PermissionManager::expand_tilde_pub(
288339
caller_path,
289340
))
@@ -325,8 +376,9 @@ impl ToolExecutor {
325376

326377
// Neither the target nor its parent exists. Relative paths are
327378
// still inside-workspace by convention; absolute / tilde paths
328-
// are treated as outside and will hit the permission gate.
329-
let is_inside_workspace = !caller_path.starts_with('/') && !caller_path.starts_with('~');
379+
// (including Windows `C:\...`) are treated as outside and will
380+
// hit the permission gate.
381+
let is_inside_workspace = !is_absolute_or_tilde(caller_path);
330382
let canonical_str = full_path.to_string_lossy().to_string();
331383
Ok(ResolvedPath {
332384
canonical: full_path,
@@ -554,18 +606,7 @@ impl ToolExecutor {
554606
if let Some(mcp_manager) = &self.mcp_manager {
555607
if mcp_manager.is_mcp_tool(tool_name).await {
556608
let mut result = mcp_manager.execute_tool(tool_name, input).await?;
557-
// Cap the server-provided text before handing it to the
558-
// model. The MCP server itself is a separate process, so
559-
// this doesn't sandbox what it can read — but it keeps
560-
// an overly-chatty or malicious server from reproducing
561-
// the "string too long" HTTP 400 that oversized internal
562-
// tool outputs used to trigger. Images pass through
563-
// untouched.
564-
result.text = truncate_for_context(
565-
&result.text,
566-
MAX_MCP_OUTPUT_TOKENS,
567-
TruncationKind::McpOutput,
568-
);
609+
cap_mcp_response(&mut result);
569610
return Ok(ToolExecutionResult::Structured(result));
570611
}
571612
}

src/tools/permissions.rs

Lines changed: 112 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::error::{Result, SofosError};
2-
use crate::tools::utils::{ConfirmationType, confirm_multi_choice};
2+
use crate::tools::utils::{ConfirmationType, confirm_multi_choice, is_absolute_path};
33
use globset::{Glob, GlobSet, GlobSetBuilder};
44
use serde::{Deserialize, Serialize};
55
use std::collections::HashSet;
@@ -396,14 +396,20 @@ impl PermissionManager {
396396
}
397397

398398
/// Extract path patterns from Bash() entries.
399-
/// Only treats entries as path grants if the content starts with / or ~ AND contains a glob char.
400-
/// Plain commands like Bash(npm test) are not path patterns.
399+
/// Only treats entries as path grants if the content is absolute or
400+
/// a tilde path AND contains a glob char. Plain commands like
401+
/// Bash(npm test) are not path patterns. `is_absolute_path` handles
402+
/// Unix (`/tmp/**`), Windows drive-letter (`C:\Users\**`), and UNC
403+
/// paths on every platform — using `Path::is_absolute` alone would
404+
/// mis-classify a Unix-style `Bash(/var/log/**)` config entry as a
405+
/// command pattern when the binary runs on Windows.
401406
fn extract_bash_path_pattern(entry: &str) -> Option<&str> {
402407
let trimmed = entry.trim();
403408
if let Some(rest) = trimmed.strip_prefix("Bash(") {
404409
if let Some(end) = rest.rfind(')') {
405410
let content = &rest[..end];
406-
if (content.starts_with('/') || content.starts_with('~')) && content.contains('*') {
411+
let looks_like_path = content.starts_with('~') || is_absolute_path(content);
412+
if looks_like_path && content.contains('*') {
407413
return Some(content);
408414
}
409415
}
@@ -459,14 +465,49 @@ impl PermissionManager {
459465
.unwrap_or(without_prefix)
460466
}
461467

468+
/// Look up the user's home directory in a way that works on both
469+
/// Unix (`$HOME`) and Windows (`%USERPROFILE%`). `std::env::home_dir`
470+
/// was re-stabilised with a correct Windows implementation in Rust
471+
/// 1.85, but reading the platform-native env var directly keeps us
472+
/// compatible with older toolchains and makes the per-platform
473+
/// choice explicit. Returns `None` when the env var is unset, which
474+
/// is the same "fall through unexpanded" signal the caller uses.
475+
fn home_dir() -> Option<PathBuf> {
476+
#[cfg(windows)]
477+
{
478+
std::env::var_os("USERPROFILE").map(PathBuf::from)
479+
}
480+
#[cfg(not(windows))]
481+
{
482+
std::env::var_os("HOME").map(PathBuf::from)
483+
}
484+
}
485+
486+
/// Expand a leading `~` or `~/` to the user's home directory. Uses
487+
/// `PathBuf::push` so the separator between the home directory and
488+
/// the rest of the path is the platform's native one — the old
489+
/// `format!("{}/{}", home, rest)` produced `C:\Users\alice/foo` on
490+
/// Windows, which Windows accepts but looks wrong on inspection.
491+
/// Paths not starting with `~` are returned unchanged.
492+
///
493+
/// Strips leading separators from the remainder before pushing
494+
/// because `PathBuf::push` *replaces* self when the argument is
495+
/// absolute — so `expand_tilde("~//foo")` without the trim would
496+
/// return `/foo` (escaped out of home) instead of the
497+
/// bash-semantic `~/foo` = `home/foo`. Matters more on Windows
498+
/// where a user-supplied `~/\\server\share\file` would be UNC-
499+
/// absolute and would likewise replace the home prefix.
462500
fn expand_tilde(path: &str) -> String {
501+
if path == "~" {
502+
return Self::home_dir()
503+
.map(|p| p.to_string_lossy().to_string())
504+
.unwrap_or_else(|| path.to_string());
505+
}
463506
if let Some(rest) = path.strip_prefix("~/") {
464-
if let Ok(home) = std::env::var("HOME") {
465-
return format!("{}/{}", home, rest);
466-
}
467-
} else if path == "~" {
468-
if let Ok(home) = std::env::var("HOME") {
469-
return home;
507+
if let Some(mut home) = Self::home_dir() {
508+
let rest = rest.trim_start_matches(['/', '\\']);
509+
home.push(rest);
510+
return home.to_string_lossy().to_string();
470511
}
471512
}
472513
path.to_string()
@@ -1186,6 +1227,67 @@ ask = []
11861227
assert_eq!(not_tilde, "./file.txt");
11871228
}
11881229

1230+
#[test]
1231+
fn test_tilde_expansion_trims_leading_separator_in_remainder() {
1232+
// `PathBuf::push` replaces self when the pushed argument is
1233+
// absolute. If the caller types `~//foo` (double slash after
1234+
// the tilde), the remainder after `~/` starts with `/`, which
1235+
// `push` would treat as absolute and escape the home
1236+
// directory entirely — `~//foo` would resolve to `/foo`,
1237+
// which is not what bash would do. The trim keeps the
1238+
// expansion rooted at the home directory.
1239+
let _lock = HOME_MUTEX.lock().unwrap();
1240+
let _temp_dir = TempDir::new().unwrap();
1241+
1242+
let original_home = std::env::var_os("HOME");
1243+
std::env::set_var("HOME", "/home/testuser");
1244+
1245+
let single = PermissionManager::expand_tilde_pub("~/foo");
1246+
let double = PermissionManager::expand_tilde_pub("~//foo");
1247+
let triple = PermissionManager::expand_tilde_pub("~///foo");
1248+
1249+
match original_home {
1250+
Some(home) => std::env::set_var("HOME", home),
1251+
None => std::env::remove_var("HOME"),
1252+
}
1253+
1254+
assert_eq!(single, "/home/testuser/foo");
1255+
assert_eq!(
1256+
double, "/home/testuser/foo",
1257+
"double-slash after tilde must not escape home"
1258+
);
1259+
assert_eq!(
1260+
triple, "/home/testuser/foo",
1261+
"any number of leading slashes must not escape home"
1262+
);
1263+
}
1264+
1265+
#[cfg(windows)]
1266+
#[test]
1267+
fn test_tilde_expansion_uses_userprofile_on_windows() {
1268+
// Regression: `expand_tilde` used to read `$HOME`, which isn't
1269+
// the canonical home-directory env var on Windows. A user
1270+
// typing `~/docs` got no expansion. The fix reads
1271+
// `%USERPROFILE%` on Windows and joins via `PathBuf::push` so
1272+
// the resulting separator is native (backslash on Windows).
1273+
let _lock = HOME_MUTEX.lock().unwrap();
1274+
let _temp_dir = TempDir::new().unwrap();
1275+
1276+
let original = std::env::var_os("USERPROFILE");
1277+
std::env::set_var("USERPROFILE", r"C:\Users\testuser");
1278+
1279+
let expanded = PermissionManager::expand_tilde_pub("~/docs/file.txt");
1280+
let expanded_dir = PermissionManager::expand_tilde_pub("~");
1281+
1282+
match original {
1283+
Some(home) => std::env::set_var("USERPROFILE", home),
1284+
None => std::env::remove_var("USERPROFILE"),
1285+
}
1286+
1287+
assert_eq!(expanded, r"C:\Users\testuser\docs\file.txt");
1288+
assert_eq!(expanded_dir, r"C:\Users\testuser");
1289+
}
1290+
11891291
#[test]
11901292
fn test_tilde_in_allow_rules() {
11911293
let _lock = HOME_MUTEX.lock().unwrap();

0 commit comments

Comments
 (0)