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 anyhow::{bail, Result};
13use std::collections::HashSet;
14use std::ffi::OsStr;
15use std::fs::Metadata;
16use std::path::{Component, Path, PathBuf};
17
18/// Optional access policy for the local workspace backend.
19///
20/// `Unrestricted` preserves the embeddable Core API's historical behavior.
21/// Interactive hosts can opt into `CredentialBoundary` to keep direct file
22/// tools inside the same credential boundary as sandboxed local commands.
23#[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        // Source-tree multi-link files are denied conservatively because one
121        // alias may live outside the workspace. Package/build stores commonly
122        // use legitimate hard links, so they remain readable unless a
123        // discovered credential identity proves the inode is sensitive.
124        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        // SAFETY: `file` owns a valid handle for this call and `information`
234        // points to writable storage of the required type.
235        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}