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::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/// Optional access policy for the local workspace backend.
21///
22/// `Unrestricted` preserves the embeddable Core API's historical behavior.
23/// Interactive hosts can opt into `CredentialBoundary` to keep direct file
24/// tools inside the same credential boundary as sandboxed local commands.
25#[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    /// Path-pattern denials are O(1); hard-link identity discovery runs on the
41    /// first `ensure_access` so interactive TUI takeover is not blocked by a
42    /// full-tree credential scan of large workspaces.
43    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        // Package/build stores often use legitimate multi-link artifacts.
87        // Current a3s-sandbox skips those trees for bulk hardlink denial and
88        // recovers only credential-inode aliases. Keep package-store paths out
89        // of the eager deny set so the multi-link policy below can allow
90        // ordinary package hardlinks while still denying credential aliases.
91        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    /// Construct an O(1) boundary for source sent to an external embedding
114    /// provider. Unlike the interactive credential boundary, it does not scan
115    /// the workspace at construction time. It rejects every multi-link source
116    /// file at the actual read instead.
117    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        // Source-tree multi-link files are denied conservatively because one
193        // alias may live outside the workspace. Package/build stores commonly
194        // use legitimate hard links, so they remain readable unless a
195        // discovered credential identity proves the inode is sensitive.
196        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        // SAFETY: `file` owns a valid handle for this call and `information`
317        // points to writable storage of the required type.
318        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}