Skip to main content

agentmux_srv/backend/
pane_env.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Instance identity must not be inheritable by the processes a pane spawns.
5//!
6//! # The defect this closes
7//!
8//! Both pane-spawn paths build their environment by inheriting srv's, and srv's
9//! environment is where the launcher injects the whole instance identity set
10//! (`DataPaths::to_env_vars` — data dir, config dir, cache dir, channel, runtime
11//! mode) and where srv adds more of its own (`AGENTMUX_DATA_HOME`,
12//! `AGENTMUX_CONFIG_HOME`, `AGENTMUX_LOCAL_URL`).
13//!
14//! * terminal panes: `portable_pty::CommandBuilder` seeds from
15//!   `std::env::vars_os()`, so `.env()` calls are overrides on a full copy.
16//! * agent panes: `tokio::process::Command` inherits by default.
17//!
18//! So every `AGENTMUX_*` variable reached every pane, and therefore every
19//! process launched from one — including another AgentMux build, which then
20//! adopted the launching instance's identity instead of the one baked into it.
21//! Confirmed live: a 0.56.3 build launched from a pane wrote into a 0.56.2
22//! instance's channel. That is invariant I6 breached through a channel I1–I6
23//! never named, because they enumerate OS objects and directories and treat the
24//! threat as "a new launch must not crash a running one" — this is neither.
25//!
26//! Full analysis, including the five prior documents that identified this and
27//! left it open:
28//! `docs/retro/retro-env-inheritance-instance-isolation-breach-2026-09-17.md`.
29//!
30//! # The rule
31//!
32//! An `AGENTMUX_*` variable crosses into a pane only if it is on [`PANE_ENV_KEEP`]
33//! — an allowlist, not a denylist, so a variable added later is excluded by
34//! default rather than leaking until someone remembers to deny it. That
35//! direction is the whole point: the previous state of the world was an
36//! implicit allow-everything.
37//!
38//! This strips only what would arrive *by inheritance*. Callers apply their own
39//! explicit `.env()` values afterwards, which is why the terminal path can strip
40//! nearly everything — it re-sets what it needs a few lines later.
41
42/// `AGENTMUX_*` variables permitted to reach a pane by inheritance.
43///
44/// Derived from an exhaustive audit of every `process.env` read in
45/// `backend/shellintegration/` (`muxsh`, `muxlog`, `muxspect`, `muxopen`,
46/// `lib/muxclient.mjs`) plus the shell rc scripts. Each entry needs a reason;
47/// anything without one does not belong here.
48pub const PANE_ENV_KEEP: &[&str] = &[
49    // The instance's own loopback API, and the key to it. `lib/muxclient.mjs`
50    // reads exactly these two, and every helper CLI goes through it.
51    //
52    // These remain the largest residual coupling: a process that inherits both
53    // holds an authenticated handle to the instance that spawned it. Replacing
54    // them with a per-invocation handoff is tracked as follow-up in the retro
55    // (§9.4) — it is a redesign of how helpers reach the server, not a strip.
56    "AGENTMUX_LOCAL_URL",
57    "AGENTMUX_AUTH_KEY",
58    // Which pane the helper is speaking for.
59    "AGENTMUX_BLOCKID",
60    "AGENTMUX_TABID",
61    // Prompt hook + `muxlog` banner (bash.sh/zsh.sh/fish.fish/pwsh.ps1).
62    // With the other two, which build a pane runs in ([`build_info`]).
63    "AGENTMUX_VERSION",
64    "AGENTMUX_BUILD",
65    "AGENTMUX_INSTANCE_CHANNEL",
66    "AGENTMUX_LOG_DIR",
67    // Agent identity: the OSC-16162 prompt hook, external tooling that
68    // selects credentials per agent, and bashwrap's per-instance cwd-state
69    // file key.
70    //
71    // AGENTMUX_AGENT_COLOR/AGENTMUX_AGENT_TEXT_COLOR decommissioned
72    // 2026-09-20 (SPEC_AGENT_HEADER_COLOR_UNIFICATION_2026_09_20.md) — the
73    // only consumer was the frontend's env-var-driven pane header/border
74    // color detection, itself replaced by reusing the agent's persisted
75    // `ui:color` (SPEC_AGENT_COLOR_2026_08_08.md). Nothing reads these two
76    // keys downstream of the OSC-16162 `cmd:env` payload anymore.
77    "AGENTMUX_AGENT_ID",
78    "AGENTMUX_AGENT_SLUG",
79    "AGENTMUX_AGENT_DISPLAY",
80    "AGENTMUX_INSTANCE_SLUG",
81];
82
83/// The nesting sentinel. Presence tells a launcher started from a pane to
84/// ignore ambient `AGENTMUX_*` and re-derive from its own exe path
85/// (`crates/launcher/src/data_dir.rs`).
86pub const NESTING_SENTINEL_KEY: &str = "AGENTMUX";
87
88/// Keys in *this process's* environment that must not cross into a pane.
89///
90/// Computed from the live environment rather than a hardcoded list, so a
91/// variable introduced anywhere — including by a dependency or a future
92/// `set_var` — is stripped without anyone updating this file.
93pub fn keys_to_strip() -> Vec<String> {
94    std::env::vars_os()
95        .filter_map(|(k, _)| k.into_string().ok())
96        .filter(|k| should_strip(k))
97        .collect()
98}
99
100/// Is `key` an AgentMux variable that must not be inherited by a pane?
101pub fn should_strip(key: &str) -> bool {
102    if !key.starts_with("AGENTMUX") {
103        return false;
104    }
105    // The sentinel is set deliberately by both spawn paths; never strip it.
106    if key == NESTING_SENTINEL_KEY {
107        return false;
108    }
109    !PANE_ENV_KEEP.contains(&key)
110}
111
112
113/// Apply the policy to a PTY command: strip inherited identity, then mark the
114/// child as running inside AgentMux.
115///
116/// Exists so a caller cannot apply half of it. The first version of this fix
117/// inlined the strip at ONE of `shell/lifecycle.rs`'s three `CommandBuilder`
118/// branches, leaving the direct-spawn path (agent CLIs launched with args) and
119/// the shell-wrapped `cmd` path fully leaking — caught in review as a P0.
120/// Call this once on the finished command instead.
121///
122/// Safe to call after the caller's own `.env()` values: everything a pane
123/// legitimately sets is on [`PANE_ENV_KEEP`], so nothing it just set is removed.
124pub fn sanitize_pty_command(c: &mut portable_pty::CommandBuilder) {
125    for key in keys_to_strip() {
126        c.env_remove(&key);
127    }
128    c.env(NESTING_SENTINEL_KEY, "1");
129    for (key, value) in build_info() {
130        c.env(key, value);
131    }
132}
133
134/// Which AgentMux a pane runs in, set on every process a pane starts so an
135/// agent can say so without reading it out of a log path:
136/// - `AGENTMUX_VERSION`: the release version (`0.59.16`). The shell helpers
137///   also use it to name log pointer files, so it stays the plain version.
138/// - `AGENTMUX_BUILD`: the exact build: a local portable's label
139///   (`0.59.16+g2a5424fe2.20261008T234046.81801`), else the version.
140/// - `AGENTMUX_INSTANCE_CHANNEL`: the channel this instance runs in (`stable`,
141///   or a local build's own channel). Information only: `AGENTMUX_CHANNEL`,
142///   which says where an instance keeps its data, never reaches a pane, and
143///   nothing reads this one to choose a data directory.
144pub fn build_info() -> &'static [(&'static str, String)] {
145    static INFO: std::sync::OnceLock<Vec<(&'static str, String)>> = std::sync::OnceLock::new();
146    INFO.get_or_init(|| {
147        let version = env!("CARGO_PKG_VERSION").to_string();
148        let build = std::env::current_exe()
149            .ok()
150            .and_then(|exe| exe.parent().and_then(agentmux_common::runtime_mode::portable_build_label))
151            .unwrap_or_else(|| version.clone());
152        let mut info = vec![("AGENTMUX_VERSION", version), ("AGENTMUX_BUILD", build)];
153        if let Some(channel) = std::env::var("AGENTMUX_CHANNEL").ok().filter(|c| !c.trim().is_empty()) {
154            info.push(("AGENTMUX_INSTANCE_CHANNEL", channel));
155        }
156        info
157    })
158}
159
160/// `sanitize_pty_command` for a `tokio::process::Command` — agent subprocesses,
161/// and the `/api/v1/shell/create` runner behind the MCP `Shell` tool, which is
162/// how CLAUDE.md tells agents to launch `task dev` / `task package`. That path
163/// was missed entirely by the first revision of this fix (ReAgent P0), which is
164/// notable because it is the single most likely way for an agent to start a
165/// second instance.
166pub fn sanitize_process_command(cmd: &mut tokio::process::Command) {
167    for key in keys_to_strip() {
168        cmd.env_remove(&key);
169    }
170    cmd.env(NESTING_SENTINEL_KEY, "1");
171    for (key, value) in build_info() {
172        cmd.env(key, value);
173    }
174}
175
176
177/// Strip **every** `AGENTMUX_*` variable, including the in-pane helper keep-set.
178///
179/// For processes that are neither ours nor helpers: provider CLIs launched for
180/// OAuth login (`server/identity_auth_spawn.rs`). Those are third-party,
181/// network-connected binaries, so even [`PANE_ENV_KEEP`] is too generous —
182/// `AGENTMUX_LOCAL_URL` would hand them this instance's API endpoint, and
183/// `AGENTMUX_AUTH_KEY` the credential to it. `muxsh` needs those; `claude
184/// login` does not.
185///
186/// The nesting sentinel is still set: it carries no identity, and it stops a
187/// launcher started anywhere below from adopting ambient state.
188pub fn sanitize_external_command(cmd: &mut tokio::process::Command) {
189    for key in all_agentmux_keys() {
190        cmd.env_remove(&key);
191    }
192    cmd.env(NESTING_SENTINEL_KEY, "1");
193}
194
195/// `sanitize_external_command` for a PTY command.
196pub fn sanitize_external_pty_command(c: &mut portable_pty::CommandBuilder) {
197    for key in all_agentmux_keys() {
198        c.env_remove(&key);
199    }
200    c.env(NESTING_SENTINEL_KEY, "1");
201}
202
203/// Every `AGENTMUX*` key in this process's environment except the sentinel.
204fn all_agentmux_keys() -> Vec<String> {
205    std::env::vars_os()
206        .filter_map(|(k, _)| k.into_string().ok())
207        .filter(|k| k.starts_with("AGENTMUX") && k != NESTING_SENTINEL_KEY)
208        .collect()
209}
210
211
212/// `sanitize_external_command` for a synchronous `std::process::Command`.
213///
214/// Needed because not every external spawn is async — `npm install` in
215/// `backend/cli_install.rs` is a blocking `.output()` call, and it runs
216/// arbitrary postinstall scripts.
217pub fn sanitize_external_std_command(cmd: &mut std::process::Command) {
218    for key in all_agentmux_keys() {
219        cmd.env_remove(&key);
220    }
221    cmd.env(NESTING_SENTINEL_KEY, "1");
222}
223
224#[cfg(test)]
225mod spawn_site_coverage {
226    //! Invariant I7: an instance's identity must not be inheritable by the
227    //! processes it spawns.
228    //!
229    //! The tests below this module check the *policy* — which variables get
230    //! stripped. Nothing checked the *call sites*, and that is the gap that let
231    //! this class of bug survive five write-ups: the policy was never the hard
232    //! part, applying it at a NEW spawn site was. Both P0s on the original fix
233    //! were exactly that.
234    //!
235    //! # Why an exact inventory rather than "does this file sanitize?"
236    //!
237    //! The first version of this test asked, per file, whether any sanitizer
238    //! appeared anywhere in it. That is too weak, and reagent was right to
239    //! block on it (#3365 P1): a file that already sanitizes one command
240    //! passes wholesale, so a new unsanitized spawn added beside it is invisible
241    //! — and that describes most non-trivial spawn files in the crate.
242    //!
243    //! Per-*occurrence* matching does not work either, and `shell/lifecycle.rs`
244    //! shows why: it builds four `CommandBuilder`s as `c` and sanitizes once,
245    //! later, at a choke point as `cmd`. That is the correct pattern, and any
246    //! syntactic per-site rule flags it. Proving it needs dataflow analysis.
247    //!
248    //! So this pins the whole surface instead: every `(file, program)` pair that
249    //! constructs a process, with an exact count and a stated classification.
250    //! A new spawn anywhere changes the map and fails, regardless of what its
251    //! neighbours do. Removing one fails too, so the table cannot rot.
252    //!
253    //! Adding a spawn is then a two-line diff: sanitize it, and say so here.
254
255    use std::collections::BTreeMap;
256    use std::path::{Path, PathBuf};
257
258    /// Ways a process gets constructed in this crate.
259    const SPAWN_MARKERS: &[&str] = &["CommandBuilder::new", "process::Command::new", "Command::new("];
260
261    /// Recognised classifications. The prefix is load-bearing: `sanitized:`
262    /// entries are cross-checked against the file actually calling a sanitizer.
263    const CLASSES: &[&str] = &["sanitized:", "probe:", "test:", "own-exe:", "not a spawn:", "mixed:"];
264
265    /// Every process spawn in srv: `(file, program, count, classification)`.
266    ///
267    /// `program` is the literal text between `new(` and the first `,` or `)`,
268    /// which is stable across edits in a way line numbers are not.
269    const SPAWN_INVENTORY: &[(&str, &str, usize, &str)] = &[
270        ("src/agents/runner.rs", "bin", 1,
271         "sanitized: agent CLI subprocess, sanitize_process_command"),
272        ("src/backend/attachments/extract.rs", "exe", 1,
273         "sanitized: our own binary re-run as `__extract` to parse one attachment in isolation; it needs no instance identity, so sanitize_external_std_command"),
274        ("src/backend/attachments/extract.rs", "harness", 1,
275         "test: re-runs the test harness as a child that sleeps, to prove the extraction deadline kills it"),
276        ("src/backend/storage/muxbus.rs", "std::env::current_exe(", 1,
277         "test: re-runs the test harness as children that save and read one keychain sign-in at once (ignored; real keychain)"),
278        ("src/backend/blockcontroller/app_server.rs", "fake_server_binary(", 1,
279         "test: runs the fixture server built above; lives and dies with the test"),
280        ("src/backend/remote/ssh.rs", "ssh", 1,
281         "probe: `ssh -V` for the client's version (forced askpass), output parsed then discarded"),
282        ("src/backend/remote/host.rs", "&self.ssh_path", 1,
283         "sanitized: a durable SSH pane's ssh (attach, end, helper install: one ssh_command), the same sanitize_process_command"),
284        ("src/backend/blockcontroller/persistent/tests/eager_resume.rs", "\"taskkill\"", 1,
285         "test: the eager-resume harness's Drop force-kills its stub CLI on Windows so a failed assertion cannot leave it running"),
286        ("src/backend/blockcontroller/persistent/tests/eager_resume.rs", "\"kill\"", 1,
287         "test: the same Drop teardown on Unix"),
288        ("src/backend/blockcontroller/persistent/tests/send_input.rs", "\"echo\"", 1,
289         "not a spawn: appears inside a comment explaining why `echo` cannot be spawned directly"),
290        ("src/backend/blockcontroller/shell/lifecycle.rs", "\"/bin/sh\"", 1,
291         "sanitized: Unix fallback shell, covered by the same single sanitize_pty_command"),
292        ("src/backend/blockcontroller/shell/lifecycle.rs", "\"cmd.exe\"", 1,
293         "sanitized: Windows fallback shell, covered by the same single sanitize_pty_command"),
294        ("src/backend/blockcontroller/shell/lifecycle.rs", "\"shell-under-test\"", 1,
295         "not a spawn: global_cmd_env_tests builds a CommandBuilder only to read back the cmd:env applied to it; never spawned"),
296        ("src/backend/blockcontroller/shell/lifecycle.rs", "&program", 1,
297         "sanitized: pane PTY, one choke-point sanitize_pty_command covers all four builders here"),
298        ("src/backend/blockcontroller/shell/lifecycle.rs", "&shell_path", 1,
299         "sanitized: the configured login shell, covered by the same sanitize_pty_command"),
300        ("src/backend/blockcontroller/shell/lifecycle.rs", "\"wsl.exe\"", 1,
301         "sanitized: a WSL pane's wsl.exe, covered by the same sanitize_pty_command"),
302        ("src/backend/shells.rs", "&c.path", 1,
303         "probe: each installed shell's `--version` for Settings' default-shell list, output parsed then discarded"),
304        ("src/backend/blockcontroller/shell/ssh_pane.rs", "ssh_path", 1,
305         "sanitized: an SSH pane's system ssh, built here and sanitized by lifecycle.rs's sanitize_pty_command"),
306        ("src/backend/blockcontroller/shell/tests.rs", "\"sleep\"", 1,
307         "test: disposable `sleep 30` made its own process-group leader, to prove a group-kill reaches an isolated group"),
308        ("src/backend/claude_layout.rs", "\"git\"", 1,
309         "test: builds a throwaway repository with a linked worktree in a temp dir, to check the CLI's memory-folder rule"),
310        ("src/backend/container_cli.rs", "\"sh\"", 2,
311         "test: Unix only; runs the container CLI install and check scripts against a stub npm in a temp dir, to prove they are idempotent and write no marker on failure"),
312        ("src/backend/lsp/supervisor.rs", "&resolved", 1,
313         "sanitized: language server, sanitize_process_command"),
314        ("src/backend/mcp_probe.rs", "command", 1,
315         "sanitized: MCP server probe, sanitize_process_command"),
316        ("src/backend/process_tracker/windows.rs", "\"cmd\"", 1,
317         "test: a short-lived child placed in a Job Object to check the below-normal priority limit applies to it"),
318        ("src/backend/process_tracker/registry.rs", "\"cmd\"", 5,
319         "test: disposable children for the job-object tracker (assign, adopt, CPU accounting)"),
320        ("src/backend/process_tracker/registry.rs", "\"sh\"", 1,
321         "test: disposable child for the job-object tracker"),
322        ("src/backend/process_tracker/scan.rs", "\"sh\"", 1,
323         "test: disposable children the scan tracker tests find and kill"),
324        ("src/backend/process_tracker/cgroup_linux.rs", "\"sleep\"", 1,
325         "test: the argv the PTY-wrap test wraps; never spawned as built"),
326        ("src/backend/process_tracker/cgroup_linux.rs", "&argv[0]", 1,
327         "test: runs the wrapped argv the cgroup tests track and kill"),
328        ("src/backend/process_tracker/cgroup_linux.rs", "\"sh\"", 1,
329         "test: disposable children the cgroup tests track and kill"),
330        ("src/sagas/close_pane.rs", "\"cmd\"", 2,
331         "test: stand-in background dev servers (Stop ends one; a failed Stop keeps one)"),
332        ("src/sagas/close_pane.rs", "\"sh\"", 2,
333         "test: stand-in background dev servers (Stop ends one; a failed Stop keeps one)"),
334        ("src/server/agent_resources.rs", "\"cmd\"", 2,
335         "test: children for the survivor check and the forced tree kill"),
336        ("src/server/agent_resources.rs", "\"sh\"", 2,
337         "test: children for the survivor check and the forced tree kill"),
338        ("src/backend/remote/helper_install.rs", "\"sh\"", 2,
339         "test: runs the install scripts in a real sh against a temp home"),
340        ("src/backend/fs_ops/host_jobs.rs", "\"mkfifo\"", 1,
341         "test: makes a FIFO for the host transfer tests (Linux only)"),
342        ("src/backend/tower_remote.rs", "\"wsl.exe\"", 1,
343         "sanitized: Tower's helper inside a WSL distribution, sanitize_external_command"),
344        ("src/backend/remote/wsl.rs", "\"wsl.exe\"", 1,
345         "probe: `wsl.exe --list --quiet`, output parsed then discarded"),
346        ("src/backend/remote/address.rs", "\"wsl.exe\"", 1,
347         "sanitized: a WSL pane's address (`cat /proc/net/...`), sanitize_external_command"),
348        ("src/backend/remote/address.rs", "binary", 1,
349         "sanitized: an SSH pane's host (`ssh -G`), sanitize_external_command"),
350        ("src/backend/shell_node.rs", "\"cmd\"", 1,
351         "sanitized: persistent shell node, choke-point sanitize_process_command"),
352        ("src/backend/shell_node.rs", "\"sh\"", 1,
353         "sanitized: Unix branch of the same node, one sanitize_process_command covers both"),
354        ("src/backend/shell_node.rs", "program", 1,
355         "sanitized: an agent Shell run elsewhere (wsl.exe for WSL, ssh for SSH), the same sanitize_process_command"),
356        ("src/backend/tool_store.rs", "\"where\"", 2,
357         "probe: Windows PATH lookup for a tool; output parsed for a path, never executed"),
358        ("src/backend/tool_store.rs", "\"which\"", 2,
359         "probe: Unix PATH lookup for a tool; output parsed for a path, never executed"),
360        ("src/backend/tool_store.rs", "cmd", 1,
361         "sanitized: probe_version runs a third-party binary, strict policy (this PR)"),
362        ("src/crash_monitor.rs", "&exe", 1,
363         "own-exe: re-spawns OUR OWN binary as the crash monitor; it must stay this instance, so inheritance is the requirement here, not the defect"),
364        ("src/backend/cli_install.rs", "\"cmd\"", 1,
365         "sanitized: shell wrapper for npm install, sanitize_external_std_command"),
366        ("src/backend/cli_install.rs", "\"npm\"", 1,
367         "sanitized: npm runs arbitrary postinstall scripts, sanitize_external_std_command"),
368        ("src/backend/cli_prune.rs", "\"ps\"", 1,
369         "sanitized: lists running command lines to see which installed CLI is in use, sanitize_external_std_command"),
370        ("src/server/cli_handlers.rs", "\"where\"", 2,
371         "probe: Windows availability check for npm before install; runs nothing else"),
372        ("src/server/cli_handlers.rs", "\"which\"", 2,
373         "probe: Unix availability check for npm before install; runs nothing else"),
374        ("src/backend/fs_ops/git.rs", "\"git\"", 5,
375         "mixed: run_git (Files pane git markers, agents' work facts) is sanitize_external_command'd; four are this file's own #[cfg(test)] module"),
376        ("src/backend/work_facts/git.rs", "\"git\"", 1,
377         "test: builds a throwaway repository in a temp dir to check the work-facts probe; production git goes through fs_ops::git's run_git"),
378        ("src/backend/fs_ops/platform.rs", "\"explorer.exe\"", 2,
379         "sanitized: Files pane open/reveal (file manager, default app), sanitize_external_std_command in platform::spawn_detached"),
380        ("src/backend/fs_ops/platform.rs", "\"open\"", 2,
381         "sanitized: Files pane open/reveal (file manager, default app), sanitize_external_std_command in platform::spawn_detached"),
382        ("src/backend/fs_ops/platform.rs", "\"xdg-open\"", 2,
383         "sanitized: Files pane open/reveal (file manager, default app), sanitize_external_std_command in platform::spawn_detached"),
384        ("src/backend/fs_watch/pool.rs", "\"wsl.exe\"", 2,
385         "test: an ignored test writes and removes a file inside a WSL distro"),
386        ("src/server/editor_handlers.rs", "\"explorer.exe\"", 1,
387         "sanitized: file manager, strict policy (I7 fix, this PR)"),
388        ("src/server/editor_handlers.rs", "\"open\"", 1,
389         "sanitized: file manager, strict policy (I7 fix, this PR)"),
390        ("src/server/editor_handlers.rs", "\"xdg-open\"", 1,
391         "sanitized: file manager, strict policy (I7 fix, this PR)"),
392        ("src/server/identity_auth_spawn.rs", "&cli_path", 1,
393         "sanitized: provider OAuth CLI, strict policy"),
394        ("src/server/identity_auth_spawn.rs", "&spawn_program", 1,
395         "sanitized: provider OAuth CLI under a PTY (the resolved npm shim), strict policy"),
396        ("src/server/install_handlers.rs", "if cfg!(windows", 1,
397         "sanitized: installer shell, sanitize_external_command"),
398        ("src/server/shell_handlers.rs", "&shell", 1,
399         "sanitized: /api/v1/shell/create runner (resolved POSIX shell), sanitize_process_command"),
400        ("src/server/system_install_handlers.rs", "\"apt-cache\"", 1,
401         "probe: package-manager availability query, no install"),
402        ("src/server/system_install_handlers.rs", "\"brew\"", 1,
403         "probe: package-manager availability query, no install"),
404        ("src/server/system_install_handlers.rs", "\"winget\"", 1,
405         "probe: package-manager availability query, no install"),
406        ("src/server/system_install_handlers.rs", "&step.program", 1,
407         "sanitized: package-manager install step, sanitize_external_command"),
408        ("src/server/system_install_handlers.rs", "program", 1,
409         "not a spawn: appears inside this module's header comment describing the argv rule"),
410        ("src/server/voice.rs", "&cli", 1,
411         "sanitized: voice CLI, sanitize_process_command"),
412        ("src/test_support.rs", "\"rustc\"", 1,
413         "test: builds the fixture App Server binary once per source hash, shared by the app_server tests"),
414        ("src/util.rs", "\"cmd\"", 3,
415         "mixed: one is open_browser's sanitized Windows branch; two are in this file's own #[cfg(test)] module"),
416        ("src/util.rs", "\"open\"", 1,
417         "sanitized: browser launch, strict policy (I7 fix, this PR)"),
418        ("src/util.rs", "\"xdg-open\"", 1,
419         "sanitized: browser launch, strict policy (I7 fix, this PR)"),
420    ];
421
422    fn rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
423        let Ok(entries) = std::fs::read_dir(dir) else { return };
424        for entry in entries.flatten() {
425            let path = entry.path();
426            if path.is_dir() {
427                rs_files(&path, out);
428            } else if path.extension().is_some_and(|e| e == "rs") {
429                out.push(path);
430            }
431        }
432    }
433
434    /// `Command::new(` must not match `AgentCommand::new(` — a substring hit
435    /// preceded by an identifier character is a different type.
436    fn line_spawns(line: &str) -> bool {
437        for marker in SPAWN_MARKERS {
438            let mut from = 0;
439            while let Some(idx) = line[from..].find(marker) {
440                let at = from + idx;
441                let prev_is_ident = at > 0
442                    && line[..at]
443                        .chars()
444                        .next_back()
445                        .is_some_and(|c| c.is_alphanumeric() || c == '_');
446                if !prev_is_ident {
447                    return true;
448                }
449                from = at + marker.len();
450            }
451        }
452        false
453    }
454
455    /// The text between `new(` and the first `,` or `)`.
456    fn program_of(line: &str) -> String {
457        let Some(at) = line.find("new(") else { return "?".to_string() };
458        let rest = &line[at + 4..];
459        let end = rest.find([',', ')']).unwrap_or(rest.len());
460        rest[..end].trim().to_string()
461    }
462
463    fn actual_inventory() -> BTreeMap<(String, String), usize> {
464        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
465        let mut files = Vec::new();
466        rs_files(&root.join("src"), &mut files);
467        assert!(files.len() > 50, "source walk found only {} files — the walk is broken", files.len());
468
469        let mut map: BTreeMap<(String, String), usize> = BTreeMap::new();
470        for file in &files {
471            let rel = file.strip_prefix(root).unwrap_or(file).to_string_lossy().replace('\\', "/");
472            // This file defines the sanitizers and names the markers; it does
473            // not spawn.
474            if rel.ends_with("backend/pane_env.rs") {
475                continue;
476            }
477            let Ok(src) = std::fs::read_to_string(file) else { continue };
478            for line in src.lines() {
479                if line_spawns(line) {
480                    *map.entry((rel.clone(), program_of(line))).or_default() += 1;
481                }
482            }
483        }
484        map
485    }
486
487    #[test]
488    fn the_spawn_surface_matches_the_inventory_exactly() {
489        let actual = actual_inventory();
490        let expected: BTreeMap<(String, String), usize> = SPAWN_INVENTORY
491            .iter()
492            .map(|(f, p, c, _)| ((f.to_string(), p.to_string()), *c))
493            .collect();
494
495        let mut problems = Vec::new();
496        for (key, count) in &actual {
497            match expected.get(key) {
498                None => problems.push(format!(
499                    "NEW spawn site: {} -> Command::new({}) x{count}",
500                    key.0, key.1
501                )),
502                Some(exp) if exp != count => problems.push(format!(
503                    "COUNT changed: {} -> Command::new({}): inventory says {exp}, source has {count}",
504                    key.0, key.1
505                )),
506                _ => {}
507            }
508        }
509        for key in expected.keys() {
510            if !actual.contains_key(key) {
511                problems.push(format!("STALE entry (no longer in source): {} -> {}", key.0, key.1));
512            }
513        }
514
515        assert!(
516            problems.is_empty(),
517            "I7: srv's process-spawn surface changed.\n  {}\n\n\
518             If you added a spawn: apply the right `pane_env::sanitize_*` to it, then add \n\
519             it to SPAWN_INVENTORY with a classification. If you removed one, drop its row. \n\
520             The inventory is pinned deliberately — a new spawn must not be able to hide \n\
521             beside an already-sanitized one (#3365 P1). Background: \n\
522             docs/retro/retro-env-inheritance-instance-isolation-breach-2026-09-17.md",
523            problems.join("\n  ")
524        );
525    }
526
527    #[test]
528    fn every_inventory_row_is_classified_and_justified() {
529        let root = Path::new(env!("CARGO_MANIFEST_DIR"));
530        for (file, program, _, reason) in SPAWN_INVENTORY {
531            assert!(
532                CLASSES.iter().any(|c| reason.starts_with(c)),
533                "{file} -> {program}: reason must start with one of {CLASSES:?}, got {reason:?}"
534            );
535            assert!(
536                reason.len() > 30,
537                "{file} -> {program}: needs a real reason, got {reason:?}"
538            );
539            // A row claiming to be sanitized must be in a file that sanitizes.
540            // Cheap, and it catches a table that says one thing while the code
541            // does another.
542            if reason.starts_with("sanitized:") {
543                let src = std::fs::read_to_string(root.join(file)).expect("read inventory file");
544                assert!(
545                    SANITIZERS_FOR_AUDIT.iter().any(|s| src.contains(s)),
546                    "{file} -> {program} is classified `sanitized:` but the file calls no sanitizer"
547                );
548            }
549        }
550    }
551
552    const SANITIZERS_FOR_AUDIT: &[&str] = &[
553        "sanitize_pty_command",
554        "sanitize_process_command",
555        "sanitize_external_command",
556        "sanitize_external_pty_command",
557        "sanitize_external_std_command",
558    ];
559}
560
561#[cfg(test)]
562mod tests {
563    use super::*;
564
565    /// The four variables behind the confirmed failures in the retro. If any of
566    /// these is ever added to the keep-set, the breach reopens.
567    #[test]
568    fn the_identity_vars_that_caused_the_breach_are_stripped() {
569        for key in [
570            "AGENTMUX_CHANNEL",        // a build joined the launching instance's channel
571            "AGENTMUX_DATA_DIR",       // …and its data dir
572            "AGENTMUX_DATA_HOME",      // a dev srv resolved another instance's data dir
573            "AGENTMUX_EXTRACTED_RUN",  // a fresh AppImage ran the previous build's binary
574            "AGENTMUX_CONFIG_DIR",
575            "AGENTMUX_CONFIG_HOME",
576            "AGENTMUX_CEF_CACHE_DIR",
577            "AGENTMUX_INSTANCE_DIR",
578            "AGENTMUX_INSTANCE_RUNTIME_DIR",
579            "AGENTMUX_AGENTS_DIR",
580            "AGENTMUX_RUNTIME_MODE",
581            "AGENTMUX_CLONE_ID",
582            "AGENTMUX_APP_PATH",
583            "AGENTMUX_SRV_PIPE_PATH",
584            "AGENTMUX_SPLASH_READY_FILE",
585            "AGENTMUX_HOME_OVERRIDE",
586        ] {
587            assert!(should_strip(key), "{key} must not be inheritable by a pane");
588        }
589    }
590
591    #[test]
592    fn the_helper_keep_set_survives() {
593        for key in PANE_ENV_KEEP {
594            assert!(!should_strip(key), "{key} is required by an in-pane helper");
595        }
596    }
597
598    /// An allowlist, not a denylist: a variable nobody has thought about yet
599    /// must be excluded by default. This is the property that makes the fix
600    /// survive variable #21 — the previous behaviour was allow-everything.
601    #[test]
602    fn an_unknown_agentmux_var_is_stripped_by_default() {
603        assert!(should_strip("AGENTMUX_SOME_FUTURE_IDENTITY_VAR"));
604    }
605
606    #[test]
607    fn non_agentmux_vars_are_left_alone() {
608        for key in ["PATH", "HOME", "TERM", "SSH_AUTH_SOCK", "LANG", "SystemRoot", "AGENT_ID"] {
609            assert!(!should_strip(key), "{key} is not ours to strip");
610        }
611    }
612
613    /// The sentinel is what tells a nested launcher to ignore ambient vars.
614    /// Stripping it would re-open the hole it exists to close.
615    #[test]
616    fn the_nesting_sentinel_is_never_stripped() {
617        assert!(!should_strip(NESTING_SENTINEL_KEY));
618    }
619
620    /// The external policy must be stricter than the pane one: a provider CLI
621    /// doing OAuth has no business holding this instance's API endpoint, and
622    /// would hold its credential too if one were present in the environment.
623    #[test]
624    fn the_external_policy_strips_even_the_helper_keep_set() {
625        std::env::set_var("AGENTMUX_LOCAL_URL", "http://127.0.0.1:1");
626        let stripped = all_agentmux_keys();
627        assert!(stripped.iter().any(|k| k == "AGENTMUX_LOCAL_URL"),
628            "external processes must not receive the instance API endpoint");
629        assert!(!stripped.iter().any(|k| k == NESTING_SENTINEL_KEY),
630            "the sentinel carries no identity and must survive");
631        std::env::remove_var("AGENTMUX_LOCAL_URL");
632    }
633
634    /// An agent asked "which AgentMux are you in?" reads it from its env.
635    #[test]
636    fn a_pane_is_told_which_build_it_runs_in() {
637        let mut cmd = tokio::process::Command::new("agent");
638        sanitize_process_command(&mut cmd);
639        let envs: std::collections::BTreeMap<String, String> = cmd
640            .as_std()
641            .get_envs()
642            .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))
643            .collect();
644        let version = env!("CARGO_PKG_VERSION");
645        assert_eq!(envs["AGENTMUX_VERSION"], version, "the plain version: log pointer files are named by it");
646        assert!(envs["AGENTMUX_BUILD"].starts_with(version));
647        assert!(!envs.contains_key("AGENTMUX_CHANNEL"), "the data channel itself still never reaches a pane");
648    }
649
650    #[test]
651    fn keys_to_strip_reads_the_live_environment() {
652        std::env::set_var("AGENTMUX_TEST_ONLY_LEAK", "1");
653        assert!(keys_to_strip().iter().any(|k| k == "AGENTMUX_TEST_ONLY_LEAK"));
654        std::env::remove_var("AGENTMUX_TEST_ONLY_LEAK");
655    }
656}