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 repo_root = match crate::git_helpers::get_repo_root() {
245        Ok(root) => root,
246        Err(_) => return,
247    };
248    let marker_path = repo_root.join(".no_agent_commit");
249    let _ = fs::remove_file(marker_path);
250}
251
252fn cleanup_git_wrapper_dir_silent() {
253    let repo_root = match crate::git_helpers::get_repo_root() {
254        Ok(root) => root,
255        Err(_) => return,
256    };
257    let track_file = repo_root.join(WRAPPER_DIR_TRACK_FILE);
258    let wrapper_dir = match fs::read_to_string(&track_file) {
259        Ok(path) => PathBuf::from(path.trim()),
260        Err(_) => return,
261    };
262
263    if !wrapper_dir.as_os_str().is_empty() {
264        let _ = fs::remove_dir_all(&wrapper_dir);
265    }
266    let _ = fs::remove_file(track_file);
267}
268
269/// Best-effort cleanup for unexpected exits (Ctrl+C, early-return, panics).
270pub fn cleanup_agent_phase_silent() {
271    end_agent_phase();
272    cleanup_git_wrapper_dir_silent();
273    uninstall_hooks_silent();
274    cleanup_generated_files_silent();
275}
276
277/// Cleanup generated files silently without workspace.
278///
279/// This is a minimal implementation for cleanup in signal handlers where
280/// workspace context is not available. Uses std::fs directly which is
281/// acceptable for this emergency cleanup scenario.
282fn cleanup_generated_files_silent() {
283    let repo_root = match crate::git_helpers::get_repo_root() {
284        Ok(root) => root,
285        Err(_) => return,
286    };
287    for file in crate::files::io::agent_files::GENERATED_FILES {
288        let absolute_path = repo_root.join(file);
289        let _ = std::fs::remove_file(absolute_path);
290    }
291}
292
293/// Clean up orphaned .`no_agent_commit` marker.
294pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
295    let repo_root = get_repo_root()?;
296    let marker_path = repo_root.join(".no_agent_commit");
297
298    if marker_path.exists() {
299        fs::remove_file(&marker_path)?;
300        logger.success("Removed orphaned .no_agent_commit marker");
301    } else {
302        logger.info("No orphaned marker found");
303    }
304
305    Ok(())
306}
307
308// ============================================================================
309// Workspace-aware variants (for testing with MemoryWorkspace)
310// ============================================================================
311
312/// Create the agent phase marker file using workspace abstraction.
313///
314/// This is a workspace-aware version of the marker file creation that uses
315/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
316///
317/// # Arguments
318///
319/// * `workspace` - The workspace to write to
320///
321/// # Returns
322///
323/// Returns `Ok(())` on success, or an error if the file cannot be created.
324#[cfg(any(test, feature = "test-utils"))]
325pub fn create_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
326    workspace.write(Path::new(MARKER_FILE), "")
327}
328
329/// Remove the agent phase marker file using workspace abstraction.
330///
331/// This is a workspace-aware version of the marker file removal that uses
332/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
333///
334/// # Arguments
335///
336/// * `workspace` - The workspace to operate on
337///
338/// # Returns
339///
340/// Returns `Ok(())` on success (including if file doesn't exist).
341#[cfg(any(test, feature = "test-utils"))]
342pub fn remove_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
343    workspace.remove_if_exists(Path::new(MARKER_FILE))
344}
345
346/// Check if the agent phase marker file exists using workspace abstraction.
347///
348/// This is a workspace-aware version that uses the Workspace trait for file I/O,
349/// making it testable with `MemoryWorkspace`.
350///
351/// # Arguments
352///
353/// * `workspace` - The workspace to check
354///
355/// # Returns
356///
357/// Returns `true` if the marker file exists, `false` otherwise.
358#[cfg(any(test, feature = "test-utils"))]
359pub fn marker_exists_with_workspace(workspace: &dyn Workspace) -> bool {
360    workspace.exists(Path::new(MARKER_FILE))
361}
362
363/// Clean up orphaned marker file using workspace abstraction.
364///
365/// This is a workspace-aware version of `cleanup_orphaned_marker` that uses
366/// the Workspace trait for file I/O, making it testable with `MemoryWorkspace`.
367///
368/// # Arguments
369///
370/// * `workspace` - The workspace to operate on
371/// * `logger` - Logger for output messages
372///
373/// # Returns
374///
375/// Returns `Ok(())` on success.
376#[cfg(any(test, feature = "test-utils"))]
377pub fn cleanup_orphaned_marker_with_workspace(
378    workspace: &dyn Workspace,
379    logger: &Logger,
380) -> io::Result<()> {
381    let marker_path = Path::new(MARKER_FILE);
382
383    if workspace.exists(marker_path) {
384        workspace.remove(marker_path)?;
385        logger.success("Removed orphaned .no_agent_commit marker");
386    } else {
387        logger.info("No orphaned marker found");
388    }
389
390    Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::workspace::MemoryWorkspace;
397
398    #[test]
399    fn test_create_marker_with_workspace() {
400        let workspace = MemoryWorkspace::new_test();
401
402        // Marker should not exist initially
403        assert!(!marker_exists_with_workspace(&workspace));
404
405        // Create marker
406        create_marker_with_workspace(&workspace).unwrap();
407
408        // Marker should now exist
409        assert!(marker_exists_with_workspace(&workspace));
410    }
411
412    #[test]
413    fn test_remove_marker_with_workspace() {
414        let workspace = MemoryWorkspace::new_test();
415
416        // Create marker first
417        create_marker_with_workspace(&workspace).unwrap();
418        assert!(marker_exists_with_workspace(&workspace));
419
420        // Remove marker
421        remove_marker_with_workspace(&workspace).unwrap();
422
423        // Marker should no longer exist
424        assert!(!marker_exists_with_workspace(&workspace));
425    }
426
427    #[test]
428    fn test_remove_marker_with_workspace_nonexistent() {
429        let workspace = MemoryWorkspace::new_test();
430
431        // Removing non-existent marker should succeed silently
432        remove_marker_with_workspace(&workspace).unwrap();
433        assert!(!marker_exists_with_workspace(&workspace));
434    }
435
436    #[test]
437    fn test_cleanup_orphaned_marker_with_workspace_exists() {
438        let workspace = MemoryWorkspace::new_test();
439        let logger = Logger::new(crate::logger::Colors { enabled: false });
440
441        // Create an orphaned marker
442        create_marker_with_workspace(&workspace).unwrap();
443        assert!(marker_exists_with_workspace(&workspace));
444
445        // Clean up should remove it
446        cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
447        assert!(!marker_exists_with_workspace(&workspace));
448    }
449
450    #[test]
451    fn test_cleanup_orphaned_marker_with_workspace_not_exists() {
452        let workspace = MemoryWorkspace::new_test();
453        let logger = Logger::new(crate::logger::Colors { enabled: false });
454
455        // No marker exists
456        assert!(!marker_exists_with_workspace(&workspace));
457
458        // Clean up should succeed without error
459        cleanup_orphaned_marker_with_workspace(&workspace, &logger).unwrap();
460        assert!(!marker_exists_with_workspace(&workspace));
461    }
462
463    #[test]
464    fn test_marker_file_constant() {
465        // Verify the constant matches expected value
466        assert_eq!(MARKER_FILE, ".no_agent_commit");
467    }
468}