Skip to main content

winx_code_agent/
sandbox.rs

1//! Opt-in Landlock filesystem sandbox (Linux only, EXPERIMENTAL).
2//!
3//! Set `WINX_SANDBOX=1` before starting winx to confine the winx process and the
4//! PTY shell children it spawns: writes are restricted to the workspace (the cwd
5//! at startup) plus `/tmp`, and the home directory is deliberately NOT in the
6//! allowlist, so a manipulated agent can neither read `~/.ssh` / `~/.aws` nor
7//! modify files outside the project.
8//!
9//! This is coarse and best-effort. A command that needs a path outside the
10//! allowlist (a compiler reading `~/.cargo`, say) will fail; extend the allowlist
11//! with `WINX_SANDBOX_RO_PATHS` / `WINX_SANDBOX_RW_PATHS` (`:`-separated absolute
12//! paths). On kernels without Landlock (< 5.13) it logs a warning and runs
13//! unsandboxed (the Landlock default compatibility is best-effort). Off by
14//! default: winx is unaffected unless you opt in.
15//!
16//! Known limitation: `/proc` is read-allowed because many programs need
17//! `/proc/self`, and Landlock (ABI v1) cannot scope rules within procfs. That
18//! also exposes other processes' `/proc/<pid>/environ`. On a single-user local
19//! machine (winx's target) those are same-user processes the agent could already
20//! read without the sandbox, so this isn't a regression - but don't rely on the
21//! sandbox to hide env-passed secrets from co-tenant processes.
22
23#[cfg(target_os = "linux")]
24use std::path::Path;
25
26#[cfg(target_os = "linux")]
27const DEFAULT_RO_PATHS: &[&str] =
28    &["/usr", "/bin", "/sbin", "/lib", "/lib64", "/lib32", "/etc", "/proc", "/sys", "/run", "/opt"];
29
30#[cfg(target_os = "linux")]
31const DEFAULT_RW_PATHS: &[&str] = &["/tmp", "/dev", "/var/tmp"];
32
33#[derive(Debug)]
34enum SandboxMessage {
35    #[cfg(target_os = "linux")]
36    Info(String),
37    Warning(String),
38}
39
40/// Deferred diagnostics from sandbox setup. Landlock must be applied before
41/// logging creates its non-blocking writer thread; messages are therefore stored
42/// and emitted immediately after the subscriber is installed.
43#[derive(Debug, Default)]
44pub struct SandboxReport {
45    messages: Vec<SandboxMessage>,
46}
47
48impl SandboxReport {
49    pub fn emit(self) {
50        for message in self.messages {
51            match message {
52                #[cfg(target_os = "linux")]
53                SandboxMessage::Info(message) => tracing::info!("{message}"),
54                SandboxMessage::Warning(message) => tracing::warn!("{message}"),
55            }
56        }
57    }
58}
59
60/// Apply the Landlock sandbox if `WINX_SANDBOX` is set. Infallible: any problem
61/// is reported and the process continues (possibly unsandboxed). The cwd at
62/// startup is taken as the workspace and granted read-write. MUST be called
63/// before logging or the async runtime create threads so every child inherits the
64/// Landlock domain.
65pub fn apply_if_requested() -> SandboxReport {
66    let mut report = SandboxReport::default();
67    if !crate::config::env_flag("WINX_SANDBOX") {
68        return report;
69    }
70
71    let workspace_root = match std::env::current_dir() {
72        Ok(dir) => dir,
73        Err(error) => {
74            report.messages.push(SandboxMessage::Warning(format!(
75                "WINX_SANDBOX: cannot determine the cwd ({error}); the workspace may not be writable under the sandbox - start winx from your project directory."
76            )));
77            std::path::PathBuf::from(".")
78        }
79    };
80
81    #[cfg(not(target_os = "linux"))]
82    {
83        let _ = &workspace_root;
84        report.messages.push(SandboxMessage::Warning(
85            "WINX_SANDBOX is set but Landlock is Linux-only; continuing UNSANDBOXED.".to_string(),
86        ));
87    }
88
89    #[cfg(target_os = "linux")]
90    match apply_linux(&workspace_root) {
91        Ok(status) => report.messages.push(SandboxMessage::Info(format!(
92            "WINX_SANDBOX active (Landlock {status:?}); writes confined to the workspace + /tmp, home not readable. Extend via WINX_SANDBOX_RO_PATHS / WINX_SANDBOX_RW_PATHS."
93        ))),
94        Err(error) => report.messages.push(SandboxMessage::Warning(format!(
95            "WINX_SANDBOX: could not apply Landlock ({error}); continuing UNSANDBOXED."
96        ))),
97    }
98    report
99}
100
101#[cfg(target_os = "linux")]
102fn apply_linux(workspace_root: &Path) -> anyhow::Result<landlock::RulesetStatus> {
103    use landlock::{Access, AccessFs, Ruleset, RulesetAttr, ABI};
104
105    // ABI V1 (kernel 5.13) is the baseline; read access includes Execute, so
106    // binaries under the RO system paths still run.
107    let abi = ABI::V1;
108    let ro = AccessFs::from_read(abi);
109    let rw = AccessFs::from_all(abi);
110
111    // `handle_access` declares the full set we govern; each rule grants a subset
112    // for its path. Default compatibility is BestEffort, so a kernel missing some
113    // (or all) of these degrades to partial/no enforcement instead of erroring.
114    let mut ruleset = Ruleset::default().handle_access(rw)?.create()?;
115
116    for path in DEFAULT_RO_PATHS {
117        ruleset = add_path(ruleset, Path::new(path), ro)?;
118    }
119    for path in env_paths("WINX_SANDBOX_RO_PATHS") {
120        ruleset = add_path(ruleset, &path, ro)?;
121    }
122    for path in DEFAULT_RW_PATHS {
123        ruleset = add_path(ruleset, Path::new(path), rw)?;
124    }
125    for path in env_paths("WINX_SANDBOX_RW_PATHS") {
126        ruleset = add_path(ruleset, &path, rw)?;
127    }
128    // The workspace is the whole point of having write access at all.
129    ruleset = add_path(ruleset, workspace_root, rw)?;
130
131    Ok(ruleset.restrict_self()?.ruleset)
132}
133
134/// Add a `path` rule, skipping paths that don't exist (not every distro has
135/// `/opt`, `/lib32`, ...) so a missing entry never aborts the whole ruleset.
136#[cfg(target_os = "linux")]
137fn add_path(
138    ruleset: landlock::RulesetCreated,
139    path: &Path,
140    access: landlock::BitFlags<landlock::AccessFs>,
141) -> anyhow::Result<landlock::RulesetCreated> {
142    use landlock::{PathBeneath, PathFd, RulesetCreatedAttr};
143    match PathFd::new(path) {
144        Ok(fd) => Ok(ruleset.add_rule(PathBeneath::new(fd, access))?),
145        Err(_) => Ok(ruleset),
146    }
147}
148
149/// Parse a `:`-separated list of absolute paths from environment `var`.
150#[cfg(target_os = "linux")]
151fn env_paths(var: &str) -> Vec<std::path::PathBuf> {
152    parse_path_list(&std::env::var(var).unwrap_or_default())
153}
154
155/// Split a `:`-separated path list, dropping empty segments (so `""`, `"/a::"`
156/// don't yield bogus empty paths).
157#[cfg(target_os = "linux")]
158fn parse_path_list(value: &str) -> Vec<std::path::PathBuf> {
159    value.split(':').filter(|segment| !segment.is_empty()).map(std::path::PathBuf::from).collect()
160}
161
162#[cfg(all(test, target_os = "linux"))]
163mod tests {
164    use super::parse_path_list;
165    use std::path::PathBuf;
166
167    #[test]
168    fn parse_path_list_splits_and_drops_empties() {
169        assert!(parse_path_list("").is_empty(), "expected empty list for an empty string");
170        assert_eq!(parse_path_list("/a:/b"), vec![PathBuf::from("/a"), PathBuf::from("/b")]);
171        assert_eq!(parse_path_list("/a::/b:"), vec![PathBuf::from("/a"), PathBuf::from("/b")]);
172    }
173}