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