Skip to main content

agentmux_srv/backend/
gh_guard.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Plain `gh` inside an agent process must not act as a human's `gh` login.
5//!
6//! Agents reach GitHub through `gh-agent`, which hands `gh` a per-call token of
7//! the agent's own. The `gh` CLI, run directly, instead reads whatever login a
8//! human made on the host — so an agent that types `gh` where it meant
9//! `gh-agent` silently acts as that human, with nothing failing. `gh` finds its
10//! accounts through `$GH_CONFIG_DIR/hosts.yml`; pointing that variable at a
11//! directory with no `hosts.yml` leaves `gh` with no accounts, so it reports
12//! "not logged in" instead. `gh-agent` is unaffected: it passes its own token.
13//!
14//! [`apply_gh_guard`] is called on every path that spawns an agent process or
15//! runs a command on an agent's behalf. It is a RESERVED variable: it always
16//! overwrites whatever the env already carries (a persisted `cmd:env`, a
17//! caller's override), because a single user-supplied value would re-open the
18//! hole. Containers keep it on `CONTAINER_ENV_DENYLIST` deliberately — the
19//! host path means nothing inside the image.
20//!
21//! Design: `a5af/shared-infrastructure` `SPEC_AGENT_PLAIN_GH_GUARD_2026_09_25`.
22
23use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26pub const GH_CONFIG_DIR: &str = "GH_CONFIG_DIR";
27
28/// Block meta on an agent's PtyShell: the agent's guard directory, which the
29/// PTY spawn sets as `GH_CONFIG_DIR` (`blockcontroller::shell::lifecycle`). A
30/// PTY shell's env is built from its own block, not from the agent's, so the
31/// guard has to travel on that block.
32pub const META_KEY_PTYSHELL_GH_CONFIG_DIR: &str = "ptyshell:ghconfigdir";
33
34/// The guard directory for an agent whose stored `cmd:env` is `agent_env`
35/// (the slug is read from it, as for the agent's own spawns), prepared the
36/// same way: created, with any login in it cleared.
37pub fn guard_dir_for(agent_env: &HashMap<String, String>) -> String {
38    let mut env = agent_env.clone();
39    apply_gh_guard(&mut env);
40    env.remove(GH_CONFIG_DIR).unwrap_or_default()
41}
42
43/// The file `gh` records its accounts in. Its absence is what "logged out"
44/// means; nothing else in the directory is touched.
45const GH_HOSTS_FILE: &str = "hosts.yml";
46
47/// Directory name for an env with no agent identity on it. Still guarded —
48/// an unattributed spawn is not a reason to fall back to the human's login.
49const UNATTRIBUTED: &str = "unattributed";
50
51/// `<config_home>/gh-<slug>`, the same location `agent.open` has always used.
52///
53/// The slug is reduced to `[a-z0-9_-]` so it can never name a path outside
54/// `config_home` — this directory is one [`clear_gh_login`] deletes from.
55pub fn agent_gh_config_dir(config_home: &Path, agent_slug: Option<&str>) -> PathBuf {
56    let slug: String = agent_slug
57        .unwrap_or("")
58        .trim()
59        .to_lowercase()
60        .chars()
61        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '-' })
62        .collect();
63    let slug = slug.trim_matches('-');
64    let slug = if slug.is_empty() { UNATTRIBUTED } else { slug };
65    config_home.join(format!("gh-{slug}"))
66}
67
68/// Create `dir` if needed and remove a `hosts.yml` from it.
69///
70/// A login can land here: `gh`'s own "not logged in" error tells the reader to
71/// run `gh auth login`, and doing that inside an agent writes exactly this
72/// file. Clearing it on every spawn is what keeps the guard from being undone
73/// by one well-meant login. Only that one file is removed; `gh`'s preferences
74/// (`config.yml`) are left alone.
75pub fn clear_gh_login(dir: &Path) -> std::io::Result<()> {
76    std::fs::create_dir_all(dir)?;
77    let hosts = dir.join(GH_HOSTS_FILE);
78    match std::fs::symlink_metadata(&hosts) {
79        // A file or a symlink: remove the entry itself, never a link's target.
80        Ok(meta) if !meta.is_dir() => std::fs::remove_file(&hosts),
81        Ok(_) => Err(std::io::Error::new(
82            std::io::ErrorKind::Other,
83            format!("{} is a directory, not a gh login file", hosts.display()),
84        )),
85        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
86        Err(e) => Err(e),
87    }
88}
89
90/// Point `GH_CONFIG_DIR` at this agent's login-free directory, under
91/// `config_home`. The agent is read from the env itself
92/// (`AGENTMUX_AGENT_SLUG`, then `AGENTMUX_AGENT_ID`).
93///
94/// Never fails the spawn: if the directory can't be prepared the variable is
95/// still set, and a missing directory is just as logged-out as an empty one.
96pub fn apply_gh_guard_in(env_vars: &mut HashMap<String, String>, config_home: &Path) {
97    let slug = ["AGENTMUX_AGENT_SLUG", "AGENTMUX_AGENT_ID"]
98        .iter()
99        .filter_map(|k| env_vars.get(*k))
100        .map(|v| v.trim())
101        .find(|v| !v.is_empty())
102        .map(str::to_string);
103    let dir = agent_gh_config_dir(config_home, slug.as_deref());
104    if let Err(e) = clear_gh_login(&dir) {
105        tracing::warn!(dir = %dir.display(), error = %e, "gh guard: could not clear the agent's gh login dir");
106    }
107    env_vars.insert(GH_CONFIG_DIR.to_string(), dir.to_string_lossy().into_owned());
108}
109
110/// [`apply_gh_guard_in`] under this instance's config dir, plus the publish
111/// guard's `core.hooksPath` ([`crate::backend::publish_guard`]): every path that
112/// spawns an agent process or runs a command for one comes through here.
113pub fn apply_gh_guard(env_vars: &mut HashMap<String, String>) {
114    apply_gh_guard_in(env_vars, &guard_config_home());
115    #[cfg(not(test))]
116    crate::backend::publish_guard::apply_publish_guard(env_vars);
117}
118
119/// Where guard dirs live. Under test, a per-process temp dir: every test that
120/// builds a spawn env reaches this, and none of them should create or clear
121/// anything in the developer's real config home.
122pub fn guard_config_home() -> PathBuf {
123    #[cfg(test)]
124    {
125        std::env::temp_dir().join(format!("agentmux-gh-guard-test-{}", std::process::id()))
126    }
127    #[cfg(not(test))]
128    {
129        crate::backend::base::get_mux_config_dir()
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136
137    fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
138        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
139    }
140
141    /// A PtyShell's guard is the agent's own directory, the one its Shell and
142    /// its CLI get, read from the agent's stored env.
143    #[test]
144    fn a_ptyshell_gets_the_agents_own_guard_dir() {
145        let dir = guard_dir_for(&env(&[("AGENTMUX_AGENT_ID", "PtyGuardTest")]));
146        assert_eq!(
147            PathBuf::from(&dir),
148            agent_gh_config_dir(&guard_config_home(), Some("ptyguardtest"))
149        );
150        let mut shell_env = env(&[("AGENTMUX_AGENT_ID", "PtyGuardTest")]);
151        apply_gh_guard(&mut shell_env);
152        assert_eq!(shell_env.get(GH_CONFIG_DIR), Some(&dir), "same as the agent's Shell");
153        assert!(!guard_dir_for(&HashMap::new()).is_empty(), "unattributed is still guarded");
154    }
155
156    #[test]
157    fn sets_a_per_agent_dir_under_config_home() {
158        let home = tempfile::tempdir().unwrap();
159        let mut e = env(&[("AGENTMUX_AGENT_SLUG", "agenty")]);
160        apply_gh_guard_in(&mut e, home.path());
161        let dir = PathBuf::from(&e[GH_CONFIG_DIR]);
162        assert_eq!(dir, home.path().join("gh-agenty"));
163        assert!(dir.is_dir(), "the dir is created so gh has somewhere empty to look");
164    }
165
166    #[test]
167    fn overrides_a_gh_config_dir_already_in_the_env() {
168        // The hole this closes: a persisted or caller-supplied value pointing
169        // at a real login must not survive into the spawn.
170        let home = tempfile::tempdir().unwrap();
171        let mut e = env(&[("AGENTMUX_AGENT_ID", "AgentY"), (GH_CONFIG_DIR, "/home/human/.config/gh")]);
172        apply_gh_guard_in(&mut e, home.path());
173        assert_eq!(PathBuf::from(&e[GH_CONFIG_DIR]), home.path().join("gh-agenty"));
174    }
175
176    #[test]
177    fn prefers_the_slug_and_falls_back_to_the_agent_id() {
178        let home = tempfile::tempdir().unwrap();
179        let mut e = env(&[("AGENTMUX_AGENT_ID", "Display Name"), ("AGENTMUX_AGENT_SLUG", "korp")]);
180        apply_gh_guard_in(&mut e, home.path());
181        assert!(e[GH_CONFIG_DIR].ends_with("gh-korp"));
182
183        let mut e = env(&[("AGENTMUX_AGENT_ID", "Korp"), ("AGENTMUX_AGENT_SLUG", "  ")]);
184        apply_gh_guard_in(&mut e, home.path());
185        assert!(e[GH_CONFIG_DIR].ends_with("gh-korp"));
186    }
187
188    #[test]
189    fn guards_an_unattributed_spawn_rather_than_skipping_it() {
190        let home = tempfile::tempdir().unwrap();
191        let mut e = HashMap::new();
192        apply_gh_guard_in(&mut e, home.path());
193        assert_eq!(PathBuf::from(&e[GH_CONFIG_DIR]), home.path().join("gh-unattributed"));
194    }
195
196    #[test]
197    fn a_slug_cannot_escape_config_home() {
198        let home = Path::new("/cfg");
199        for bad in ["../../etc", "..", "a/b", "a\\b", "C:\\x", "/abs"] {
200            let dir = agent_gh_config_dir(home, Some(bad));
201            assert_eq!(dir.parent(), Some(home), "{bad:?} -> {}", dir.display());
202            let name = dir.file_name().unwrap().to_string_lossy().into_owned();
203            assert!(name.starts_with("gh-") && !name.contains(['/', '\\', '.']), "{bad:?} -> {name}");
204        }
205    }
206
207    #[test]
208    fn clears_a_login_left_in_the_dir_and_keeps_preferences() {
209        let home = tempfile::tempdir().unwrap();
210        let dir = agent_gh_config_dir(home.path(), Some("agenty"));
211        std::fs::create_dir_all(&dir).unwrap();
212        std::fs::write(dir.join("hosts.yml"), "github.com:\n  user: someone\n").unwrap();
213        std::fs::write(dir.join("config.yml"), "editor: vim\n").unwrap();
214
215        let mut e = env(&[("AGENTMUX_AGENT_SLUG", "agenty")]);
216        apply_gh_guard_in(&mut e, home.path());
217
218        assert!(!dir.join("hosts.yml").exists(), "a login in the guard dir must not survive a spawn");
219        assert!(dir.join("config.yml").exists(), "gh preferences are not a login and are left alone");
220    }
221
222    #[test]
223    fn a_dir_named_hosts_yml_is_refused_not_recursed_into() {
224        let home = tempfile::tempdir().unwrap();
225        let dir = agent_gh_config_dir(home.path(), Some("agenty"));
226        std::fs::create_dir_all(dir.join("hosts.yml")).unwrap();
227        assert!(clear_gh_login(&dir).is_err());
228        // The variable is still set: the spawn is not failed over this.
229        let mut e = env(&[("AGENTMUX_AGENT_SLUG", "agenty")]);
230        apply_gh_guard_in(&mut e, home.path());
231        assert_eq!(PathBuf::from(&e[GH_CONFIG_DIR]), dir);
232    }
233}