pub use super::phase::ProtectionCheckResult;
use super::cleanup;
use super::cleanup::cleanup_agent_phase_at;
use super::marker;
use super::marker::marker_path_from_ralph_dir;
use super::marker::{
add_owner_write_if_not_symlink, repair_marker_if_tampered, set_readonly_mode_if_not_symlink,
};
use super::path_wrapper;
use super::path_wrapper::remove_wrapper_dir_and_entry;
use super::path_wrapper::TRACK_FILENAME;
use super::phase;
use super::phase::{
check_and_install_wrapper, check_marker_integrity, check_track_file_integrity,
HEAD_OID_FILENAME,
};
use super::phase_state::{AGENT_PHASE_HOOKS_DIR, AGENT_PHASE_RALPH_DIR, AGENT_PHASE_REPO_ROOT};
use super::script::{escape_shell_single_quoted, make_wrapper_content};
use crate::git_helpers::install::{HOOK_MARKER, RALPH_HOOK_NAMES};
use crate::git_helpers::repo::{
get_hooks_dir_from, get_repo_root, ralph_git_dir, resolve_protection_scope,
resolve_protection_scope_from,
};
use crate::git_helpers::verify::{enforce_hook_permissions, reinstall_hooks_if_tampered};
use crate::logger::Logger;
use crate::workspace::Workspace;
use std::env;
use std::fs::{self, OpenOptions};
use std::path::{Path, PathBuf};
use which::which;
mod io {
pub type Result<T> = std::io::Result<T>;
pub type Error = std::io::Error;
pub type ErrorKind = std::io::ErrorKind;
}
const WRAPPER_DIR_PREFIX: &str = "ralph-git-wrapper-";
pub struct GitHelpers {
real_git: Option<PathBuf>,
wrapper_dir: Option<PathBuf>,
wrapper_repo_root: Option<PathBuf>,
}
impl GitHelpers {
pub(crate) const fn new() -> Self {
Self {
real_git: None,
wrapper_dir: None,
wrapper_repo_root: None,
}
}
fn init_real_git(&mut self) {
if self.real_git.is_none() {
self.real_git = which("git").ok();
}
}
}
impl Default for GitHelpers {
fn default() -> Self {
Self::new()
}
}
pub fn disable_git_wrapper(helpers: &mut GitHelpers) {
let removed_wrapper_dir = helpers.wrapper_dir.take();
if let Some(wrapper_dir_path) = removed_wrapper_dir.as_ref() {
remove_wrapper_dir_and_entry(wrapper_dir_path);
}
let repo_root = helpers
.wrapper_repo_root
.take()
.or_else(|| get_repo_root().ok());
let track_file = resolve_track_file_path(&repo_root);
cleanup_wrapper_track_file(&track_file, &removed_wrapper_dir);
}
fn resolve_track_file_path(repo_root: &Option<PathBuf>) -> PathBuf {
repo_root.as_ref().map_or_else(
|| PathBuf::from(".git/ralph").join(TRACK_FILENAME),
|r| ralph_git_dir(r).join(TRACK_FILENAME),
)
}
fn cleanup_wrapper_track_file(track_file: &Path, removed_wrapper_dir: &Option<PathBuf>) {
if let Ok(content) = fs::read_to_string(track_file) {
let wrapper_dir = PathBuf::from(content.trim());
let same_as_removed = removed_wrapper_dir
.as_ref()
.is_some_and(|p| p == &wrapper_dir);
if !same_as_removed {
remove_wrapper_dir_and_entry(&wrapper_dir);
}
}
#[cfg(unix)]
add_owner_write_if_not_symlink(track_file);
let _ = fs::remove_file(track_file);
}
pub fn start_agent_phase(helpers: &mut GitHelpers) -> io::Result<()> {
let repo_root = get_repo_root()?;
start_agent_phase_in_repo(&repo_root, helpers)
}
fn store_hooks_dir_if_resolvable(repo_root: &Path) {
if let Ok(hooks_dir) = get_hooks_dir_from(repo_root) {
if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
*guard = Some(hooks_dir);
}
}
}
fn store_agent_phase_paths(repo_root: &Path, ralph_dir: &Path) {
if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
*guard = Some(repo_root.to_path_buf());
}
if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
*guard = Some(ralph_dir.to_path_buf());
}
store_hooks_dir_if_resolvable(repo_root);
}
pub fn start_agent_phase_in_repo(repo_root: &Path, helpers: &mut GitHelpers) -> io::Result<()> {
helpers.wrapper_repo_root = Some(repo_root.to_path_buf());
let ralph_dir = ralph_git_dir(repo_root);
store_agent_phase_paths(repo_root, &ralph_dir);
repair_marker_if_tampered(repo_root)?;
#[cfg(unix)]
set_readonly_mode_if_not_symlink(&marker_path_from_ralph_dir(&ralph_dir), 0o444);
crate::git_helpers::install::install_hooks_in_repo(repo_root)?;
enable_git_wrapper_at(repo_root, helpers)?;
phase::capture_head_oid(repo_root);
Ok(())
}
pub fn end_agent_phase() {
if let Ok(repo_root) = get_repo_root() {
end_agent_phase_in_repo(&repo_root);
}
}
pub fn end_agent_phase_in_repo(repo_root: &Path) {
let ralph_dir = ralph_git_dir(repo_root);
end_agent_phase_in_repo_at_ralph_dir(repo_root, &ralph_dir);
}
pub fn clear_agent_phase_global_state() {
if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
*guard = None;
}
if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
*guard = None;
}
if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
*guard = None;
}
}
fn end_agent_phase_in_repo_at_ralph_dir(repo_root: &Path, ralph_dir: &Path) {
marker::remove_legacy_marker(repo_root);
let ralph_dir_ok =
crate::git_helpers::repo::sanitize_ralph_git_dir_at(ralph_dir).unwrap_or(false);
let marker_path = marker_path_from_ralph_dir(ralph_dir);
#[cfg(unix)]
add_owner_write_if_not_symlink(&marker_path);
let _ = fs::remove_file(&marker_path);
if ralph_dir_ok {
remove_head_oid_file(ralph_dir);
path_wrapper::cleanup_stray_tmp_files(ralph_dir);
let _ = fs::remove_dir(ralph_dir);
}
}
fn remove_head_oid_file(ralph_dir: &Path) {
let head_oid_path = ralph_dir.join(HEAD_OID_FILENAME);
if fs::symlink_metadata(&head_oid_path).is_err() {
return;
}
#[cfg(unix)]
add_owner_write_if_not_symlink(&head_oid_path);
let _ = fs::remove_file(&head_oid_path);
}
fn resolve_wrapper_dir(ralph_dir: &Path) -> Option<PathBuf> {
let tracked = path_wrapper::read_tracked_wrapper_dir(ralph_dir);
let on_path =
path_wrapper::find_wrapper_dir_on_path().filter(|p| path_wrapper::is_safe_existing_dir(p));
tracked.or(on_path)
}
fn do_restore_wrapper_tracking_file(
repo_root: &Path,
dir: &Path,
result: &mut ProtectionCheckResult,
logger: &Logger,
) {
logger.warn("Git wrapper tracking file missing or invalid — restoring");
result.tampering_detected = true;
result.details.push("Git wrapper tracking file missing or invalid — restored".to_string());
if let Err(e) = path_wrapper::write_track_file_atomic(repo_root, dir) {
logger.warn(&format!("Failed to restore wrapper tracking file: {e}"));
}
}
fn restore_wrapper_tracking_file_if_missing(
ralph_dir: &Path,
repo_root: &Path,
wrapper_dir: &Option<PathBuf>,
result: &mut ProtectionCheckResult,
logger: &Logger,
) {
if path_wrapper::read_tracked_wrapper_dir(ralph_dir).is_none() {
if let Some(ref dir) = wrapper_dir {
do_restore_wrapper_tracking_file(repo_root, dir, result, logger);
}
}
}
fn check_hooks_present(repo_root: &Path) -> bool {
get_hooks_dir_from(repo_root)
.ok()
.is_some_and(|hooks_dir| {
RALPH_HOOK_NAMES.iter().any(|name| {
let path = hooks_dir.join(name);
path.exists()
&& matches!(
crate::files::file_contains_marker(&path, HOOK_MARKER),
Ok(true)
)
})
})
}
fn check_marker_present(marker_path: &Path) -> bool {
fs::symlink_metadata(marker_path)
.ok()
.is_some_and(|m| m.file_type().is_file() && !m.file_type().is_symlink())
}
fn flag_missing_protections_if_needed(
marker_path: &Path,
repo_root: &Path,
result: &mut ProtectionCheckResult,
logger: &Logger,
) {
if !check_marker_present(marker_path) && !check_hooks_present(repo_root) {
logger.warn("Agent-phase git protections missing — reinstalling");
result.tampering_detected = true;
result
.details
.push("Marker and hooks missing before agent spawn — reinstalling".to_string());
}
}
fn handle_reinstall_result(
reinstall: io::Result<bool>,
result: &mut ProtectionCheckResult,
logger: &Logger,
) {
match reinstall {
Ok(true) => {
result.tampering_detected = true;
result
.details
.push("Git hooks tampered with or missing — reinstalled".to_string());
}
Err(e) => {
logger.warn(&format!("Failed to verify/reinstall hooks: {e}"));
}
Ok(false) => {}
}
}
fn check_unauthorized_commit(repo_root: &Path, result: &mut ProtectionCheckResult, logger: &Logger) {
if phase::detect_unauthorized_commit(repo_root) {
logger.warn("CRITICAL: HEAD OID changed — unauthorized commit detected!");
result.tampering_detected = true;
result
.details
.push("HEAD OID changed since last check — unauthorized commit detected".to_string());
phase::capture_head_oid(repo_root);
}
}
pub fn ensure_agent_phase_protections(logger: &Logger) -> ProtectionCheckResult {
let mut result = ProtectionCheckResult::default();
let Ok(scope) = resolve_protection_scope() else {
return result;
};
let repo_root = scope.repo_root.clone();
let ralph_dir = scope.ralph_dir.clone();
let marker_path = marker_path_from_ralph_dir(&ralph_dir);
let track_file_path = path_wrapper::track_file_path_for_ralph_dir(&ralph_dir);
check_marker_integrity(&ralph_dir, &repo_root, &mut result, logger);
check_track_file_integrity(&ralph_dir, &repo_root, &mut result, logger);
let wrapper_dir = resolve_wrapper_dir(&ralph_dir);
if let Some(ref dir) = wrapper_dir {
path_wrapper::prepend_wrapper_dir_to_path(dir);
}
restore_wrapper_tracking_file_if_missing(&ralph_dir, &repo_root, &wrapper_dir, &mut result, logger);
check_and_install_wrapper(
&repo_root,
&ralph_dir,
&marker_path,
&track_file_path,
&mut result,
logger,
);
flag_missing_protections_if_needed(&marker_path, &repo_root, &mut result, logger);
phase::check_and_repair_marker_symlink(&marker_path, &repo_root, &mut result, logger);
phase::check_and_repair_marker_permissions(&marker_path, &repo_root, &mut result, logger);
handle_reinstall_result(reinstall_hooks_if_tampered(logger), &mut result, logger);
#[cfg(unix)]
enforce_hook_permissions(&repo_root, logger);
phase::check_track_file_permissions(&track_file_path, &mut result, logger);
check_unauthorized_commit(&repo_root, &mut result, logger);
result
}
pub fn cleanup_orphaned_wrapper_at(repo_root: &Path) {
cleanup::cleanup_prior_wrapper(repo_root);
}
pub fn cleanup_agent_phase_silent() {
let repo_root = AGENT_PHASE_REPO_ROOT
.try_lock()
.ok()
.and_then(|guard| guard.clone())
.or_else(|| get_repo_root().ok());
let Some(repo_root) = repo_root else {
return;
};
let stored_ralph_dir = AGENT_PHASE_RALPH_DIR
.try_lock()
.ok()
.and_then(|guard| guard.clone());
let stored_hooks_dir = AGENT_PHASE_HOOKS_DIR
.try_lock()
.ok()
.and_then(|guard| guard.clone());
cleanup_agent_phase_at(
&repo_root,
stored_ralph_dir.as_deref(),
stored_hooks_dir.as_deref(),
);
}
pub fn cleanup_agent_phase_silent_at(repo_root: &Path) {
cleanup_agent_phase_at(repo_root, None, None);
}
pub fn cleanup_agent_phase_protections_silent_at(repo_root: &Path) {
cleanup_agent_phase_at(repo_root, None, None);
}
#[cfg(any(test, feature = "test-utils"))]
pub fn set_agent_phase_paths_for_test(
repo_root: Option<PathBuf>,
ralph_dir: Option<PathBuf>,
hooks_dir: Option<PathBuf>,
) {
if let Ok(mut guard) = AGENT_PHASE_REPO_ROOT.lock() {
*guard = repo_root;
}
if let Ok(mut guard) = AGENT_PHASE_RALPH_DIR.lock() {
*guard = ralph_dir;
}
if let Ok(mut guard) = AGENT_PHASE_HOOKS_DIR.lock() {
*guard = hooks_dir;
}
}
#[cfg(any(test, feature = "test-utils"))]
#[must_use]
pub fn get_agent_phase_paths_for_test() -> (Option<PathBuf>, Option<PathBuf>, Option<PathBuf>) {
let repo_root = AGENT_PHASE_REPO_ROOT
.lock()
.ok()
.and_then(|guard| guard.clone());
let ralph_dir = AGENT_PHASE_RALPH_DIR
.lock()
.ok()
.and_then(|guard| guard.clone());
let hooks_dir = AGENT_PHASE_HOOKS_DIR
.lock()
.ok()
.and_then(|guard| guard.clone());
(repo_root, ralph_dir, hooks_dir)
}
pub fn capture_head_oid(repo_root: &Path) {
phase::capture_head_oid(repo_root)
}
pub fn detect_unauthorized_commit(repo_root: &Path) -> bool {
phase::detect_unauthorized_commit(repo_root)
}
pub fn try_remove_ralph_dir(repo_root: &Path) -> bool {
cleanup::remove_ralph_dir(repo_root)
}
pub fn verify_ralph_dir_removed(repo_root: &Path) -> Vec<String> {
cleanup::verify_ralph_dir_removed(repo_root)
}
pub fn cleanup_orphaned_marker(logger: &Logger) -> io::Result<()> {
cleanup::cleanup_orphaned_marker(logger)
}
pub fn verify_wrapper_cleaned(repo_root: &Path) -> Vec<String> {
cleanup::verify_wrapper_cleaned(repo_root)
}
pub fn marker_exists_with_workspace(workspace: &dyn Workspace) -> bool {
workspace.exists(Path::new(".git/ralph/no_agent_commit"))
}
pub fn create_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
workspace.write(Path::new(".git/ralph/no_agent_commit"), "")
}
pub fn remove_marker_with_workspace(workspace: &dyn Workspace) -> io::Result<()> {
workspace.remove_if_exists(Path::new(".git/ralph/no_agent_commit"))
}
pub fn cleanup_orphaned_marker_with_workspace(
workspace: &dyn Workspace,
logger: &Logger,
) -> io::Result<()> {
let (removed_marker, removed_legacy_marker) = detect_and_remove_orphaned_markers(workspace)?;
if removed_marker || removed_legacy_marker {
logger.success("Removed orphaned enforcement marker");
} else {
logger.info("No orphaned marker found");
}
Ok(())
}
fn detect_and_remove_orphaned_markers(workspace: &dyn Workspace) -> io::Result<(bool, bool)> {
let marker_path = Path::new(".git/ralph/no_agent_commit");
let legacy_marker_path = Path::new(".no_agent_commit");
let removed_marker = if workspace.exists(marker_path) {
workspace.remove(marker_path)?;
true
} else {
false
};
let removed_legacy_marker = if workspace.exists(legacy_marker_path) {
workspace.remove(legacy_marker_path)?;
true
} else {
false
};
Ok((removed_marker, removed_legacy_marker))
}
fn validate_git_binary(real_git: &Path, git_path_str: &str) -> io::Result<()> {
if !real_git.is_absolute() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"git binary path is not absolute: '{git_path_str}'. \
Using absolute paths prevents potential security issues."
),
));
}
if !real_git.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("git binary does not exist at path: '{git_path_str}'"),
));
}
#[cfg(unix)]
validate_git_binary_unix(real_git, git_path_str)?;
Ok(())
}
#[cfg(unix)]
fn validate_git_binary_unix(real_git: &Path, git_path_str: &str) -> io::Result<()> {
match fs::metadata(real_git) {
Ok(metadata) if metadata.file_type().is_dir() => Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("git binary path is a directory, not a file: '{git_path_str}'"),
)),
Ok(_) => Ok(()),
Err(_) => Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("cannot access git binary metadata at path: '{git_path_str}'"),
)),
}
}
fn path_to_escaped_str(path: &Path, label: &str) -> io::Result<String> {
let s = path.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("{label} contains invalid UTF-8 characters; cannot create wrapper script"),
)
})?;
escape_shell_single_quoted(s)
}
fn build_wrapper_escaped_args(
scope: &crate::git_helpers::repo::ProtectionScope,
) -> io::Result<(String, String, String, String)> {
let normalized_repo_root =
crate::git_helpers::repo::normalize_protection_scope_path(&scope.repo_root);
let normalized_git_dir =
crate::git_helpers::repo::normalize_protection_scope_path(&scope.git_dir);
let ralph_dir = &scope.ralph_dir;
let marker_path = marker_path_from_ralph_dir(ralph_dir);
let track_file_path = path_wrapper::track_file_path_for_ralph_dir(ralph_dir);
let marker_escaped = path_to_escaped_str(&marker_path, "marker path")?;
let track_escaped = path_to_escaped_str(&track_file_path, "track file path")?;
let repo_root_escaped = path_to_escaped_str(&normalized_repo_root, "repo root")?;
let git_dir_escaped = path_to_escaped_str(&normalized_git_dir, "git dir")?;
Ok((marker_escaped, track_escaped, repo_root_escaped, git_dir_escaped))
}
fn write_wrapper_script(wrapper_path: &Path, content: &str) -> io::Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(wrapper_path)?;
std::io::Write::write_all(&mut file, content.as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(wrapper_path)?.permissions();
perms.set_mode(0o555);
fs::set_permissions(wrapper_path, perms)?;
}
Ok(())
}
fn enable_git_wrapper_at(repo_root: &Path, helpers: &mut GitHelpers) -> io::Result<()> {
cleanup::cleanup_prior_wrapper(repo_root);
helpers.init_real_git();
let Some(real_git) = helpers.real_git.as_ref() else {
return Ok(());
};
let git_path_str = real_git.to_str().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"git binary path contains invalid UTF-8 characters; cannot create wrapper script",
)
})?;
validate_git_binary(real_git, git_path_str)?;
let git_path_escaped = escape_shell_single_quoted(git_path_str)?;
helpers.wrapper_repo_root = Some(repo_root.to_path_buf());
let scope = resolve_protection_scope_from(repo_root)?;
let (marker_escaped, track_escaped, repo_root_escaped, git_dir_escaped) =
build_wrapper_escaped_args(&scope)?;
let wrapper_content = make_wrapper_content(
&git_path_escaped,
&marker_escaped,
&track_escaped,
&repo_root_escaped,
&git_dir_escaped,
);
let wrapper_dir = tempfile::Builder::new()
.prefix(WRAPPER_DIR_PREFIX)
.tempdir()?;
let wrapper_dir_path = wrapper_dir.keep();
let wrapper_path = wrapper_dir_path.join("git");
write_wrapper_script(&wrapper_path, &wrapper_content)?;
let current_path = env::var("PATH").unwrap_or_default();
env::set_var(
"PATH",
format!("{}:{}", wrapper_dir_path.display(), current_path),
);
path_wrapper::write_track_file_atomic(repo_root, &wrapper_dir_path)?;
helpers.wrapper_dir = Some(wrapper_dir_path);
Ok(())
}