Skip to main content

ralph_workflow/git_helpers/
wrapper.rs

1//! Git wrapper for blocking commits during agent phase.
2//!
3//! This module provides safety mechanisms to prevent accidental commits while
4//! an AI agent is actively modifying files. It works through two mechanisms:
5//!
6//! - **Marker file**: Creates `.no_agent_commit` in the repo root during agent
7//!   execution. Both the git wrapper and hooks check for this file.
8//! - **PATH wrapper**: Installs a temporary `git` wrapper script that intercepts
9//!   `commit`, `push`, and `tag` commands when the marker file exists.
10//!
11//! The wrapper is automatically cleaned up when the agent phase ends, even on
12//! unexpected exits (Ctrl+C, panics) via [`cleanup_agent_phase_silent`].
13
14use super::hooks::{install_hooks, uninstall_hooks_silent};
15use super::repo::get_repo_root;
16use crate::logger::Logger;
17use crate::workspace::Workspace;
18use std::env;
19use std::fs::{self, File};
20use std::io::{self, Write};
21use std::path::{Path, PathBuf};
22use tempfile::TempDir;
23use which::which;
24
25const WRAPPER_DIR_TRACK_FILE: &str = ".agent/git-wrapper-dir.txt";
26
27/// Marker file path for blocking commits during agent phase.
28const MARKER_FILE: &str = ".no_agent_commit";
29
30/// Git helper state.
31pub struct GitHelpers {
32    real_git: Option<PathBuf>,
33    wrapper_dir: Option<TempDir>,
34}
35
36impl GitHelpers {
37    pub(crate) const fn new() -> Self {
38        Self {
39            real_git: None,
40            wrapper_dir: None,
41        }
42    }
43
44    /// Find the real git binary path.
45    fn init_real_git(&mut self) {
46        if self.real_git.is_none() {
47            self.real_git = which("git").ok();
48        }
49    }
50}
51
52impl Default for GitHelpers {
53    fn default() -> Self {
54        Self::new()
55    }
56}
57
58/// Escape a path for safe use in a POSIX shell single-quoted string.
59///
60/// Single quotes in POSIX shells cannot contain literal single quotes.
61/// The standard workaround is to end the quote, add an escaped quote, and restart the quote.
62/// This function rejects paths with newlines since they can't be safely handled.
63fn escape_shell_single_quoted(path: &str) -> io::Result<String> {
64    // Reject newlines - they cannot be safely handled in shell scripts
65    if path.contains('\n') || path.contains('\r') {
66        return Err(io::Error::new(
67            io::ErrorKind::InvalidInput,
68            "git path contains newline characters, cannot create safe shell wrapper",
69        ));
70    }
71    // Replace ' with '\' (end literal string, escaped quote, restart literal string)
72    Ok(path.replace('\'', "'\\''"))
73}
74
75/// Enable git wrapper that blocks commits during agent phase.
76pub fn enable_git_wrapper(helpers: &mut GitHelpers) -> io::Result<()> {
77    helpers.init_real_git();
78    let Some(real_git) = helpers.real_git.as_ref() else {
79        // Ralph's git operations use libgit2 and should work without the `git` CLI installed.
80        // The wrapper is only a safety feature for intercepting `git commit/push/tag`.
81        // If no `git` binary is available, there's nothing to wrap, so we no-op.
82        return Ok(());
83    };
84
85    // Validate git path is valid UTF-8 for shell script generation.
86    // On Unix systems, paths are typically valid UTF-8, but some filesystems
87    // may contain invalid UTF-8 sequences. In such cases, we cannot safely
88    // generate a shell wrapper and should return an error.
89    let git_path_str = real_git.to_str().ok_or_else(|| {
90        io::Error::new(
91            io::ErrorKind::InvalidData,
92            "git binary path contains invalid UTF-8 characters; cannot create wrapper script",
93        )
94    })?;
95
96    // Validate that the git path is an absolute path.
97    // This prevents potential issues with relative paths and ensures
98    // we're using a known, trusted git binary location.
99    if !real_git.is_absolute() {
100        return Err(io::Error::new(
101            io::ErrorKind::InvalidInput,
102            format!(
103                "git binary path is not absolute: '{git_path_str}'. \
104                 Using absolute paths prevents potential security issues."
105            ),
106        ));
107    }
108
109    // Additional validation: ensure the git binary exists and is executable.
110    // This prevents following symlinks to non-executable files or directories.
111    if !real_git.exists() {
112        return Err(io::Error::new(
113            io::ErrorKind::NotFound,
114            format!("git binary does not exist at path: '{git_path_str}'"),
115        ));
116    }
117
118    // On Unix systems, verify it's a regular file (not a directory or special file).
119    #[cfg(unix)]
120    {
121        match fs::metadata(real_git) {
122            Ok(metadata) => {
123                let file_type = metadata.file_type();
124                if file_type.is_dir() {
125                    return Err(io::Error::new(
126                        io::ErrorKind::InvalidInput,
127                        format!("git binary path is a directory, not a file: '{git_path_str}'"),
128                    ));
129                }
130                if file_type.is_symlink() {
131                    // Don't follow symlinks - require the actual path to be the binary.
132                    // This prevents symlink-based attacks.
133                    return Err(io::Error::new(
134                        io::ErrorKind::InvalidInput,
135                        format!("git binary path is a symlink; use the actual binary path: '{git_path_str}'"),
136                    ));
137                }
138            }
139            Err(_) => {
140                return Err(io::Error::new(
141                    io::ErrorKind::PermissionDenied,
142                    format!("cannot access git binary metadata at path: '{git_path_str}'"),
143                ));
144            }
145        }
146    }
147
148    let wrapper_dir = tempfile::tempdir()?;
149    let wrapper_path = wrapper_dir.path().join("git");
150
151    // Escape the git path for shell script to prevent command injection.
152    // Use a helper function to properly handle edge cases and reject unsafe paths.
153    let git_path_escaped = escape_shell_single_quoted(git_path_str)?;
154
155    let wrapper_content = format!(
156        r#"#!/usr/bin/env sh
157set -eu
158repo_root="$('{git_path_escaped}' rev-parse --show-toplevel 2>/dev/null || pwd)"
159if [ -f "$repo_root/.no_agent_commit" ]; then
160  subcmd="${{1-}}"
161  case "$subcmd" in
162    commit|push|tag)
163      echo "Blocked: git $subcmd disabled during agent phase (.no_agent_commit present)." >&2
164      exit 1
165      ;;
166  esac
167fi
168exec '{git_path_escaped}' "$@"
169"#
170    );
171
172    let mut file = File::create(&wrapper_path)?;
173    file.write_all(wrapper_content.as_bytes())?;
174
175    #[cfg(unix)]
176    {
177        use std::os::unix::fs::PermissionsExt;
178        let mut perms = fs::metadata(&wrapper_path)?.permissions();
179        perms.set_mode(0o755);
180        fs::set_permissions(&wrapper_path, perms)?;
181    }
182
183    // Prepend wrapper dir to PATH.
184    let current_path = env::var("PATH").unwrap_or_default();
185    env::set_var(
186        "PATH",
187        format!("{}:{}", wrapper_dir.path().display(), current_path),
188    );
189
190    fs::create_dir_all(".agent")?;
191    fs::write(
192        WRAPPER_DIR_TRACK_FILE,
193        wrapper_dir.path().display().to_string(),
194    )?;
195
196    helpers.wrapper_dir = Some(wrapper_dir);
197    Ok(())
198}
199
200/// Disable git wrapper.
201///
202/// # Thread Safety
203///
204/// This function modifies the process-wide `PATH` environment variable, which is
205/// inherently not thread-safe. If multiple threads were concurrently modifying PATH,
206/// there could be a TOCTOU (time-of-check-time-of-use) race condition. However,
207/// in Ralph's usage, this function is only called from the main thread during
208/// controlled shutdown sequences, so this is acceptable in practice.
209pub fn disable_git_wrapper(helpers: &mut GitHelpers) {
210    if let Some(wrapper_dir) = helpers.wrapper_dir.take() {
211        let wrapper_dir_path = wrapper_dir.path().to_path_buf();
212        let _ = fs::remove_dir_all(&wrapper_dir_path);
213        // Remove from PATH.
214        // Note: This read-modify-write sequence on PATH has a theoretical TOCTOU race,
215        // but in practice it's safe because Ralph only calls this from the main thread
216        // during controlled shutdown.
217        if let Ok(path) = env::var("PATH") {
218            let wrapper_str = wrapper_dir_path.to_string_lossy();
219            let new_path: String = path
220                .split(':')
221                .filter(|p| !p.contains(wrapper_str.as_ref()))
222                .collect::<Vec<_>>()
223                .join(":");
224            env::set_var("PATH", new_path);
225        }
226    }
227    let _ = fs::remove_file(WRAPPER_DIR_TRACK_FILE);
228}
229
230/// Start agent phase (creates marker file, installs hooks, enables wrapper).
231pub fn start_agent_phase(helpers: &mut GitHelpers) -> io::Result<()> {
232    File::create(".no_agent_commit")?;
233    install_hooks()?;
234    enable_git_wrapper(helpers)?;
235    Ok(())
236}
237
238/// End agent phase (removes marker file).
239pub fn end_agent_phase() {
240    let repo_root = match crate::git_helpers::get_repo_root() {
241        Ok(root) => root,
242        Err(_) => return,
243    };
244    let marker_path = repo_root.join(".no_agent_commit");
245    let _ = fs::remove_file(marker_path);
246}
247
248fn cleanup_git_wrapper_dir_silent() {
249    let repo_root = match crate::git_helpers::get_repo_root() {
250        Ok(root) => root,
251        Err(_) => return,
252    };
253    let track_file = repo_root.join(WRAPPER_DIR_TRACK_FILE);
254    let wrapper_dir = match fs::read_to_string(&track_file) {
255        Ok(path) => PathBuf::from(path.trim()),
256        Err(_) => return,
257    };
258
259    if !wrapper_dir.as_os_str().is_empty() {
260        let _ = fs::remove_dir_all(&wrapper_dir);
261    }
262    let _ = fs::remove_file(track_file);
263}
264
265/// Best-effort cleanup for unexpected exits (Ctrl+C, early-return, panics).
266pub fn cleanup_agent_phase_silent() {
267    end_agent_phase();
268    cleanup_git_wrapper_dir_silent();
269    uninstall_hooks_silent();
270    cleanup_generated_files_silent();
271}
272
273/// Cleanup generated files silently without workspace.
274///
275/// This is a minimal implementation for cleanup in signal handlers where
276/// workspace context is not available. Uses std::fs directly which is
277/// acceptable for this emergency cleanup scenario.
278fn cleanup_generated_files_silent() {
279    let repo_root = match crate::git_helpers::get_repo_root() {
280        Ok(root) => root,
281        Err(_) => return,
282    };
283    for file in crate::files::io::agent_files::GENERATED_FILES {
284        let absolute_path = repo_root.join(file);
285        let _ = std::fs::remove_file(absolute_path);
286    }
287}
288
289/// Clean up orphaned .`no_agent_commit` marker.
290pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
291    let repo_root = get_repo_root()?;
292    let marker_path = repo_root.join(".no_agent_commit");
293
294    if marker_path.exists() {
295        fs::remove_file(&marker_path)?;
296        logger.success("Removed orphaned .no_agent_commit marker");
297    } else {
298        logger.info("No orphaned marker found");
299    }
300
301    Ok(())
302}
303
304// ============================================================================
305// Workspace-aware variants
306// ============================================================================
307
308/// Create the agent phase marker file using workspace abstraction.
309///
310/// This is a workspace-aware version of the marker file creation that uses
311/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
312///
313/// # Arguments
314///
315/// * `workspace` - The workspace to write to
316///
317/// # Returns
318///
319/// Returns `Ok(())` on success, or an error if the file cannot be created.
320pub fn create_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
321    workspace.write(Path::new(MARKER_FILE), "")
322}
323
324/// Remove the agent phase marker file using workspace abstraction.
325///
326/// This is a workspace-aware version of the marker file removal that uses
327/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
328///
329/// # Arguments
330///
331/// * `workspace` - The workspace to operate on
332///
333/// # Returns
334///
335/// Returns `Ok(())` on success (including if file doesn't exist).
336pub fn remove_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
337    workspace.remove_if_exists(Path::new(MARKER_FILE))
338}
339
340/// Check if the agent phase marker file exists using workspace abstraction.
341///
342/// This is a workspace-aware version that uses the Workspace trait for file I/O,
343/// making it testable with `MemoryWorkspace`.
344///
345/// # Arguments
346///
347/// * `workspace` - The workspace to check
348///
349/// # Returns
350///
351/// Returns `true` if the marker file exists, `false` otherwise.
352pub fn marker_exists_with_workspace(workspace: &dyn Workspace) -> bool {
353    workspace.exists(Path::new(MARKER_FILE))
354}
355
356/// Clean up orphaned marker file using workspace abstraction.
357///
358/// This is a workspace-aware version of `cleanup_orphaned_marker` that uses
359/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
360///
361/// # Arguments
362///
363/// * `workspace` - The workspace to operate on
364/// * `logger` - Logger for output messages
365///
366/// # Returns
367///
368/// Returns `Ok(())` on success.
369pub fn cleanup_orphaned_marker_with_workspace(
370    workspace: &dyn Workspace,
371    logger: &Logger,
372) -> io::Result<()> {
373    let marker_path = Path::new(MARKER_FILE);
374
375    if workspace.exists(marker_path) {
376        workspace.remove(marker_path)?;
377        logger.success("Removed orphaned .no_agent_commit marker");
378    } else {
379        logger.info("No orphaned marker found");
380    }
381
382    Ok(())
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::workspace::MemoryWorkspace;
389
390    #[test]
391    fn test_create_marker_with_workspace() {
392        let workspace = MemoryWorkspace::new_test();
393
394        // Marker should not exist initially
395        assert!(!marker_exists_with_workspace(&workspace));
396
397        // Create marker
398        create_marker_with_workspace(&workspace).unwrap();
399
400        // Marker should now exist
401        assert!(marker_exists_with_workspace(&workspace));
402    }
403
404    #[test]
405    fn test_remove_marker_with_workspace() {
406        let workspace = MemoryWorkspace::new_test();
407
408        // Create marker first
409        create_marker_with_workspace(&workspace).unwrap();
410        assert!(marker_exists_with_workspace(&workspace));
411
412        // Remove marker
413        remove_marker_with_workspace(&workspace).unwrap();
414
415        // Marker should no longer exist
416        assert!(!marker_exists_with_workspace(&workspace));
417    }
418
419    #[test]
420    fn test_remove_marker_with_workspace_nonexistent() {
421        let workspace = MemoryWorkspace::new_test();
422
423        // Removing non-existent marker should succeed silently
424        remove_marker_with_workspace(&workspace).unwrap();
425        assert!(!marker_exists_with_workspace(&workspace));
426    }
427
428    #[test]
429    fn test_cleanup_orphaned_marker_with_workspace_exists() {
430        let workspace = MemoryWorkspace::new_test();
431        let logger = Logger::new(crate::logger::Colors { enabled: false });
432
433        // Create an orphaned marker
434        create_marker_with_workspace(&workspace).unwrap();
435        assert!(marker_exists_with_workspace(&workspace));
436
437        // Clean up should remove it
438        cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
439        assert!(!marker_exists_with_workspace(&workspace));
440    }
441
442    #[test]
443    fn test_cleanup_orphaned_marker_with_workspace_not_exists() {
444        let workspace = MemoryWorkspace::new_test();
445        let logger = Logger::new(crate::logger::Colors { enabled: false });
446
447        // No marker exists
448        assert!(!marker_exists_with_workspace(&workspace));
449
450        // Clean up should succeed without error
451        cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
452        assert!(!marker_exists_with_workspace(&workspace));
453    }
454
455    #[test]
456    fn test_marker_file_constant() {
457        // Verify the constant matches expected value
458        assert_eq!(MARKER_FILE, ".no_agent_commit");
459    }
460}