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
//! 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"];
/// 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
}
/// `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())
}
/// 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).
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 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"]);
}
}
}