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