1#![deny(clippy::unwrap_used, clippy::expect_used)]
3#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
4use std::path::{Path, PathBuf};
28use tokio::process::Command;
29
30#[derive(Clone, Debug, Default)]
32pub struct SandboxProfile {
33 pub allowed_paths: Vec<PathBuf>,
35 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
48pub fn protected_command(program: &str, data_dir: &Path, profile: &SandboxProfile) -> Command {
59 platform_command(program, data_dir, profile)
60}
61
62fn try_canonicalize(path: &Path) -> PathBuf {
65 std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
66}
67
68pub 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
129pub 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}