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
356
357
//! Scrub Claude Code's process-local session markers from a managed spawn's
//! environment (issue #4467).
//!
//! Why: when `tm` is invoked from inside a Claude Code session — the PM
//! delegating, the daemon having been started from a session, or an operator
//! simply running `tm` in a `claude` pane — it inherits the markers Claude Code
//! injects into every child process it spawns. `tm` then passed that whole
//! environment through to the `claude` it spawns, and the inherited
//! [`TRANSCRIPT_SUPPRESSING_MARKER`] made Claude Code turn session persistence
//! OFF for the managed session:
//!
//! > Transcript saving is off — inherited CLAUDE_CODE_CHILD_SESSION marker
//!
//! The consequence was silent: managed sessions got no native `--resume` /
//! `--continue` / `/rewind` recovery and never appeared in `--resume`, so a
//! session that died lost everything since tm's last (summary-only) snapshot.
//!
//! The marker set below is not guesswork. It is exactly what Claude Code's own
//! child-process env injector sets, read out of the `claude` 2.1.220 bundle:
//!
//! ```text
//! function KDt(e){ let t={CLAUDECODE:"1", CLAUDE_CODE_SESSION_ID:e.sessionId,
//! CLAUDE_CODE_CHILD_SESSION:"1", CLAUDE_PID:String(process.pid)};
//! if(e.effortLevel!==void 0) t.CLAUDE_EFFORT=e.effortLevel; ... }
//! ```
//!
//! plus `CLAUDE_CODE_EXECPATH` (the parent interpreter's path), which appears on
//! that same bundle's own list of variables it refuses to propagate into a
//! shell snapshot. Every entry names the PARENT process — its session id, its
//! pid, its binary, its current turn's effort level — so none of them can be
//! correct for a fresh managed session.
//!
//! What this module deliberately does NOT scrub is as load-bearing as what it
//! does: see [`DELIBERATE_SPAWN_ENV`]. A blanket scrub of `CLAUDE_*` would take
//! `CLAUDE_CONFIG_DIR` with it and instantly re-break issue #4451 — #4455
//! depends on that relocation to put the bundled agent roster in a settings
//! tier a managed session actually loads.
//!
//! Test: `crates/trusty-mpm/src/core/claude_env_scrub_tests.rs`.
/// The marker whose INHERITED presence disables Claude Code transcript saving.
///
/// Why: this is the single trigger, not one of several. The gate in the `claude`
/// 2.1.220 bundle reads:
///
/// ```text
/// function Zkt(){
/// if (Z.CLAUDE_CODE_FORCE_SESSION_PERSISTENCE) return false;
/// if (!(Z.CLAUDE_CODE_CHILD_SESSION && PN() && !o_())) return false;
/// return !Tsg(); // Tsg(): tmux show-environment -g CLAUDE_CODE_CHILD_SESSION
/// }
/// ```
///
/// Named separately from the rest of [`INHERITED_SESSION_MARKERS`] because the
/// `tm doctor` probe asserts specifically on THIS variable: the others are
/// correctness hygiene, this one is the difference between a recoverable
/// managed session and an unrecoverable one.
///
/// Note the upstream escape hatch `Tsg()`: a marker present in the tmux SERVER's
/// global environment suppresses the heuristic. For a tmux PANE the marker's
/// presence and the escape hatch are coupled — tmux's `update-environment` does
/// not carry `CLAUDE_*`, so a marker only reaches a pane when the server's global
/// env has it, which is exactly the condition that trips the hatch.
///
/// Two things make the hatch unreliable anyway, and both argue for scrubbing:
/// `Esg()` runs `spawnSync("tmux", …)` by BARE NAME with a 250 ms timeout and
/// returns false on throw or non-zero status — so it FAILS OPEN INTO SUPPRESSION.
/// A pane running on the daemon's minimal launchd `PATH` (the #1298 hazard) or a
/// machine loaded enough to blow 250 ms both flip a masked session to
/// not-saving. And `tm run` has no tmux at all, so `Esg()` returns false on its
/// first line (`if(!Z.TMUX) return false`) and the suppression fires every time.
/// Scrubbing the variable is what makes the outcome deterministic instead of
/// dependent on a probe that can fail open.
/// What: the literal variable name.
/// Test: `suppressing_marker_is_scrubbed`.
pub const TRANSCRIPT_SUPPRESSING_MARKER: &str = "CLAUDE_CODE_CHILD_SESSION";
/// Claude Code's process-local markers, which a managed spawn must not inherit.
///
/// Why: see the module doc — every entry is set by Claude Code's own
/// child-process env injector and describes the PARENT process, so inheriting
/// any of them mis-attributes the new session. `CLAUDE_CODE_CHILD_SESSION`
/// additionally disables transcript saving outright (issue #4467).
/// What: the marker names, in the order they appear on the spawn's `env -u`
/// prefix. [`TRANSCRIPT_SUPPRESSING_MARKER`] leads so the most consequential
/// entry is the first one a reader (or a pane's shell history) sees.
/// Test: `marker_list_is_free_of_deliberate_spawn_env`,
/// `marker_list_has_no_duplicates`.
pub const INHERITED_SESSION_MARKERS: & = &;
/// tm's own launch stamps, which a child spawn must never inherit (#8453).
///
/// Why: `tm hook --pm-guard` reads `TRUSTY_MPM_SESSION_PROFILE` as the
/// session's LAUNCH-TIME profile decision. A supervisor session's value
/// reaches everything it starts — `tm run`, a daemon, the tmux server — so a
/// child that inherited it would carry a stamp no launch of its own produced.
/// Kept apart from [`INHERITED_SESSION_MARKERS`] because these are not Claude
/// Code's markers: `tm doctor` reports those as a live leak, and a supervisor
/// session's own stamp is not one.
/// What: the stamp names. [`env_unset_flags`] and [`scrub_command`] clear them
/// with the markers; a launch that decides a profile assigns the stamp again
/// AFTER the scrub (`session_profile::launch_env`).
/// Test: `both_helpers_clear_the_profile_stamp`,
/// `the_env_prefix_really_drops_an_inherited_profile_stamp`.
pub const INHERITED_TM_STAMPS: & = &;
/// Toolchain overrides a child spawn must never inherit (#8583).
///
/// Why: a `tm` run through a mise shim carries the `RUSTUP_TOOLCHAIN` mise
/// resolved for the directory `tm` started in, not for the session's project.
/// rustup ranks that variable above a `rust-toolchain.toml` pin, so every agent
/// shell built on the wrong toolchain. The daemon, started by launchd, never
/// had the variable, so the scrub also makes every launch path agree.
/// What: the variable names. The session's own project then decides: its pin
/// file, a mise shim, or the rustup default.
/// Test: `both_helpers_clear_the_toolchain_override`,
/// `the_env_prefix_really_drops_an_inherited_toolchain_override`.
pub const INHERITED_TOOLCHAIN_OVERRIDES: & = &;
/// Every variable a child spawn removes from the environment it inherits.
///
/// Why (#8453): the one list [`env_unset_flags`], [`scrub_command`] and
/// `runtime::managed_env_unset` all read, so every launch path that already
/// scrubs Claude Code's markers clears tm's stamps too.
/// What: [`INHERITED_SESSION_MARKERS`], then [`INHERITED_TM_STAMPS`], then
/// [`INHERITED_TOOLCHAIN_OVERRIDES`].
/// Test: `both_helpers_clear_the_profile_stamp`,
/// `both_helpers_clear_the_toolchain_override`.
/// Environment variables a managed spawn sets DELIBERATELY — never scrub these.
///
/// Why (issue #4451 / #4455): this list exists to make over-scrubbing a
/// mechanical failure instead of a judgement call. `CLAUDE_CONFIG_DIR` is
/// relocated on purpose — it isolates the managed session's auth AND, since
/// #4455, is what puts the bundled agent roster in the `user` settings tier the
/// spawn's `--setting-sources user,project,local` flag loads. Scrub it and every
/// delegation silently degrades to `general-purpose` again. `CLAUDE_CODE_OAUTH_TOKEN`
/// is injected by #2246 to bypass the `CLAUDE_CONFIG_DIR`-keyed Keychain
/// divergence that otherwise produces a login loop.
///
/// Two further inherited variables are deliberately absent from
/// [`INHERITED_SESSION_MARKERS`] and are NOT scrubbed, because neither is
/// process-local state and neither affects transcript saving:
/// `CLAUDE_CODE_MAX_OUTPUT_TOKENS` is an operator-facing configuration knob
/// (scrubbing it would silently override the operator's intent), and
/// `CLAUDE_CODE_ENTRYPOINT` already carries the value a fresh CLI spawn would
/// compute for itself (`cli`). On `CLAUDE_CODE_ENTRYPOINT` specifically: unset is
/// NOT universally equivalent to `"cli"` — the bundle gates one code path on
/// `e === "cli" || e === "remote"`, so scrubbing it would silently disable that
/// path, which is why keeping it is the safer choice at the observed value. The
/// keep is value-dependent rather than proven-safe for every value: an inherited
/// `sdk-ts`/`sdk-py`/`sdk-cli` drops one built-in agent from the roster and
/// `claude-vscode` changes `clientType`. Neither is reachable from a `tm` spawn,
/// whose parent is always a `cli` session.
///
/// `AI_AGENT` and `TRACEPARENT` are set by the same upstream injector but are
/// also left alone. `AI_AGENT` self-heals: the bundle overwrites any inherited
/// value beginning `claude-code_`/`claude-code/` with its own at startup, so a
/// leaked value cannot reach the session stale. `TRACEPARENT` is only set when
/// tracing is on and its sole effect is span parenting. Both are generically
/// named, so a third party could legitimately set them.
///
/// `CLAUDE_CODE_INVOKED_SKILLS` appears on the same upstream non-propagation
/// list that justifies including `CLAUDE_CODE_EXECPATH`, and is deliberately NOT
/// scrubbed: it was not in the observed leak set, nothing in this workspace reads
/// it, and it carries no session identity — it records which skills the parent
/// turn invoked. Unlike the entries above, a stale value cannot mis-attribute a
/// session or suppress a transcript, so scrubbing it would be unjustified churn
/// on a list whose whole value is that every entry has a stated reason.
/// What: the variable names a managed spawn assigns rather than unsets.
/// Test: `marker_list_is_free_of_deliberate_spawn_env`,
/// `config_dir_is_never_scrubbed`.
pub const DELIBERATE_SPAWN_ENV: & = &;
/// The POSIX `env` unset flags that scrub every [`scrubbed_on_spawn`] entry.
///
/// Why: the tmux-pane spawn paths build a shell command STRING, so the scrub has
/// to be expressed as `env` flags rather than a `Command` mutation. Generating
/// them from the shared list keeps the string-command paths
/// (`spawn_command` / `resume_command`) and the `Command`-based paths
/// ([`scrub_command`]) from ever scrubbing different sets.
/// What: `" -u NAME"` per marker, each with a LEADING space so the result
/// concatenates directly onto an existing `env -u …` prefix. Marker names are
/// compile-time constants containing no shell metacharacters, so they need no
/// quoting. Every flag precedes any `NAME=VALUE` assignment, as POSIX `env`
/// grammar requires — an assignment before a `-u` makes `env` stop parsing
/// options and try to exec `-u` as a command.
/// Test: `env_unset_flags_covers_every_marker`,
/// `env_unset_flags_are_space_prefixed`, `both_helpers_clear_the_profile_stamp`.
/// Remove every [`scrubbed_on_spawn`] entry from a [`std::process::Command`].
///
/// Why: the in-place relaunch (`build_inplace_exec_command`) and the
/// stream-json backend (`control::backend::stream_json`) exec `claude` through a
/// `Command` with no shell in between, so [`env_unset_flags`] does not apply to
/// them — but they leak exactly the same markers and need exactly the same
/// scrub. Using `env_remove` (rather than filtering an env map) keeps the
/// removal an explicit, inspectable part of the command that `get_envs()`
/// surfaces as `(key, None)`, which is what the tests assert on.
/// What: calls [`std::process::Command::env_remove`] once per marker.
/// Test: `scrub_command_removes_every_marker`,
/// `scrub_command_leaves_deliberate_env_intact`, `both_helpers_clear_the_profile_stamp`.
/// Parse the variable names an `env …` prefix string unsets via `-u NAME`.
///
/// Why: this is what lets the `tm doctor` probe read the scrub set out of the
/// REAL spawn command instead of restating the constant. A check that compared
/// [`INHERITED_SESSION_MARKERS`] against itself would be a tautology and would
/// stay green if someone dropped the flags from the spawn builder — the exact
/// shape of blind spot issue #4467 is about.
/// What: whitespace-splits `prefix`, skips the leading `env` word, and collects
/// the token following each standalone `-u`. Parsing STOPS at the first token
/// that ends `env`'s own argument list — a `NAME=VALUE` assignment or the
/// COMMAND word. A trailing `-u` with no operand yields nothing.
///
/// Both stops model POSIX `env [OPTION]... [NAME=VALUE]... [COMMAND] [ARG]...`
/// rather than merely scanning the whole string for `-u`.
///
/// The assignment stop keeps this from reporting a variable as unset on a prefix
/// that `env` would actually reject: `env CLAUDE_CODE_CHILD_SESSION=1 -u FOO
/// claude` makes `env` stop parsing options at the assignment and try to exec
/// `-u` as a command (`env: -u: No such file or directory`, exit 127). A parser
/// that reported `FOO` unset there would let the `tm doctor` probe pass on an
/// ordering that kills every spawn.
///
/// The COMMAND stop (review round 2) matters because a builder can emit NO
/// assignments at all, so the assignment stop never fires on its output and the
/// scan would otherwise run to the end of the line. A future `claude` flag pair
/// spelled `-u <value>` would then be miscounted as an unset variable. Not
/// reachable today — this closes the hole before a flag makes it reachable.
/// #4181 gave [`crate::core::model_inject::build_claude_command`] the same
/// optional `CLAUDE_CONFIG_DIR` / `CLAUDE_CODE_OAUTH_TOKEN` assignments the
/// daemon prefix carries, so on that line the assignment stop fires. #6495 then
/// gave every remaining shell builder — including
/// [`crate::core::model_inject::build_inplace_session_command`] and
/// `build_client_session_command`, which were assignment-free until then — the
/// unconditional
/// [`crate::core::alt_screen::configured_shell_assignments`] operand text
/// (#7160 added a second variable to it, #8405 made the renderer config-decided), so the assignment stop now fires on all of
/// them. `daemon::spawn_command`'s
/// `relaunch_command` is the one that stays assignment-free, which is what keeps
/// the COMMAND stop reachable.
/// Test: `parse_env_unset_vars_reads_the_real_spawn_prefix`,
/// `parse_env_unset_vars_stops_at_the_first_assignment`,
/// `parse_env_unset_vars_stops_at_the_command_word`,
/// `parse_env_unset_vars_tolerates_trailing_flag`.
/// Which [`INHERITED_SESSION_MARKERS`] are live in the CURRENT process env.
///
/// Why: `tm doctor` reports these so an operator can see whether this
/// invocation is itself running with the leak present — the difference between
/// "the scrub is configured correctly" (always checkable) and "the leak is
/// actually reaching me right now" (situational). Reported as context, never as
/// the pass/fail condition, because `tm doctor` run from a plain shell has no
/// markers set and the check must still be meaningful there.
/// What: delegates to [`markers_present_in`] with a real-environment lookup.
/// Test: covered via [`markers_present_in`].
/// Pure core of [`markers_present_in_env`]: which markers does `is_set` report?
///
/// Why: taking the lookup as a parameter keeps this testable without mutating
/// the process environment. `std::env::set_var` is `unsafe` in edition 2024 and
/// mutates state shared by every test in the binary, so a test that set a real
/// marker could flip the result of any concurrently-running test that reads the
/// environment. Injecting the lookup removes the shared mutable state instead of
/// trying to synchronise around it.
/// What: the subset of [`INHERITED_SESSION_MARKERS`] for which `is_set` returns
/// true. Presence is what matters, not value — Claude Code sets these to `"1"`
/// or an id, and an empty value would still have been inherited.
/// Test: `markers_present_in_detects_a_set_marker`,
/// `markers_present_in_is_empty_for_a_clean_env`,
/// `markers_present_in_reports_every_marker_when_all_are_set`.