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