Skip to content

Commit f1b48c9

Browse files
authored
Reduce default fs_read trust permission to current working directory only (#2824)
* Reduce default fs_read trust permission to current working directory only Previously by default fs_read is trusted to read any file on user's file system. This PR reduces the fs_read permission to CWD only. This means user can still access any file under CWD without prompt. But if user needs to access file outside CWD, she will be prompted for explicit approval. User can still explicitly add fs_read to trusted tools in chat / agent definition so fs_read can read any file without prompt. This change essentially adds a layer of defense against prompt injection by following the least-privilege principle. * remove allow_read_only since it is always false now
1 parent 776f2ed commit f1b48c9

File tree

4 files changed

+112
-19
lines changed

4 files changed

+112
-19
lines changed

crates/chat-cli/src/cli/agent/mod.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -815,7 +815,7 @@ impl Agents {
815815
// This "static" way avoids needing to construct a tool instance.
816816
fn default_permission_label(&self, tool_name: &str) -> String {
817817
let label = match tool_name {
818-
"fs_read" => "trusted".dark_green().bold(),
818+
"fs_read" => "trust working directory".dark_grey(),
819819
"fs_write" => "not trusted".dark_grey(),
820820
#[cfg(not(windows))]
821821
"execute_bash" => "trust read-only commands".dark_grey(),
@@ -1142,9 +1142,9 @@ mod tests {
11421142

11431143
let label = agents.display_label("fs_read", &ToolOrigin::Native);
11441144
// With no active agent, it should fall back to default permissions
1145-
// fs_read has a default of "trusted"
1145+
// fs_read has a default of "trust working directory"
11461146
assert!(
1147-
label.contains("trusted"),
1147+
label.contains("trust working directory"),
11481148
"fs_read should show default trusted permission, instead found: {}",
11491149
label
11501150
);
@@ -1173,7 +1173,7 @@ mod tests {
11731173
// Test default permissions for known tools
11741174
let fs_read_label = agents.display_label("fs_read", &ToolOrigin::Native);
11751175
assert!(
1176-
fs_read_label.contains("trusted"),
1176+
fs_read_label.contains("trust working directory"),
11771177
"fs_read should be trusted by default, instead found: {}",
11781178
fs_read_label
11791179
);

crates/chat-cli/src/cli/chat/tools/fs_read.rs

Lines changed: 100 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -109,28 +109,32 @@ impl FsRead {
109109
allowed_paths: Vec<String>,
110110
#[serde(default)]
111111
denied_paths: Vec<String>,
112-
#[serde(default = "default_allow_read_only")]
112+
#[serde(default)]
113113
allow_read_only: bool,
114114
}
115115

116-
fn default_allow_read_only() -> bool {
117-
true
118-
}
119-
120116
let is_in_allowlist = matches_any_pattern(&agent.allowed_tools, "fs_read");
121-
match agent.tools_settings.get("fs_read") {
122-
Some(settings) => {
117+
let settings = agent.tools_settings.get("fs_read").cloned()
118+
.unwrap_or_else(|| serde_json::json!({}));
119+
120+
{
123121
let Settings {
124-
allowed_paths,
122+
mut allowed_paths,
125123
denied_paths,
126124
allow_read_only,
127-
} = match serde_json::from_value::<Settings>(settings.clone()) {
125+
} = match serde_json::from_value::<Settings>(settings) {
128126
Ok(settings) => settings,
129127
Err(e) => {
130128
error!("Failed to deserialize tool settings for fs_read: {:?}", e);
131129
return PermissionEvalResult::Ask;
132130
},
133131
};
132+
133+
// Always add current working directory to allowed paths
134+
if let Ok(cwd) = os.env.current_dir() {
135+
allowed_paths.push(cwd.to_string_lossy().to_string());
136+
}
137+
134138
let allow_set = {
135139
let mut builder = GlobSetBuilder::new();
136140
for path in &allowed_paths {
@@ -259,10 +263,7 @@ impl FsRead {
259263
PermissionEvalResult::Ask
260264
},
261265
}
262-
},
263-
None if is_in_allowlist => PermissionEvalResult::Allow,
264-
_ => PermissionEvalResult::Ask,
265-
}
266+
}
266267
}
267268

268269
pub async fn invoke(&self, os: &Os, updates: &mut impl Write) -> Result<InvokeOutput> {
@@ -862,6 +863,7 @@ fn format_mode(mode: u32) -> [char; 9] {
862863
#[cfg(test)]
863864
mod tests {
864865
use std::collections::HashMap;
866+
use std::path::PathBuf;
865867

866868
use super::*;
867869
use crate::cli::agent::ToolSettingTarget;
@@ -1397,7 +1399,7 @@ mod tests {
13971399
}
13981400

13991401
#[tokio::test]
1400-
async fn test_eval_perm() {
1402+
async fn test_eval_perm_denied_path() {
14011403
const DENIED_PATH_OR_FILE: &str = "/some/denied/path";
14021404
const DENIED_PATH_OR_FILE_GLOB: &str = "/denied/glob/**/path";
14031405

@@ -1447,4 +1449,88 @@ mod tests {
14471449
&& deny_list.iter().filter(|p| *p == DENIED_PATH_OR_FILE).collect::<Vec<_>>().len() == 2
14481450
));
14491451
}
1452+
1453+
#[tokio::test]
1454+
async fn test_eval_perm_allowed_path_and_cwd() {
1455+
1456+
// by default the fake env uses "/" as the CWD.
1457+
// change it to a sub folder so we can test fs_read reading files outside CWD
1458+
let os = Os::new().await.unwrap();
1459+
os.env.set_current_dir_for_test(PathBuf::from("/home/user"));
1460+
1461+
let agent = Agent {
1462+
name: "test_agent".to_string(),
1463+
tools_settings: {
1464+
let mut map = HashMap::new();
1465+
map.insert(
1466+
ToolSettingTarget("fs_read".to_string()),
1467+
serde_json::json!({
1468+
"allowedPaths": ["/explicitly/allowed/path"]
1469+
}),
1470+
);
1471+
map
1472+
},
1473+
..Default::default() // Not in allowed_tools, allow_read_only = false
1474+
};
1475+
1476+
// Test 1: Explicitly allowed path should work
1477+
let allowed_tool = serde_json::from_value::<FsRead>(serde_json::json!({
1478+
"operations": [
1479+
{ "path": "/explicitly/allowed/path", "mode": "Directory" },
1480+
{ "path": "/explicitly/allowed/path/file.txt", "mode": "Line" },
1481+
]
1482+
})).unwrap();
1483+
let res = allowed_tool.eval_perm(&os, &agent);
1484+
assert!(matches!(res, PermissionEvalResult::Allow));
1485+
1486+
// Test 2: CWD should always be allowed
1487+
let cwd_tool = serde_json::from_value::<FsRead>(serde_json::json!({
1488+
"operations": [
1489+
{ "path": "/home/user/", "mode": "Directory" },
1490+
{ "path": "/home/user/file.txt", "mode": "Line" },
1491+
]
1492+
})).unwrap();
1493+
let res = cwd_tool.eval_perm(&os, &agent);
1494+
assert!(matches!(res, PermissionEvalResult::Allow));
1495+
1496+
// Test 3: Outside CWD and not explicitly allowed should ask
1497+
let outside_tool = serde_json::from_value::<FsRead>(serde_json::json!({
1498+
"operations": [
1499+
{ "path": "/tmp/not/allowed/file.txt", "mode": "Line" }
1500+
]
1501+
})).unwrap();
1502+
let res = outside_tool.eval_perm(&os, &agent);
1503+
assert!(matches!(res, PermissionEvalResult::Ask));
1504+
}
1505+
1506+
#[tokio::test]
1507+
async fn test_eval_perm_no_settings_cwd_behavior() {
1508+
let os = Os::new().await.unwrap();
1509+
os.env.set_current_dir_for_test(PathBuf::from("/home/user"));
1510+
1511+
let agent = Agent {
1512+
name: "test_agent".to_string(),
1513+
tools_settings: HashMap::new(), // No fs_read settings
1514+
..Default::default()
1515+
};
1516+
1517+
// Test 1: CWD should be allowed even with no settings
1518+
let cwd_tool = serde_json::from_value::<FsRead>(serde_json::json!({
1519+
"operations": [
1520+
{ "path": "/home/user/", "mode": "Directory" },
1521+
{ "path": "/home/user/file.txt", "mode": "Line" },
1522+
]
1523+
})).unwrap();
1524+
let res = cwd_tool.eval_perm(&os, &agent);
1525+
assert!(matches!(res, PermissionEvalResult::Allow));
1526+
1527+
// Test 2: Outside CWD should ask for permission
1528+
let outside_tool = serde_json::from_value::<FsRead>(serde_json::json!({
1529+
"operations": [
1530+
{ "path": "/tmp/not/allowed/file.txt", "mode": "Line" }
1531+
]
1532+
})).unwrap();
1533+
let res = outside_tool.eval_perm(&os, &agent);
1534+
assert!(matches!(res, PermissionEvalResult::Ask));
1535+
}
14501536
}

crates/chat-cli/src/cli/chat/tools/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ use crate::cli::agent::{
5757
use crate::cli::chat::line_tracker::FileLineTracker;
5858
use crate::os::Os;
5959

60-
pub const DEFAULT_APPROVE: [&str; 1] = ["fs_read"];
60+
pub const DEFAULT_APPROVE: [&str; 0] = [];
6161
pub const NATIVE_TOOLS: [&str; 8] = [
6262
"fs_read",
6363
"fs_write",

crates/chat-cli/src/os/env.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,13 @@ impl Env {
132132
}
133133
}
134134

135+
pub fn set_current_dir_for_test(&self, path: PathBuf) {
136+
use inner::Inner;
137+
if let Inner::Fake(fake) = &self.0 {
138+
fake.lock().unwrap().cwd = path;
139+
}
140+
}
141+
135142
pub fn current_exe(&self) -> Result<PathBuf, io::Error> {
136143
use inner::Inner;
137144
match &self.0 {

0 commit comments

Comments
 (0)