agentmux_srv/backend/
gh_guard.rs1use std::collections::HashMap;
24use std::path::{Path, PathBuf};
25
26pub const GH_CONFIG_DIR: &str = "GH_CONFIG_DIR";
27
28pub const META_KEY_PTYSHELL_GH_CONFIG_DIR: &str = "ptyshell:ghconfigdir";
33
34pub 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
43const GH_HOSTS_FILE: &str = "hosts.yml";
46
47const UNATTRIBUTED: &str = "unattributed";
50
51pub 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
68pub 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 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
90pub 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
110pub 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
119pub 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 #[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 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 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}