1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
use crate::{
error::SandboxError,
sandbox::{ProtectHome, ProtectSystem, SandboxPolicy},
};
use std::path::Path;
/// Apply the sandbox policy using macOS sandbox profiles.
pub fn apply_sandbox(policy: &SandboxPolicy) -> Result<(), SandboxError> {
let config = &policy.config;
// 1. Resource limits (before sandbox)
apply_resource_limits(config)?;
// 2. macOS sandbox profile
let profile = generate_profile(policy)?;
macos_sandbox_sys::create_sandbox_with_parameters(profile, 0, &[]).map_err(SandboxError::MacOS)
}
fn apply_resource_limits(config: &crate::sandbox::Sandbox) -> Result<(), SandboxError> {
let limits: [(Option<u64>, libc::c_int); 8] = [
(config.limit_nofile, libc::RLIMIT_NOFILE),
(config.limit_nproc, libc::RLIMIT_NPROC),
(config.limit_core, libc::RLIMIT_CORE),
(config.limit_fsize, libc::RLIMIT_FSIZE),
(config.limit_data, libc::RLIMIT_DATA),
(config.limit_stack, libc::RLIMIT_STACK),
(config.limit_cpu, libc::RLIMIT_CPU),
(config.limit_memlock, libc::RLIMIT_MEMLOCK),
];
for (value, resource) in limits {
if let Some(limit) = value {
let rlim = libc::rlimit {
rlim_cur: limit,
rlim_max: limit,
};
let ret = unsafe { libc::setrlimit(resource, &rlim) };
if ret != 0 {
return Err(SandboxError::Io(std::io::Error::last_os_error()));
}
}
}
Ok(())
}
fn generate_profile(policy: &SandboxPolicy) -> Result<String, SandboxError> {
let config = &policy.config;
let mut profile = String::from("(version 1)\n");
profile.push_str("(allow default)\n");
// ProtectSystem
match config.protect_system {
ProtectSystem::No => {}
ProtectSystem::Yes => {
profile.push_str("(deny file-write* (subpath \"/usr\"))\n");
profile.push_str("(deny file-write* (subpath \"/System\"))\n");
profile.push_str("(deny file-write* (subpath \"/Library\"))\n");
}
ProtectSystem::Full => {
profile.push_str("(deny file-write* (subpath \"/usr\"))\n");
profile.push_str("(deny file-write* (subpath \"/System\"))\n");
profile.push_str("(deny file-write* (subpath \"/Library\"))\n");
profile.push_str("(deny file-write* (subpath \"/etc\"))\n");
profile.push_str("(deny file-write* (subpath \"/private/etc\"))\n");
}
ProtectSystem::Strict => {
// Deny all writes first; the explicit read_write_paths
// re-allows are emitted later, after ProtectHome, so they
// also override its read+write deny when an RW path lives
// under $HOME (every bougie service's data/run/log/conf
// does).
profile.push_str("(deny file-write*)\n");
}
}
// ProtectHome — emitted before path-specific allows so the latter
// win under sbpl's last-match-wins semantics.
//
// We deny `file-read-data` rather than the broader `file-read*`:
// the wildcard covers `file-read-metadata` too, which breaks
// namei() traversal in POSIX `/bin/sh` and causes `cd
// /allowed/path` to fail with ENOTDIR even when the target is in
// a re-allowed subpath. (rabbitmq's `sbin/rabbitmq-env` shell
// helper tripped this.) Denying read-data preserves content
// confidentiality — file *bytes* in `$HOME` stay unreadable —
// while letting the kernel stat path components for traversal,
// which is what cd/chdir/dyld@rpath actually need. The cost is
// that filenames under $HOME become visible; acceptable for a
// dev sandbox where the real concern is leaking source / secrets
// / credentials by content.
match config.protect_home {
ProtectHome::No => {}
ProtectHome::Yes => {
if let Ok(home) = std::env::var("HOME") {
profile.push_str(&format!(
"(deny file-read-data file-write* (subpath \"{}\"))\n",
escape_path(&home)
));
}
profile.push_str(
"(deny file-read-data file-write* (regex #\"^/Users/[^/]+\"))\n",
);
}
ProtectHome::ReadOnly => {
if let Ok(home) = std::env::var("HOME") {
profile.push_str(&format!(
"(deny file-write* (subpath \"{}\"))\n",
escape_path(&home)
));
}
profile.push_str("(deny file-write* (regex #\"^/Users/[^/]+\"))\n");
}
}
// ReadWritePaths — explicit read+write allow. Emitted AFTER
// ProtectHome so the allow wins over any home-wide deny when an
// RW path lives under $HOME. The `file-read-data` allow mirrors
// the deny above so the override applies to the exact operation
// sbpl matches; the `file-read*` half handles the other read
// sub-ops not denied by ProtectHome.
if matches!(config.protect_system, ProtectSystem::Strict) {
for path in &config.read_write_paths {
if let Ok(canonical) = canonicalize_path(path) {
profile.push_str(&format!(
"(allow file-read* file-read-data file-write* (subpath \"{}\"))\n",
escape_path(&canonical)
));
}
}
}
// ReadOnlyPaths — explicit read-allow + write-deny. Without the
// read-allow, dyld can't resolve dependent dylibs in
// `$BOUGIE_HOME/store/*` (e.g. redis-server loading openssl via
// `@rpath/libssl.3.dylib`). Both `file-read*` and the explicit
// `file-read-data` are emitted: see the comment on ProtectHome
// above for why matching the exact operation matters.
for path in &config.read_only_paths {
if let Ok(canonical) = canonicalize_path(path) {
profile.push_str(&format!(
"(allow file-read* file-read-data (subpath \"{}\"))\n",
escape_path(&canonical)
));
profile.push_str(&format!(
"(deny file-write* (subpath \"{}\"))\n",
escape_path(&canonical)
));
}
}
// InaccessiblePaths - deny all access
for path in &config.inaccessible_paths {
if let Ok(canonical) = canonicalize_path(path) {
profile.push_str(&format!(
"(deny file-read* file-write* (subpath \"{}\"))\n",
escape_path(&canonical)
));
}
}
// NoExecPaths - deny execution
for path in &config.no_exec_paths {
if let Ok(canonical) = canonicalize_path(path) {
profile.push_str(&format!(
"(deny process-exec* (subpath \"{}\"))\n",
escape_path(&canonical)
));
}
}
// ExecPaths - in strict mode, only allow execution from these paths
// This requires denying all exec first, then allowing specific paths
if !config.exec_paths.is_empty() {
profile.push_str("(deny process-exec*)\n");
for path in &config.exec_paths {
if let Ok(canonical) = canonicalize_path(path) {
profile.push_str(&format!(
"(allow process-exec* (subpath \"{}\"))\n",
escape_path(&canonical)
));
}
}
// Also allow execution from system paths for basic functionality
profile.push_str("(allow process-exec* (subpath \"/usr\"))\n");
profile.push_str("(allow process-exec* (subpath \"/bin\"))\n");
profile.push_str("(allow process-exec* (subpath \"/sbin\"))\n");
profile.push_str("(allow process-exec* (subpath \"/System\"))\n");
}
// PrivateNetwork - deny all network access
if config.private_network {
profile.push_str("(deny network*)\n");
}
Ok(profile)
}
fn canonicalize_path(path: &Path) -> Result<String, SandboxError> {
path.canonicalize()
.map(|p| p.to_string_lossy().into_owned())
.map_err(|e| {
SandboxError::InvalidPath(format!("Failed to canonicalize path {:?}: {}", path, e))
})
}
fn escape_path(path: &str) -> String {
// Escape backslashes and double quotes for the sandbox profile S-expression
path.replace('\\', "\\\\").replace('"', "\\\"")
}