aperion_shield/sandbox/
mod.rs1use std::path::{Path, PathBuf};
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum SandboxLevel {
41 Off,
42 Secrets,
43 Strict,
44}
45
46impl SandboxLevel {
47 pub fn parse(s: &str) -> anyhow::Result<Self> {
48 match s.to_ascii_lowercase().as_str() {
49 "off" => Ok(Self::Off),
50 "secrets" => Ok(Self::Secrets),
51 "strict" => Ok(Self::Strict),
52 other => anyhow::bail!(
53 "unknown --sandbox level '{}' (expected off | secrets | strict)",
54 other
55 ),
56 }
57 }
58}
59
60#[derive(Debug, Clone)]
61pub struct SandboxConfig {
62 pub level: SandboxLevel,
63 pub allow_paths: Vec<PathBuf>,
66 pub allow_network: bool,
69 pub home: Option<PathBuf>,
71}
72
73impl Default for SandboxConfig {
74 fn default() -> Self {
75 Self {
76 level: SandboxLevel::Off,
77 allow_paths: Vec::new(),
78 allow_network: false,
79 home: None,
80 }
81 }
82}
83
84#[derive(Debug, Clone, PartialEq, Eq)]
87pub enum Confinement {
88 None,
89 Seatbelt { level: SandboxLevel },
90}
91
92impl std::fmt::Display for Confinement {
93 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94 match self {
95 Confinement::None => write!(f, "unconfined"),
96 Confinement::Seatbelt { level } => {
97 write!(f, "seatbelt:{}", match level {
98 SandboxLevel::Off => "off",
99 SandboxLevel::Secrets => "secrets",
100 SandboxLevel::Strict => "strict",
101 })
102 }
103 }
104 }
105}
106
107const SECRET_SUBPATHS: &[&str] = &[
109 ".ssh",
110 ".aws",
111 ".gnupg",
112 ".gcloud",
113 ".config/gcloud",
114 ".azure",
115 ".kube",
116 ".netrc",
117 ".docker/config.json",
118 ".npmrc",
119 ".pypirc",
120 ".cargo/credentials.toml",
121];
122
123fn sbpl_escape(p: &Path) -> String {
124 p.to_string_lossy().replace('\\', "\\\\").replace('"', "\\\"")
128}
129
130fn home_dir(cfg: &SandboxConfig) -> PathBuf {
131 cfg.home
132 .clone()
133 .or_else(|| std::env::var_os("HOME").map(PathBuf::from))
134 .unwrap_or_else(|| PathBuf::from("/"))
135}
136
137pub fn seatbelt_profile(cfg: &SandboxConfig) -> String {
145 let home = home_dir(cfg);
146 let mut out = String::from("(version 1)\n(allow default)\n");
147
148 for sub in SECRET_SUBPATHS {
150 let p = home.join(sub);
151 if cfg.allow_paths.iter().any(|a| p.starts_with(a) || a.starts_with(&p)) {
152 continue;
153 }
154 out.push_str(&format!(
155 "(deny file-read* file-write* (subpath \"{}\"))\n",
156 sbpl_escape(&p)
157 ));
158 }
159
160 if cfg.level == SandboxLevel::Strict {
161 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
162 out.push_str("(deny file-write*)\n");
165 out.push_str(&format!(
166 "(allow file-write* (subpath \"{}\"))\n",
167 sbpl_escape(&cwd)
168 ));
169 for p in ["/tmp", "/private/tmp", "/private/var/tmp", "/private/var/folders", "/dev/null", "/dev/tty"] {
170 let kind = if p.starts_with("/dev/") { "literal" } else { "subpath" };
171 out.push_str(&format!("(allow file-write* ({} \"{}\"))\n", kind, p));
172 }
173 for p in &cfg.allow_paths {
174 out.push_str(&format!(
175 "(allow file-read* file-write* (subpath \"{}\"))\n",
176 sbpl_escape(p)
177 ));
178 }
179 if !cfg.allow_network {
180 out.push_str("(deny network*)\n");
181 }
182 }
183 out
184}
185
186pub fn wrap_command(cmd: &[String], cfg: &SandboxConfig) -> anyhow::Result<(Vec<String>, Confinement)> {
193 if cfg.level == SandboxLevel::Off || cmd.is_empty() {
194 return Ok((cmd.to_vec(), Confinement::None));
195 }
196
197 #[cfg(target_os = "macos")]
198 {
199 let profile = seatbelt_profile(cfg);
200 let mut wrapped = vec![
201 "/usr/bin/sandbox-exec".to_string(),
202 "-p".to_string(),
203 profile,
204 ];
205 wrapped.extend(cmd.iter().cloned());
206 Ok((wrapped, Confinement::Seatbelt { level: cfg.level }))
207 }
208
209 #[cfg(not(target_os = "macos"))]
210 {
211 match cfg.level {
212 SandboxLevel::Strict => anyhow::bail!(
213 "--sandbox strict is not supported on this platform yet \
214 (macOS Seatbelt only); refusing to run unconfined when \
215 strict confinement was requested"
216 ),
217 _ => {
218 log::warn!(
219 "[shield] --sandbox {:?} requested but no sandbox backend \
220 exists on this platform yet -- upstream runs UNCONFINED",
221 cfg.level
222 );
223 Ok((cmd.to_vec(), Confinement::None))
224 }
225 }
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 fn cfg(level: SandboxLevel) -> SandboxConfig {
234 SandboxConfig {
235 level,
236 allow_paths: vec![],
237 allow_network: false,
238 home: Some(PathBuf::from("/Users/testhome")),
239 }
240 }
241
242 #[test]
243 fn off_is_passthrough() {
244 let cmd = vec!["echo".to_string(), "hi".to_string()];
245 let (wrapped, conf) = wrap_command(&cmd, &cfg(SandboxLevel::Off)).unwrap();
246 assert_eq!(wrapped, cmd);
247 assert_eq!(conf, Confinement::None);
248 }
249
250 #[test]
251 fn secrets_profile_denies_credential_dirs() {
252 let p = seatbelt_profile(&cfg(SandboxLevel::Secrets));
253 assert!(p.starts_with("(version 1)\n(allow default)\n"));
254 assert!(p.contains("(deny file-read* file-write* (subpath \"/Users/testhome/.ssh\"))"));
255 assert!(p.contains("/Users/testhome/.aws"));
256 assert!(p.contains("/Users/testhome/.kube"));
257 }
258
259 #[test]
260 fn secrets_allow_path_exempts_dir() {
261 let mut c = cfg(SandboxLevel::Secrets);
262 c.allow_paths.push(PathBuf::from("/Users/testhome/.ssh"));
263 let p = seatbelt_profile(&c);
264 assert!(!p.contains("/Users/testhome/.ssh"));
265 assert!(p.contains("/Users/testhome/.aws")); }
267
268 #[test]
269 fn strict_profile_confines_writes_and_network() {
270 let p = seatbelt_profile(&cfg(SandboxLevel::Strict));
271 assert!(p.contains("(deny file-write*)"));
272 assert!(p.contains("(deny network*)"));
273 assert!(p.contains("/Users/testhome/.ssh")); let mut c = cfg(SandboxLevel::Strict);
275 c.allow_network = true;
276 assert!(!seatbelt_profile(&c).contains("(deny network*)"));
277 }
278
279 #[test]
280 fn profile_escapes_quotes_in_paths() {
281 let mut c = cfg(SandboxLevel::Secrets);
282 c.home = Some(PathBuf::from("/Users/we\"ird"));
283 let p = seatbelt_profile(&c);
284 assert!(p.contains("/Users/we\\\"ird/.ssh"));
285 }
286
287 #[cfg(target_os = "macos")]
288 #[test]
289 fn macos_wraps_with_sandbox_exec() {
290 let cmd = vec!["/bin/echo".to_string(), "hi".to_string()];
291 let (wrapped, conf) = wrap_command(&cmd, &cfg(SandboxLevel::Secrets)).unwrap();
292 assert_eq!(wrapped[0], "/usr/bin/sandbox-exec");
293 assert_eq!(wrapped[1], "-p");
294 assert!(wrapped[2].contains("(deny file-read*"));
295 assert_eq!(&wrapped[3..], &cmd[..]);
296 assert_eq!(conf, Confinement::Seatbelt { level: SandboxLevel::Secrets });
297 }
298}