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
//! A process-wide fence around the operator's home config paths (#8545).
//!
//! Why: the `tm` bin target's `guided_fallback_*` tests drove the real launch
//! pipeline, which resolved its deploy destinations from `$HOME` and deployed
//! the full skill and agent roster into `~/.trusty-tools/trusty-mpm/claude-config`
//! and `~/.trusty-mpm/framework`. Repointing `$HOME` cannot contain that class in
//! the bin target: `env_isolation_tests` bans the write there (#5544), and a
//! redirected `$HOME` makes [`crate::core::host_state_gate`] refuse tmux for the
//! whole binary (#5784). A fence needs no environment write at all.
//! What: [`arm`] records the fenced roots once per process; [`check`] panics when
//! a write destination sits under one of them. Production never arms the fence,
//! so there [`check`] is one `OnceLock` read that finds nothing. A test binary
//! arms it before `main`, so a test that reaches a home-config writer fails by
//! name, at the writer, before anything is written.
//! Test: `home_write_fence::tests`; the `tm` bin target arms it in
//! `test_support` and asserts that in `the_home_write_fence_is_armed_for_this_binary`.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
/// Home entries a test must never write: Claude Code's user config, and every
/// trusty-* install and config root.
const FENCED_HOME_ENTRIES: &[&str] = &[".claude", ".claude.json", ".trusty-mpm", ".trusty-tools"];
static FENCED_ROOTS: OnceLock<Vec<PathBuf>> = OnceLock::new();
/// The `$CLAUDE_CONFIG_DIR` root [`arm_for_this_process`] fenced, recorded when
/// that call armed the fence. #8545: a test asserts against this record, not a
/// re-read of the variable a `#[serial]` test may repoint mid-run.
static ARMED_CLAUDE_CONFIG_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
/// The fenced roots under one home directory.
pub fn fenced_roots_under(home: &Path) -> Vec<PathBuf> {
FENCED_HOME_ENTRIES.iter().map(|e| home.join(e)).collect()
}
/// Arm the fence for this process, once.
///
/// Why: arming must happen before any test thread exists, so the caller is a
/// pre-`main` constructor; a second call is a no-op rather than a widening.
/// What: fences [`fenced_roots_under`] each of `homes`. Returns `true` when this
/// call armed the fence.
/// Test: `tests::arm_is_first_writer_wins`.
pub fn arm(homes: &[&Path]) -> bool {
FENCED_ROOTS.set(fence_roots(homes, None)).is_ok()
}
/// The roots to fence: [`fenced_roots_under`] each of `homes`, plus
/// `claude_config_dir` itself when one is given.
///
/// Why (#8545): Claude Code reads its user config from `$CLAUDE_CONFIG_DIR`
/// when that is set, and it may lie outside every home.
/// What: pure; duplicates dropped.
/// Test: `tests::a_claude_config_dir_outside_home_is_fenced`.
fn fence_roots(homes: &[&Path], claude_config_dir: Option<&Path>) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = homes.iter().flat_map(|h| fenced_roots_under(h)).collect();
roots.extend(claude_config_dir.map(Path::to_path_buf));
// `$HOME` and the passwd home are usually one path; `dedup` alone keeps
// non-adjacent repeats.
let mut seen = std::collections::HashSet::new();
roots.retain(|root| seen.insert(root.clone()));
roots
}
/// A `$CLAUDE_CONFIG_DIR` value as a fence root: `None` when unset or empty.
///
/// Test: `tests::an_empty_claude_config_dir_fences_nothing`.
fn claude_config_dir_root(value: Option<std::ffi::OsString>) -> Option<PathBuf> {
value.filter(|v| !v.is_empty()).map(PathBuf::from)
}
/// Arm the fence over this process's `$HOME`, the password-database home and
/// `$CLAUDE_CONFIG_DIR`.
///
/// Why: `$HOME` is where a home-resolving writer goes; the password-database
/// home is the operator's real one even when `$HOME` was repointed (#5784).
/// #8545: `$CLAUDE_CONFIG_DIR` is the operator's live Claude config when set.
/// What: arms [`fence_roots`] over whichever of the three resolve, and records
/// the `$CLAUDE_CONFIG_DIR` root it read when this call armed the fence.
/// Test: `the_home_write_fence_is_armed_for_this_binary` (tm bin target),
/// `tests::a_claude_config_dir_outside_home_is_fenced`,
/// `tests::the_startup_claude_config_dir_is_an_armed_root_of_this_process`.
pub fn arm_for_this_process() -> bool {
let env_home = std::env::var_os("HOME")
.filter(|v| !v.is_empty())
.map(PathBuf::from);
let passwd_home = crate::core::host_state_gate::passwd_home_dir();
let homes: Vec<&Path> = env_home
.iter()
.chain(passwd_home.iter())
.map(PathBuf::as_path)
.collect();
let claude_config_dir = claude_config_dir_root(std::env::var_os("CLAUDE_CONFIG_DIR"));
let armed = FENCED_ROOTS
.set(fence_roots(&homes, claude_config_dir.as_deref()))
.is_ok();
if armed {
let _ = ARMED_CLAUDE_CONFIG_DIR.set(claude_config_dir);
}
armed
}
/// The `$CLAUDE_CONFIG_DIR` root [`arm_for_this_process`] read when it armed the
/// fence: outer `None` when that call never armed it, inner `None` when the
/// variable was unset or empty.
#[cfg(test)]
fn armed_claude_config_dir() -> Option<Option<&'static Path>> {
ARMED_CLAUDE_CONFIG_DIR.get().map(Option::as_deref)
}
/// The roots this process fences; empty when the fence is not armed.
pub fn armed_roots() -> &'static [PathBuf] {
FENCED_ROOTS.get().map_or(&[], Vec::as_slice)
}
/// The fenced root `dest` sits under, if any. Pure; compares lexically.
pub fn fenced_root<'a>(dest: &Path, roots: &'a [PathBuf]) -> Option<&'a Path> {
roots
.iter()
.find(|root| dest.starts_with(root))
.map(PathBuf::as_path)
}
/// Panic when the armed fence covers `dest`.
///
/// Why: every home-config writer here is best-effort — it logs a failure and
/// carries on — so an `Err` would be swallowed and the test would pass. Only a
/// panic fails the offending test by name.
/// What: no-op unless armed; otherwise panics naming `dest` and its root.
/// Test: `tests::check_panics_only_under_a_fenced_root`.
pub fn check(dest: &Path) {
check_against(dest, armed_roots());
}
/// [`check`] against explicit roots, so the panic is testable without arming.
fn check_against(dest: &Path, roots: &[PathBuf]) {
if let Some(root) = fenced_root(dest, roots) {
panic!(
"#8545: a test reached a write to {} under the fenced home path {}. \
Pass the writer an explicit temp root (FrameworkPaths::under, \
ManagedPaths::from_root, a `home` argument) instead of resolving \
the operator's home.",
dest.display(),
root.display()
);
}
}
#[cfg(test)]
#[path = "home_write_fence_tests.rs"]
mod tests;