Skip to main content

a3s_code_core/workspace/
local_access.rs

1//! Local workspace credential boundary shared by built-in file operations.
2//!
3//! The process sandbox protects shell commands, but local built-in tools do
4//! not execute inside that process. Hosts that select
5//! [`LocalWorkspaceAccessPolicy::CredentialBoundary`] therefore need the same
6//! credential and hard-link rules at the workspace backend itself.
7
8use 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/// Optional access policy for the local workspace backend.
20///
21/// `Unrestricted` preserves the embeddable Core API's historical behavior.
22/// Interactive hosts can opt into `CredentialBoundary` to keep direct file
23/// tools inside the same credential boundary as sandboxed local commands.
24#[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    /// Construct an O(1) boundary for source sent to an external embedding
80    /// provider. Unlike the interactive credential boundary, it does not scan
81    /// the workspace at construction time. It rejects every multi-link source
82    /// file at the actual read instead.
83    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        // Source-tree multi-link files are denied conservatively because one
145        // alias may live outside the workspace. Package/build stores commonly
146        // use legitimate hard links, so they remain readable unless a
147        // discovered credential identity proves the inode is sensitive.
148        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        // SAFETY: `file` owns a valid handle for this call and `information`
269        // points to writable storage of the required type.
270        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}