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
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
//! macOS Seatbelt self-sandbox for launchd-owned lean-ctx processes (#356).
//!
//! The daemon, proxy and auto-updater run as LaunchAgents — i.e. under
//! `launchd` (`ppid 1`) with their own TCC identity. Any `stat`/`read_dir`/
//! `realpath` they perform under `~/Documents`, `~/Desktop` or `~/Downloads`
//! pops the macOS privacy prompt *in lean-ctx's own name*, and because every
//! release re-signs the binary (new cdhash) the grant is invalidated on each
//! update, so it re-prompts forever.
//!
//! The opt-out path guards in [`crate::core::pathutil`] avoid those accesses
//! per call site, but that is fragile: one forgotten probe — or a dependency
//! that walks the filesystem — reintroduces the prompt. This module adds a
//! hard, kernel-enforced backstop: the LaunchAgent `ProgramArguments` are
//! wrapped in `sandbox-exec` with a profile that *denies* file access under the
//! three TCC-protected home directories. If any code path touches them anyway,
//! the kernel refuses with `EPERM` silently — the TCC subsystem is never
//! consulted, so no prompt can appear. Everything else is permitted
//! (`allow default`), so the processes keep full functionality.
//!
//! The deny is silent (no `(with send-signal SIGKILL)`): a production process
//! must survive a stray access, losing only that one read. The SIGKILL variant
//! lives solely in `tests/tcc_sandbox.sh`, where dying is how the regression
//! test *detects* an access.
use std::path::Path;
use std::process::Command;
/// Absolute path to the system `sandbox-exec`. Hard-coded rather than resolved
/// via `PATH` so the LaunchAgent invocation never depends on the environment.
const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
/// The macOS "magic" home subdirectories whose mere enumeration trips the TCC
/// privacy prompt (#356).
const TCC_PROTECTED_SUBDIRS: [&str; 3] = ["Documents", "Desktop", "Downloads"];
/// Env sentinel marking that this process is already running under (or is
/// exempt from) the deny-`~/Documents` seatbelt. Set both by the LaunchAgent
/// plist `EnvironmentVariables` (see [`pinned_layout_env_xml`]) and by the
/// self re-exec ([`reexec_under_seatbelt_if_needed`]), so a current-code plist
/// never re-wraps and the re-exec can never loop.
pub const SEATBELT_SENTINEL: &str = "LEAN_CTX_SEATBELT";
/// Build the inline Seatbelt (SBPL) profile that denies all file access under
/// the three TCC-protected home directories while allowing everything else.
///
/// Returns `None` when the home directory cannot be resolved (the caller then
/// falls back to an unwrapped invocation). The home path is canonicalized: the
/// kernel matches sandbox `subpath` filters against the *canonical* path, so a
/// symlinked home would otherwise make the deny rule silently miss. This runs
/// in the CLI/setup context (which holds the TCC grant) and only stats the home
/// directory itself — never a protected subdir — so it cannot trip the prompt.
pub fn tcc_deny_profile() -> Option<String> {
let home = dirs::home_dir()?;
let home = std::fs::canonicalize(&home).unwrap_or(home);
Some(build_profile(&home))
}
/// Assemble the single-line SBPL profile for a concrete home directory.
fn build_profile(home: &Path) -> String {
let mut subpaths = String::new();
for sub in TCC_PROTECTED_SUBDIRS {
let p = home.join(sub);
subpaths.push_str(&format!(
" (subpath \"{}\")",
sbpl_escape(&p.to_string_lossy())
));
}
// SBPL evaluates last-match-wins, so the deny must follow the allow-default.
format!("(version 1) (allow default) (deny file-read* file-write*{subpaths})")
}
/// Escape a path for embedding inside an SBPL double-quoted string literal.
/// SBPL uses C-style escaping, so backslashes and double quotes must be escaped.
fn sbpl_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
/// Wrap launchd `ProgramArguments` so the spawned process runs under the
/// deny-`~/Documents` Seatbelt sandbox (#356).
///
/// Returns `[SANDBOX_EXEC, "-p", <profile>, binary, args…]` when `sandbox-exec`
/// is present and accepts the generated profile (smoke-tested against
/// `/usr/bin/true`, so a malformed profile can never wedge the LaunchAgent in a
/// `KeepAlive` crash-loop). Otherwise returns the plain `[binary, args…]`: the
/// binary always launches, with the path guards as the remaining safety layer.
pub fn wrap_launchd_args(binary: &str, args: &[&str]) -> Vec<String> {
if let Some(profile) = tcc_deny_profile()
&& sandbox_exec_usable(&profile)
{
let mut wrapped = vec![
SANDBOX_EXEC.to_string(),
"-p".to_string(),
profile,
binary.to_string(),
];
wrapped.extend(args.iter().map(|a| (*a).to_string()));
return wrapped;
}
unwrapped_args(binary, args)
}
/// Plain, unwrapped invocation `[binary, args…]` used as the safe fallback.
fn unwrapped_args(binary: &str, args: &[&str]) -> Vec<String> {
let mut out = vec![binary.to_string()];
out.extend(args.iter().map(|a| (*a).to_string()));
out
}
/// Pure decision: should a launchd-standalone process re-exec itself under the
/// seatbelt? Only when it is its own TCC identity *and* not already marked.
/// Factored out so the policy is unit-testable without spawning processes.
#[cfg(target_os = "macos")]
fn should_reexec_under_seatbelt(standalone: bool, sentinel_present: bool) -> bool {
standalone && !sentinel_present
}
/// Belt-and-suspenders (#356): if this is a launchd-standalone process
/// (daemon / proxy / auto-updater — `ppid 1`, its own TCC identity) that is not
/// already running under the seatbelt, re-exec self wrapped in `sandbox-exec`
/// with the deny-`~/Documents` profile. This closes the one gap the plist
/// rewrite cannot: an install whose LaunchAgent plists predate the seatbelt and
/// is only ever upgraded via `brew upgrade` (which bypasses lean-ctx's updater,
/// so the plists are never regenerated). Without this such a process boots
/// unwrapped and can still trip the TCC prompt.
///
/// No-op unless macOS + standalone + sentinel-absent + `sandbox-exec` accepts
/// the profile. On `exec` failure it returns and the caller continues unwrapped
/// (the [`crate::core::pathutil`] path guards remain the safety net) rather than
/// aborting a `KeepAlive` daemon into a crash loop.
#[cfg(target_os = "macos")]
pub fn reexec_under_seatbelt_if_needed() {
use std::os::unix::process::CommandExt;
let sentinel = std::env::var_os(SEATBELT_SENTINEL).is_some();
let standalone = crate::core::pathutil::process_is_tcc_standalone();
if !should_reexec_under_seatbelt(standalone, sentinel) {
return;
}
let Some(profile) = tcc_deny_profile() else {
return;
};
if !sandbox_exec_usable(&profile) {
return;
}
let Ok(exe) = std::env::current_exe() else {
return;
};
let args: Vec<String> = std::env::args().skip(1).collect();
// `exec` replaces the image; it only returns on failure.
let err = Command::new(SANDBOX_EXEC)
.arg("-p")
.arg(&profile)
.arg(&exe)
.args(&args)
.env(SEATBELT_SENTINEL, "1")
.exec();
tracing::warn!("#356 seatbelt re-exec failed, continuing unwrapped: {err}");
}
/// `true` if `sandbox-exec` exists and successfully runs a no-op under
/// `profile`. Guards against both a missing binary and an SBPL syntax error,
/// either of which would otherwise turn a `KeepAlive` LaunchAgent into a
/// crash-loop.
fn sandbox_exec_usable(profile: &str) -> bool {
if !Path::new(SANDBOX_EXEC).exists() {
return false;
}
Command::new(SANDBOX_EXEC)
.args(["-p", profile, "/usr/bin/true"])
.output()
.is_ok_and(|o| o.status.success())
}
/// Build the `EnvironmentVariables` plist block that pins a launchd-spawned
/// lean-ctx process to the directory layout the *installing CLI* resolves (#449).
///
/// A LaunchAgent inherits only launchd's minimal environment (no `HOME`, no XDG
/// vars), so the proxy/daemon would otherwise resolve a *different* config/data
/// dir than the CLI that installed it: it never sees the user's `config.toml`
/// edits (the live-upstream reload reads an empty/foreign config) and derives a
/// mismatched session token. Baking the exact resolved dirs into the plist makes
/// the managed process always agree with the CLI, on every platform layout
/// (legacy `~/.lean-ctx`, mixed, or split XDG).
///
/// Returns the full `<key>EnvironmentVariables</key><dict>…</dict>` block
/// (4-space indented, trailing newline) or an empty string when nothing resolves.
pub fn pinned_layout_env_xml() -> String {
let mut entries: Vec<(&str, String)> = Vec::new();
// #356 sentinel: marks the spawned process as managed by current-code
// (it carries the seatbelt wrapper, or wrapping is impossible on this host).
// Its presence makes `reexec_under_seatbelt_if_needed` a no-op, so a current
// plist never double-wraps — only stale, pre-sentinel plists re-exec.
entries.push((SEATBELT_SENTINEL, "1".to_string()));
if let Some(home) = dirs::home_dir() {
entries.push(("HOME", home.display().to_string()));
}
for (key, dir) in [
("LEAN_CTX_CONFIG_DIR", crate::core::paths::config_dir()),
("LEAN_CTX_DATA_DIR", crate::core::paths::data_dir()),
("LEAN_CTX_STATE_DIR", crate::core::paths::state_dir()),
("LEAN_CTX_CACHE_DIR", crate::core::paths::cache_dir()),
] {
if let Ok(p) = dir {
entries.push((key, p.display().to_string()));
}
}
if entries.is_empty() {
return String::new();
}
let body = entries
.iter()
.map(|(k, v)| {
format!(
" <key>{k}</key>\n <string>{}</string>",
xml_escape(v)
)
})
.collect::<Vec<_>>()
.join("\n");
format!(" <key>EnvironmentVariables</key>\n <dict>\n{body}\n </dict>\n")
}
/// Render a ProgramArguments list as XML-escaped plist `<string>` lines, each
/// prefixed with `indent` and joined by newlines — ready to drop inside the
/// `<array>` body of a LaunchAgent plist.
pub fn program_args_xml(args: &[String], indent: &str) -> String {
args.iter()
.map(|a| format!("{indent}<string>{}</string>", xml_escape(a)))
.collect::<Vec<_>>()
.join("\n")
}
/// Minimal XML escaping for plist `<string>` bodies: `&`, `<` and `>` must be
/// encoded so the plist stays well-formed. Double quotes are valid in element
/// content and are left as-is (launchd's parser hands them through verbatim).
pub fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_allows_default_and_denies_magic_dirs() {
let profile = build_profile(Path::new("/Users/dev"));
assert!(profile.contains("(version 1)"));
assert!(profile.contains("(allow default)"));
assert!(profile.contains("(deny file-read* file-write*"));
assert!(profile.contains("(subpath \"/Users/dev/Documents\")"));
assert!(profile.contains("(subpath \"/Users/dev/Desktop\")"));
assert!(profile.contains("(subpath \"/Users/dev/Downloads\")"));
}
#[test]
fn profile_is_single_line_and_deny_follows_allow() {
// Last-match-wins SBPL: the deny must come after allow-default.
let profile = build_profile(Path::new("/Users/dev"));
assert!(!profile.contains('\n'));
let allow_idx = profile.find("(allow default)").unwrap();
let deny_idx = profile.find("(deny file-read*").unwrap();
assert!(allow_idx < deny_idx);
}
#[test]
fn tcc_deny_profile_names_all_three_dirs() {
if let Some(profile) = tcc_deny_profile() {
assert!(profile.contains("/Documents\")"));
assert!(profile.contains("/Desktop\")"));
assert!(profile.contains("/Downloads\")"));
}
}
#[test]
fn sbpl_escape_handles_quotes_and_backslashes() {
assert_eq!(sbpl_escape(r#"/Users/a"b"#), r#"/Users/a\"b"#);
assert_eq!(sbpl_escape(r"/Users/a\b"), r"/Users/a\\b");
assert_eq!(sbpl_escape("/Users/normal"), "/Users/normal");
}
#[test]
fn unwrapped_args_prepend_binary() {
let got = unwrapped_args("/bin/lean-ctx", &["serve", "--_foreground-daemon"]);
assert_eq!(got, vec!["/bin/lean-ctx", "serve", "--_foreground-daemon"]);
}
#[test]
fn program_args_xml_escapes_and_indents() {
let args = vec![
"/usr/bin/sandbox-exec".to_string(),
"-p".to_string(),
"(deny a&b<c>)".to_string(),
];
let xml = program_args_xml(&args, " ");
assert!(xml.contains(" <string>/usr/bin/sandbox-exec</string>"));
assert!(xml.contains("&"));
assert!(xml.contains("<"));
assert!(xml.contains(">"));
// No raw ampersand may survive — that would break the plist XML.
assert!(!xml.contains("a&b"));
}
#[test]
fn pinned_layout_env_pins_all_categories() {
let xml = pinned_layout_env_xml();
assert!(xml.starts_with(" <key>EnvironmentVariables</key>"));
assert!(xml.contains("<dict>"));
assert!(xml.trim_end().ends_with("</dict>"));
for key in [
"LEAN_CTX_CONFIG_DIR",
"LEAN_CTX_DATA_DIR",
"LEAN_CTX_STATE_DIR",
"LEAN_CTX_CACHE_DIR",
] {
assert!(xml.contains(&format!("<key>{key}</key>")), "missing {key}");
}
}
#[test]
fn pinned_layout_env_carries_seatbelt_sentinel() {
// The plist must mark current-code processes so the boot-time re-exec
// guard (#356) treats them as already-wrapped and never double-wraps.
let xml = pinned_layout_env_xml();
assert!(xml.contains(&format!("<key>{SEATBELT_SENTINEL}</key>")));
}
#[test]
#[cfg(target_os = "macos")]
fn reexec_policy_only_fires_for_unmarked_standalone() {
// Standalone + no sentinel = stale plist → must re-exec under seatbelt.
assert!(should_reexec_under_seatbelt(true, false));
// Already marked (current plist or prior re-exec) → never loop.
assert!(!should_reexec_under_seatbelt(true, true));
// Terminal/editor child (host TCC grant) → never re-exec.
assert!(!should_reexec_under_seatbelt(false, false));
assert!(!should_reexec_under_seatbelt(false, true));
}
#[test]
fn wrap_includes_sandbox_exec_and_binary_when_usable() {
// Assert the real wrapping only when sandbox-exec works on this host
// (CI macOS runners have it); otherwise verify the safe fallback.
let wrapped = wrap_launchd_args("/bin/lean-ctx", &["proxy", "start"]);
if wrapped.first().map(String::as_str) == Some(SANDBOX_EXEC) {
assert_eq!(wrapped[1], "-p");
assert!(wrapped[2].contains("(deny file-read*"));
assert_eq!(wrapped[3], "/bin/lean-ctx");
assert_eq!(&wrapped[4..], &["proxy", "start"]);
} else {
assert_eq!(wrapped, vec!["/bin/lean-ctx", "proxy", "start"]);
}
}
}