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 refuse_promote(&self, workspace: &Path, relative: &Path) -> Result<()> {
124 let destination = workspace.join(relative);
125 let metadata = std::fs::metadata(&destination).ok();
126 self.ensure_access(
127 workspace,
128 relative,
129 Some(&destination),
130 metadata.as_ref(),
131 None,
132 "promote",
133 )
134 }
135
136 pub(crate) fn ensure_access(
137 &self,
138 workspace: &Path,
139 logical_path: &Path,
140 resolved_path: Option<&Path>,
141 metadata: Option<&Metadata>,
142 opened_hard_link_count: Option<u64>,
143 operation: &'static str,
144 ) -> Result<()> {
145 if self.path_is_denied(logical_path) {
146 return self.denied(operation);
147 }
148 let scan = self.credential_scan();
149 if scan.is_some_and(|scan| scan.denied_hardlink_paths.contains(logical_path)) {
150 return self.denied(operation);
151 }
152
153 let resolved_relative = match resolved_path {
154 Some(path) => match path.strip_prefix(workspace) {
155 Ok(relative) => Some(relative),
156 Err(_) => return self.denied(operation),
157 },
158 None => None,
159 };
160 if resolved_relative.is_some_and(|path| self.path_is_denied(path)) {
161 return self.denied(operation);
162 }
163 if resolved_relative
164 .is_some_and(|path| scan.is_some_and(|scan| scan.denied_hardlink_paths.contains(path)))
165 {
166 return self.denied(operation);
167 }
168
169 let Some(metadata) = metadata.filter(|metadata| metadata.is_file()) else {
170 return Ok(());
171 };
172 let checked_path = resolved_path
173 .map(Path::to_path_buf)
174 .unwrap_or_else(|| workspace.join(logical_path));
175 let link_count =
176 opened_hard_link_count.unwrap_or_else(|| hard_link_count(&checked_path, metadata));
177 if link_count <= 1 {
178 return Ok(());
179 }
180 if matches!(self.kind, AccessBoundaryKind::SourceEgress) {
181 return self.denied(operation);
182 }
183
184 let relative = resolved_relative.unwrap_or(logical_path);
185 let Some(identity) = FileIdentity::from_path(&checked_path, metadata) else {
186 return self.denied(operation);
187 };
188 let aliases_known_sensitive =
189 scan.is_some_and(|scan| scan.sensitive_file_ids.contains(&identity));
190 let inside_package_or_build_tree = is_skipped_workspace_tree(relative);
191
192 if aliases_known_sensitive || !inside_package_or_build_tree {
197 return self.denied(operation);
198 }
199
200 Ok(())
201 }
202
203 fn path_is_denied(&self, path: &Path) -> bool {
204 match self.kind {
205 AccessBoundaryKind::SourceEgress => source_egress::path_is_denied(path),
206 AccessBoundaryKind::Credential { .. } => is_sensitive_workspace_path(path),
207 }
208 }
209
210 fn denied(&self, operation: &'static str) -> Result<()> {
211 let name = match self.kind {
212 AccessBoundaryKind::Credential { .. } => "credential",
213 AccessBoundaryKind::SourceEgress => "source egress",
214 };
215 bail!("local workspace {name} boundary denied {operation} access")
216 }
217}
218
219fn is_sensitive_workspace_path(path: &Path) -> bool {
220 let Some(components) = normalized_components(path) else {
221 return true;
222 };
223 if components.is_empty() {
224 return false;
225 }
226
227 const EXACT_PATHS: &[&[&str]] = &[
228 &[".netrc"],
229 &[".npmrc"],
230 &[".pypirc"],
231 &[".git-credentials"],
232 &[".a3s", "os-auth.json"],
233 &[".codex", "auth.json"],
234 &[".claude", ".credentials.json"],
235 &[".claude.json"],
236 ];
237 if EXACT_PATHS.iter().any(|expected| {
238 expected.len() == components.len()
239 && expected
240 .iter()
241 .zip(&components)
242 .all(|(expected, actual)| expected.eq_ignore_ascii_case(actual))
243 }) {
244 return true;
245 }
246
247 for component in components {
248 if component
249 .get(..4)
250 .is_some_and(|prefix| prefix.eq_ignore_ascii_case(".env"))
251 {
252 return true;
253 }
254 if should_skip_workspace_scan_directory(OsStr::new(component)) {
255 return false;
256 }
257 }
258 false
259}
260
261fn is_skipped_workspace_tree(path: &Path) -> bool {
262 normalized_components(path).is_some_and(|components| {
263 components
264 .into_iter()
265 .any(|component| should_skip_workspace_scan_directory(OsStr::new(component)))
266 })
267}
268
269fn normalized_components(path: &Path) -> Option<Vec<&str>> {
270 let mut components = Vec::new();
271 for component in path.components() {
272 match component {
273 Component::CurDir => {}
274 Component::Normal(component) => components.push(component.to_str()?),
275 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
276 }
277 }
278 Some(components)
279}
280
281#[cfg(unix)]
282#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
283struct FileIdentity {
284 device: u64,
285 inode: u64,
286}
287
288#[cfg(unix)]
289impl FileIdentity {
290 fn from_path(_path: &Path, metadata: &Metadata) -> Option<Self> {
291 use std::os::unix::fs::MetadataExt;
292 Some(Self {
293 device: metadata.dev(),
294 inode: metadata.ino(),
295 })
296 }
297}
298
299#[cfg(windows)]
300#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
301struct FileIdentity {
302 volume: u32,
303 index: u64,
304}
305
306#[cfg(windows)]
307impl FileIdentity {
308 fn from_path(path: &Path, _metadata: &Metadata) -> Option<Self> {
309 use std::os::windows::io::AsRawHandle;
310 use windows_sys::Win32::Storage::FileSystem::{
311 GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION,
312 };
313
314 let file = std::fs::File::open(path).ok()?;
315 let mut information = unsafe { std::mem::zeroed::<BY_HANDLE_FILE_INFORMATION>() };
316 if unsafe { GetFileInformationByHandle(file.as_raw_handle(), &mut information) } == 0 {
319 return None;
320 }
321 Some(Self {
322 volume: information.dwVolumeSerialNumber,
323 index: (u64::from(information.nFileIndexHigh) << 32)
324 | u64::from(information.nFileIndexLow),
325 })
326 }
327}
328
329#[cfg(not(any(unix, windows)))]
330#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
331struct FileIdentity;
332
333#[cfg(not(any(unix, windows)))]
334impl FileIdentity {
335 fn from_path(_path: &Path, _metadata: &Metadata) -> Option<Self> {
336 None
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn sensitive_workspace_paths_match_nested_env_and_fixed_credentials() {
346 for path in [
347 ".env",
348 ".env.local",
349 "apps/api/.ENV.production",
350 ".env-secrets/value",
351 ".npmrc",
352 ".a3s/os-auth.json",
353 ".codex/auth.json",
354 ] {
355 assert!(
356 is_sensitive_workspace_path(Path::new(path)),
357 "{path} should be sensitive"
358 );
359 }
360 for path in [
361 "src/env.rs",
362 "node_modules/pkg/.env",
363 "target/debug/.env",
364 ".git/objects/.env",
365 ".codex/config.acl",
366 ] {
367 assert!(
368 !is_sensitive_workspace_path(Path::new(path)),
369 "{path} should remain readable"
370 );
371 }
372 }
373}