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 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        // Source-tree multi-link files are denied conservatively because one
180        // alias may live outside the workspace. Package/build stores commonly
181        // use legitimate hard links, so they remain readable unless a
182        // discovered credential identity proves the inode is sensitive.
183        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        // SAFETY: `file` owns a valid handle for this call and `information`
304        // points to writable storage of the required type.
305        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}