agent_sdk_toolkit/workspace/
bounds.rs1use 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)]
20pub struct BoundedWorkspace {
23 pub(super) policy: WorkspacePolicy,
25}
26
27impl BoundedWorkspace {
28 pub fn new(policy: WorkspacePolicy) -> Self {
32 Self { policy }
33 }
34
35 pub fn policy(&self) -> &WorkspacePolicy {
39 &self.policy
40 }
41
42 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 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(¤t)
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 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 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}