Skip to main content

zeph_tools/
cwd.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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    /// Target directory path (absolute or relative to current working directory).
27    path: String,
28}
29
30/// Resolve `path` (expanding `~`, and resolving relative paths against the current cwd),
31/// verify it falls within `allowed_paths`, and change the process working directory to it.
32/// Returns the new absolute (canonicalized) cwd on success.
33///
34/// Shared by [`SetCwdExecutor`] (the LLM-invoked `set_working_directory` tool) and the
35/// user-invoked `/cd` slash command (`zeph-core`'s `WorktreeAccess::change_working_directory`) —
36/// both entry points into the same underlying mechanism, per #6032 FR-001/FR-011: `/cd` must
37/// not duplicate this resolution logic.
38///
39/// Per spec 063 FR-001/"Never" (SEC-2): `/cd` "must not become a bypass for the per-path file
40/// read sandbox" — the sandbox check runs *before* `set_current_dir`, so a rejected path never
41/// mutates the process cwd. `allowed_paths` uses the same `zeph_common::security` containment
42/// check as `FileExecutor`/`DiagnosticsExecutor`; an empty slice matches those callers'
43/// convention (see [`SetCwdExecutor::new`]) rather than allowing every path.
44///
45/// # Errors
46///
47/// Returns [`std::io::Error`] if the target path does not exist, is not a directory, is not
48/// readable (mirrors `std::env::set_current_dir`'s error contract — see
49/// `set_cwd_errors_on_nonexistent_path`), or falls outside `allowed_paths`
50/// (`io::ErrorKind::PermissionDenied`).
51pub fn resolve_and_set_cwd(path: &str, allowed_paths: &[PathBuf]) -> std::io::Result<PathBuf> {
52    let target = expand_tilde(PathBuf::from(path));
53
54    // Resolve relative paths against current cwd before changing.
55    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/// Tool executor that changes the agent process working directory.
67///
68/// Implements the `set_working_directory` tool. The LLM calls this when it needs
69/// to change context for a series of operations. Shell `cd` inside child processes
70/// has no effect on the agent's cwd — this tool is the only persistent mechanism.
71///
72/// Sandboxed to `allowed_paths` (#6032 SEC-2), mirroring [`crate::file::FileExecutor`] and
73/// [`crate::diagnostics::DiagnosticsExecutor`] — a `cd` outside the configured sandbox is
74/// rejected rather than silently permitted, closing a gap the LLM-invoked tool previously
75/// shared with `/cd` before this fix.
76#[derive(Debug)]
77pub struct SetCwdExecutor {
78    allowed_paths: Vec<PathBuf>,
79}
80
81impl SetCwdExecutor {
82    /// Create a new executor sandboxed to `allowed_paths`.
83    ///
84    /// An empty `allowed_paths` defaults to `[current_dir]`, matching
85    /// [`crate::file::FileExecutor::new`]/[`crate::diagnostics::DiagnosticsExecutor::new`]'s
86    /// convention — not "allow every path".
87    #[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(&params.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        // The returned summary is the new cwd.
181        let new_cwd = std::env::current_dir().unwrap();
182        assert_eq!(out.summary, new_cwd.display().to_string());
183        // Restore cwd so parallel tests are not affected.
184        let _ = std::env::set_current_dir(&original_cwd);
185    }
186
187    // --- tilde expansion regression (#5415) ---
188
189    #[tokio::test]
190    async fn set_cwd_expands_tilde_in_runtime_argument() {
191        // Regression for #5415: a `~`-prefixed path coming from an LLM tool
192        // call must resolve to the real home directory, mirroring the fix
193        // for `DiagnosticsExecutor::validate_path`.
194        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        // Restore cwd so parallel tests are not affected.
215        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    // --- sandbox enforcement regression (#6032 SEC-2) ---
243
244    #[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}