Skip to main content

agentmux_srv/backend/
publish_guard.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Put `gh-agent`'s publish guard in front of every `git push` an agent makes.
5//!
6//! `gh-agent` writes a git hooks directory whose `pre-push` scans what a push
7//! to a public repository would publish and refuses it on a hit; the other
8//! hooks in it run the repository's own hook of that name. Git only uses it if
9//! `core.hooksPath` points there, and a repository's own `core.hooksPath` (this
10//! repo sets `.githooks`) beats a global one. Configuration from the
11//! environment (`GIT_CONFIG_COUNT`, `GIT_CONFIG_KEY_<n>`, `GIT_CONFIG_VALUE_<n>`)
12//! beats both, so that is how agents get it.
13//!
14//! The directory is computed the way `gh-agent hooks-path` computes it, from
15//! the agent's own env first and then this process's, rather than by running
16//! `gh-agent` on every spawn. It is only used once `gh-agent` has written it
17//! (any `gh-agent` 0.5+ run as git's credential helper does); an agent started
18//! before that gets it at its next start. Without it the spawn goes ahead
19//! unchanged: the guard is an extra check, never a reason to fail.
20//!
21//! Like `GH_CONFIG_DIR` from `backend::gh_guard`, the entry is reserved: a value
22//! for `core.hooksPath` already in the env is replaced, not kept.
23
24use std::collections::HashMap;
25use std::path::{Path, PathBuf};
26use std::sync::Once;
27
28const COUNT: &str = "GIT_CONFIG_COUNT";
29const HOOKS_PATH_KEY: &str = "core.hooksPath";
30/// Larger counts already in the env are not trusted (and not looped over).
31const MAX_ENTRIES: usize = 256;
32/// The line `gh-agent` writes into every hook it generates.
33const MARKER: &str = "gh-agent publish guard";
34
35/// Block meta on an agent's PtyShell: the hooks directory, which the PTY spawn
36/// sets through `GIT_CONFIG_*` (`blockcontroller::shell::lifecycle`). A PTY
37/// shell's env is built from its own block, not from the agent's, so the
38/// setting travels on that block, as `gh_guard`'s directory does.
39pub const META_KEY_PTYSHELL_GIT_HOOKS_PATH: &str = "ptyshell:githookspath";
40
41/// Where `gh-agent` writes its hooks: `GH_AGENT_HOOKS_DIR`, else
42/// `%LOCALAPPDATA%\gh-agent-hooks` on Windows and
43/// `$XDG_DATA_HOME/gh-agent/hooks` (default `~/.local/share`) elsewhere.
44pub fn gh_agent_hooks_dir(get: impl Fn(&str) -> Option<String>, home: &Path, windows: bool) -> PathBuf {
45    let get = |k: &str| get(k).filter(|v| !v.is_empty());
46    if let Some(dir) = get("GH_AGENT_HOOKS_DIR") {
47        return PathBuf::from(dir);
48    }
49    if windows {
50        let base = get("LOCALAPPDATA").map(PathBuf::from).unwrap_or_else(|| home.join("AppData").join("Local"));
51        return base.join("gh-agent-hooks");
52    }
53    let base = get("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".local").join("share"));
54    base.join("gh-agent").join("hooks")
55}
56
57/// Whether `dir` holds hooks `gh-agent` wrote.
58pub fn is_guard_hooks_dir(dir: &Path) -> bool {
59    std::fs::read_to_string(dir.join("pre-push")).map(|s| s.contains(MARKER)).unwrap_or(false)
60}
61
62fn parse_count(get: &impl Fn(&str) -> Option<String>) -> usize {
63    get(COUNT)
64        .and_then(|c| c.trim().parse().ok())
65        .filter(|c: &usize| *c <= MAX_ENTRIES)
66        .unwrap_or(0)
67}
68
69/// The variables that set `key = value` on top of the `GIT_CONFIG_*` entries
70/// `get` already sees. Every slot that names `key` (case-insensitively, as git
71/// does for the section and name) gets the value, since git uses the last one
72/// it finds; with none, a slot is appended.
73pub fn git_config_entries(get: impl Fn(&str) -> Option<String>, key: &str, value: &str) -> Vec<(String, String)> {
74    let count = parse_count(&get);
75    let mut out: Vec<(String, String)> = (0..count)
76        .filter(|i| get(&format!("GIT_CONFIG_KEY_{i}")).is_some_and(|k| k.eq_ignore_ascii_case(key)))
77        .map(|i| (format!("GIT_CONFIG_VALUE_{i}"), value.to_string()))
78        .collect();
79    if !out.is_empty() {
80        out.push((COUNT.to_string(), count.to_string()));
81        return out;
82    }
83    vec![
84        (format!("GIT_CONFIG_KEY_{count}"), key.to_string()),
85        (format!("GIT_CONFIG_VALUE_{count}"), value.to_string()),
86        (COUNT.to_string(), (count + 1).to_string()),
87    ]
88}
89
90/// `env` without its `core.hooksPath` entries, the others renumbered: for a
91/// container, where the host's hooks directory means nothing, but any other
92/// `GIT_CONFIG_*` setting the agent's env carries must still work.
93pub fn without_hooks_path(env: &HashMap<String, String>) -> HashMap<String, String> {
94    let get = |k: &str| env.get(k).cloned();
95    let count = parse_count(&get);
96    let kept: Vec<(String, String)> = (0..count)
97        .filter_map(|i| Some((get(&format!("GIT_CONFIG_KEY_{i}"))?, get(&format!("GIT_CONFIG_VALUE_{i}")).unwrap_or_default())))
98        .filter(|(k, _)| !k.eq_ignore_ascii_case(HOOKS_PATH_KEY))
99        .collect();
100    if kept.len() == count {
101        return env.clone();
102    }
103    let mut out: HashMap<String, String> = env
104        .iter()
105        .filter(|(k, _)| k.as_str() != COUNT && !k.starts_with("GIT_CONFIG_KEY_") && !k.starts_with("GIT_CONFIG_VALUE_"))
106        .map(|(k, v)| (k.clone(), v.clone()))
107        .collect();
108    for (i, (k, v)) in kept.iter().enumerate() {
109        out.insert(format!("GIT_CONFIG_KEY_{i}"), k.clone());
110        out.insert(format!("GIT_CONFIG_VALUE_{i}"), v.clone());
111    }
112    if !kept.is_empty() {
113        out.insert(COUNT.to_string(), kept.len().to_string());
114    }
115    out
116}
117
118/// `key` from `env`; on Windows, where variable names ignore case, in any casing.
119fn env_var(env: &HashMap<String, String>, key: &str, windows: bool) -> Option<String> {
120    env.get(key)
121        .or_else(|| windows.then(|| env.iter().find(|(k, _)| k.eq_ignore_ascii_case(key)).map(|(_, v)| v)).flatten())
122        .cloned()
123}
124
125/// The hooks directory for a spawn whose own env is `env` (falling back to
126/// `process` for anything it doesn't set), if `gh-agent` has written it.
127pub fn hooks_dir_for(env: &HashMap<String, String>, process: impl Fn(&str) -> Option<String>) -> Option<PathBuf> {
128    let get = |k: &str| env_var(env, k, cfg!(windows)).or_else(|| process(k));
129    let home = get(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
130        .filter(|h| !h.is_empty())
131        .map(PathBuf::from)
132        .or_else(dirs::home_dir)
133        .unwrap_or_default();
134    let dir = gh_agent_hooks_dir(&get, &home, cfg!(windows));
135    is_guard_hooks_dir(&dir).then_some(dir)
136}
137
138/// Point `core.hooksPath` at `hooks_dir` in `env`, an overlay on `process`.
139pub fn apply_publish_guard_in(env: &mut HashMap<String, String>, hooks_dir: &Path, process: impl Fn(&str) -> Option<String>) {
140    let entries = git_config_entries(|k| env.get(k).cloned().or_else(|| process(k)), HOOKS_PATH_KEY, &hooks_dir.to_string_lossy());
141    env.extend(entries);
142}
143
144/// [`apply_publish_guard_in`] with the hooks directory this spawn's env and
145/// this host give. Not called under test (see `gh_guard::apply_gh_guard`), so
146/// tests never read the host's real hooks.
147#[cfg_attr(test, allow(dead_code))]
148pub fn apply_publish_guard(env: &mut HashMap<String, String>) {
149    let process = |k: &str| std::env::var(k).ok();
150    match hooks_dir_for(env, process) {
151        Some(dir) => apply_publish_guard_in(env, &dir, process),
152        None => {
153            static WARN: Once = Once::new();
154            WARN.call_once(|| {
155                tracing::warn!(
156                    "publish guard: gh-agent's hooks are not written, so agents' pushes are not scanned; \
157                     install gh-agent 0.5 or later and run `gh-agent setup-git`, then restart agents"
158                );
159            });
160        }
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn env(pairs: &[(&str, &str)]) -> HashMap<String, String> {
169        pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
170    }
171
172    fn no_process(_: &str) -> Option<String> {
173        None
174    }
175
176    fn guard_dir() -> tempfile::TempDir {
177        let dir = tempfile::tempdir().unwrap();
178        std::fs::write(dir.path().join("pre-push"), format!("#!/bin/sh\n# {MARKER}: test\n")).unwrap();
179        dir
180    }
181
182    #[test]
183    fn hooks_dir_follows_gh_agent() {
184        let home = Path::new("/home/a");
185        let none = |_: &str| None;
186        assert_eq!(gh_agent_hooks_dir(none, home, false), PathBuf::from("/home/a/.local/share/gh-agent/hooks"));
187        assert_eq!(
188            gh_agent_hooks_dir(|k| (k == "XDG_DATA_HOME").then(|| "/x".to_string()), home, false),
189            PathBuf::from("/x/gh-agent/hooks")
190        );
191        assert_eq!(
192            gh_agent_hooks_dir(|k| (k == "LOCALAPPDATA").then(|| "C:/L".to_string()), home, true),
193            PathBuf::from("C:/L/gh-agent-hooks")
194        );
195        assert_eq!(gh_agent_hooks_dir(none, home, true), PathBuf::from("/home/a/AppData/Local/gh-agent-hooks"));
196        assert_eq!(
197            gh_agent_hooks_dir(|k| (k == "GH_AGENT_HOOKS_DIR").then(|| "/o".to_string()), home, true),
198            PathBuf::from("/o")
199        );
200    }
201
202    #[test]
203    fn the_agents_own_env_decides_where_the_hooks_are() {
204        let dir = guard_dir();
205        let agent = env(&[("GH_AGENT_HOOKS_DIR", &dir.path().to_string_lossy())]);
206        assert_eq!(hooks_dir_for(&agent, no_process).as_deref(), Some(dir.path()));
207        // the process env is the fallback
208        let p = dir.path().to_string_lossy().into_owned();
209        assert_eq!(hooks_dir_for(&HashMap::new(), |k| (k == "GH_AGENT_HOOKS_DIR").then(|| p.clone())).as_deref(), Some(dir.path()));
210        // not written yet, or written by something else: nothing
211        let empty = tempfile::tempdir().unwrap();
212        let agent = env(&[("GH_AGENT_HOOKS_DIR", &empty.path().to_string_lossy())]);
213        assert_eq!(hooks_dir_for(&agent, no_process), None);
214        std::fs::write(empty.path().join("pre-push"), "#!/bin/sh\n# someone else's hook\n").unwrap();
215        assert_eq!(hooks_dir_for(&agent, no_process), None);
216    }
217
218    #[test]
219    fn windows_variable_names_match_in_any_casing() {
220        let e = env(&[("Gh_Agent_Hooks_Dir", "/h")]);
221        assert_eq!(env_var(&e, "GH_AGENT_HOOKS_DIR", true).as_deref(), Some("/h"));
222        assert_eq!(env_var(&e, "GH_AGENT_HOOKS_DIR", false), None);
223    }
224
225    #[test]
226    fn sets_core_hooks_path_through_the_environment() {
227        let dir = guard_dir();
228        let mut e = env(&[("AGENTMUX_AGENT_ID", "AgentY")]);
229        apply_publish_guard_in(&mut e, dir.path(), no_process);
230        assert_eq!(e[COUNT], "1");
231        assert_eq!(e["GIT_CONFIG_KEY_0"], HOOKS_PATH_KEY);
232        assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_0"]), dir.path());
233    }
234
235    #[test]
236    fn appends_after_entries_already_there_and_replaces_its_own() {
237        let dir = guard_dir();
238        let mut e = env(&[(COUNT, "1"), ("GIT_CONFIG_KEY_0", "user.name"), ("GIT_CONFIG_VALUE_0", "x")]);
239        apply_publish_guard_in(&mut e, dir.path(), no_process);
240        assert_eq!(e[COUNT], "2");
241        assert_eq!(e["GIT_CONFIG_KEY_0"], "user.name");
242        assert_eq!(e["GIT_CONFIG_KEY_1"], HOOKS_PATH_KEY);
243        // applied again (a persisted cmd:env carrying an old value): same slot, no growth
244        e.insert("GIT_CONFIG_VALUE_1".into(), "/somewhere/else".into());
245        apply_publish_guard_in(&mut e, dir.path(), no_process);
246        assert_eq!(e[COUNT], "2");
247        assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_1"]), dir.path());
248    }
249
250    #[test]
251    fn a_hooks_path_entry_from_elsewhere_is_replaced_not_kept() {
252        let dir = guard_dir();
253        let mut e = env(&[(COUNT, "1"), ("GIT_CONFIG_KEY_0", "core.hookspath"), ("GIT_CONFIG_VALUE_0", "/dev/null")]);
254        apply_publish_guard_in(&mut e, dir.path(), no_process);
255        assert_eq!(e[COUNT], "1");
256        assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_0"]), dir.path());
257    }
258
259    #[test]
260    fn builds_on_entries_the_process_env_already_has() {
261        let dir = guard_dir();
262        let mut e = HashMap::new();
263        let process = |k: &str| match k {
264            "GIT_CONFIG_COUNT" => Some("2".to_string()),
265            "GIT_CONFIG_KEY_1" => Some("core.hooksPath".to_string()),
266            _ => None,
267        };
268        apply_publish_guard_in(&mut e, dir.path(), process);
269        assert_eq!(e[COUNT], "2", "reuses the process env's own hooksPath slot");
270        assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_1"]), dir.path());
271        assert!(!e.contains_key("GIT_CONFIG_KEY_0"), "the other inherited slot is left alone");
272    }
273
274    #[test]
275    fn every_hooks_path_slot_gets_the_guard_because_git_uses_the_last() {
276        let dir = guard_dir();
277        let mut e = env(&[
278            (COUNT, "3"),
279            ("GIT_CONFIG_KEY_0", "core.hooksPath"),
280            ("GIT_CONFIG_VALUE_0", "/old"),
281            ("GIT_CONFIG_KEY_1", "user.name"),
282            ("GIT_CONFIG_VALUE_1", "x"),
283            ("GIT_CONFIG_KEY_2", "CORE.HOOKSPATH"),
284            ("GIT_CONFIG_VALUE_2", "/dev/null"),
285        ]);
286        apply_publish_guard_in(&mut e, dir.path(), no_process);
287        assert_eq!(e[COUNT], "3");
288        assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_0"]), dir.path());
289        assert_eq!(PathBuf::from(&e["GIT_CONFIG_VALUE_2"]), dir.path());
290        assert_eq!(e["GIT_CONFIG_VALUE_1"], "x");
291    }
292
293    #[test]
294    fn a_container_keeps_its_other_git_settings_but_not_the_host_hooks_path() {
295        let e = env(&[
296            (COUNT, "3"),
297            ("GIT_CONFIG_KEY_0", "url.x.insteadOf"),
298            ("GIT_CONFIG_VALUE_0", "y"),
299            ("GIT_CONFIG_KEY_1", "core.hooksPath"),
300            ("GIT_CONFIG_VALUE_1", "/host/hooks"),
301            ("GIT_CONFIG_KEY_2", "http.sslCAInfo"),
302            ("GIT_CONFIG_VALUE_2", "/ca.pem"),
303            ("OTHER", "kept"),
304        ]);
305        let out = without_hooks_path(&e);
306        assert_eq!(out[COUNT], "2");
307        assert_eq!(out["GIT_CONFIG_KEY_0"], "url.x.insteadOf");
308        assert_eq!(out["GIT_CONFIG_KEY_1"], "http.sslCAInfo");
309        assert_eq!(out["GIT_CONFIG_VALUE_1"], "/ca.pem");
310        assert!(!out.contains_key("GIT_CONFIG_KEY_2"));
311        assert_eq!(out["OTHER"], "kept");
312        let only = env(&[(COUNT, "1"), ("GIT_CONFIG_KEY_0", "core.hooksPath"), ("GIT_CONFIG_VALUE_0", "/h")]);
313        assert!(without_hooks_path(&only).is_empty(), "nothing left: no count either");
314        let untouched = env(&[("A", "b")]);
315        assert_eq!(without_hooks_path(&untouched), untouched);
316    }
317
318    #[test]
319    fn an_absurd_count_is_not_trusted_or_looped_over() {
320        let dir = guard_dir();
321        let mut e = env(&[(COUNT, "999999999999")]);
322        apply_publish_guard_in(&mut e, dir.path(), no_process);
323        assert_eq!(e[COUNT], "1");
324        assert_eq!(e["GIT_CONFIG_KEY_0"], HOOKS_PATH_KEY);
325    }
326
327    #[test]
328    fn entries_for_a_pty_spawn_keep_the_slots_its_env_already_uses() {
329        // what a PTY's CommandBuilder already holds from cmd:env
330        let existing = env(&[(COUNT, "1"), ("GIT_CONFIG_KEY_0", "url.x.insteadOf"), ("GIT_CONFIG_VALUE_0", "y")]);
331        let pairs = git_config_entries(|k| existing.get(k).cloned(), HOOKS_PATH_KEY, "/h");
332        assert!(pairs.contains(&("GIT_CONFIG_KEY_1".to_string(), HOOKS_PATH_KEY.to_string())));
333        assert!(pairs.contains(&("GIT_CONFIG_VALUE_1".to_string(), "/h".to_string())));
334        assert!(pairs.contains(&(COUNT.to_string(), "2".to_string())));
335    }
336}