1use crate::sandbox::srt::{
9 hard_link_count, sensitive_paths, should_skip_workspace_scan_directory,
10 workspace_hardlink_paths, workspace_sensitive_paths,
11};
12use crate::workspace::source_egress;
13use anyhow::{bail, Result};
14use std::collections::HashSet;
15use std::ffi::OsStr;
16use std::fs::Metadata;
17use std::path::{Component, Path, PathBuf};
18
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25pub enum LocalWorkspaceAccessPolicy {
26 #[default]
27 Unrestricted,
28 CredentialBoundary,
29}
30
31#[derive(Debug)]
32pub(crate) struct LocalWorkspaceAccessBoundary {
33 sensitive_file_ids: HashSet<FileIdentity>,
34 denied_hardlink_paths: HashSet<PathBuf>,
35 source_egress: bool,
36 name: &'static str,
37}
38
39impl LocalWorkspaceAccessBoundary {
40 pub(crate) fn for_policy(policy: LocalWorkspaceAccessPolicy, workspace: &Path) -> Option<Self> {
41 match policy {
42 LocalWorkspaceAccessPolicy::Unrestricted => None,
43 LocalWorkspaceAccessPolicy::CredentialBoundary => Some(Self::discover(workspace)),
44 }
45 }
46
47 fn discover(workspace: &Path) -> Self {
48 let mut paths = sensitive_paths();
49 if let Ok(workspace_paths) = workspace_sensitive_paths(workspace) {
50 paths.extend(workspace_paths);
51 }
52 paths.sort();
53 paths.dedup();
54
55 let sensitive_file_ids = paths
56 .into_iter()
57 .filter_map(|path| {
58 let metadata = std::fs::metadata(&path).ok()?;
59 metadata
60 .is_file()
61 .then(|| FileIdentity::from_path(&path, &metadata))?
62 })
63 .collect();
64
65 let denied_hardlink_paths = workspace_hardlink_paths(workspace)
66 .unwrap_or_default()
67 .into_iter()
68 .filter_map(|path| path.strip_prefix(workspace).ok().map(Path::to_path_buf))
69 .collect();
70
71 Self {
72 sensitive_file_ids,
73 denied_hardlink_paths,
74 source_egress: false,
75 name: "credential",
76 }
77 }
78
79 pub(crate) fn for_source_egress() -> Self {
84 Self {
85 sensitive_file_ids: HashSet::new(),
86 denied_hardlink_paths: HashSet::new(),
87 source_egress: true,
88 name: "source egress",
89 }
90 }
91
92 pub(crate) fn ensure_access(
93 &self,
94 workspace: &Path,
95 logical_path: &Path,
96 resolved_path: Option<&Path>,
97 metadata: Option<&Metadata>,
98 opened_hard_link_count: Option<u64>,
99 operation: &'static str,
100 ) -> Result<()> {
101 if self.path_is_denied(logical_path) {
102 return self.denied(operation);
103 }
104 if self.denied_hardlink_paths.contains(logical_path) {
105 return self.denied(operation);
106 }
107
108 let resolved_relative = match resolved_path {
109 Some(path) => match path.strip_prefix(workspace) {
110 Ok(relative) => Some(relative),
111 Err(_) => return self.denied(operation),
112 },
113 None => None,
114 };
115 if resolved_relative.is_some_and(|path| self.path_is_denied(path)) {
116 return self.denied(operation);
117 }
118 if resolved_relative.is_some_and(|path| self.denied_hardlink_paths.contains(path)) {
119 return self.denied(operation);
120 }
121
122 let Some(metadata) = metadata.filter(|metadata| metadata.is_file()) else {
123 return Ok(());
124 };
125 let checked_path = resolved_path
126 .map(Path::to_path_buf)
127 .unwrap_or_else(|| workspace.join(logical_path));
128 let link_count =
129 opened_hard_link_count.unwrap_or_else(|| hard_link_count(&checked_path, metadata));
130 if link_count <= 1 {
131 return Ok(());
132 }
133 if self.source_egress {
134 return self.denied(operation);
135 }
136
137 let relative = resolved_relative.unwrap_or(logical_path);
138 let Some(identity) = FileIdentity::from_path(&checked_path, metadata) else {
139 return self.denied(operation);
140 };
141 let aliases_known_sensitive = self.sensitive_file_ids.contains(&identity);
142 let inside_package_or_build_tree = is_skipped_workspace_tree(relative);
143
144 if aliases_known_sensitive || !inside_package_or_build_tree {
149 return self.denied(operation);
150 }
151
152 Ok(())
153 }
154
155 fn path_is_denied(&self, path: &Path) -> bool {
156 if self.source_egress {
157 source_egress::path_is_denied(path)
158 } else {
159 is_sensitive_workspace_path(path)
160 }
161 }
162
163 fn denied(&self, operation: &'static str) -> Result<()> {
164 bail!(
165 "local workspace {} boundary denied {operation} access",
166 self.name
167 )
168 }
169}
170
171fn is_sensitive_workspace_path(path: &Path) -> bool {
172 let Some(components) = normalized_components(path) else {
173 return true;
174 };
175 if components.is_empty() {
176 return false;
177 }
178
179 const EXACT_PATHS: &[&[&str]] = &[
180 &[".netrc"],
181 &[".npmrc"],
182 &[".pypirc"],
183 &[".git-credentials"],
184 &[".a3s", "os-auth.json"],
185 &[".codex", "auth.json"],
186 &[".claude", ".credentials.json"],
187 &[".claude.json"],
188 ];
189 if EXACT_PATHS.iter().any(|expected| {
190 expected.len() == components.len()
191 && expected
192 .iter()
193 .zip(&components)
194 .all(|(expected, actual)| expected.eq_ignore_ascii_case(actual))
195 }) {
196 return true;
197 }
198
199 for component in components {
200 if component
201 .get(..4)
202 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(".env"))
203 {
204 return true;
205 }
206 if should_skip_workspace_scan_directory(OsStr::new(component)) {
207 return false;
208 }
209 }
210 false
211}
212
213fn is_skipped_workspace_tree(path: &Path) -> bool {
214 normalized_components(path).is_some_and(|components| {
215 components
216 .into_iter()
217 .any(|component| should_skip_workspace_scan_directory(OsStr::new(component)))
218 })
219}
220
221fn normalized_components(path: &Path) -> Option<Vec<&str>> {
222 let mut components = Vec::new();
223 for component in path.components() {
224 match component {
225 Component::CurDir => {}
226 Component::Normal(component) => components.push(component.to_str()?),
227 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
228 }
229 }
230 Some(components)
231}
232
233#[cfg(unix)]
234#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
235struct FileIdentity {
236 device: u64,
237 inode: u64,
238}
239
240#[cfg(unix)]
241impl FileIdentity {
242 fn from_path(_path: &Path, metadata: &Metadata) -> Option<Self> {
243 use std::os::unix::fs::MetadataExt;
244 Some(Self {
245 device: metadata.dev(),
246 inode: metadata.ino(),
247 })
248 }
249}
250
251#[cfg(windows)]
252#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
253struct FileIdentity {
254 volume: u32,
255 index: u64,
256}
257
258#[cfg(windows)]
259impl FileIdentity {
260 fn from_path(path: &Path, _metadata: &Metadata) -> Option<Self> {
261 use std::os::windows::io::AsRawHandle;
262 use windows_sys::Win32::Storage::FileSystem::{
263 GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
264 };
265
266 let file = std::fs::File::open(path).ok()?;
267 let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
268 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
271 return None;
272 }
273 Some(Self {
274 volume: information.dwVolumeSerialNumber,
275 index: (u64::from(information.nFileIndexHigh) << 32)
276 | u64::from(information.nFileIndexLow),
277 })
278 }
279}
280
281#[cfg(not(any(unix, windows)))]
282#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
283struct FileIdentity;
284
285#[cfg(not(any(unix, windows)))]
286impl FileIdentity {
287 fn from_path(_path: &Path, _metadata: &Metadata) -> Option<Self> {
288 None
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295
296 #[test]
297 fn sensitive_workspace_paths_match_nested_env_and_fixed_credentials() {
298 for path in [
299 ".env",
300 ".env.local",
301 "apps/api/.ENV.production",
302 ".env-secrets/value",
303 ".npmrc",
304 ".a3s/os-auth.json",
305 ".codex/auth.json",
306 ] {
307 assert!(
308 is_sensitive_workspace_path(Path::new(path)),
309 "{path} should be sensitive"
310 );
311 }
312 for path in [
313 "src/env.rs",
314 "node_modules/pkg/.env",
315 "target/debug/.env",
316 ".git/objects/.env",
317 ".codex/config.acl",
318 ] {
319 assert!(
320 !is_sensitive_workspace_path(Path::new(path)),
321 "{path} should remain readable"
322 );
323 }
324 }
325}