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")]
27use tracing::info;
28use tracing::warn;
29
30#[cfg(target_os = "linux")]
31const DEFAULT_RO_PATHS: &[&str] =
32    &["/usr", "/bin", "/sbin", "/lib", "/lib64", "/lib32", "/etc", "/proc", "/sys", "/run", "/opt"];
33
34#[cfg(target_os = "linux")]
35const DEFAULT_RW_PATHS: &[&str] = &["/tmp", "/dev", "/var/tmp"];
36
37/// Apply the Landlock sandbox if `WINX_SANDBOX` is set. Infallible: any problem
38/// is logged and the process continues (possibly unsandboxed). The cwd at startup
39/// is taken as the workspace and granted read-write. MUST be called before the
40/// async runtime is built so its worker threads inherit the Landlock domain.
41pub fn apply_if_requested() {
42    if !crate::config::env_flag("WINX_SANDBOX") {
43        return;
44    }
45
46    let workspace_root = match std::env::current_dir() {
47        Ok(dir) => dir,
48        Err(e) => {
49            warn!(
50                "WINX_SANDBOX: cannot determine the cwd ({e}); the workspace may not be writable \
51                 under the sandbox - start winx from your project directory."
52            );
53            std::path::PathBuf::from(".")
54        }
55    };
56
57    #[cfg(not(target_os = "linux"))]
58    {
59        let _ = &workspace_root;
60        warn!("WINX_SANDBOX is set but Landlock is Linux-only; continuing UNSANDBOXED.");
61    }
62
63    #[cfg(target_os = "linux")]
64    match apply_linux(&workspace_root) {
65        Ok(status) => info!(
66            "WINX_SANDBOX active (Landlock {status:?}); writes confined to the workspace + /tmp, \
67             home not readable. Extend via WINX_SANDBOX_RO_PATHS / WINX_SANDBOX_RW_PATHS."
68        ),
69        Err(e) => warn!("WINX_SANDBOX: could not apply Landlock ({e}); continuing UNSANDBOXED."),
70    }
71}
72
73#[cfg(target_os = "linux")]
74fn apply_linux(workspace_root: &Path) -> anyhow::Result<landlock::RulesetStatus> {
75    use landlock::{Access, AccessFs, Ruleset, RulesetAttr, ABI};
76
77    // ABI V1 (kernel 5.13) is the baseline; read access includes Execute, so
78    // binaries under the RO system paths still run.
79    let abi = ABI::V1;
80    let ro = AccessFs::from_read(abi);
81    let rw = AccessFs::from_all(abi);
82
83    // `handle_access` declares the full set we govern; each rule grants a subset
84    // for its path. Default compatibility is BestEffort, so a kernel missing some
85    // (or all) of these degrades to partial/no enforcement instead of erroring.
86    let mut ruleset = Ruleset::default().handle_access(rw)?.create()?;
87
88    for path in DEFAULT_RO_PATHS {
89        ruleset = add_path(ruleset, Path::new(path), ro)?;
90    }
91    for path in env_paths("WINX_SANDBOX_RO_PATHS") {
92        ruleset = add_path(ruleset, &path, ro)?;
93    }
94    for path in DEFAULT_RW_PATHS {
95        ruleset = add_path(ruleset, Path::new(path), rw)?;
96    }
97    for path in env_paths("WINX_SANDBOX_RW_PATHS") {
98        ruleset = add_path(ruleset, &path, rw)?;
99    }
100    // The workspace is the whole point of having write access at all.
101    ruleset = add_path(ruleset, workspace_root, rw)?;
102
103    Ok(ruleset.restrict_self()?.ruleset)
104}
105
106/// Add a `path` rule, skipping paths that don't exist (not every distro has
107/// `/opt`, `/lib32`, ...) so a missing entry never aborts the whole ruleset.
108#[cfg(target_os = "linux")]
109fn add_path(
110    ruleset: landlock::RulesetCreated,
111    path: &Path,
112    access: landlock::BitFlags<landlock::AccessFs>,
113) -> anyhow::Result<landlock::RulesetCreated> {
114    use landlock::{PathBeneath, PathFd, RulesetCreatedAttr};
115    match PathFd::new(path) {
116        Ok(fd) => Ok(ruleset.add_rule(PathBeneath::new(fd, access))?),
117        Err(_) => Ok(ruleset),
118    }
119}
120
121/// Parse a `:`-separated list of absolute paths from environment `var`.
122#[cfg(target_os = "linux")]
123fn env_paths(var: &str) -> Vec<std::path::PathBuf> {
124    parse_path_list(&std::env::var(var).unwrap_or_default())
125}
126
127/// Split a `:`-separated path list, dropping empty segments (so `""`, `"/a::"`
128/// don't yield bogus empty paths).
129#[cfg(target_os = "linux")]
130fn parse_path_list(value: &str) -> Vec<std::path::PathBuf> {
131    value.split(':').filter(|segment| !segment.is_empty()).map(std::path::PathBuf::from).collect()
132}
133
134#[cfg(all(test, target_os = "linux"))]
135mod tests {
136    use super::parse_path_list;
137    use std::path::PathBuf;
138
139    #[test]
140    fn parse_path_list_splits_and_drops_empties() {
141        assert!(parse_path_list("").is_empty());
142        assert_eq!(parse_path_list("/a:/b"), vec![PathBuf::from("/a"), PathBuf::from("/b")]);
143        assert_eq!(parse_path_list("/a::/b:"), vec![PathBuf::from("/a"), PathBuf::from("/b")]);
144    }
145}