1pub use super::phase::ProtectionCheckResult;
7
8use super::cleanup;
9use super::cleanup::cleanup_agent_phase_at;
10use super::marker;
11use super::marker::marker_path_from_ralph_dir;
12use super::marker::{
13 add_owner_write_if_not_symlink, repair_marker_if_tampered, set_readonly_mode_if_not_symlink,
14};
15use super::path_wrapper;
16use super::path_wrapper::remove_wrapper_dir_and_entry;
17use super::path_wrapper::TRACK_FILENAME;
18use super::phase;
19use super::phase::{
20 check_and_install_wrapper, check_marker_integrity, check_track_file_integrity,
21 HEAD_OID_FILENAME,
22};
23use super::phase_state::{AGENT_PHASE_HOOKS_DIR, AGENT_PHASE_RALPH_DIR, AGENT_PHASE_REPO_ROOT};
24use super::script::{escape_shell_single_quoted, make_wrapper_content};
25use crate::git_helpers::install::{HOOK_MARKER, RALPH_HOOK_NAMES};
26use crate::git_helpers::repo::{
27 get_hooks_dir_from, get_repo_root, ralph_git_dir, resolve_protection_scope,
28 resolve_protection_scope_from,
29};
30use crate::git_helpers::verify::{enforce_hook_permissions, reinstall_hooks_if_tampered};
31use crate::logger::Logger;
32use crate::workspace::Workspace;
33use std::env;
34use std::fs::{self, OpenOptions};
35use std::path::{Path, PathBuf};
36use which::which;
37
38mod io {
39 pub type Result<T> = std::io::Result<T>;
40 pub type Error = std::io::Error;
41 pub type ErrorKind = std::io::ErrorKind;
42}
43
44const WRAPPER_DIR_PREFIX: &str = "ralph-git-wrapper-";
45
46pub struct GitHelpers {
47 real_git: Option<PathBuf>,
48 wrapper_dir: Option<PathBuf>,
49 wrapper_repo_root: Option<PathBuf>,
50}
51
52impl GitHelpers {
53 pub(crate) const fn new() -> Self {
54 Self {
55 real_git: None,
56 wrapper_dir: None,
57 wrapper_repo_root: None,
58 }
59 }
60
61 fn init_real_git(&mut self) {
62 if self.real_git.is_none() {
63 self.real_git = which("git").ok();
64 }
65 }
66}
67
68impl Default for GitHelpers {
69 fn default() -> Self {
70 Self::new()
71 }
72}
73
74pub fn disable_git_wrapper(helpers: &mut GitHelpers) {
75 let removed_wrapper_dir = helpers.wrapper_dir.take();
76
77 if let Some(wrapper_dir_path) = removed_wrapper_dir.as_ref() {
78 remove_wrapper_dir_and_entry(wrapper_dir_path);
79 }
80
81 let repo_root = helpers
82 .wrapper_repo_root
83 .take()
84 .or_else(|| get_repo_root().ok());
85
86 let track_file = resolve_track_file_path(&repo_root);
87
88 cleanup_wrapper_track_file(&track_file, &removed_wrapper_dir);
89}
90
91fn resolve_track_file_path(repo_root: &Option<PathBuf>) -> PathBuf {
92 repo_root.as_ref().map_or_else(
93 || PathBuf::from(".git/ralph").join(TRACK_FILENAME),
94 |r| ralph_git_dir(r).join(TRACK_FILENAME),
95 )
96}
97
98fn cleanup_wrapper_track_file(track_file: &Path, removed_wrapper_dir: &Option<PathBuf>) {
99 if let Ok(content) = fs::read_to_string(track_file) {
100 let wrapper_dir = PathBuf::from(content.trim());
101 let same_as_removed = removed_wrapper_dir
102 .as_ref()
103 .is_some_and(|p| p == &wrapper_dir);
104 if !same_as_removed {
105 remove_wrapper_dir_and_entry(&wrapper_dir);
106 }
107 }
108
109 #[cfg(unix)]
110 add_owner_write_if_not_symlink(track_file);
111 let _ = fs::remove_file(track_file);
112}
113
114pub fn start_agent_phase(helpers: &mut GitHelpers) -> io::Result<()> {
115 let repo_root = get_repo_root()?;
116 start_agent_phase_in_repo(&repo_root, helpers)
117}
118
119fn store_hooks_dir_if_resolvable(repo_root: &Path) {
120 if let Ok(hooks_dir) = get_hooks_dir_from(repo_root) {
121 if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
122 *guard = Some(hooks_dir);
123 }
124 }
125}
126
127fn store_agent_phase_paths(repo_root: &Path, ralph_dir: &Path) {
128 if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
129 *guard = Some(repo_root.to_path_buf());
130 }
131 if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
132 *guard = Some(ralph_dir.to_path_buf());
133 }
134 store_hooks_dir_if_resolvable(repo_root);
135}
136
137pub fn start_agent_phase_in_repo(repo_root: &Path, helpers: &mut GitHelpers) -> io::Result<()> {
138 helpers.wrapper_repo_root = Some(repo_root.to_path_buf());
139
140 let ralph_dir = ralph_git_dir(repo_root);
141 store_agent_phase_paths(repo_root, &ralph_dir);
142
143 repair_marker_if_tampered(repo_root)?;
144 #[cfg(unix)]
145 set_readonly_mode_if_not_symlink(&marker_path_from_ralph_dir(&ralph_dir), 0o444);
146 crate::git_helpers::install::install_hooks_in_repo(repo_root)?;
147 enable_git_wrapper_at(repo_root, helpers)?;
148
149 phase::capture_head_oid(repo_root);
150 Ok(())
151}
152
153pub fn end_agent_phase() {
154 if let Ok(repo_root) = get_repo_root() {
155 end_agent_phase_in_repo(&repo_root);
156 }
157}
158
159pub fn end_agent_phase_in_repo(repo_root: &Path) {
160 let ralph_dir = ralph_git_dir(repo_root);
161 end_agent_phase_in_repo_at_ralph_dir(repo_root, &ralph_dir);
162}
163
164pub fn clear_agent_phase_global_state() {
165 if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
166 *guard = None;
167 }
168 if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
169 *guard = None;
170 }
171 if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
172 *guard = None;
173 }
174}
175
176fn end_agent_phase_in_repo_at_ralph_dir(repo_root: &Path, ralph_dir: &Path) {
177 marker::remove_legacy_marker(repo_root);
178
179 let ralph_dir_ok =
180 crate::git_helpers::repo::sanitize_ralph_git_dir_at(ralph_dir).unwrap_or(false);
181
182 let marker_path = marker_path_from_ralph_dir(ralph_dir);
183 #[cfg(unix)]
184 add_owner_write_if_not_symlink(&marker_path);
185 let _ = fs::remove_file(&marker_path);
186
187 if ralph_dir_ok {
188 remove_head_oid_file(ralph_dir);
189 path_wrapper::cleanup_stray_tmp_files(ralph_dir);
190 let _ = fs::remove_dir(ralph_dir);
191 }
192}
193
194fn remove_head_oid_file(ralph_dir: &Path) {
195 let head_oid_path = ralph_dir.join(HEAD_OID_FILENAME);
196 if fs::symlink_metadata(&head_oid_path).is_err() {
197 return;
198 }
199 #[cfg(unix)]
200 add_owner_write_if_not_symlink(&head_oid_path);
201 let _ = fs::remove_file(&head_oid_path);
202}
203
204fn resolve_wrapper_dir(ralph_dir: &Path) -> Option<PathBuf> {
205 let tracked = path_wrapper::read_tracked_wrapper_dir(ralph_dir);
206 let on_path =
207 path_wrapper::find_wrapper_dir_on_path().filter(|p| path_wrapper::is_safe_existing_dir(p));
208 tracked.or(on_path)
209}
210
211fn do_restore_wrapper_tracking_file(
212 repo_root: &Path,
213 dir: &Path,
214 result: &mut ProtectionCheckResult,
215 logger: &Logger,
216) {
217 logger.warn("Git wrapper tracking file missing or invalid — restoring");
218 result.tampering_detected = true;
219 result.details.push("Git wrapper tracking file missing or invalid — restored".to_string());
220 if let Err(e) = path_wrapper::write_track_file_atomic(repo_root, dir) {
221 logger.warn(&format!("Failed to restore wrapper tracking file: {e}"));
222 }
223}
224
225fn restore_wrapper_tracking_file_if_missing(
226 ralph_dir: &Path,
227 repo_root: &Path,
228 wrapper_dir: &Option<PathBuf>,
229 result: &mut ProtectionCheckResult,
230 logger: &Logger,
231) {
232 if path_wrapper::read_tracked_wrapper_dir(ralph_dir).is_none() {
233 if let Some(ref dir) = wrapper_dir {
234 do_restore_wrapper_tracking_file(repo_root, dir, result, logger);
235 }
236 }
237}
238
239fn check_hooks_present(repo_root: &Path) -> bool {
240 get_hooks_dir_from(repo_root)
241 .ok()
242 .is_some_and(|hooks_dir| {
243 RALPH_HOOK_NAMES.iter().any(|name| {
244 let path = hooks_dir.join(name);
245 path.exists()
246 && matches!(
247 crate::files::file_contains_marker(&path, HOOK_MARKER),
248 Ok(true)
249 )
250 })
251 })
252}
253
254fn check_marker_present(marker_path: &Path) -> bool {
255 fs::symlink_metadata(marker_path)
256 .ok()
257 .is_some_and(|m| m.file_type().is_file() && !m.file_type().is_symlink())
258}
259
260fn flag_missing_protections_if_needed(
261 marker_path: &Path,
262 repo_root: &Path,
263 result: &mut ProtectionCheckResult,
264 logger: &Logger,
265) {
266 if !check_marker_present(marker_path) && !check_hooks_present(repo_root) {
267 logger.warn("Agent-phase git protections missing — reinstalling");
268 result.tampering_detected = true;
269 result
270 .details
271 .push("Marker and hooks missing before agent spawn — reinstalling".to_string());
272 }
273}
274
275fn handle_reinstall_result(
276 reinstall: io::Result<bool>,
277 result: &mut ProtectionCheckResult,
278 logger: &Logger,
279) {
280 match reinstall {
281 Ok(true) => {
282 result.tampering_detected = true;
283 result
284 .details
285 .push("Git hooks tampered with or missing — reinstalled".to_string());
286 }
287 Err(e) => {
288 logger.warn(&format!("Failed to verify/reinstall hooks: {e}"));
289 }
290 Ok(false) => {}
291 }
292}
293
294fn check_unauthorized_commit(repo_root: &Path, result: &mut ProtectionCheckResult, logger: &Logger) {
295 if phase::detect_unauthorized_commit(repo_root) {
296 logger.warn("CRITICAL: HEAD OID changed — unauthorized commit detected!");
297 result.tampering_detected = true;
298 result
299 .details
300 .push("HEAD OID changed since last check — unauthorized commit detected".to_string());
301 phase::capture_head_oid(repo_root);
302 }
303}
304
305pub fn ensure_agent_phase_protections(logger: &Logger) -> ProtectionCheckResult {
306 let mut result = ProtectionCheckResult::default();
307
308 let Ok(scope) = resolve_protection_scope() else {
309 return result;
310 };
311 let repo_root = scope.repo_root.clone();
312 let ralph_dir = scope.ralph_dir.clone();
313 let marker_path = marker_path_from_ralph_dir(&ralph_dir);
314 let track_file_path = path_wrapper::track_file_path_for_ralph_dir(&ralph_dir);
315
316 check_marker_integrity(&ralph_dir, &repo_root, &mut result, logger);
317 check_track_file_integrity(&ralph_dir, &repo_root, &mut result, logger);
318
319 let wrapper_dir = resolve_wrapper_dir(&ralph_dir);
320 if let Some(ref dir) = wrapper_dir {
321 path_wrapper::prepend_wrapper_dir_to_path(dir);
322 }
323 restore_wrapper_tracking_file_if_missing(&ralph_dir, &repo_root, &wrapper_dir, &mut result, logger);
324
325 check_and_install_wrapper(
326 &repo_root,
327 &ralph_dir,
328 &marker_path,
329 &track_file_path,
330 &mut result,
331 logger,
332 );
333
334 flag_missing_protections_if_needed(&marker_path, &repo_root, &mut result, logger);
335
336 phase::check_and_repair_marker_symlink(&marker_path, &repo_root, &mut result, logger);
337 phase::check_and_repair_marker_permissions(&marker_path, &repo_root, &mut result, logger);
338
339 handle_reinstall_result(reinstall_hooks_if_tampered(logger), &mut result, logger);
340
341 #[cfg(unix)]
342 enforce_hook_permissions(&repo_root, logger);
343
344 phase::check_track_file_permissions(&track_file_path, &mut result, logger);
345 check_unauthorized_commit(&repo_root, &mut result, logger);
346
347 result
348}
349
350pub fn cleanup_orphaned_wrapper_at(repo_root: &Path) {
351 cleanup::cleanup_prior_wrapper(repo_root);
352}
353
354pub fn cleanup_agent_phase_silent() {
355 let repo_root = AGENT_PHASE_REPO_ROOT
356 .try_lock()
357 .ok()
358 .and_then(|guard| guard.clone())
359 .or_else(|| get_repo_root().ok());
360
361 let Some(repo_root) = repo_root else {
362 return;
363 };
364
365 let stored_ralph_dir = AGENT_PHASE_RALPH_DIR
366 .try_lock()
367 .ok()
368 .and_then(|guard| guard.clone());
369 let stored_hooks_dir = AGENT_PHASE_HOOKS_DIR
370 .try_lock()
371 .ok()
372 .and_then(|guard| guard.clone());
373
374 cleanup_agent_phase_at(
375 &repo_root,
376 stored_ralph_dir.as_deref(),
377 stored_hooks_dir.as_deref(),
378 );
379}
380
381pub fn cleanup_agent_phase_silent_at(repo_root: &Path) {
382 cleanup_agent_phase_at(repo_root, None, None);
383}
384
385pub fn cleanup_agent_phase_protections_silent_at(repo_root: &Path) {
386 cleanup_agent_phase_at(repo_root, None, None);
387}
388
389#[cfg(any(test, feature = "test-utils"))]
390pub fn set_agent_phase_paths_for_test(
391 repo_root: Option<PathBuf>,
392 ralph_dir: Option<PathBuf>,
393 hooks_dir: Option<PathBuf>,
394) {
395 if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
396 *guard = repo_root;
397 }
398 if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
399 *guard = ralph_dir;
400 }
401 if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
402 *guard = hooks_dir;
403 }
404}
405
406#[cfg(any(test, feature = "test-utils"))]
407#[must_use]
408pub fn get_agent_phase_paths_for_test() -> (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>) {
409 let repo_root = AGENT_PHASE_REPO_ROOT
410 .lock()
411 .ok()
412 .and_then(|guard| guard.clone());
413 let ralph_dir = AGENT_PHASE_RALPH_DIR
414 .lock()
415 .ok()
416 .and_then(|guard| guard.clone());
417 let hooks_dir = AGENT_PHASE_HOOKS_DIR
418 .lock()
419 .ok()
420 .and_then(|guard| guard.clone());
421 (repo_root, ralph_dir, hooks_dir)
422}
423
424pub fn capture_head_oid(repo_root: &Path) {
425 phase::capture_head_oid(repo_root)
426}
427
428pub fn detect_unauthorized_commit(repo_root: &Path) -> bool {
429 phase::detect_unauthorized_commit(repo_root)
430}
431
432pub fn try_remove_ralph_dir(repo_root: &Path) -> bool {
433 cleanup::remove_ralph_dir(repo_root)
434}
435
436pub fn verify_ralph_dir_removed(repo_root: &Path) -> Vec<String> {
437 cleanup::verify_ralph_dir_removed(repo_root)
438}
439
440pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
441 cleanup::cleanup_orphaned_marker(logger)
442}
443
444pub fn verify_wrapper_cleaned(repo_root: &Path) -> Vec<String> {
445 cleanup::verify_wrapper_cleaned(repo_root)
446}
447
448pub fn marker_exists_with_workspace(workspace: &dyn Workspace) -> bool {
449 workspace.exists(Path::new(".git/ralph/no_agent_commit"))
450}
451
452pub fn create_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
453 workspace.write(Path::new(".git/ralph/no_agent_commit"), "")
454}
455
456pub fn remove_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
457 workspace.remove_if_exists(Path::new(".git/ralph/no_agent_commit"))
458}
459
460pub fn cleanup_orphaned_marker_with_workspace(
461 workspace: &dyn Workspace,
462 logger: &Logger,
463) -> io::Result<()> {
464 let (removed_marker, removed_legacy_marker) = detect_and_remove_orphaned_markers(workspace)?;
465
466 if removed_marker || removed_legacy_marker {
467 logger.success("Removed orphaned enforcement marker");
468 } else {
469 logger.info("No orphaned marker found");
470 }
471 Ok(())
472}
473
474fn detect_and_remove_orphaned_markers(workspace: &dyn Workspace) -> io::Result<(bool, bool)> {
475 let marker_path = Path::new(".git/ralph/no_agent_commit");
476 let legacy_marker_path = Path::new(".no_agent_commit");
477
478 let removed_marker = if workspace.exists(marker_path) {
479 workspace.remove(marker_path)?;
480 true
481 } else {
482 false
483 };
484
485 let removed_legacy_marker = if workspace.exists(legacy_marker_path) {
486 workspace.remove(legacy_marker_path)?;
487 true
488 } else {
489 false
490 };
491
492 Ok((removed_marker, removed_legacy_marker))
493}
494
495fn validate_git_binary(real_git: &Path, git_path_str: &str) -> io::Result<()> {
496 if !real_git.is_absolute() {
497 return Err(io::Error::new(
498 io::ErrorKind::InvalidInput,
499 format!(
500 "git binary path is not absolute: '{git_path_str}'. \
501 Using absolute paths prevents potential security issues."
502 ),
503 ));
504 }
505 if !real_git.exists() {
506 return Err(io::Error::new(
507 io::ErrorKind::NotFound,
508 format!("git binary does not exist at path: '{git_path_str}'"),
509 ));
510 }
511 #[cfg(unix)]
512 validate_git_binary_unix(real_git, git_path_str)?;
513 Ok(())
514}
515
516#[cfg(unix)]
517fn validate_git_binary_unix(real_git: &Path, git_path_str: &str) -> io::Result<()> {
518 match fs::metadata(real_git) {
519 Ok(metadata) if metadata.file_type().is_dir() => Err(io::Error::new(
520 io::ErrorKind::InvalidInput,
521 format!("git binary path is a directory, not a file: '{git_path_str}'"),
522 )),
523 Ok(_) => Ok(()),
524 Err(_) => Err(io::Error::new(
525 io::ErrorKind::PermissionDenied,
526 format!("cannot access git binary metadata at path: '{git_path_str}'"),
527 )),
528 }
529}
530
531fn path_to_escaped_str(path: &Path, label: &str) -> io::Result<String> {
532 let s = path.to_str().ok_or_else(|| {
533 io::Error::new(
534 io::ErrorKind::InvalidData,
535 format!("{label} contains invalid UTF-8 characters; cannot create wrapper script"),
536 )
537 })?;
538 escape_shell_single_quoted(s)
539}
540
541fn build_wrapper_escaped_args(
542 scope: &crate::git_helpers::repo::ProtectionScope,
543) -> io::Result<(String, String, String, String)> {
544 let normalized_repo_root =
545 crate::git_helpers::repo::normalize_protection_scope_path(&scope.repo_root);
546 let normalized_git_dir =
547 crate::git_helpers::repo::normalize_protection_scope_path(&scope.git_dir);
548 let ralph_dir = &scope.ralph_dir;
549 let marker_path = marker_path_from_ralph_dir(ralph_dir);
550 let track_file_path = path_wrapper::track_file_path_for_ralph_dir(ralph_dir);
551
552 let marker_escaped = path_to_escaped_str(&marker_path, "marker path")?;
553 let track_escaped = path_to_escaped_str(&track_file_path, "track file path")?;
554 let repo_root_escaped = path_to_escaped_str(&normalized_repo_root, "repo root")?;
555 let git_dir_escaped = path_to_escaped_str(&normalized_git_dir, "git dir")?;
556 Ok((marker_escaped, track_escaped, repo_root_escaped, git_dir_escaped))
557}
558
559fn write_wrapper_script(wrapper_path: &Path, content: &str) -> io::Result<()> {
560 let mut file = OpenOptions::new()
561 .write(true)
562 .create_new(true)
563 .open(wrapper_path)?;
564 std::io::Write::write_all(&mut file, content.as_bytes())?;
565 #[cfg(unix)]
566 {
567 use std::os::unix::fs::PermissionsExt;
568 let mut perms = fs::metadata(wrapper_path)?.permissions();
569 perms.set_mode(0o555);
570 fs::set_permissions(wrapper_path, perms)?;
571 }
572 Ok(())
573}
574
575fn enable_git_wrapper_at(repo_root: &Path, helpers: &mut GitHelpers) -> io::Result<()> {
576 cleanup::cleanup_prior_wrapper(repo_root);
577
578 helpers.init_real_git();
579 let Some(real_git) = helpers.real_git.as_ref() else {
580 return Ok(());
581 };
582
583 let git_path_str = real_git.to_str().ok_or_else(|| {
584 io::Error::new(
585 io::ErrorKind::InvalidData,
586 "git binary path contains invalid UTF-8 characters; cannot create wrapper script",
587 )
588 })?;
589 validate_git_binary(real_git, git_path_str)?;
590
591 let git_path_escaped = escape_shell_single_quoted(git_path_str)?;
592
593 helpers.wrapper_repo_root = Some(repo_root.to_path_buf());
594
595 let scope = resolve_protection_scope_from(repo_root)?;
596 let (marker_escaped, track_escaped, repo_root_escaped, git_dir_escaped) =
597 build_wrapper_escaped_args(&scope)?;
598
599 let wrapper_content = make_wrapper_content(
600 &git_path_escaped,
601 &marker_escaped,
602 &track_escaped,
603 &repo_root_escaped,
604 &git_dir_escaped,
605 );
606
607 let wrapper_dir = tempfile::Builder::new()
608 .prefix(WRAPPER_DIR_PREFIX)
609 .tempdir()?;
610 let wrapper_dir_path = wrapper_dir.keep();
611 let wrapper_path = wrapper_dir_path.join("git");
612 write_wrapper_script(&wrapper_path, &wrapper_content)?;
613
614 let current_path = env::var("PATH").unwrap_or_default();
615 env::set_var(
616 "PATH",
617 format!("{}:{}", wrapper_dir_path.display(), current_path),
618 );
619
620 path_wrapper::write_track_file_atomic(repo_root, &wrapper_dir_path)?;
621
622 helpers.wrapper_dir = Some(wrapper_dir_path);
623 Ok(())
624}