1use std::path::PathBuf;
5
6use schemars::JsonSchema;
7use serde::Deserialize;
8
9use zeph_common::ToolName;
10
11use crate::executor::{
12 ClaimSource, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
13};
14use crate::file::expand_tilde;
15use crate::registry::{InvocationHint, ToolDef};
16
17const TOOL_NAME: &str = "set_working_directory";
18
19const TOOL_DESCRIPTION: &str = "Change the agent's working directory. \
20Shell commands (`bash`) run in child processes — a `cd` inside them does NOT persist. \
21Use this tool when you need to change the working context for subsequent operations. \
22Returns the new absolute working directory path on success.";
23
24#[derive(Deserialize, JsonSchema)]
25struct SetCwdParams {
26 path: String,
28}
29
30pub fn resolve_and_set_cwd(path: &str, allowed_paths: &[PathBuf]) -> std::io::Result<PathBuf> {
52 let target = expand_tilde(PathBuf::from(path));
53
54 let resolved = if target.is_absolute() {
56 target
57 } else {
58 std::env::current_dir()?.join(target)
59 };
60
61 let canonical = zeph_common::security::validate_path_within(&resolved, allowed_paths)?;
62 std::env::set_current_dir(&canonical)?;
63 Ok(canonical)
64}
65
66#[derive(Debug)]
77pub struct SetCwdExecutor {
78 allowed_paths: Vec<PathBuf>,
79}
80
81impl SetCwdExecutor {
82 #[must_use]
88 pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
89 let paths = if allowed_paths.is_empty() {
90 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
91 } else {
92 allowed_paths.into_iter().map(expand_tilde).collect()
93 };
94 Self {
95 allowed_paths: paths
96 .into_iter()
97 .map(|p| p.canonicalize().unwrap_or(p))
98 .collect(),
99 }
100 }
101}
102
103impl ToolExecutor for SetCwdExecutor {
104 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
105 if call.tool_id != TOOL_NAME {
106 return Ok(None);
107 }
108 let params: SetCwdParams = deserialize_params(&call.params)?;
109 let new_cwd =
110 resolve_and_set_cwd(¶ms.path, &self.allowed_paths).map_err(ToolError::Execution)?;
111 let summary = new_cwd.display().to_string();
112
113 Ok(Some(ToolOutput {
114 tool_name: ToolName::new(TOOL_NAME),
115 summary,
116 blocks_executed: 1,
117 filter_stats: None,
118 diff: None,
119 streamed: false,
120 terminal_id: None,
121 locations: None,
122 raw_response: None,
123 claim_source: Some(ClaimSource::FileSystem),
124 ..Default::default()
125 }))
126 }
127
128 fn tool_definitions(&self) -> Vec<ToolDef> {
129 vec![ToolDef {
130 id: TOOL_NAME.into(),
131 description: TOOL_DESCRIPTION.into(),
132 schema: schemars::schema_for!(SetCwdParams),
133 invocation: InvocationHint::ToolCall,
134 output_schema: None,
135 server_id: None,
136 }]
137 }
138
139 fn is_tool_retryable(&self, _tool_id: &str) -> bool {
140 false
141 }
142
143 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
144 Ok(None)
145 }
146
147 crate::tool_executor_no_inner_defaults!();
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153
154 fn make_call(path: &str) -> ToolCall {
155 let mut params = serde_json::Map::new();
156 params.insert(
157 "path".to_owned(),
158 serde_json::Value::String(path.to_owned()),
159 );
160 ToolCall {
161 tool_id: ToolName::new(TOOL_NAME),
162 params,
163 caller_id: None,
164 context: None,
165
166 tool_call_id: String::new(),
167 skill_name: None,
168 }
169 }
170
171 #[tokio::test]
172 async fn set_cwd_changes_process_cwd() {
173 let original_cwd = std::env::current_dir().unwrap();
174 let dir = tempfile::tempdir().unwrap();
175 let executor = SetCwdExecutor::new(vec![dir.path().to_path_buf()]);
176 let call = make_call(dir.path().to_str().unwrap());
177 let result = executor.execute_tool_call(&call).await.unwrap();
178 assert!(result.is_some());
179 let out = result.unwrap();
180 let new_cwd = std::env::current_dir().unwrap();
182 assert_eq!(out.summary, new_cwd.display().to_string());
183 let _ = std::env::set_current_dir(&original_cwd);
185 }
186
187 #[tokio::test]
190 async fn set_cwd_expands_tilde_in_runtime_argument() {
191 let original_cwd = std::env::current_dir().unwrap();
195 let home = dirs::home_dir().expect("home dir must be resolvable in test env");
196 let subdir = tempfile::Builder::new()
197 .prefix("zeph_test_cwd_tilde_")
198 .tempdir_in(&home)
199 .expect("failed to create temp dir under home");
200 let dir_name = subdir.path().file_name().unwrap().to_str().unwrap();
201
202 let executor = SetCwdExecutor::new(vec![subdir.path().to_path_buf()]);
203 let call = make_call(&format!("~/{dir_name}"));
204 let result = executor.execute_tool_call(&call).await.unwrap();
205 assert!(result.is_some());
206
207 let new_cwd = std::env::current_dir().unwrap();
208 assert_eq!(new_cwd, subdir.path().canonicalize().unwrap());
209 assert!(
210 !new_cwd.to_string_lossy().contains('~'),
211 "tilde must not appear in resolved cwd: {new_cwd:?}"
212 );
213
214 let _ = std::env::set_current_dir(&original_cwd);
216 }
217
218 #[tokio::test]
219 async fn set_cwd_returns_none_for_unknown_tool() {
220 let executor = SetCwdExecutor::new(vec![]);
221 let call = ToolCall {
222 tool_id: ToolName::new("other_tool"),
223 params: serde_json::Map::new(),
224 caller_id: None,
225 context: None,
226
227 tool_call_id: String::new(),
228 skill_name: None,
229 };
230 let result = executor.execute_tool_call(&call).await.unwrap();
231 assert!(result.is_none());
232 }
233
234 #[tokio::test]
235 async fn set_cwd_errors_on_nonexistent_path() {
236 let executor = SetCwdExecutor::new(vec![]);
237 let call = make_call("/nonexistent/path/that/does/not/exist");
238 let result = executor.execute_tool_call(&call).await;
239 assert!(result.is_err());
240 }
241
242 #[tokio::test]
245 async fn set_cwd_rejects_path_outside_allowed_paths() {
246 let original_cwd = std::env::current_dir().unwrap();
247 let allowed_root = tempfile::tempdir().unwrap();
248 let outside = tempfile::tempdir().unwrap();
249 let executor = SetCwdExecutor::new(vec![allowed_root.path().to_path_buf()]);
250
251 let call = make_call(outside.path().to_str().unwrap());
252 let result = executor.execute_tool_call(&call).await;
253
254 assert!(
255 result.is_err(),
256 "cd to a directory outside allowed_paths must be rejected"
257 );
258 assert_eq!(
259 std::env::current_dir().unwrap(),
260 original_cwd,
261 "process cwd must be unchanged after a rejected cd"
262 );
263 }
264
265 #[test]
266 fn resolve_and_set_cwd_rejects_path_outside_allowed_paths() {
267 let original_cwd = std::env::current_dir().unwrap();
268 let allowed_root = tempfile::tempdir().unwrap();
269 let outside = tempfile::tempdir().unwrap();
270 let allowed = vec![allowed_root.path().canonicalize().unwrap()];
271
272 let result = resolve_and_set_cwd(outside.path().to_str().unwrap(), &allowed);
273
274 let err = result.unwrap_err();
275 assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
276 assert_eq!(
277 std::env::current_dir().unwrap(),
278 original_cwd,
279 "process cwd must be unchanged after a rejected cd"
280 );
281 }
282
283 #[test]
284 fn resolve_and_set_cwd_allows_path_inside_allowed_paths() {
285 let original_cwd = std::env::current_dir().unwrap();
286 let dir = tempfile::tempdir().unwrap();
287 let allowed = vec![dir.path().canonicalize().unwrap()];
288
289 let result = resolve_and_set_cwd(dir.path().to_str().unwrap(), &allowed);
290
291 assert!(result.is_ok());
292 assert_eq!(result.unwrap(), dir.path().canonicalize().unwrap());
293 let _ = std::env::set_current_dir(&original_cwd);
294 }
295
296 #[test]
297 fn tool_definitions_contains_set_working_directory() {
298 let executor = SetCwdExecutor::new(vec![]);
299 let defs = executor.tool_definitions();
300 assert_eq!(defs.len(), 1);
301 assert_eq!(defs[0].id.as_ref(), TOOL_NAME);
302 }
303}