Skip to main content

kernex_sandbox/
lib.rs

1//! # kernex-sandbox
2#![deny(clippy::unwrap_used, clippy::expect_used)]
3#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
4//!
5//! OS-level system protection for AI agent subprocesses.
6//!
7//! Uses a **blocklist** approach: everything is allowed by default, then
8//! dangerous system directories and the runtime's core data are blocked.
9//!
10//! - **macOS**: Apple Seatbelt via `sandbox-exec -p <profile>` — denies reads
11//!   and writes to `{data_dir}/data/` (memory.db) and `config.toml`; denies
12//!   writes to `/System`, `/bin`, `/sbin`, `/usr/{bin,sbin,lib,libexec}`,
13//!   `/private/etc`, `/Library`.
14//! - **Linux**: Landlock LSM via `pre_exec` hook (kernel 5.13+) — broad
15//!   read-only on `/` with full access to `$HOME`, `/tmp`, `/var/tmp`, `/opt`,
16//!   `/srv`, `/run`, `/media`, `/mnt`; restricted access to `{data_dir}/data/`
17//!   and `config.toml`.
18//! - **Other**: Falls back to a plain command with a warning.
19//!
20//! Also provides [`is_write_blocked`] and [`is_read_blocked`] for code-level
21//! enforcement in tool executors (protects memory.db and config.toml on all
22//! platforms).
23//!
24//! This crate is intentionally standalone with zero internal dependencies,
25//! making it usable outside the Kernex ecosystem.
26
27use std::path::{Path, PathBuf};
28use tokio::process::Command;
29
30/// Configuration for system sandbox restrictions.
31#[derive(Clone, Debug, Default)]
32pub struct SandboxProfile {
33    /// Extra paths that should be fully writable (Linux Landlock allowlist).
34    pub allowed_paths: Vec<PathBuf>,
35    /// Extra paths that should be completely blocked for read/write.
36    pub blocked_paths: Vec<PathBuf>,
37}
38
39#[cfg(not(any(target_os = "macos", target_os = "linux")))]
40use tracing::warn;
41
42#[cfg(target_os = "macos")]
43mod seatbelt;
44
45#[cfg(target_os = "linux")]
46mod landlock_sandbox;
47
48/// Build a [`Command`] with OS-level system protection.
49///
50/// Always active — blocks writes to dangerous system directories and
51/// the runtime's core data directory (memory.db). No configuration needed.
52///
53/// `data_dir` is the runtime data directory (e.g. `~/.kernex/`). Writes to
54/// `{data_dir}/data/` are blocked (protects memory.db). All other paths
55/// under `data_dir` (workspace, skills, projects) remain writable.
56///
57/// On unsupported platforms, logs a warning and returns a plain command.
58pub fn protected_command(program: &str, data_dir: &Path, profile: &SandboxProfile) -> Command {
59    platform_command(program, data_dir, profile)
60}
61
62/// Best-effort path canonicalization. Returns the canonicalized path or the
63/// original if canonicalization fails (file doesn't exist yet, permissions, etc.).
64fn try_canonicalize(path: &Path) -> PathBuf {
65    std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
66}
67
68/// Check if a write to the given path should be blocked.
69///
70/// Returns `true` if the path targets a protected location:
71/// - Dangerous OS directories (`/System`, `/bin`, `/sbin`, `/usr/bin`, etc.)
72/// - The runtime's core data directory (`{data_dir}/data/`) — protects memory.db
73///
74/// Resolves symlinks before comparison to prevent bypass via symlink chains.
75/// Used by tool executors for code-level enforcement.
76pub fn is_write_blocked(path: &Path, data_dir: &Path, profile: Option<&SandboxProfile>) -> bool {
77    let abs = if path.is_absolute() {
78        path.to_path_buf()
79    } else {
80        return true;
81    };
82
83    let resolved = try_canonicalize(&abs);
84
85    let data_data = try_canonicalize(&data_dir.join("data"));
86    if resolved.starts_with(&data_data) {
87        return true;
88    }
89
90    let config_file = try_canonicalize(&data_dir.join("config.toml"));
91    if resolved == config_file {
92        return true;
93    }
94
95    if let Some(prof) = profile {
96        for blocked in &prof.blocked_paths {
97            if resolved.starts_with(try_canonicalize(blocked)) {
98                return true;
99            }
100        }
101    }
102
103    let blocked_prefixes: &[&str] = &[
104        "/System",
105        "/bin",
106        "/sbin",
107        "/usr/bin",
108        "/usr/sbin",
109        "/usr/lib",
110        "/usr/libexec",
111        "/private/etc",
112        "/Library",
113        "/etc",
114        "/boot",
115        "/proc",
116        "/sys",
117        "/dev",
118    ];
119
120    for prefix in blocked_prefixes {
121        if resolved.starts_with(prefix) {
122            return true;
123        }
124    }
125
126    false
127}
128
129/// Check if a read from the given path should be blocked.
130///
131/// Returns `true` if the path targets a protected location:
132/// - The runtime's core data directory (`{data_dir}/data/`) — protects memory.db
133/// - The runtime's config file (`{data_dir}/config.toml`) — protects API keys
134/// - The actual config file at `config_path` (may differ from data_dir) — protects secrets
135///
136/// Resolves symlinks before comparison to prevent bypass via symlink chains.
137/// Used by tool executors for code-level enforcement.
138pub fn is_read_blocked(
139    path: &Path,
140    data_dir: &Path,
141    config_path: Option<&Path>,
142    profile: Option<&SandboxProfile>,
143) -> bool {
144    let abs = if path.is_absolute() {
145        path.to_path_buf()
146    } else {
147        return true;
148    };
149
150    let resolved = try_canonicalize(&abs);
151
152    let data_data = try_canonicalize(&data_dir.join("data"));
153    if resolved.starts_with(&data_data) {
154        return true;
155    }
156
157    let config_in_data = try_canonicalize(&data_dir.join("config.toml"));
158    if resolved == config_in_data {
159        return true;
160    }
161
162    if let Some(cp) = config_path {
163        let resolved_config = try_canonicalize(cp);
164        if resolved == resolved_config {
165            return true;
166        }
167    }
168
169    if let Some(prof) = profile {
170        for blocked in &prof.blocked_paths {
171            if resolved.starts_with(try_canonicalize(blocked)) {
172                return true;
173            }
174        }
175    }
176
177    false
178}
179
180#[cfg(target_os = "macos")]
181fn platform_command(program: &str, data_dir: &Path, profile: &SandboxProfile) -> Command {
182    seatbelt::protected_command(program, data_dir, profile)
183}
184
185#[cfg(target_os = "linux")]
186fn platform_command(program: &str, data_dir: &Path, profile: &SandboxProfile) -> Command {
187    landlock_sandbox::protected_command(program, data_dir, profile)
188}
189
190#[cfg(not(any(target_os = "macos", target_os = "linux")))]
191fn platform_command(program: &str, _data_dir: &Path, _profile: &SandboxProfile) -> Command {
192    warn!("OS-level protection not available on this platform; using code-level enforcement only");
193    Command::new(program)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use std::path::PathBuf;
200
201    #[test]
202    fn test_protected_command_returns_command() {
203        let data_dir = PathBuf::from("/tmp/ws");
204        let profile = SandboxProfile::default();
205        let cmd = protected_command("claude", &data_dir, &profile);
206        let program = cmd.as_std().get_program().to_string_lossy().to_string();
207        assert!(!program.is_empty());
208    }
209
210    #[test]
211    fn test_is_write_blocked_data_dir() {
212        let data_dir = PathBuf::from("/home/user/.kernex");
213        assert!(is_write_blocked(
214            Path::new("/home/user/.kernex/data/memory.db"),
215            &data_dir,
216            None
217        ));
218        assert!(is_write_blocked(
219            Path::new("/home/user/.kernex/data/"),
220            &data_dir,
221            None
222        ));
223    }
224
225    #[test]
226    fn test_is_write_blocked_allows_workspace() {
227        let data_dir = PathBuf::from("/home/user/.kernex");
228        assert!(!is_write_blocked(
229            Path::new("/home/user/.kernex/workspace/test.txt"),
230            &data_dir,
231            None
232        ));
233        assert!(!is_write_blocked(
234            Path::new("/home/user/.kernex/skills/test/SKILL.md"),
235            &data_dir,
236            None
237        ));
238    }
239
240    #[test]
241    fn test_is_write_blocked_system_dirs() {
242        let data_dir = PathBuf::from("/home/user/.kernex");
243        assert!(is_write_blocked(
244            Path::new("/System/Library/test"),
245            &data_dir,
246            None
247        ));
248        assert!(is_write_blocked(Path::new("/bin/sh"), &data_dir, None));
249        assert!(is_write_blocked(Path::new("/usr/bin/env"), &data_dir, None));
250        assert!(is_write_blocked(
251            Path::new("/private/etc/hosts"),
252            &data_dir,
253            None
254        ));
255        assert!(is_write_blocked(
256            Path::new("/Library/Preferences/test"),
257            &data_dir,
258            None
259        ));
260    }
261
262    #[test]
263    fn test_is_write_blocked_allows_normal_paths() {
264        let data_dir = PathBuf::from("/home/user/.kernex");
265        assert!(!is_write_blocked(Path::new("/tmp/test"), &data_dir, None));
266        assert!(!is_write_blocked(
267            Path::new("/home/user/documents/test"),
268            &data_dir,
269            None
270        ));
271        assert!(!is_write_blocked(
272            Path::new("/usr/local/bin/something"),
273            &data_dir,
274            None
275        ));
276    }
277
278    #[test]
279    fn test_is_write_blocked_no_string_prefix_false_positive() {
280        let data_dir = PathBuf::from("/home/user/.kernex");
281        assert!(!is_write_blocked(
282            Path::new("/binaries/test"),
283            &data_dir,
284            None
285        ));
286    }
287
288    #[test]
289    fn test_is_write_blocked_relative_path() {
290        let data_dir = PathBuf::from("/home/user/.kernex");
291        assert!(is_write_blocked(
292            Path::new("relative/path"),
293            &data_dir,
294            None
295        ));
296        assert!(is_write_blocked(
297            Path::new("../../data/memory.db"),
298            &data_dir,
299            None
300        ));
301    }
302
303    #[test]
304    fn test_is_write_blocked_config_toml() {
305        let data_dir = PathBuf::from("/home/user/.kernex");
306        assert!(is_write_blocked(
307            Path::new("/home/user/.kernex/config.toml"),
308            &data_dir,
309            None
310        ));
311    }
312
313    #[test]
314    fn test_is_read_blocked_data_dir() {
315        let data_dir = PathBuf::from("/home/user/.kernex");
316        assert!(is_read_blocked(
317            Path::new("/home/user/.kernex/data/memory.db"),
318            &data_dir,
319            None,
320            None
321        ));
322        assert!(is_read_blocked(
323            Path::new("/home/user/.kernex/data/"),
324            &data_dir,
325            None,
326            None
327        ));
328    }
329
330    #[test]
331    fn test_is_read_blocked_config() {
332        let data_dir = PathBuf::from("/home/user/.kernex");
333        assert!(is_read_blocked(
334            Path::new("/home/user/.kernex/config.toml"),
335            &data_dir,
336            None,
337            None
338        ));
339    }
340
341    #[test]
342    fn test_is_read_blocked_external_config() {
343        let data_dir = PathBuf::from("/home/user/.kernex");
344        let ext_config = PathBuf::from("/opt/kernex/config.toml");
345        assert!(is_read_blocked(
346            Path::new("/opt/kernex/config.toml"),
347            &data_dir,
348            Some(ext_config.as_path()),
349            None
350        ));
351        assert!(!is_read_blocked(
352            Path::new("/opt/kernex/other.toml"),
353            &data_dir,
354            Some(ext_config.as_path()),
355            None
356        ));
357    }
358
359    #[test]
360    fn test_is_read_blocked_allows_workspace() {
361        let data_dir = PathBuf::from("/home/user/.kernex");
362        assert!(!is_read_blocked(
363            Path::new("/home/user/.kernex/workspace/test.txt"),
364            &data_dir,
365            None,
366            None
367        ));
368        assert!(!is_read_blocked(
369            Path::new("/home/user/.kernex/skills/test/SKILL.md"),
370            &data_dir,
371            None,
372            None
373        ));
374    }
375
376    #[test]
377    fn test_is_read_blocked_allows_stores() {
378        let data_dir = PathBuf::from("/home/user/.kernex");
379        assert!(!is_read_blocked(
380            Path::new("/home/user/.kernex/stores/trading/store.db"),
381            &data_dir,
382            None,
383            None
384        ));
385    }
386
387    #[test]
388    fn test_is_read_blocked_relative_path() {
389        let data_dir = PathBuf::from("/home/user/.kernex");
390        assert!(is_read_blocked(
391            Path::new("relative/path"),
392            &data_dir,
393            None,
394            None
395        ));
396        assert!(is_read_blocked(
397            Path::new("../../data/memory.db"),
398            &data_dir,
399            None,
400            None
401        ));
402    }
403}