Skip to main content

aft/
agent_child_env.rs

1//! AFT-owned environment and files for first-party agent children.
2//!
3//! The governance controls in this module are attached to spawned bash and PTY
4//! children. AFT never edits the user's shell startup files or global Git
5//! configuration, so an operator's terminal keeps its existing behavior.
6
7use std::collections::HashMap;
8use std::ffi::OsString;
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use std::sync::{Mutex, OnceLock};
13use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
14
15use crate::config::Config;
16
17pub const SHIMS_DIR_NAME: &str = "shims";
18pub const GIT_HOOKS_DIR_NAME: &str = "git-hooks";
19const GIT_HOOKS_QUARANTINE_DIR_NAME: &str = "quarantine";
20const PREPARE_COMMIT_MSG: &str = "prepare-commit-msg";
21// This is the complete hook inventory documented by `githooks(5)`, including
22// receive-side and specialized hooks. Agent Git can operate on bare repositories
23// and invoke less-common porcelain, so limiting dispatch to commit hooks would
24// silently disable repository policy for those operations.
25const MANAGED_GIT_HOOK_NAMES: &[&str] = &[
26    "applypatch-msg",
27    "pre-applypatch",
28    "post-applypatch",
29    "pre-commit",
30    "pre-merge-commit",
31    PREPARE_COMMIT_MSG,
32    "commit-msg",
33    "post-commit",
34    "pre-rebase",
35    "post-checkout",
36    "post-merge",
37    "pre-push",
38    "pre-receive",
39    "update",
40    "proc-receive",
41    "post-receive",
42    "post-update",
43    "push-to-checkout",
44    "pre-auto-gc",
45    "post-rewrite",
46    "sendemail-validate",
47    "fsmonitor-watchman",
48    "p4-changelist",
49    "p4-prepare-changelist",
50    "p4-post-changelist",
51    "p4-pre-submit",
52    "post-index-change",
53    "reference-transaction",
54];
55const GH_SHIMS_DIR_ENV: &str = "AFT_GH_SHIMS_DIR";
56const GH_SHIM_BINARY_ENV: &str = "AFT_GH_SHIM_BINARY";
57const GIT_CO_AUTHOR_ENV: &str = "AFT_GIT_CO_AUTHOR";
58const STORAGE_DIR_ENV: &str = "AFT_STORAGE_DIR";
59const GH_SHIM_STATE_DIR_ENV: &str = "AFT_GH_SHIM_STATE_DIR";
60const SUBC_CREDENTIAL_ENV_PREFIX: &str = "SUBC_";
61const SUBC_IDENTITY_ENV_KEYS: [&str; 2] = [
62    subc_protocol::SUBC_MODULE_ID_ENV,
63    subc_protocol::SUBC_LAUNCH_NONCE_ENV,
64];
65
66/// Git for Windows runs shebang hooks through its bundled POSIX shell, so the
67/// same dispatcher bytes work there and on Unix. Dispatch never reads stdin and
68/// ends with `exec`, preserving Git's arguments, stdin, and the repository hook's
69/// exit status.
70const GIT_HOOK_DISPATCHER_TEMPLATE: &str = r#"#!/bin/sh
71# AFT selects this hook through the agent child's environment. It does not alter
72# the repository or the user's Git configuration.
73hook_name=@HOOK_NAME@
74@PRE_DISPATCH@
75dispatch_candidate() {
76  candidate=$1
77  shift
78  if [ -x "$candidate" ]; then
79    # A repository may explicitly point core.hooksPath back at AFT's managed
80    # directory. Identity comparison also catches symlink and hard-link loops.
81    if [ "$candidate" -ef "$0" ] 2>/dev/null; then
82      return
83    fi
84    exec "$candidate" "$@"
85  fi
86}
87
88repo_root=$(git rev-parse --show-toplevel 2>/dev/null || git rev-parse --absolute-git-dir 2>/dev/null || :)
89if [ -z "$repo_root" ]; then
90  exit 0
91fi
92
93repo_hooks=$(git config --local core.hooksPath 2>/dev/null || :)
94if [ -n "$repo_hooks" ]; then
95  case "$repo_hooks" in
96    /*|[A-Za-z]:[\\/]*) candidate="$repo_hooks/$hook_name" ;;
97    \~/*) candidate="${HOME:-}${repo_hooks#\~}/$hook_name" ;;
98    *) candidate="$repo_root/$repo_hooks/$hook_name" ;;
99  esac
100  dispatch_candidate "$candidate" "$@"
101fi
102
103# Do not use `git rev-parse --git-path hooks/...` here: it honors the injected
104# core.hooksPath and resolves this dispatcher back to itself.
105git_dir=$(git rev-parse --git-dir 2>/dev/null || :)
106if [ -n "$git_dir" ]; then
107  case "$git_dir" in
108    /*|[A-Za-z]:[\\/]*) candidate="$git_dir/hooks/$hook_name" ;;
109    *) candidate="$repo_root/$git_dir/hooks/$hook_name" ;;
110  esac
111  dispatch_candidate "$candidate" "$@"
112fi
113
114dispatch_candidate "$repo_root/.githooks/$hook_name" "$@"
115exit 0
116"#;
117
118const PREPARE_COMMIT_MSG_PRE_DISPATCH: &str = r#"# Agent-labeled commits are joint work too, so subjects such as "mason:" do not
119# receive an attribution exemption. Attribution runs before the repository hook
120# so that hook can validate or amend the resulting message.
121msg_file=$1
122mode=${AFT_GIT_CO_AUTHOR:-off}
123line=
124
125case "$mode" in
126  off|'') ;;
127  auto)
128    if [ -n "${AFT_GH_SHIM_BINARY:-}" ]; then
129      line=$("$AFT_GH_SHIM_BINARY" gh-shim --co-author-line 2>/dev/null || :)
130    fi
131    ;;
132  *) line="Co-authored-by: $mode" ;;
133esac
134
135if [ -n "$line" ]; then
136  identity=${line#Co-authored-by: }
137  git interpret-trailers --in-place --if-exists doNothing \
138    --trailer "Co-authored-by=$identity" "$msg_file" 2>/dev/null || :
139fi
140"#;
141
142fn managed_git_hook_contents(hook_name: &str) -> String {
143    let pre_dispatch = if hook_name == PREPARE_COMMIT_MSG {
144        PREPARE_COMMIT_MSG_PRE_DISPATCH
145    } else {
146        ""
147    };
148    GIT_HOOK_DISPATCHER_TEMPLATE
149        .replace("@HOOK_NAME@", hook_name)
150        .replace("@PRE_DISPATCH@", pre_dispatch)
151}
152
153/// Refresh files selected by the resolved configuration. This runs during
154/// configure and is also cheap enough to repair a stale entry immediately
155/// before a child spawn.
156pub fn maintain(config: &Config, storage_root: &Path) -> Result<(), String> {
157    let shims_dir = storage_root.join(SHIMS_DIR_NAME);
158    if config.gh_shim.enabled {
159        let binary = shim_binary(config)?;
160        match reject_self_referential_pin(&binary, &shims_dir)
161            .and_then(|()| probe_gh_shim_binary(&binary))
162        {
163            Ok(()) => ensure_gh_entry(&shims_dir, &binary)?,
164            Err(reason) => {
165                crate::slog_warn!(
166                    "[agent_child_env] refusing gh shim candidate {}: {reason}",
167                    binary.display()
168                );
169                if !existing_gh_entry_is_valid(&shims_dir) {
170                    remove_gh_entry(&shims_dir)?;
171                    crate::slog_warn!(
172                        "[agent_child_env] removed unverified gh shim entry after refusing candidate {}",
173                        binary.display()
174                    );
175                }
176            }
177        }
178    } else {
179        remove_gh_entry(&shims_dir)?;
180    }
181
182    if config.git.co_author != "off" {
183        ensure_managed_git_hooks(&storage_root.join(GIT_HOOKS_DIR_NAME))?;
184    }
185    Ok(())
186}
187
188/// Remove inherited governance markers from THIS PROCESS's environment.
189///
190/// A daemon is the injector of these markers, never a consumer: when an agent
191/// whose own environment was governed by an outer daemon spawns a nested aft
192/// process (test harnesses, tooling, warmup), the inherited markers would leak
193/// into every child this process spawns regardless of this process's own
194/// configuration gates. Called once at server startup, before threads spawn;
195/// the gh-shim invocation path (which legitimately reads the shims marker)
196/// dispatches before this runs.
197pub fn scrub_inherited_process_markers() {
198    if let Some(stale) = crate::environment::non_empty_os_var(GH_SHIMS_DIR_ENV).map(PathBuf::from) {
199        if let Some(inherited) = std::env::var_os("PATH") {
200            let cleaned: Vec<_> = std::env::split_paths(&inherited)
201                .filter(|entry| entry != &stale)
202                .collect();
203            if let Ok(path) = std::env::join_paths(cleaned) {
204                std::env::set_var("PATH", path);
205            }
206        }
207        std::env::remove_var(GH_SHIMS_DIR_ENV);
208    }
209    std::env::remove_var(GIT_CO_AUTHOR_ENV);
210    std::env::remove_var(GH_SHIM_BINARY_ENV);
211    let aft_hooks_value = std::env::var_os("GIT_CONFIG_VALUE_0")
212        .is_some_and(|value| Path::new(&value).ends_with(GIT_HOOKS_DIR_NAME));
213    if aft_hooks_value
214        && std::env::var_os("GIT_CONFIG_KEY_0").as_deref()
215            == Some(std::ffi::OsStr::new("core.hooksPath"))
216    {
217        std::env::remove_var("GIT_CONFIG_COUNT");
218        std::env::remove_var("GIT_CONFIG_KEY_0");
219        std::env::remove_var("GIT_CONFIG_VALUE_0");
220    }
221}
222
223/// True for environment variables reserved for subc's supervised-spawn
224/// identity. Tool children are not the module process and must never inherit
225/// present or future members of this credential family.
226pub(crate) fn is_subc_credential_env_key(key: &str) -> bool {
227    #[cfg(windows)]
228    {
229        key.as_bytes()
230            .get(..SUBC_CREDENTIAL_ENV_PREFIX.len())
231            .is_some_and(|prefix| {
232                prefix.eq_ignore_ascii_case(SUBC_CREDENTIAL_ENV_PREFIX.as_bytes())
233            })
234    }
235    #[cfg(not(windows))]
236    {
237        key.starts_with(SUBC_CREDENTIAL_ENV_PREFIX)
238    }
239}
240
241/// Apply request overrides to a non-PTY child and remove subc credentials from
242/// both the inherited process environment and explicit command overrides.
243pub(crate) fn apply_to_command(command: &mut Command, environment: &HashMap<String, String>) {
244    command.envs(environment);
245
246    let mut credential_keys = std::env::vars_os()
247        .map(|(key, _)| key)
248        .filter(|key| key.to_str().is_some_and(is_subc_credential_env_key))
249        .collect::<Vec<_>>();
250    credential_keys.extend(
251        command
252            .get_envs()
253            .map(|(key, _)| key.to_os_string())
254            .filter(|key| key.to_str().is_some_and(is_subc_credential_env_key)),
255    );
256    for key in credential_keys {
257        command.env_remove(key);
258    }
259}
260
261/// Remove subc credentials from portable-pty's complete environment snapshot.
262/// CommandBuilder materializes the process environment when it is constructed,
263/// so filtering the builder covers Unix exec and Windows CreateProcess alike.
264pub(crate) fn scrub_pty_command(command: &mut portable_pty::CommandBuilder) {
265    let mut credential_keys = std::env::vars_os()
266        .map(|(key, _)| key)
267        .filter(|key| key.to_str().is_some_and(is_subc_credential_env_key))
268        .collect::<Vec<_>>();
269    credential_keys.extend(
270        command
271            .iter_full_env_as_str()
272            .map(|(key, _)| OsString::from(key))
273            .filter(|key| key.to_str().is_some_and(is_subc_credential_env_key)),
274    );
275    credential_keys.extend(SUBC_IDENTITY_ENV_KEYS.map(OsString::from));
276    for key in credential_keys {
277        command.env_remove(key);
278    }
279}
280
281/// Add governance to one child environment. This is the single seam used
282/// before foreground, background, sandboxed, and PTY launch planning.
283pub fn inject(
284    config: &Config,
285    storage_root: &Path,
286    environment: &mut HashMap<String, String>,
287) -> Result<(), String> {
288    // The module uses these launch-identity variables to authenticate its own
289    // daemon connection. Remove them only from the child snapshot so the module
290    // process retains the credentials it needs.
291    environment.retain(|key, _| !is_subc_credential_env_key(key));
292
293    let gh_enabled = config.gh_shim.enabled;
294    let co_author_enabled = config.git.co_author != "off";
295
296    // The inherited environment may already carry governance markers injected
297    // by an OUTER daemon (agents spawn daemons in tests and tooling). Each
298    // feature owns its markers in both directions: when disabled here, strip
299    // what a parent injected so this process's children reflect THIS gate.
300    // Only self-identifying values are removed - user-owned GIT_CONFIG_* is
301    // untouched unless it provably points at an AFT-generated hooks dir.
302    if !gh_enabled {
303        if let Some(stale_shims) = environment.remove(GH_SHIMS_DIR_ENV) {
304            if let Some(inherited) = environment.get("PATH").map(OsString::from) {
305                let stale = PathBuf::from(&stale_shims);
306                let cleaned: Vec<_> = std::env::split_paths(&inherited)
307                    .filter(|entry| entry != &stale)
308                    .collect();
309                if let Ok(path) = std::env::join_paths(cleaned) {
310                    environment.insert("PATH".to_string(), path.to_string_lossy().into_owned());
311                }
312            }
313        }
314    }
315    if !co_author_enabled {
316        environment.remove(GIT_CO_AUTHOR_ENV);
317        environment.remove(GH_SHIM_BINARY_ENV);
318        let aft_hooks_value = environment
319            .get("GIT_CONFIG_VALUE_0")
320            .is_some_and(|value| Path::new(value).ends_with(GIT_HOOKS_DIR_NAME));
321        if aft_hooks_value
322            && environment.get("GIT_CONFIG_KEY_0").map(String::as_str) == Some("core.hooksPath")
323        {
324            environment.remove("GIT_CONFIG_COUNT");
325            environment.remove("GIT_CONFIG_KEY_0");
326            environment.remove("GIT_CONFIG_VALUE_0");
327        }
328    }
329    if !gh_enabled && !co_author_enabled {
330        return Ok(());
331    }
332
333    // Hooks and shims can invoke the AFT binary after the daemon's configure
334    // request has completed. PROPAGATE an explicit storage override so those
335    // child commands stay in the same storage universe - but never ORIGINATE
336    // one: injecting the default-resolved shared root as an explicit env var
337    // outranks XDG-based isolation in every nested process (field incident:
338    // the daemon injected the real shared root into agent bash lanes, and 41
339    // test-suite fixtures that isolate via HOME/XDG resolved the production
340    // store). Children that resolve storage by default reach the same root
341    // anyway; explicitness is only preserved, never minted.
342    if let Some(explicit) = crate::environment::non_empty_os_var(STORAGE_DIR_ENV) {
343        environment.insert(
344            STORAGE_DIR_ENV.to_string(),
345            explicit.to_string_lossy().into_owned(),
346        );
347    }
348    // Preserve an explicitly selected gh-shim state directory for hooks and
349    // nested AFT children, but never mint one from the operator's default.
350    if let Some(explicit) = crate::environment::non_empty_os_var(GH_SHIM_STATE_DIR_ENV) {
351        environment.insert(
352            GH_SHIM_STATE_DIR_ENV.to_string(),
353            explicit.to_string_lossy().into_owned(),
354        );
355    }
356    maintain(config, storage_root)?;
357
358    if gh_enabled {
359        let shims_dir = storage_root.join(SHIMS_DIR_NAME);
360        let inherited = environment
361            .get("PATH")
362            .map(OsString::from)
363            .unwrap_or_else(|| crate::effective_path::effective_path().to_os_string());
364        let mut entries = vec![shims_dir.clone()];
365        entries.extend(std::env::split_paths(&inherited).filter(|entry| entry != &shims_dir));
366        let path = std::env::join_paths(entries)
367            .map_err(|error| format!("failed to construct governed child PATH: {error}"))?;
368        environment.insert("PATH".to_string(), path.to_string_lossy().into_owned());
369        environment.insert(
370            GH_SHIMS_DIR_ENV.to_string(),
371            shims_dir.to_string_lossy().into_owned(),
372        );
373    }
374
375    if co_author_enabled {
376        environment.insert("GIT_CONFIG_COUNT".to_string(), "1".to_string());
377        environment.insert("GIT_CONFIG_KEY_0".to_string(), "core.hooksPath".to_string());
378        environment.insert(
379            "GIT_CONFIG_VALUE_0".to_string(),
380            storage_root
381                .join(GIT_HOOKS_DIR_NAME)
382                .to_string_lossy()
383                .into_owned(),
384        );
385        environment.insert(GIT_CO_AUTHOR_ENV.to_string(), config.git.co_author.clone());
386        if config.git.co_author == "auto" {
387            environment.insert(
388                GH_SHIM_BINARY_ENV.to_string(),
389                shim_binary(config)?.to_string_lossy().into_owned(),
390            );
391        }
392    }
393
394    Ok(())
395}
396
397pub fn shim_binary(config: &Config) -> Result<PathBuf, String> {
398    let binary = match config.gh_shim.binary_path.as_ref() {
399        Some(path) => path.clone(),
400        None => std::env::current_exe()
401            .map_err(|error| format!("failed to resolve the running AFT binary: {error}"))?,
402    };
403    if !binary.is_absolute() {
404        return Err(format!(
405            "gh_shim.binary_path must be absolute: {}",
406            binary.display()
407        ));
408    }
409    Ok(binary)
410}
411
412/// Refuse a shim candidate that lives inside the managed shims directory.
413///
414/// A pin pointing at the shims dir's own image is self-referential: maintain()
415/// then always finds the link "consistent" with its candidate and the image
416/// can only go stale — no version comparison can ever trigger a refresh. The
417/// 2026-08-27 incident: a frozen Aug-25 copy refused the production-signed
418/// manifest fleet-wide while every validity probe kept passing (a liveness
419/// answer to a freshness question). Pins must reference a path something
420/// external refreshes — the deploy path a placement updates, or no pin at all
421/// so the running binary is the candidate.
422fn reject_self_referential_pin(binary: &Path, shims_dir: &Path) -> Result<(), String> {
423    let canonical_binary = binary
424        .canonicalize()
425        .unwrap_or_else(|_| binary.to_path_buf());
426    let canonical_dir = shims_dir
427        .canonicalize()
428        .unwrap_or_else(|_| shims_dir.to_path_buf());
429    if canonical_binary.starts_with(&canonical_dir) {
430        return Err(format!(
431            "gh_shim.binary_path points inside the managed shims directory ({}); a self-referential pin freezes the shim forever - point it at the deploy path a placement refreshes (e.g. ~/.local/share/cortexkit/bin/ck-aft) or remove it to track the running binary",
432            binary.display()
433        ));
434    }
435    Ok(())
436}
437
438#[derive(Clone, Debug, Eq, Hash, PartialEq)]
439struct ShimProbeCacheKey {
440    path: PathBuf,
441    modified: Option<Duration>,
442    size: u64,
443}
444
445#[derive(serde::Deserialize)]
446struct ShimSelfReport {
447    shim_version: String,
448    gh_routing_schema_floor: u64,
449}
450
451static SHIM_PROBE_CACHE: OnceLock<Mutex<HashMap<ShimProbeCacheKey, Result<(), String>>>> =
452    OnceLock::new();
453
454/// Verify behavior rather than executable names: installation may point at a
455/// renamed AFT image, while a process that merely resembles one must not become
456/// the agent child's `gh` command.
457fn probe_gh_shim_binary(binary: &Path) -> Result<(), String> {
458    let metadata =
459        fs::metadata(binary).map_err(|error| format!("could not stat candidate: {error}"))?;
460    let key = ShimProbeCacheKey {
461        path: binary.to_path_buf(),
462        modified: metadata
463            .modified()
464            .ok()
465            .and_then(|time| time.duration_since(UNIX_EPOCH).ok()),
466        size: metadata.len(),
467    };
468    let cache = SHIM_PROBE_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
469    if let Some(cached) = cache
470        .lock()
471        .unwrap_or_else(std::sync::PoisonError::into_inner)
472        .get(&key)
473        .cloned()
474    {
475        return cached;
476    }
477
478    let result = probe_gh_shim_binary_uncached(binary);
479    cache
480        .lock()
481        .unwrap_or_else(std::sync::PoisonError::into_inner)
482        .insert(key, result.clone());
483    result
484}
485
486fn probe_gh_shim_binary_uncached(binary: &Path) -> Result<(), String> {
487    // Invoke the image directly, including on Windows where the managed entry is
488    // a gh.cmd wrapper. This keeps validation independent of the wrapper's shell.
489    let output = Command::new(binary)
490        .args(["gh-shim", "--shim-version"])
491        .output()
492        .map_err(|error| format!("could not execute --shim-version probe: {error}"))?;
493    if !output.status.success() {
494        return Err(format!(
495            "--shim-version probe exited with {status}",
496            status = output.status
497        ));
498    }
499    let report: ShimSelfReport = serde_json::from_slice(&output.stdout)
500        .map_err(|error| format!("--shim-version probe emitted invalid JSON: {error}"))?;
501    if report.shim_version.is_empty() || report.gh_routing_schema_floor == 0 {
502        return Err("--shim-version probe omitted required shim identity fields".to_string());
503    }
504    Ok(())
505}
506
507#[cfg(unix)]
508fn existing_gh_entry_is_valid(shims_dir: &Path) -> bool {
509    let entry = shims_dir.join("gh");
510    let binary = match fs::read_link(&entry) {
511        Ok(target) if target.is_absolute() => target,
512        Ok(target) => shims_dir.join(target),
513        Err(_) => entry,
514    };
515    probe_gh_shim_binary(&binary).is_ok()
516}
517
518#[cfg(windows)]
519fn existing_gh_entry_is_valid(shims_dir: &Path) -> bool {
520    let entry = shims_dir.join("gh.cmd");
521    let Ok(wrapper) = fs::read_to_string(entry) else {
522        return false;
523    };
524    let Some(binary) = wrapper
525        .strip_prefix("@echo off\r\n\"")
526        .and_then(|line| line.strip_suffix("\" gh-shim %*\r\n"))
527        .map(|path| PathBuf::from(path.replace("%%", "%")))
528    else {
529        return false;
530    };
531    probe_gh_shim_binary(&binary).is_ok()
532}
533
534#[cfg(not(any(unix, windows)))]
535fn existing_gh_entry_is_valid(_shims_dir: &Path) -> bool {
536    false
537}
538
539fn ensure_managed_git_hooks(hooks_dir: &Path) -> Result<(), String> {
540    fs::create_dir_all(hooks_dir).map_err(|error| {
541        format!(
542            "failed to create child Git hooks directory {}: {error}",
543            hooks_dir.display()
544        )
545    })?;
546    let expected = MANAGED_GIT_HOOK_NAMES
547        .iter()
548        .map(|name| (*name, managed_git_hook_contents(name)))
549        .collect::<Vec<_>>();
550    quarantine_foreign_hook_entries(hooks_dir, &expected)?;
551    for (name, contents) in expected {
552        let hook = hooks_dir.join(name);
553        write_if_changed(&hook, contents.as_bytes())?;
554        #[cfg(unix)]
555        set_executable(&hook)?;
556    }
557    Ok(())
558}
559
560fn quarantine_foreign_hook_entries(
561    hooks_dir: &Path,
562    expected: &[(&str, String)],
563) -> Result<(), String> {
564    let mut foreign = Vec::new();
565    for entry in fs::read_dir(hooks_dir).map_err(|error| {
566        format!(
567            "failed to inspect AFT-owned Git hooks directory {}: {error}",
568            hooks_dir.display()
569        )
570    })? {
571        let entry = entry.map_err(|error| {
572            format!(
573                "failed to inspect an entry in AFT-owned Git hooks directory {}: {error}",
574                hooks_dir.display()
575            )
576        })?;
577        let path = entry.path();
578        let name = entry.file_name();
579        let is_quarantine_dir = name == GIT_HOOKS_QUARANTINE_DIR_NAME
580            && fs::symlink_metadata(&path).is_ok_and(|metadata| {
581                metadata.file_type().is_dir() && !metadata.file_type().is_symlink()
582            });
583        if is_quarantine_dir {
584            continue;
585        }
586        let expected_contents = name
587            .to_str()
588            .and_then(|name| expected.iter().find(|(expected, _)| *expected == name))
589            .map(|(_, contents)| contents.as_bytes());
590        let is_expected_file = expected_contents.is_some_and(|contents| {
591            fs::symlink_metadata(&path).is_ok_and(|metadata| {
592                metadata.file_type().is_file()
593                    && !metadata.file_type().is_symlink()
594                    && fs::read(&path).is_ok_and(|actual| actual == contents)
595            })
596        });
597        if !is_expected_file {
598            foreign.push(path);
599        }
600    }
601    if foreign.is_empty() {
602        return Ok(());
603    }
604
605    let quarantine = hooks_dir.join(GIT_HOOKS_QUARANTINE_DIR_NAME);
606    let mut moved = Vec::new();
607    if fs::symlink_metadata(&quarantine)
608        .is_ok_and(|metadata| !metadata.file_type().is_dir() || metadata.file_type().is_symlink())
609    {
610        let staging = hooks_dir.join(format!(
611            ".quarantine-stage-{}-{}",
612            std::process::id(),
613            SystemTime::now()
614                .duration_since(UNIX_EPOCH)
615                .unwrap_or_default()
616                .as_nanos()
617        ));
618        fs::create_dir(&staging).map_err(|error| {
619            format!(
620                "failed to stage the Git hook quarantine directory {}: {error}",
621                staging.display()
622            )
623        })?;
624        let destination_name = quarantine_entry_name(&quarantine, 0);
625        fs::rename(&quarantine, staging.join(&destination_name)).map_err(|error| {
626            format!(
627                "failed to quarantine reserved entry {}: {error}",
628                quarantine.display()
629            )
630        })?;
631        fs::rename(&staging, &quarantine).map_err(|error| {
632            format!(
633                "failed to install Git hook quarantine directory {}: {error}",
634                quarantine.display()
635            )
636        })?;
637        moved.push(quarantine.join(destination_name));
638        foreign.retain(|path| path != &quarantine);
639    } else {
640        fs::create_dir_all(&quarantine).map_err(|error| {
641            format!(
642                "failed to create Git hook quarantine directory {}: {error}",
643                quarantine.display()
644            )
645        })?;
646    }
647
648    for (index, source) in foreign.into_iter().enumerate() {
649        let destination = quarantine.join(quarantine_entry_name(&source, index + 1));
650        match fs::rename(&source, &destination) {
651            Ok(()) => moved.push(destination),
652            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
653            Err(error) => {
654                return Err(format!(
655                    "failed to quarantine foreign Git hook {} as {}: {error}",
656                    source.display(),
657                    destination.display()
658                ));
659            }
660        }
661    }
662    if !moved.is_empty() {
663        log_quarantined_hook_entries(hooks_dir, &moved);
664    }
665    Ok(())
666}
667
668fn quarantine_entry_name(source: &Path, index: usize) -> String {
669    let original = source
670        .file_name()
671        .unwrap_or_default()
672        .to_string_lossy()
673        .chars()
674        .map(|character| {
675            if character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | '-') {
676                character
677            } else {
678                '_'
679            }
680        })
681        .collect::<String>();
682    let timestamp = SystemTime::now()
683        .duration_since(UNIX_EPOCH)
684        .unwrap_or_default()
685        .as_nanos();
686    format!("{timestamp}-{}-{index}-{original}", std::process::id())
687}
688
689fn log_quarantined_hook_entries(hooks_dir: &Path, moved: &[PathBuf]) {
690    const WINDOW: Duration = Duration::from_secs(60);
691    static LAST_WARNING: OnceLock<Mutex<HashMap<PathBuf, Instant>>> = OnceLock::new();
692    let now = Instant::now();
693    let should_log = match LAST_WARNING
694        .get_or_init(|| Mutex::new(HashMap::new()))
695        .try_lock()
696    {
697        Ok(mut warnings) => {
698            if warnings.len() > 512 {
699                warnings.retain(|_, last| now.duration_since(*last) < WINDOW);
700            }
701            match warnings.get(hooks_dir) {
702                Some(last) if now.duration_since(*last) < WINDOW => false,
703                _ => {
704                    warnings.insert(hooks_dir.to_path_buf(), now);
705                    true
706                }
707            }
708        }
709        Err(_) => true,
710    };
711    if !should_log {
712        return;
713    }
714
715    let destinations = moved
716        .iter()
717        .map(|path| path.display().to_string())
718        .collect::<Vec<_>>()
719        .join(", ");
720    let message = format!(
721        "[agent_child_env] quarantined foreign content from AFT-owned Git hooks directory {}: {destinations}",
722        hooks_dir.display()
723    );
724    crate::slog_warn!("{message}");
725    #[cfg(test)]
726    quarantine_test_logs().lock().unwrap().push(message);
727}
728
729#[cfg(test)]
730fn quarantine_test_logs() -> &'static Mutex<Vec<String>> {
731    static LOGS: OnceLock<Mutex<Vec<String>>> = OnceLock::new();
732    LOGS.get_or_init(|| Mutex::new(Vec::new()))
733}
734
735#[cfg(unix)]
736fn ensure_gh_entry(shims_dir: &Path, binary: &Path) -> Result<(), String> {
737    use std::os::unix::fs::symlink;
738
739    fs::create_dir_all(shims_dir).map_err(|error| {
740        format!(
741            "failed to create gh shim directory {}: {error}",
742            shims_dir.display()
743        )
744    })?;
745    let entry = shims_dir.join("gh");
746    if fs::read_link(&entry).ok().as_deref() == Some(binary) {
747        return Ok(());
748    }
749    if entry.is_dir() {
750        return Err(format!(
751            "cannot replace gh shim entry because it is a directory: {}",
752            entry.display()
753        ));
754    }
755    let temporary = shims_dir.join(format!(".gh.tmp.{}", std::process::id()));
756    let _ = fs::remove_file(&temporary);
757    symlink(binary, &temporary).map_err(|error| {
758        format!(
759            "failed to create gh shim link {} -> {}: {error}",
760            temporary.display(),
761            binary.display()
762        )
763    })?;
764    fs::rename(&temporary, &entry).map_err(|error| {
765        let _ = fs::remove_file(&temporary);
766        format!(
767            "failed to install gh shim link {}: {error}",
768            entry.display()
769        )
770    })
771}
772
773#[cfg(windows)]
774fn ensure_gh_entry(shims_dir: &Path, binary: &Path) -> Result<(), String> {
775    fs::create_dir_all(shims_dir).map_err(|error| {
776        format!(
777            "failed to create gh shim directory {}: {error}",
778            shims_dir.display()
779        )
780    })?;
781    write_if_changed(&shims_dir.join("gh.cmd"), &windows_gh_cmd(binary))
782}
783
784#[cfg(not(any(unix, windows)))]
785fn ensure_gh_entry(_shims_dir: &Path, _binary: &Path) -> Result<(), String> {
786    Err("gh child PATH injection is unsupported on this platform".to_string())
787}
788
789fn remove_gh_entry(shims_dir: &Path) -> Result<(), String> {
790    for name in ["gh", "gh.cmd"] {
791        let entry = shims_dir.join(name);
792        match fs::remove_file(&entry) {
793            Ok(()) => {}
794            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
795            Err(error) => {
796                return Err(format!(
797                    "failed to remove disabled gh shim entry {}: {error}",
798                    entry.display()
799                ));
800            }
801        }
802    }
803    match fs::remove_dir(shims_dir) {
804        Ok(()) => Ok(()),
805        Err(error)
806            if matches!(
807                error.kind(),
808                std::io::ErrorKind::NotFound | std::io::ErrorKind::DirectoryNotEmpty
809            ) =>
810        {
811            Ok(())
812        }
813        Err(error) => Err(format!(
814            "failed to remove empty gh shim directory {}: {error}",
815            shims_dir.display()
816        )),
817    }
818}
819
820fn write_if_changed(path: &Path, bytes: &[u8]) -> Result<(), String> {
821    if fs::read(path).is_ok_and(|existing| existing == bytes) {
822        return Ok(());
823    }
824    if path.is_dir() {
825        return Err(format!(
826            "cannot replace managed child file because it is a directory: {}",
827            path.display()
828        ));
829    }
830    let parent = path
831        .parent()
832        .ok_or_else(|| format!("managed child file has no parent: {}", path.display()))?;
833    fs::create_dir_all(parent).map_err(|error| {
834        format!(
835            "failed to create managed child directory {}: {error}",
836            parent.display()
837        )
838    })?;
839    let temporary = parent.join(format!(
840        ".{}.tmp.{}",
841        path.file_name().unwrap_or_default().to_string_lossy(),
842        std::process::id()
843    ));
844    fs::write(&temporary, bytes).map_err(|error| {
845        format!(
846            "failed to write managed child file {}: {error}",
847            temporary.display()
848        )
849    })?;
850    // Windows rename does not replace an existing destination. Managed files
851    // contain no user data, so remove only the exact stale file before install.
852    #[cfg(windows)]
853    if path.exists() {
854        fs::remove_file(path).map_err(|error| {
855            format!(
856                "failed to replace stale managed child file {}: {error}",
857                path.display()
858            )
859        })?;
860    }
861    fs::rename(&temporary, path).map_err(|error| {
862        let _ = fs::remove_file(&temporary);
863        format!(
864            "failed to install managed child file {}: {error}",
865            path.display()
866        )
867    })
868}
869
870#[cfg(unix)]
871fn set_executable(path: &Path) -> Result<(), String> {
872    use std::os::unix::fs::PermissionsExt;
873
874    let mut permissions = fs::metadata(path)
875        .map_err(|error| {
876            format!(
877                "failed to read hook permissions {}: {error}",
878                path.display()
879            )
880        })?
881        .permissions();
882    permissions.set_mode(0o755);
883    fs::set_permissions(path, permissions)
884        .map_err(|error| format!("failed to make hook executable {}: {error}", path.display()))
885}
886
887/// Render the Windows command wrapper separately so its quoting contract can be
888/// checked on every development platform; `cmd.exe` dispatch still requires
889/// the native Windows CI oracle.
890pub fn windows_gh_cmd(binary: &Path) -> Vec<u8> {
891    let rendered = binary.to_string_lossy();
892    debug_assert!(!rendered.contains('"'));
893    let rendered = rendered.replace('%', "%%");
894    format!("@echo off\r\n\"{rendered}\" gh-shim %*\r\n").into_bytes()
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900    use crate::config::{Config, GitConfig};
901
902    #[cfg(unix)]
903    const TEST_CO_AUTHOR: &str = "Pair Agent <pair@example.test>";
904
905    #[test]
906    fn disabled_features_leave_the_requested_environment_byte_identical() {
907        let mut config = Config::default();
908        config.gh_shim.enabled = false;
909        config.git = GitConfig::default();
910        let before = HashMap::from([
911            ("PATH".to_string(), "/one:/two".to_string()),
912            ("CUSTOM".to_string(), "value".to_string()),
913        ]);
914        let mut after = before.clone();
915        inject(&config, Path::new("/unused"), &mut after).unwrap();
916        assert_eq!(after, before);
917    }
918
919    #[test]
920    fn child_environment_strips_the_complete_subc_credential_family_before_config_gates() {
921        let mut config = Config::default();
922        config.gh_shim.enabled = false;
923        config.git = GitConfig::default();
924        let mut environment = HashMap::from([
925            ("SUBC_MODULE_ID".to_string(), "aft".to_string()),
926            ("SUBC_LAUNCH_NONCE".to_string(), "nonce".to_string()),
927            (
928                "SUBC_FUTURE_CREDENTIAL".to_string(),
929                "future-secret".to_string(),
930            ),
931            ("CUSTOM".to_string(), "kept".to_string()),
932        ]);
933
934        inject(&config, Path::new("/unused"), &mut environment).unwrap();
935
936        assert_eq!(environment.get("CUSTOM").map(String::as_str), Some("kept"));
937        assert!(
938            environment
939                .keys()
940                .all(|key| !is_subc_credential_env_key(key)),
941            "a subc supervised-spawn credential remained in the child snapshot"
942        );
943    }
944
945    #[test]
946    fn command_adapters_remove_explicit_subc_identity_material() {
947        let request_environment = HashMap::from([
948            ("SUBC_MODULE_ID".to_string(), "request-aft".to_string()),
949            ("CUSTOM".to_string(), "kept".to_string()),
950        ]);
951        let mut command = Command::new("unused-test-command");
952        command
953            .env("SUBC_LAUNCH_NONCE", "ambient-nonce")
954            .env("SUBC_FUTURE_CREDENTIAL", "future-secret");
955        apply_to_command(&mut command, &request_environment);
956        let configured = command
957            .get_envs()
958            .map(|(key, value)| (key.to_string_lossy().into_owned(), value))
959            .collect::<HashMap<_, _>>();
960        assert_eq!(
961            configured.get("CUSTOM").copied().flatten(),
962            Some(std::ffi::OsStr::new("kept"))
963        );
964        for key in [
965            "SUBC_MODULE_ID",
966            "SUBC_LAUNCH_NONCE",
967            "SUBC_FUTURE_CREDENTIAL",
968        ] {
969            assert_eq!(
970                configured.get(key).copied().flatten(),
971                None,
972                "std::process child retained {key}"
973            );
974        }
975
976        let mut pty_command = portable_pty::CommandBuilder::new("unused-test-command");
977        pty_command.env("SUBC_MODULE_ID", "aft");
978        pty_command.env("SUBC_LAUNCH_NONCE", "nonce");
979        pty_command.env("SUBC_FUTURE_CREDENTIAL", "future-secret");
980        pty_command.env("CUSTOM", "kept");
981        scrub_pty_command(&mut pty_command);
982        assert_eq!(
983            pty_command.get_env("CUSTOM"),
984            Some(std::ffi::OsStr::new("kept"))
985        );
986        for key in [
987            "SUBC_MODULE_ID",
988            "SUBC_LAUNCH_NONCE",
989            "SUBC_FUTURE_CREDENTIAL",
990        ] {
991            assert_eq!(pty_command.get_env(key), None, "PTY child retained {key}");
992        }
993    }
994
995    #[test]
996    fn agent_process_creation_sites_cannot_bypass_the_child_environment_funnel() {
997        // Normalize line endings first: Windows checkouts materialize these
998        // sources with CRLF, and a split marker containing a bare \n would
999        // silently never match there - leaving the test half in the counted
1000        // text and failing the inventory with test-code spawn sites.
1001        let registry_source = include_str!("bash_background/registry.rs").replace("\r\n", "\n");
1002        let registry = registry_source
1003            .split("#[cfg(test)]\nmod tests")
1004            .next()
1005            .unwrap();
1006        let pty_source = include_str!("bash_background/pty_process.rs").replace("\r\n", "\n");
1007        let pty = pty_source
1008            .split("// Every test in this module")
1009            .next()
1010            .unwrap();
1011        let sandbox = include_str!("sandbox_spawn.rs");
1012
1013        let detached_spawns = registry.matches(".spawn()").count();
1014        assert_eq!(detached_spawns, 2, "agent detached spawn inventory drifted");
1015        assert_eq!(
1016            registry
1017                .matches("agent_child_env::apply_to_command")
1018                .count(),
1019            detached_spawns,
1020            "every detached spawn must apply the scrubbed child environment"
1021        );
1022
1023        let pty_spawns = pty.matches(".spawn_command(").count();
1024        assert_eq!(pty_spawns, 1, "agent PTY spawn inventory drifted");
1025        assert_eq!(
1026            pty.matches("sandbox_spawn::pty_command_for_plan(").count(),
1027            pty_spawns,
1028            "every PTY spawn must use the scrubbed command factory"
1029        );
1030        assert!(
1031            sandbox.contains("agent_child_env::scrub_pty_command(&mut command)"),
1032            "the PTY command factory no longer scrubs child credentials"
1033        );
1034    }
1035
1036    #[cfg(unix)]
1037    #[test]
1038    fn configure_maintenance_refreshes_stale_gh_links_and_removes_disabled_entries() {
1039        let temp = tempfile::tempdir().unwrap();
1040        let first = temp.path().join("aft-first");
1041        let second = temp.path().join("aft-second");
1042        write_self_reporting_shim(&first);
1043        write_self_reporting_shim(&second);
1044        let mut config = Config::default();
1045        config.gh_shim.binary_path = Some(first);
1046        maintain(&config, temp.path()).unwrap();
1047        let entry = temp.path().join("shims/gh");
1048        assert_eq!(
1049            fs::read_link(&entry).unwrap(),
1050            config.gh_shim.binary_path.as_deref().unwrap()
1051        );
1052
1053        config.gh_shim.binary_path = Some(second);
1054        maintain(&config, temp.path()).unwrap();
1055        assert_eq!(
1056            fs::read_link(&entry).unwrap(),
1057            config.gh_shim.binary_path.as_deref().unwrap()
1058        );
1059
1060        config.gh_shim.enabled = false;
1061        maintain(&config, temp.path()).unwrap();
1062        assert!(fs::symlink_metadata(entry).is_err());
1063    }
1064
1065    #[cfg(unix)]
1066    #[test]
1067    fn configure_maintenance_refuses_harnesses_and_preserves_verified_shims() {
1068        let temp = tempfile::tempdir().unwrap();
1069        let verified = temp.path().join("aft-verified");
1070        let harness = temp.path().join("aft-test-harness");
1071        write_self_reporting_shim(&verified);
1072        write_executable(
1073            &harness,
1074            "#!/bin/sh\nif [ \"${2:-}\" = \"--shim-version\" ]; then exit 2; fi\nexit 0\n",
1075        );
1076
1077        let mut config = Config::default();
1078        config.gh_shim.binary_path = Some(verified.clone());
1079        maintain(&config, temp.path()).unwrap();
1080        let entry = temp.path().join("shims/gh");
1081        assert_eq!(fs::read_link(&entry).unwrap(), verified);
1082
1083        config.gh_shim.binary_path = Some(harness);
1084        maintain(&config, temp.path()).unwrap();
1085        assert_eq!(
1086            fs::read_link(&entry).unwrap(),
1087            verified,
1088            "a rejected candidate must not replace a verified shim"
1089        );
1090
1091        fs::remove_file(&entry).unwrap();
1092        maintain(&config, temp.path()).unwrap();
1093        assert!(
1094            fs::symlink_metadata(entry).is_err(),
1095            "a rejected candidate must not install a new gh entry"
1096        );
1097    }
1098
1099    #[test]
1100    fn windows_wrapper_uses_the_explicit_gh_shim_dispatch_form() {
1101        assert_eq!(
1102            String::from_utf8(windows_gh_cmd(Path::new(r"C:\AFT Dev\aft.exe"))).unwrap(),
1103            "@echo off\r\n\"C:\\AFT Dev\\aft.exe\" gh-shim %*\r\n"
1104        );
1105    }
1106
1107    #[test]
1108    fn generated_hook_stays_posix_and_documents_joint_agent_attribution() {
1109        let hook = managed_git_hook_contents(PREPARE_COMMIT_MSG);
1110        assert!(hook.starts_with("#!/bin/sh\n"));
1111        assert!(!hook.contains("[["));
1112        assert!(!hook.contains("function "));
1113        assert!(!hook.contains("mason:*)"));
1114        assert!(hook.contains("do not\n# receive an attribution exemption"));
1115        assert!(hook.contains("git interpret-trailers --in-place --if-exists doNothing"));
1116        assert!(hook.contains("--trailer \"Co-authored-by=$identity\" \"$msg_file\""));
1117        assert!(!hook.contains(">> \"$msg_file\""));
1118    }
1119
1120    #[cfg(unix)]
1121    fn run_git(repo: &Path, args: &[&str], environment: &HashMap<String, String>) {
1122        let status = std::process::Command::new("git")
1123            .args(args)
1124            .current_dir(repo)
1125            .envs(environment)
1126            .status()
1127            .unwrap();
1128        assert!(status.success(), "git {args:?} failed: {status}");
1129    }
1130
1131    // The timeout turns a dispatcher that re-enters itself (an infinite hook
1132    // loop) into a failure; it is not a bound on commit latency. A hook-chained
1133    // commit spawns several git and shell processes, which under a parallel test
1134    // gate on macOS can take multiple seconds each, so keep it far above that.
1135    #[cfg(unix)]
1136    const HOOK_REENTRY_GUARD: Duration = Duration::from_secs(60);
1137
1138    #[cfg(unix)]
1139    fn run_git_with_timeout(
1140        repo: &Path,
1141        args: &[&str],
1142        environment: &HashMap<String, String>,
1143        timeout: Duration,
1144    ) -> std::process::Output {
1145        use std::process::Stdio;
1146
1147        let mut child = Command::new("git")
1148            .args(args)
1149            .current_dir(repo)
1150            .envs(environment)
1151            .stdout(Stdio::piped())
1152            .stderr(Stdio::piped())
1153            .spawn()
1154            .unwrap();
1155        let deadline = Instant::now() + timeout;
1156        loop {
1157            if child.try_wait().unwrap().is_some() {
1158                return child.wait_with_output().unwrap();
1159            }
1160            if Instant::now() >= deadline {
1161                child.kill().unwrap();
1162                let output = child.wait_with_output().unwrap();
1163                panic!(
1164                    "git {args:?} exceeded {timeout:?}; stdout={} stderr={}",
1165                    String::from_utf8_lossy(&output.stdout),
1166                    String::from_utf8_lossy(&output.stderr)
1167                );
1168            }
1169            std::thread::sleep(Duration::from_millis(10));
1170        }
1171    }
1172
1173    #[cfg(unix)]
1174    fn initialize_repo(repo: &Path) {
1175        fs::create_dir_all(repo).unwrap();
1176        let environment = HashMap::new();
1177        run_git(repo, &["init", "--quiet"], &environment);
1178        run_git(repo, &["config", "user.name", "AFT Test"], &environment);
1179        run_git(
1180            repo,
1181            &["config", "user.email", "aft-test@example.test"],
1182            &environment,
1183        );
1184        fs::write(repo.join("tracked.txt"), "one\n").unwrap();
1185        run_git(repo, &["add", "tracked.txt"], &environment);
1186    }
1187
1188    #[cfg(unix)]
1189    fn prepare_merge_fixture(repo: &Path) {
1190        let environment = HashMap::new();
1191        initialize_repo(repo);
1192        run_git(repo, &["commit", "--quiet", "-m", "initial"], &environment);
1193        run_git(repo, &["checkout", "--quiet", "-b", "topic"], &environment);
1194        fs::write(repo.join("topic.txt"), "topic\n").unwrap();
1195        run_git(repo, &["add", "topic.txt"], &environment);
1196        run_git(repo, &["commit", "--quiet", "-m", "topic"], &environment);
1197        run_git(repo, &["checkout", "--quiet", "-"], &environment);
1198    }
1199
1200    #[cfg(unix)]
1201    fn commit_message(repo: &Path) -> String {
1202        let output = std::process::Command::new("git")
1203            .args(["cat-file", "commit", "HEAD"])
1204            .current_dir(repo)
1205            .output()
1206            .unwrap();
1207        assert!(output.status.success());
1208        String::from_utf8(output.stdout)
1209            .unwrap()
1210            .split_once("\n\n")
1211            .unwrap()
1212            .1
1213            .to_string()
1214    }
1215
1216    #[cfg(unix)]
1217    fn co_author_environment(storage: &Path) -> HashMap<String, String> {
1218        let mut config = Config::default();
1219        config.gh_shim.enabled = false;
1220        config.git.co_author = TEST_CO_AUTHOR.to_string();
1221        let mut environment = HashMap::new();
1222        inject(&config, storage, &mut environment).unwrap();
1223        environment
1224    }
1225
1226    #[cfg(unix)]
1227    fn expected_co_author_message(subject: &str) -> String {
1228        format!("{subject}\n\nCo-authored-by: {TEST_CO_AUTHOR}\n")
1229    }
1230
1231    #[cfg(unix)]
1232    fn assert_single_co_author_message(message: &str, subject: &str) {
1233        assert_eq!(message, expected_co_author_message(subject));
1234        assert_eq!(message.matches("Co-authored-by:").count(), 1);
1235    }
1236
1237    #[cfg(unix)]
1238    fn run_generated_hook(
1239        repo: &Path,
1240        hook: &Path,
1241        message_file: &Path,
1242        environment: &HashMap<String, String>,
1243    ) {
1244        let status = std::process::Command::new(hook)
1245            .arg(message_file)
1246            .current_dir(repo)
1247            .envs(environment)
1248            .status()
1249            .unwrap();
1250        assert!(status.success(), "generated hook failed: {status}");
1251    }
1252
1253    #[cfg(unix)]
1254    fn write_executable(path: &Path, body: &str) {
1255        fs::write(path, body).unwrap();
1256        set_executable(path).unwrap();
1257    }
1258
1259    #[cfg(unix)]
1260    fn write_self_reporting_shim(path: &Path) {
1261        write_executable(
1262            path,
1263            "#!/bin/sh\nif [ \"${1:-}\" = \"gh-shim\" ] && [ \"${2:-}\" = \"--shim-version\" ]; then\n  printf '%s\\n' '{\"shim_version\":\"test\",\"gh_routing_schema_floor\":1}'\n  exit 0\nfi\nexit 1\n",
1264        );
1265    }
1266
1267    #[test]
1268    fn child_environment_propagates_explicit_storage_override_but_never_originates_one() {
1269        let _guard = crate::test_env::process_env_lock();
1270        let storage = tempfile::tempdir().unwrap();
1271        let mut config = Config::default();
1272        config.git.co_author = "Pair Agent <pair@example.test>".to_string();
1273
1274        // No explicit override in the parent: the child gets NONE. Injecting the
1275        // default-resolved root as an explicit env var would outrank XDG-based
1276        // isolation in nested processes (the 41-fixture field incident).
1277        let previous = std::env::var_os(STORAGE_DIR_ENV);
1278        std::env::remove_var(STORAGE_DIR_ENV);
1279        let mut environment = HashMap::new();
1280        inject(&config, storage.path(), &mut environment).unwrap();
1281        assert_eq!(environment.get(STORAGE_DIR_ENV), None);
1282
1283        // Explicit override present: propagated verbatim so spawned children
1284        // stay in the same storage universe (the original leak-class fix).
1285        let explicit = tempfile::tempdir().unwrap();
1286        std::env::set_var(STORAGE_DIR_ENV, explicit.path());
1287        let mut environment = HashMap::new();
1288        inject(&config, storage.path(), &mut environment).unwrap();
1289        assert_eq!(
1290            environment.get(STORAGE_DIR_ENV),
1291            Some(&explicit.path().to_string_lossy().into_owned())
1292        );
1293        match previous {
1294            Some(value) => std::env::set_var(STORAGE_DIR_ENV, value),
1295            None => std::env::remove_var(STORAGE_DIR_ENV),
1296        }
1297    }
1298
1299    #[cfg(unix)]
1300    #[test]
1301    fn generated_hook_separates_a_merge_subject_without_a_final_newline() {
1302        let temp = tempfile::tempdir().unwrap();
1303        let repo = temp.path().join("repo");
1304        let storage = temp.path().join("storage");
1305        prepare_merge_fixture(&repo);
1306
1307        let environment = co_author_environment(&storage);
1308        run_git(
1309            &repo,
1310            &[
1311                "merge",
1312                "--no-ff",
1313                "--quiet",
1314                "-m",
1315                "merge subject",
1316                "topic",
1317            ],
1318            &environment,
1319        );
1320
1321        assert_single_co_author_message(&commit_message(&repo), "merge subject");
1322    }
1323
1324    #[cfg(unix)]
1325    #[test]
1326    fn generated_hook_keeps_plain_commit_m_messages_in_trailer_form() {
1327        let temp = tempfile::tempdir().unwrap();
1328        let repo = temp.path().join("repo");
1329        let storage = temp.path().join("storage");
1330        initialize_repo(&repo);
1331
1332        let environment = co_author_environment(&storage);
1333        run_git(
1334            &repo,
1335            &["commit", "--quiet", "-m", "plain subject"],
1336            &environment,
1337        );
1338
1339        assert_single_co_author_message(&commit_message(&repo), "plain subject");
1340    }
1341
1342    #[cfg(unix)]
1343    #[test]
1344    fn generated_hook_does_not_duplicate_a_trailer_when_rerun() {
1345        let temp = tempfile::tempdir().unwrap();
1346        let repo = temp.path().join("repo");
1347        let storage = temp.path().join("storage");
1348        initialize_repo(&repo);
1349        let environment = co_author_environment(&storage);
1350        let hook = storage.join(GIT_HOOKS_DIR_NAME).join(PREPARE_COMMIT_MSG);
1351        let message_file = repo.join("message");
1352        fs::write(&message_file, "rerun subject").unwrap();
1353
1354        run_generated_hook(&repo, &hook, &message_file, &environment);
1355        run_generated_hook(&repo, &hook, &message_file, &environment);
1356
1357        assert_single_co_author_message(
1358            &fs::read_to_string(message_file).unwrap(),
1359            "rerun subject",
1360        );
1361    }
1362
1363    #[cfg(unix)]
1364    #[test]
1365    fn generated_hook_does_nothing_when_another_co_author_exists() {
1366        let temp = tempfile::tempdir().unwrap();
1367        let repo = temp.path().join("repo");
1368        let storage = temp.path().join("storage");
1369        initialize_repo(&repo);
1370        let environment = co_author_environment(&storage);
1371        let hook = storage.join(GIT_HOOKS_DIR_NAME).join(PREPARE_COMMIT_MSG);
1372        let message_file = repo.join("message");
1373        let original = "existing subject\n\nCo-authored-by: Other Agent <other@example.test>\n";
1374        fs::write(&message_file, original).unwrap();
1375
1376        run_generated_hook(&repo, &hook, &message_file, &environment);
1377
1378        assert_eq!(fs::read_to_string(message_file).unwrap(), original);
1379    }
1380
1381    #[cfg(unix)]
1382    #[test]
1383    fn generated_hook_and_chained_sibling_add_only_one_matching_trailer() {
1384        let temp = tempfile::tempdir().unwrap();
1385        let repo = temp.path().join("repo");
1386        let storage = temp.path().join("storage");
1387        prepare_merge_fixture(&repo);
1388        let local_hook = repo.join(".git/hooks/prepare-commit-msg");
1389        write_executable(
1390            &local_hook,
1391            "#!/bin/sh\nprintf '%s\\n' invoked > sibling-hook-ran\ngit interpret-trailers --in-place --if-exists doNothing --trailer \"Co-authored-by=Pair Agent <pair@example.test>\" \"$1\"\n",
1392        );
1393
1394        let environment = co_author_environment(&storage);
1395        run_git(
1396            &repo,
1397            &[
1398                "merge",
1399                "--no-ff",
1400                "--quiet",
1401                "-m",
1402                "chained merge subject",
1403                "topic",
1404            ],
1405            &environment,
1406        );
1407
1408        assert_eq!(
1409            fs::read_to_string(repo.join("sibling-hook-ran")).unwrap(),
1410            "invoked\n"
1411        );
1412        assert_single_co_author_message(&commit_message(&repo), "chained merge subject");
1413    }
1414
1415    #[cfg(unix)]
1416    #[test]
1417    fn auto_hook_is_idempotent_and_chains_default_repository_hook() {
1418        let temp = tempfile::tempdir().unwrap();
1419        let repo = temp.path().join("repo");
1420        let storage = temp.path().join("storage");
1421        initialize_repo(&repo);
1422        let shim = temp.path().join("fake-aft");
1423        write_executable(
1424            &shim,
1425            "#!/bin/sh\nprintf '%s\\n' 'Co-authored-by: aft-alfonso[bot] <318960130+aft-alfonso[bot]@users.noreply.github.com>'\n",
1426        );
1427        let local_hook = repo.join(".git/hooks/prepare-commit-msg");
1428        write_executable(
1429            &local_hook,
1430            "#!/bin/sh\nprintf '%s\\n' 'Local-Hook: default' >> \"$1\"\n",
1431        );
1432
1433        let mut config = Config::default();
1434        config.gh_shim.enabled = false;
1435        config.gh_shim.binary_path = Some(shim);
1436        config.git.co_author = "auto".to_string();
1437        let mut environment = HashMap::new();
1438        inject(&config, &storage, &mut environment).unwrap();
1439        run_git(
1440            &repo,
1441            &["commit", "--quiet", "-m", "mason: joint work"],
1442            &environment,
1443        );
1444        run_git(
1445            &repo,
1446            &["commit", "--quiet", "--amend", "--no-edit"],
1447            &environment,
1448        );
1449
1450        let message = commit_message(&repo);
1451        assert_eq!(message.matches("Co-authored-by:").count(), 1);
1452        assert!(message.contains(
1453            "Co-authored-by: aft-alfonso[bot] <318960130+aft-alfonso[bot]@users.noreply.github.com>"
1454        ));
1455        assert_eq!(message.matches("Local-Hook: default").count(), 2);
1456    }
1457
1458    #[cfg(unix)]
1459    #[test]
1460    fn maintenance_generates_the_complete_posix_dispatcher_set() {
1461        let temp = tempfile::tempdir().unwrap();
1462        let storage = temp.path().join("storage");
1463        let mut config = Config::default();
1464        config.gh_shim.enabled = false;
1465        config.git.co_author = TEST_CO_AUTHOR.to_string();
1466
1467        maintain(&config, &storage).unwrap();
1468
1469        let hooks_dir = storage.join(GIT_HOOKS_DIR_NAME);
1470        let mut generated = fs::read_dir(&hooks_dir)
1471            .unwrap()
1472            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1473            .collect::<Vec<_>>();
1474        generated.sort();
1475        let mut expected = MANAGED_GIT_HOOK_NAMES
1476            .iter()
1477            .map(|name| (*name).to_string())
1478            .collect::<Vec<_>>();
1479        expected.sort();
1480        assert_eq!(generated, expected);
1481        for name in MANAGED_GIT_HOOK_NAMES {
1482            let body = fs::read_to_string(hooks_dir.join(name)).unwrap();
1483            assert!(
1484                body.starts_with("#!/bin/sh\n"),
1485                "{name} is not a POSIX hook"
1486            );
1487            assert!(body.contains(&format!("hook_name={name}\n")));
1488            assert!(!body.lines().any(|line| {
1489                !line.trim_start().starts_with('#') && line.contains("rev-parse --git-path")
1490            }));
1491            assert!(body.contains("rev-parse --git-dir"));
1492            assert!(body.contains("-ef \"$0\""));
1493        }
1494    }
1495
1496    #[cfg(unix)]
1497    #[test]
1498    fn maintenance_quarantines_contamination_logs_and_regenerates() {
1499        let temp = tempfile::tempdir().unwrap();
1500        let storage = temp.path().join("storage");
1501        let hooks_dir = storage.join(GIT_HOOKS_DIR_NAME);
1502        let mut config = Config::default();
1503        config.gh_shim.enabled = false;
1504        config.git.co_author = TEST_CO_AUTHOR.to_string();
1505        maintain(&config, &storage).unwrap();
1506        fs::write(
1507            hooks_dir.join("pre-commit"),
1508            "#!/bin/sh\necho foreign lefthook fallback\n",
1509        )
1510        .unwrap();
1511        fs::write(hooks_dir.join("unknown-manager-hook"), "foreign\n").unwrap();
1512
1513        maintain(&config, &storage).unwrap();
1514
1515        assert_eq!(
1516            fs::read_to_string(hooks_dir.join("pre-commit")).unwrap(),
1517            managed_git_hook_contents("pre-commit")
1518        );
1519        let quarantined = fs::read_dir(hooks_dir.join(GIT_HOOKS_QUARANTINE_DIR_NAME))
1520            .unwrap()
1521            .map(|entry| entry.unwrap().path())
1522            .collect::<Vec<_>>();
1523        assert_eq!(quarantined.len(), 2);
1524        assert!(quarantined.iter().any(|path| {
1525            path.file_name()
1526                .unwrap()
1527                .to_string_lossy()
1528                .ends_with("pre-commit")
1529                && fs::read_to_string(path)
1530                    .unwrap()
1531                    .contains("foreign lefthook fallback")
1532        }));
1533        assert!(quarantined.iter().any(|path| {
1534            path.file_name()
1535                .unwrap()
1536                .to_string_lossy()
1537                .ends_with("unknown-manager-hook")
1538        }));
1539        let hook_dir_text = hooks_dir.display().to_string();
1540        let warning_count = quarantine_test_logs()
1541            .lock()
1542            .unwrap()
1543            .iter()
1544            .filter(|message| message.contains(&hook_dir_text))
1545            .count();
1546        assert_eq!(warning_count, 1, "the contamination sweep did not log once");
1547
1548        fs::write(hooks_dir.join("another-foreign-hook"), "foreign again\n").unwrap();
1549        maintain(&config, &storage).unwrap();
1550        let warning_count = quarantine_test_logs()
1551            .lock()
1552            .unwrap()
1553            .iter()
1554            .filter(|message| message.contains(&hook_dir_text))
1555            .count();
1556        assert_eq!(
1557            warning_count, 1,
1558            "quarantine warnings were not rate-limited"
1559        );
1560    }
1561
1562    #[cfg(unix)]
1563    #[test]
1564    fn quarantine_content_guard_detects_a_one_byte_managed_hook_mutation() {
1565        let temp = tempfile::tempdir().unwrap();
1566        let storage = temp.path().join("storage");
1567        let hooks_dir = storage.join(GIT_HOOKS_DIR_NAME);
1568        let mut config = Config::default();
1569        config.gh_shim.enabled = false;
1570        config.git.co_author = TEST_CO_AUTHOR.to_string();
1571        maintain(&config, &storage).unwrap();
1572        assert!(!hooks_dir.join(GIT_HOOKS_QUARANTINE_DIR_NAME).exists());
1573
1574        let hook = hooks_dir.join("commit-msg");
1575        let mut mutated = fs::read(&hook).unwrap();
1576        mutated.push(b' ');
1577        fs::write(&hook, mutated).unwrap();
1578        maintain(&config, &storage).unwrap();
1579
1580        let quarantine = hooks_dir.join(GIT_HOOKS_QUARANTINE_DIR_NAME);
1581        assert_eq!(fs::read_dir(quarantine).unwrap().count(), 1);
1582        assert_eq!(
1583            fs::read_to_string(hook).unwrap(),
1584            managed_git_hook_contents("commit-msg")
1585        );
1586    }
1587
1588    #[cfg(unix)]
1589    #[test]
1590    fn local_hooks_path_without_a_hook_does_not_reenter_the_dispatcher() {
1591        let temp = tempfile::tempdir().unwrap();
1592        let repo = temp.path().join("repo");
1593        let storage = temp.path().join("storage");
1594        initialize_repo(&repo);
1595        run_git(
1596            &repo,
1597            &["config", "core.hooksPath", ".githooks"],
1598            &HashMap::new(),
1599        );
1600        fs::create_dir_all(repo.join(".githooks")).unwrap();
1601        let environment = co_author_environment(&storage);
1602
1603        let output = run_git_with_timeout(
1604            &repo,
1605            &["commit", "--quiet", "-m", "no repository hook"],
1606            &environment,
1607            HOOK_REENTRY_GUARD,
1608        );
1609
1610        assert!(
1611            output.status.success(),
1612            "commit failed: {}",
1613            String::from_utf8_lossy(&output.stderr)
1614        );
1615    }
1616
1617    #[cfg(unix)]
1618    #[test]
1619    fn local_hooks_path_pointing_to_managed_directory_does_not_reenter() {
1620        let temp = tempfile::tempdir().unwrap();
1621        let repo = temp.path().join("repo");
1622        let storage = temp.path().join("storage");
1623        initialize_repo(&repo);
1624        let environment = co_author_environment(&storage);
1625        let managed = storage.join(GIT_HOOKS_DIR_NAME);
1626        run_git(
1627            &repo,
1628            &["config", "core.hooksPath", managed.to_str().unwrap()],
1629            &HashMap::new(),
1630        );
1631
1632        let output = run_git_with_timeout(
1633            &repo,
1634            &["commit", "--quiet", "-m", "self guard"],
1635            &environment,
1636            HOOK_REENTRY_GUARD,
1637        );
1638
1639        assert!(
1640            output.status.success(),
1641            "commit failed: {}",
1642            String::from_utf8_lossy(&output.stderr)
1643        );
1644    }
1645
1646    #[cfg(unix)]
1647    #[test]
1648    fn prepare_commit_msg_adds_attribution_before_repository_hook() {
1649        let temp = tempfile::tempdir().unwrap();
1650        let repo = temp.path().join("repo");
1651        let storage = temp.path().join("storage");
1652        initialize_repo(&repo);
1653        write_executable(
1654            &repo.join(".git/hooks/prepare-commit-msg"),
1655            "#!/bin/sh\ngrep -q '^Co-authored-by: Pair Agent <pair@example.test>$' \"$1\" || exit 91\nprintf '%s\\n' 'Local-Hook: after-attribution' >> \"$1\"\n",
1656        );
1657
1658        let environment = co_author_environment(&storage);
1659        run_git(
1660            &repo,
1661            &["commit", "--quiet", "-m", "ordered chain"],
1662            &environment,
1663        );
1664
1665        let message = commit_message(&repo);
1666        let co_author = message.find("Co-authored-by:").unwrap();
1667        let local = message.find("Local-Hook: after-attribution").unwrap();
1668        assert!(co_author < local);
1669    }
1670
1671    #[cfg(unix)]
1672    #[test]
1673    fn dot_githooks_fallback_runs_when_other_candidates_are_absent() {
1674        let temp = tempfile::tempdir().unwrap();
1675        let repo = temp.path().join("repo");
1676        let storage = temp.path().join("storage");
1677        initialize_repo(&repo);
1678        fs::create_dir_all(repo.join(".githooks")).unwrap();
1679        write_executable(
1680            &repo.join(".githooks/pre-commit"),
1681            "#!/bin/sh\nprintf '%s\\n' invoked > dot-githooks-ran\n",
1682        );
1683
1684        let environment = co_author_environment(&storage);
1685        run_git(
1686            &repo,
1687            &["commit", "--quiet", "-m", "fallback"],
1688            &environment,
1689        );
1690
1691        assert_eq!(
1692            fs::read_to_string(repo.join("dot-githooks-ran")).unwrap(),
1693            "invoked\n"
1694        );
1695    }
1696
1697    #[cfg(unix)]
1698    #[test]
1699    fn injected_hooks_dispatch_repository_pre_push_and_preserve_stdin() {
1700        let temp = tempfile::tempdir().unwrap();
1701        let repo = temp.path().join("repo");
1702        let remote = temp.path().join("remote.git");
1703        let storage = temp.path().join("storage");
1704        initialize_repo(&repo);
1705        run_git(
1706            &repo,
1707            &["commit", "--quiet", "-m", "initial"],
1708            &HashMap::new(),
1709        );
1710        run_git(
1711            temp.path(),
1712            &["init", "--quiet", "--bare", remote.to_str().unwrap()],
1713            &HashMap::new(),
1714        );
1715        run_git(
1716            &repo,
1717            &["remote", "add", "origin", remote.to_str().unwrap()],
1718            &HashMap::new(),
1719        );
1720        write_executable(
1721            &repo.join(".git/hooks/pre-push"),
1722            "#!/bin/sh\nprintf '%s\\n' invoked > pre-push-ran\ncat > pre-push-stdin\n",
1723        );
1724
1725        let environment = co_author_environment(&storage);
1726        run_git(
1727            &repo,
1728            &["push", "--quiet", "origin", "HEAD:refs/heads/main"],
1729            &environment,
1730        );
1731
1732        assert_eq!(
1733            fs::read_to_string(repo.join("pre-push-ran")).unwrap(),
1734            "invoked\n"
1735        );
1736        assert!(
1737            fs::read_to_string(repo.join("pre-push-stdin"))
1738                .unwrap()
1739                .contains("refs/heads/main"),
1740            "the repository hook did not receive Git's original stdin"
1741        );
1742    }
1743
1744    #[cfg(unix)]
1745    #[test]
1746    fn injected_hooks_preserve_failing_pre_commit_exit_status() {
1747        let temp = tempfile::tempdir().unwrap();
1748        let repo = temp.path().join("repo");
1749        let storage = temp.path().join("storage");
1750        initialize_repo(&repo);
1751        write_executable(
1752            &repo.join(".git/hooks/pre-commit"),
1753            "#!/bin/sh\nprintf '%s\\n' invoked > pre-commit-ran\nexit 73\n",
1754        );
1755
1756        let status = Command::new("git")
1757            .args(["commit", "--quiet", "-m", "blocked"])
1758            .current_dir(&repo)
1759            .envs(co_author_environment(&storage))
1760            .status()
1761            .unwrap();
1762
1763        assert!(
1764            !status.success(),
1765            "a failing repository hook must block commit"
1766        );
1767        assert_eq!(
1768            fs::read_to_string(repo.join("pre-commit-ran")).unwrap(),
1769            "invoked\n"
1770        );
1771    }
1772
1773    #[cfg(unix)]
1774    #[test]
1775    fn injected_hooks_respect_repo_local_lefthook_style_path() {
1776        let temp = tempfile::tempdir().unwrap();
1777        let repo = temp.path().join("repo");
1778        let storage = temp.path().join("storage");
1779        initialize_repo(&repo);
1780        run_git(
1781            &repo,
1782            &["config", "core.hooksPath", ".lefthook"],
1783            &HashMap::new(),
1784        );
1785        fs::create_dir_all(repo.join(".lefthook")).unwrap();
1786        write_executable(
1787            &repo.join(".lefthook/pre-commit"),
1788            "#!/bin/sh\nprintf '%s\\n' invoked > lefthook-pre-commit-ran\n",
1789        );
1790
1791        let environment = co_author_environment(&storage);
1792        run_git(
1793            &repo,
1794            &["commit", "--quiet", "-m", "custom hooks path"],
1795            &environment,
1796        );
1797
1798        assert_eq!(
1799            fs::read_to_string(repo.join("lefthook-pre-commit-ran")).unwrap(),
1800            "invoked\n"
1801        );
1802    }
1803
1804    #[cfg(unix)]
1805    #[test]
1806    fn explicit_hook_skips_derivation_and_chains_custom_hooks_path() {
1807        let temp = tempfile::tempdir().unwrap();
1808        let repo = temp.path().join("repo");
1809        let storage = temp.path().join("storage");
1810        initialize_repo(&repo);
1811        let environment = HashMap::new();
1812        run_git(
1813            &repo,
1814            &["config", "core.hooksPath", ".custom-hooks"],
1815            &environment,
1816        );
1817        let custom_hook = repo.join(".custom-hooks/prepare-commit-msg");
1818        fs::create_dir_all(custom_hook.parent().unwrap()).unwrap();
1819        write_executable(
1820            &custom_hook,
1821            "#!/bin/sh\nprintf '%s\\n' 'Local-Hook: custom' >> \"$1\"\n",
1822        );
1823
1824        let mut config = Config::default();
1825        config.gh_shim.enabled = false;
1826        config.git.co_author = "Pair Agent <pair@example.test>".to_string();
1827        let mut environment = HashMap::new();
1828        inject(&config, &storage, &mut environment).unwrap();
1829        assert!(!environment.contains_key(GH_SHIM_BINARY_ENV));
1830        run_git(
1831            &repo,
1832            &["commit", "--quiet", "-m", "explicit pair"],
1833            &environment,
1834        );
1835
1836        let message = commit_message(&repo);
1837        assert!(message.contains("Co-authored-by: Pair Agent <pair@example.test>"));
1838        assert!(message.contains("Local-Hook: custom"));
1839    }
1840}
1841
1842#[cfg(test)]
1843mod self_referential_pin_tests {
1844    use super::*;
1845
1846    /// A pin inside the shims dir freezes the image forever (maintain always
1847    /// sees link==candidate); it must refuse with deploy-path steering.
1848    #[test]
1849    fn pin_inside_shims_dir_is_refused_with_steering() {
1850        let dir = tempfile::tempdir().unwrap();
1851        let shims = dir.path().join("shims");
1852        std::fs::create_dir_all(&shims).unwrap();
1853        let frozen = shims.join("gh-shim-image");
1854        std::fs::write(&frozen, b"x").unwrap();
1855        let error = reject_self_referential_pin(&frozen, &shims).unwrap_err();
1856        assert!(error.contains("self-referential"), "{error}");
1857        assert!(error.contains("deploy path"), "{error}");
1858    }
1859
1860    /// Negative control: an external pin (the deploy path shape) passes this
1861    /// gate; if this fails, the guard over-rejects and no pin works at all.
1862    #[test]
1863    fn external_pin_is_not_rejected() {
1864        let dir = tempfile::tempdir().unwrap();
1865        let shims = dir.path().join("shims");
1866        std::fs::create_dir_all(&shims).unwrap();
1867        let deploy = dir.path().join("bin").join("ck-aft");
1868        std::fs::create_dir_all(deploy.parent().unwrap()).unwrap();
1869        std::fs::write(&deploy, b"x").unwrap();
1870        assert!(reject_self_referential_pin(&deploy, &shims).is_ok());
1871    }
1872}