Skip to main content

agent_sdk_toolkit/workspace/
bounds.rs

1//! Concrete workspace tool helpers layered over core tool/effect contracts. Use these
2//! modules for bounded read, search, edit, write, and format-aware extraction
3//! behavior under a host-selected workspace policy. Reads search local files;
4//! edit/write helpers may mutate files only through explicit executor calls. This
5//! file contains the bounds portion of that contract.
6//!
7use std::{
8    fs,
9    path::{Component, Path, PathBuf},
10};
11
12use agent_sdk_core::{AgentError, AgentErrorKind, RetryClassification};
13
14use super::{
15    policy::WorkspacePolicy,
16    util::{policy_denial, tool_failure},
17};
18
19#[derive(Clone, Debug)]
20/// Workspace bounded workspace request or result value.
21/// Creating the value does not touch the filesystem; workspace executors document read, write, edit, or search effects.
22pub struct BoundedWorkspace {
23    /// Value used by this record or request.
24    pub(super) policy: WorkspacePolicy,
25}
26
27impl BoundedWorkspace {
28    /// Creates a new workspace::bounds value with explicit
29    /// caller-provided inputs. This constructor is data-only and
30    /// performs no I/O or external side effects.
31    pub fn new(policy: WorkspacePolicy) -> Self {
32        Self { policy }
33    }
34
35    /// Returns policy for the current value.
36    /// This is a read-only or data-construction helper unless the method body explicitly calls
37    /// a port or store.
38    pub fn policy(&self) -> &WorkspacePolicy {
39        &self.policy
40    }
41
42    /// Resolve existing file.
43    /// This resolves and validates a workspace path using filesystem metadata; it does not read
44    /// or write file contents.
45    pub(super) fn resolve_existing_file(&self, path: &str) -> Result<PathBuf, AgentError> {
46        let path = self.resolve_workspace_path(path)?;
47        self.validate_existing_path(&path)?;
48        if !path.is_file() {
49            return Err(AgentError::new(
50                AgentErrorKind::ToolFailure,
51                RetryClassification::UserActionNeeded,
52                "workspace path is not a file",
53            ));
54        }
55        Ok(path)
56    }
57
58    /// Resolve for write.
59    /// This resolves and validates a workspace path using filesystem metadata; it does not read
60    /// or write file contents.
61    pub(super) fn resolve_for_write(&self, path: &str) -> Result<PathBuf, AgentError> {
62        let path = self.resolve_workspace_path(path)?;
63        let parent = path.parent().ok_or_else(|| {
64            AgentError::contract_violation("workspace write path must have a parent")
65        })?;
66        if !parent.exists() {
67            return Err(policy_denial(
68                "workspace write parent directory does not exist",
69            ));
70        }
71        self.validate_write_path(&path, parent)?;
72        Ok(path)
73    }
74
75    fn resolve_workspace_path(&self, path: &str) -> Result<PathBuf, AgentError> {
76        let relative = Path::new(path);
77        if relative.is_absolute() {
78            return Err(policy_denial("workspace path must be relative"));
79        }
80        for component in relative.components() {
81            match component {
82                Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
83                    return Err(policy_denial("workspace path escapes root"));
84                }
85                Component::Normal(name)
86                    if !self.policy.include_hidden && name.to_string_lossy().starts_with('.') =>
87                {
88                    return Err(policy_denial("hidden workspace paths are disabled"));
89                }
90                _ => {}
91            }
92        }
93        Ok(self.policy.root.join(relative))
94    }
95
96    fn validate_existing_path(&self, path: &Path) -> Result<(), AgentError> {
97        if !self.policy.follow_symlinks {
98            self.validate_no_symlink_components(path)?;
99        }
100        let root = self.canonical_root()?;
101        let target = path.canonicalize().map_err(tool_failure)?;
102        if !target.starts_with(&root) {
103            return Err(policy_denial("workspace path escapes root"));
104        }
105        Ok(())
106    }
107
108    fn validate_write_path(&self, path: &Path, parent: &Path) -> Result<(), AgentError> {
109        if !self.policy.follow_symlinks {
110            self.validate_no_symlink_components(parent)?;
111            if path.exists()
112                && fs::symlink_metadata(path)
113                    .map_err(tool_failure)?
114                    .file_type()
115                    .is_symlink()
116            {
117                return Err(policy_denial("symlink traversal is disabled"));
118            }
119        }
120        let root = self.canonical_root()?;
121        let parent = parent.canonicalize().map_err(tool_failure)?;
122        if !parent.starts_with(&root) {
123            return Err(policy_denial("workspace path escapes root"));
124        }
125        if path.exists() {
126            let target = path.canonicalize().map_err(tool_failure)?;
127            if !target.starts_with(&root) {
128                return Err(policy_denial("workspace path escapes root"));
129            }
130        }
131        Ok(())
132    }
133
134    fn validate_no_symlink_components(&self, path: &Path) -> Result<(), AgentError> {
135        let root = self.canonical_root()?;
136        let relative = path
137            .strip_prefix(&self.policy.root)
138            .map_err(|_| policy_denial("workspace path escapes root"))?;
139        let mut current = root;
140        for component in relative.components() {
141            let Component::Normal(name) = component else {
142                continue;
143            };
144            current.push(name);
145            if fs::symlink_metadata(&current)
146                .map_err(tool_failure)?
147                .file_type()
148                .is_symlink()
149            {
150                return Err(policy_denial("symlink traversal is disabled"));
151            }
152        }
153        Ok(())
154    }
155
156    fn canonical_root(&self) -> Result<PathBuf, AgentError> {
157        self.policy.root.canonicalize().map_err(tool_failure)
158    }
159
160    /// Visits files under the configured workspace policy.
161    /// This reads directory entries and file metadata, skips denied paths, and
162    /// invokes the caller callback for allowed files without mutating them.
163    pub(super) fn visit_files(
164        &self,
165        dir: &Path,
166        visit: &mut dyn FnMut(&Path) -> Result<(), AgentError>,
167    ) -> Result<(), AgentError> {
168        for entry in fs::read_dir(dir).map_err(tool_failure)? {
169            let entry = entry.map_err(tool_failure)?;
170            let path = entry.path();
171            let name = entry.file_name();
172            if !self.policy.include_hidden && name.to_string_lossy().starts_with('.') {
173                continue;
174            }
175            let file_type = entry.file_type().map_err(tool_failure)?;
176            if file_type.is_symlink() && !self.policy.follow_symlinks {
177                continue;
178            }
179            if file_type.is_dir() {
180                self.visit_files(&path, visit)?;
181            } else if file_type.is_file() {
182                visit(&path)?;
183            }
184        }
185        Ok(())
186    }
187
188    /// Converts an allowed path into a workspace-relative UTF-8 path.
189    /// This only checks the path prefix and string encoding; it does not read
190    /// file contents or mutate files.
191    pub(super) fn relative_path(&self, path: &Path) -> Result<String, AgentError> {
192        path.strip_prefix(&self.policy.root)
193            .map_err(|_| policy_denial("workspace path escapes root"))?
194            .to_str()
195            .map(str::to_string)
196            .ok_or_else(|| AgentError::contract_violation("workspace path is not UTF-8"))
197    }
198}