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
//! `tm daemon --sandbox`: the isolation contract for a live-check daemon (#9121).
//!
//! Why: a qa live-check started a "sandbox" daemon with its own `$HOME` and
//! port. It inherited `TELEGRAM_BOT_TOKEN` and polled the real bot
//! (`TerminatedByOtherGetUpdates`). A fresh `$HOME` is not isolation: the
//! credential store and the Keychain belong to the OS user, not to `$HOME`.
//! What: [`enter`] refuses unless every environment variable is on the closed
//! [`ENV_ALLOWLIST`], `TRUSTY_DATA_DIR_OVERRIDE` is set, `TRUSTY_SANDBOX` is
//! exactly `1` ([`Refusal::SandboxFlagNotSet`] otherwise, so trusty-common reads
//! no `.env.local`, #9178), and `$HOME` is not the account's home. On success
//! it latches `trusty_mpm::secret_source::enter_sandbox`, so no credential tier
//! is read.
//! [`gate_channel_pollers`] keeps the Telegram bot down. Every check fails
//! closed: an answer that cannot be determined refuses.
//! Test: `daemon_sandbox_tests.rs`; end to end, `tests/sandbox_daemon_9121.rs`.
use std::ffi::OsString;
use std::fmt;
use std::path::{Path, PathBuf};
use trusty_common::DATA_DIR_OVERRIDE_ENV;
use trusty_common::credentials::{SANDBOX_ENV_VAR, sandbox_flag_set};
/// The only variables a sandbox daemon may inherit, compared exactly.
///
/// Why (#9121, Architect ruling): a denylist of secret-shaped suffixes misses
/// any credential spelled another way (`GITHUB_PAT`, `DATABASE_URL` with a
/// password). A closed allowlist refuses everything not named here. Each
/// entry, and why the daemon needs it:
/// - `HOME`: the sandbox's own home, checked against the account home below.
/// - `PATH`: the daemon spawns `tmux`, `git` and `claude` by name.
/// - `TRUSTY_DATA_DIR_OVERRIDE`: required; keeps data and sockets out of the
/// real OS data directory, which `$HOME` does not redirect on macOS.
/// - `TRUSTY_MPM_ADDR`: the sandbox's own listen address.
/// - `TMPDIR`: the per-user temp directory on macOS.
/// - `TERM`, `LANG`, [`LOCALE_CATEGORIES`]: terminal type and locale for the
/// tmux panes and git output the daemon reads back.
/// - `USER`, `LOGNAME`, `SHELL`: the account name and login shell tmux and git
/// read; names, not credentials.
/// - `RUST_LOG`: the log filter.
/// - `TRUSTY_SANDBOX`: required to be exactly `1`; tells trusty-common to load
/// no `.env.local` (#9178). A flag, not a credential.
///
/// Plus [`CF_TEXT_ENCODING`], which macOS sets inside the process itself.
const ENV_ALLOWLIST: &[&str] = &[
"HOME",
"PATH",
DATA_DIR_OVERRIDE_ENV,
"TRUSTY_MPM_ADDR",
"TMPDIR",
"TERM",
"LANG",
"USER",
"LOGNAME",
"SHELL",
"RUST_LOG",
// #9178: the `.env.local` opt-out, required by `refusals`. A literal, not
// `SANDBOX_ENV_VAR`: `scripts/sandbox_daemon_selftest.sh` parses this list.
"TRUSTY_SANDBOX",
];
/// The locale categories allowed by name — the `LC_*` family, closed.
///
/// Why: an open `LC_` prefix would admit `LC_API_KEY`. These are the POSIX
/// categories plus glibc's six extra ones, and nothing else.
const LOCALE_CATEGORIES: &[&str] = &[
"LC_ALL",
"LC_COLLATE",
"LC_CTYPE",
"LC_MESSAGES",
"LC_MONETARY",
"LC_NUMERIC",
"LC_TIME",
"LC_ADDRESS",
"LC_IDENTIFICATION",
"LC_MEASUREMENT",
"LC_NAME",
"LC_PAPER",
"LC_TELEPHONE",
];
/// macOS CoreFoundation's text-encoding hint.
///
/// Why: CoreFoundation sets it in every process it loads into, so the daemon
/// carries it even when started under `env -i`
/// (`an_allowlisted_environment_passes_the_allowlist_check` caught it). It is
/// allowed only in CF's own `0x<hex>:0x<hex>:0x<hex>` form, so it cannot carry
/// anything else past the allowlist.
const CF_TEXT_ENCODING: &str = "__CF_USER_TEXT_ENCODING";
/// Whether `(name, value)` is CoreFoundation's own [`CF_TEXT_ENCODING`].
///
/// Test: `only_a_well_formed_cf_text_encoding_is_allowed`.
pub(crate) fn is_cf_text_encoding(name: &OsString, value: &OsString) -> bool {
let field = |f: &str| {
let hex = f.strip_prefix("0x").unwrap_or(f);
(1..=8).contains(&hex.len()) && hex.bytes().all(|b| b.is_ascii_hexdigit())
};
name == CF_TEXT_ENCODING
&& value.to_str().is_some_and(|v| {
let fields: Vec<&str> = v.split(':').collect();
fields.len() == 3 && fields.iter().all(|f| field(f))
})
}
/// The parts of the environment the sandbox contract reads. Holds no value of
/// any variable outside the allowlist — only names.
#[derive(Debug, Clone, Default)]
pub(crate) struct SandboxEnv {
/// Variable NAMES present but not on the allowlist, sorted.
pub(crate) disallowed_names: Vec<String>,
/// `TRUSTY_DATA_DIR_OVERRIDE`, when set.
pub(crate) data_dir_override: Option<OsString>,
/// `TRUSTY_SANDBOX`, when set.
pub(crate) sandbox_flag: Option<OsString>,
/// `$HOME`, when set.
pub(crate) home: Option<PathBuf>,
/// The account's home from the password database, when it resolves.
pub(crate) account_home: Option<PathBuf>,
}
impl SandboxEnv {
/// Read this process's environment. Values are dropped at the iterator,
/// before anything stores them; only [`CF_TEXT_ENCODING`]'s is inspected.
fn capture() -> Self {
let names = std::env::vars_os()
.filter(|(name, value)| !is_cf_text_encoding(name, value))
.map(|(name, _)| name);
Self {
disallowed_names: disallowed_names(names),
data_dir_override: std::env::var_os(DATA_DIR_OVERRIDE_ENV),
sandbox_flag: std::env::var_os(SANDBOX_ENV_VAR),
home: std::env::var_os("HOME").map(PathBuf::from),
account_home: account_home(),
}
}
}
/// One reason a sandbox daemon refuses to start.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Refusal {
/// Variables outside [`ENV_ALLOWLIST`] are present; carries names only.
DisallowedEnv(Vec<String>),
/// `TRUSTY_DATA_DIR_OVERRIDE` is unset or empty.
NoDataDirOverride,
/// `TRUSTY_SANDBOX` is not exactly `1`, so `.env.local` would load.
SandboxFlagNotSet,
/// `$HOME` is unset or empty.
NoHome,
/// `$HOME` does not resolve to an existing directory.
HomeUnresolvable(PathBuf),
/// The password database names no home for this user.
AccountHomeUnknown,
/// `$HOME` resolves to the account's real home.
HomeIsAccountHome(PathBuf),
}
impl fmt::Display for Refusal {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DisallowedEnv(names) => write!(
f,
"the environment carries variables outside the sandbox allowlist \
(values not shown): {}",
names.join(", ")
),
Self::NoDataDirOverride => write!(f, "{DATA_DIR_OVERRIDE_ENV} is not set"),
Self::SandboxFlagNotSet => write!(
f,
"{SANDBOX_ENV_VAR} is not set to exactly 1 (value not shown), so \
`.env.local` credentials would load"
),
Self::NoHome => write!(f, "$HOME is not set"),
Self::HomeUnresolvable(home) => {
write!(f, "$HOME ({}) is not an existing directory", home.display())
}
Self::AccountHomeUnknown => write!(
f,
"the password database names no home for this user, so $HOME cannot be \
shown to differ from it"
),
Self::HomeIsAccountHome(home) => write!(
f,
"$HOME ({}) is this user's real home; a sandbox needs its own",
home.display()
),
}
}
}
/// Validate the environment and, only when it is isolated, enter sandbox mode.
///
/// Why: the refusal has to run before the daemon does anything else, and the
/// latch has to be set before anything can read a credential.
/// What: [`enter_with`] over this process's environment and the library latch.
/// Test: `enter_with`'s tests; end to end, `tests/sandbox_daemon_9121.rs`.
pub(crate) fn enter() -> anyhow::Result<()> {
enter_with(
&SandboxEnv::capture(),
trusty_mpm::secret_source::enter_sandbox,
)
}
/// [`enter`] with the environment and the latch injected.
///
/// Why: the latch is process-wide, so a test that set it would change what
/// every parallel sibling's credential read returns.
/// What: every [`refusals`] entry becomes one clause of the error, and `latch`
/// is never called; with no refusal, `latch` runs once.
/// Test: `an_isolated_clean_environment_enters_and_latches`,
/// `a_refused_sandbox_never_latches`.
pub(crate) fn enter_with(env: &SandboxEnv, latch: impl FnOnce()) -> anyhow::Result<()> {
let refused = refusals(env);
if !refused.is_empty() {
let reasons: Vec<String> = refused.iter().map(ToString::to_string).collect();
// #9121 (#7247): name the launcher by role — this binary ships to
// projects that have no such script path.
anyhow::bail!(
"refusing to start `tm daemon --sandbox` (#9121): {}. Start a sandbox daemon \
with the project's sandbox launcher (`env -i` + allowlist).",
reasons.join("; ")
);
}
latch();
tracing::info!(
"sandbox mode (#9121): no channel poller starts and no credential tier is consulted"
);
Ok(())
}
/// Every reason `env` is not an isolated sandbox. Empty means isolated.
///
/// Test: `a_variable_outside_the_allowlist_refuses_and_names_only_the_variables`,
/// `refuses_without_a_data_dir_override`,
/// `refuses_unless_the_sandbox_flag_is_exactly_one`, `refuses_when_home_is_unset`,
/// `refuses_when_home_is_the_account_home`,
/// `refuses_when_home_symlinks_to_the_account_home`,
/// `refuses_when_the_account_home_is_unknown`,
/// `refuses_when_home_does_not_exist`.
pub(crate) fn refusals(env: &SandboxEnv) -> Vec<Refusal> {
let mut out = Vec::new();
if !env.disallowed_names.is_empty() {
out.push(Refusal::DisallowedEnv(env.disallowed_names.clone()));
}
if env
.data_dir_override
.as_ref()
.is_none_or(|value| value.is_empty())
{
out.push(Refusal::NoDataDirOverride);
}
// #9178: same exact-`1` rule as trusty-common's `.env.local` opt-out; an
// absent or non-UTF-8 value is not `1`, so it refuses.
if !sandbox_flag_set(env.sandbox_flag.as_deref()) {
out.push(Refusal::SandboxFlagNotSet);
}
if let Some(refusal) = home_refusal(env.home.as_deref(), env.account_home.as_deref()) {
out.push(refusal);
}
out
}
/// Whether `home` is a home of its own, compared after resolving symlinks.
///
/// What: `None` only when `home` is set, resolves to an existing directory, the
/// account home is known, and the two differ. An account home that does not
/// resolve is compared as written: an existing `home` cannot equal it.
fn home_refusal(home: Option<&Path>, account: Option<&Path>) -> Option<Refusal> {
let Some(home) = home.filter(|h| !h.as_os_str().is_empty()) else {
return Some(Refusal::NoHome);
};
let Some(account) = account.filter(|a| !a.as_os_str().is_empty()) else {
return Some(Refusal::AccountHomeUnknown);
};
let Ok(resolved_home) = std::fs::canonicalize(home) else {
return Some(Refusal::HomeUnresolvable(home.to_path_buf()));
};
let resolved_account = std::fs::canonicalize(account).unwrap_or_else(|_| account.to_path_buf());
(resolved_home == resolved_account).then(|| Refusal::HomeIsAccountHome(home.to_path_buf()))
}
/// The names among `names` that are not on the allowlist, sorted and
/// de-duplicated.
///
/// What: a name is allowed only when it equals an [`ENV_ALLOWLIST`] or
/// [`LOCALE_CATEGORIES`] entry exactly — case included, as the OS compares
/// them. A name that is not UTF-8 is reported by its lossy form and is never
/// allowed.
/// Test: `disallowed_names_admits_only_the_closed_allowlist`.
pub(crate) fn disallowed_names(names: impl Iterator<Item = OsString>) -> Vec<String> {
let mut out: Vec<String> = names
.filter(|name| {
name.to_str().is_none_or(|name| {
!ENV_ALLOWLIST.contains(&name) && !LOCALE_CATEGORIES.contains(&name)
})
})
.map(|name| name.to_string_lossy().into_owned())
.collect();
out.sort();
out.dedup();
out
}
/// Run `spawn` only outside sandbox mode.
///
/// Why: in sandbox mode no channel poller may start, whatever a credential
/// tier would have answered. Gating the spawn, not only the token, keeps the
/// bot down even if the credential gate regressed.
/// Test: `sandbox_never_spawns_the_telegram_bot`,
/// `outside_sandbox_the_bot_spawn_runs`.
pub(crate) fn gate_channel_pollers<T>(
sandbox: bool,
spawn: impl FnOnce() -> Option<T>,
) -> Option<T> {
if sandbox {
tracing::info!("sandbox mode (#9121): the Telegram bot is not started");
return None;
}
spawn()
}
/// The account's home, from the password database rather than `$HOME`.
///
/// Why: `$HOME` is exactly what a sandbox reassigns, so it cannot be the
/// reference. `getpwuid(getuid())` is what the OS records for this user, and
/// the Keychain the incident reached is keyed to that same user.
/// What: `None` on a failed lookup or an empty home, which [`refusals`] treats
/// as indeterminate and refuses.
fn account_home() -> Option<PathBuf> {
match nix::unistd::User::from_uid(nix::unistd::Uid::current()) {
Ok(Some(user)) if !user.dir.as_os_str().is_empty() => Some(user.dir),
_ => None,
}
}
#[cfg(test)]
#[path = "daemon_sandbox_tests.rs"]
mod tests;