1use 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";
21const 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
66const 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
153pub 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
188pub 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
223pub(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
241pub(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
261pub(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
281pub fn inject(
284 config: &Config,
285 storage_root: &Path,
286 environment: &mut HashMap<String, String>,
287) -> Result<(), String> {
288 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 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 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 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
412fn 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
454fn 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 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 #[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
887pub 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 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 #[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 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 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 #[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 #[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}