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 std::env;
18use std::fs::{self, File};
19use std::io::{self, Write};
20use std::path::PathBuf;
21use tempfile::TempDir;
22use which::which;
23
24const WRAPPER_DIR_TRACK_FILE: &str = ".agent/git-wrapper-dir.txt";
25
26/// Git helper state.
27pub struct GitHelpers {
28    real_git: Option<PathBuf>,
29    wrapper_dir: Option<TempDir>,
30}
31
32impl GitHelpers {
33    pub(crate) const fn new() -> Self {
34        Self {
35            real_git: None,
36            wrapper_dir: None,
37        }
38    }
39
40    /// Find the real git binary path.
41    fn init_real_git(&mut self) {
42        if self.real_git.is_none() {
43            self.real_git = which("git").ok();
44        }
45    }
46}
47
48impl Default for GitHelpers {
49    fn default() -> Self {
50        Self::new()
51    }
52}
53
54/// Escape a path for safe use in a POSIX shell single-quoted string.
55///
56/// Single quotes in POSIX shells cannot contain literal single quotes.
57/// The standard workaround is to end the quote, add an escaped quote, and restart the quote.
58/// This function rejects paths with newlines since they can't be safely handled.
59fn escape_shell_single_quoted(path: &str) -> io::Result<String> {
60    // Reject newlines - they cannot be safely handled in shell scripts
61    if path.contains('\n') || path.contains('\r') {
62        return Err(io::Error::new(
63            io::ErrorKind::InvalidInput,
64            "git path contains newline characters, cannot create safe shell wrapper",
65        ));
66    }
67    // Replace ' with '\' (end literal string, escaped quote, restart literal string)
68    Ok(path.replace('\'', "'\\''"))
69}
70
71/// Enable git wrapper that blocks commits during agent phase.
72pub fn enable_git_wrapper(helpers: &mut GitHelpers) -> io::Result<()> {
73    helpers.init_real_git();
74    let Some(real_git) = helpers.real_git.as_ref() else {
75        // Ralph's git operations use libgit2 and should work without the `git` CLI installed.
76        // The wrapper is only a safety feature for intercepting `git commit/push/tag`.
77        // If no `git` binary is available, there's nothing to wrap, so we no-op.
78        return Ok(());
79    };
80
81    // Validate git path is valid UTF-8 for shell script generation.
82    // On Unix systems, paths are typically valid UTF-8, but some filesystems
83    // may contain invalid UTF-8 sequences. In such cases, we cannot safely
84    // generate a shell wrapper and should return an error.
85    let git_path_str = real_git.to_str().ok_or_else(|| {
86        io::Error::new(
87            io::ErrorKind::InvalidData,
88            "git binary path contains invalid UTF-8 characters; cannot create wrapper script",
89        )
90    })?;
91
92    // Validate that the git path is an absolute path.
93    // This prevents potential issues with relative paths and ensures
94    // we're using a known, trusted git binary location.
95    if !real_git.is_absolute() {
96        return Err(io::Error::new(
97            io::ErrorKind::InvalidInput,
98            format!(
99                "git binary path is not absolute: '{git_path_str}'. \
100                 Using absolute paths prevents potential security issues."
101            ),
102        ));
103    }
104
105    // Additional validation: ensure the git binary exists and is executable.
106    // This prevents following symlinks to non-executable files or directories.
107    if !real_git.exists() {
108        return Err(io::Error::new(
109            io::ErrorKind::NotFound,
110            format!("git binary does not exist at path: '{git_path_str}'"),
111        ));
112    }
113
114    // On Unix systems, verify it's a regular file (not a directory or special file).
115    #[cfg(unix)]
116    {
117        match fs::metadata(real_git) {
118            Ok(metadata) => {
119                let file_type = metadata.file_type();
120                if file_type.is_dir() {
121                    return Err(io::Error::new(
122                        io::ErrorKind::InvalidInput,
123                        format!("git binary path is a directory, not a file: '{git_path_str}'"),
124                    ));
125                }
126                if file_type.is_symlink() {
127                    // Don't follow symlinks - require the actual path to be the binary.
128                    // This prevents symlink-based attacks.
129                    return Err(io::Error::new(
130                        io::ErrorKind::InvalidInput,
131                        format!("git binary path is a symlink; use the actual binary path: '{git_path_str}'"),
132                    ));
133                }
134            }
135            Err(_) => {
136                return Err(io::Error::new(
137                    io::ErrorKind::PermissionDenied,
138                    format!("cannot access git binary metadata at path: '{git_path_str}'"),
139                ));
140            }
141        }
142    }
143
144    let wrapper_dir = tempfile::tempdir()?;
145    let wrapper_path = wrapper_dir.path().join("git");
146
147    // Escape the git path for shell script to prevent command injection.
148    // Use a helper function to properly handle edge cases and reject unsafe paths.
149    let git_path_escaped = escape_shell_single_quoted(git_path_str)?;
150
151    let wrapper_content = format!(
152        r#"#!/usr/bin/env sh
153set -eu
154repo_root="$('{git_path_escaped}' rev-parse --show-toplevel 2>/dev/null || pwd)"
155if [ -f "$repo_root/.no_agent_commit" ]; then
156  subcmd="${{1-}}"
157  case "$subcmd" in
158    commit|push|tag)
159      echo "Blocked: git $subcmd disabled during agent phase (.no_agent_commit present)." >&2
160      exit 1
161      ;;
162  esac
163fi
164exec '{git_path_escaped}' "$@"
165"#
166    );
167
168    let mut file = File::create(&wrapper_path)?;
169    file.write_all(wrapper_content.as_bytes())?;
170
171    #[cfg(unix)]
172    {
173        use std::os::unix::fs::PermissionsExt;
174        let mut perms = fs::metadata(&wrapper_path)?.permissions();
175        perms.set_mode(0o755);
176        fs::set_permissions(&wrapper_path, perms)?;
177    }
178
179    // Prepend wrapper dir to PATH.
180    let current_path = env::var("PATH").unwrap_or_default();
181    env::set_var(
182        "PATH",
183        format!("{}:{}", wrapper_dir.path().display(), current_path),
184    );
185
186    fs::create_dir_all(".agent")?;
187    fs::write(
188        WRAPPER_DIR_TRACK_FILE,
189        wrapper_dir.path().display().to_string(),
190    )?;
191
192    helpers.wrapper_dir = Some(wrapper_dir);
193    Ok(())
194}
195
196/// Disable git wrapper.
197///
198/// # Thread Safety
199///
200/// This function modifies the process-wide `PATH` environment variable, which is
201/// inherently not thread-safe. If multiple threads were concurrently modifying PATH,
202/// there could be a TOCTOU (time-of-check-time-of-use) race condition. However,
203/// in Ralph's usage, this function is only called from the main thread during
204/// controlled shutdown sequences, so this is acceptable in practice.
205pub fn disable_git_wrapper(helpers: &mut GitHelpers) {
206    if let Some(wrapper_dir) = helpers.wrapper_dir.take() {
207        let wrapper_dir_path = wrapper_dir.path().to_path_buf();
208        let _ = fs::remove_dir_all(&wrapper_dir_path);
209        // Remove from PATH.
210        // Note: This read-modify-write sequence on PATH has a theoretical TOCTOU race,
211        // but in practice it's safe because Ralph only calls this from the main thread
212        // during controlled shutdown.
213        if let Ok(path) = env::var("PATH") {
214            let wrapper_str = wrapper_dir_path.to_string_lossy();
215            let new_path: String = path
216                .split(':')
217                .filter(|p| !p.contains(wrapper_str.as_ref()))
218                .collect::<Vec<_>>()
219                .join(":");
220            env::set_var("PATH", new_path);
221        }
222    }
223    let _ = fs::remove_file(WRAPPER_DIR_TRACK_FILE);
224}
225
226/// Start agent phase (creates marker file, installs hooks, enables wrapper).
227pub fn start_agent_phase(helpers: &mut GitHelpers) -> io::Result<()> {
228    File::create(".no_agent_commit")?;
229    install_hooks()?;
230    enable_git_wrapper(helpers)?;
231    Ok(())
232}
233
234/// End agent phase (removes marker file).
235pub fn end_agent_phase() {
236    let _ = fs::remove_file(".no_agent_commit");
237}
238
239fn cleanup_git_wrapper_dir_silent() {
240    let wrapper_dir = match fs::read_to_string(WRAPPER_DIR_TRACK_FILE) {
241        Ok(path) => PathBuf::from(path.trim()),
242        Err(_) => return,
243    };
244
245    if !wrapper_dir.as_os_str().is_empty() {
246        let _ = fs::remove_dir_all(&wrapper_dir);
247    }
248    let _ = fs::remove_file(WRAPPER_DIR_TRACK_FILE);
249}
250
251/// Best-effort cleanup for unexpected exits (Ctrl+C, early-return, panics).
252pub fn cleanup_agent_phase_silent() {
253    end_agent_phase();
254    cleanup_git_wrapper_dir_silent();
255    uninstall_hooks_silent();
256    crate::files::cleanup_generated_files();
257}
258
259/// Clean up orphaned .`no_agent_commit` marker.
260pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
261    let repo_root = get_repo_root()?;
262    let marker_path = repo_root.join(".no_agent_commit");
263
264    if marker_path.exists() {
265        fs::remove_file(&marker_path)?;
266        logger.success("Removed orphaned .no_agent_commit marker");
267    } else {
268        logger.info("No orphaned marker found");
269    }
270
271    Ok(())
272}