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