ralph-workflow 0.7.18

PROMPT-driven multi-agent orchestrator for git repos
Documentation
//! Git wrapper script generation for agent-phase commit protection.
//!
//! The wrapper script is installed in a temp directory that is prepended to PATH.
//! When the marker file exists, the wrapper blocks mutating git commands
//! (commit, push, tag) while allowing read-only commands (status, log, diff).

const WRAPPER_MARKER: &str = "RALPH_AGENT_PHASE_GIT_WRAPPER";

/// Escape a path for safe use in a POSIX shell single-quoted string.
///
/// Single quotes in POSIX shells cannot contain literal single quotes.
/// The standard workaround is to end the quote, add an escaped quote, and restart the quote.
/// This function rejects paths with newlines since they can't be safely handled.
pub(crate) fn escape_shell_single_quoted(path: &str) -> std::io::Result<String> {
    if path.contains('\n') || path.contains('\r') {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            "git path contains newline characters, cannot create safe shell wrapper",
        ));
    }
    Ok(path.replace('\'', "'\\''"))
}

/// Generate the git wrapper script content.
///
/// When protections are active, the wrapper enforces a strict allowlist of
/// read-only subcommands and blocks everything else.
///
/// Protections are considered active when either:
/// - `<git-dir>/ralph/no_agent_commit` exists (absolute path embedded at install time), OR
/// - `<git-dir>/ralph/git-wrapper-dir.txt` exists (defense-in-depth against marker deletion).
///
/// `git_path_escaped`, `marker_path_escaped`, and `track_file_path_escaped` must already be
/// shell-single-quote-escaped absolute paths.
pub(crate) fn make_wrapper_content(
    git_path_escaped: &str,
    marker_path_escaped: &str,
    track_file_path_escaped: &str,
    active_repo_root_escaped: &str,
    active_git_dir_escaped: &str,
) -> String {
    format!(
        r#"#!/usr/bin/env bash
  set -euo pipefail
  # {WRAPPER_MARKER} - generated by ralph
  # NOTE: `command git` still routes through this PATH wrapper because `command`
  # only skips shell functions and aliases, not PATH entries. This wrapper is a
  # real file in PATH, so it is always invoked for any `git` command.
  marker='{marker_path_escaped}'
  track_file='{track_file_path_escaped}'
  active_repo_root='{active_repo_root_escaped}'
  active_git_dir='{active_git_dir_escaped}'
  path_is_within() {{
    local candidate="$1"
    local scope_root="$2"
    [[ "$candidate" == "$scope_root" || "$candidate" == "$scope_root"/* ]]
  }}
  normalize_scope_dir() {{
    local candidate="$1"
    if [ -z "$candidate" ] || [ ! -d "$candidate" ]; then
      printf '%s\n' "$candidate"
      return
    fi
    if canonical=$(cd "$candidate" 2>/dev/null && pwd -P); then
      printf '%s\n' "$canonical"
    else
      printf '%s\n' "$candidate"
    fi
  }}
  # Treat either the marker or the wrapper track file as an active agent-phase signal.
  # This makes the wrapper resilient if an agent deletes the marker mid-run.
  if [ -f "$marker" ] || [ -f "$track_file" ]; then
    # Unset environment variables that could be used to bypass the wrapper
    # by pointing git at a different repository or exec path.
    unset GIT_DIR
    unset GIT_WORK_TREE
    unset GIT_EXEC_PATH
    subcmd=""
    repo_args=()
    repo_arg_pending=0
    skip_next=0
     for arg in "$@"; do
       if [ "$repo_arg_pending" = "1" ]; then
         repo_args+=("$arg")
         repo_arg_pending=0
         continue
       fi
       if [ "$skip_next" = "1" ]; then
         skip_next=0
         continue
       fi
       case "$arg" in
        -C|--git-dir|--work-tree)
          repo_args+=("$arg")
          repo_arg_pending=1
          ;;
       --git-dir=*|--work-tree=*|-C=*)
         repo_args+=("$arg")
         ;;
       --namespace|-c|--config|--exec-path)
         skip_next=1
         ;;
       --namespace=*|--exec-path=*|-c=*|--config=*)
         ;;
       -*)
         ;;
       *)
         subcmd="$arg"
         break
         ;;
      esac
    done
    target_repo_root=""
    target_git_dir=""
    if [ "${{#repo_args[@]}}" -gt 0 ]; then
      target_repo_root=$( '{git_path_escaped}' "${{repo_args[@]}}" rev-parse --path-format=absolute --show-toplevel 2>/dev/null || true )
      target_git_dir=$( '{git_path_escaped}' "${{repo_args[@]}}" rev-parse --path-format=absolute --git-dir 2>/dev/null || true )
    else
      target_repo_root=$( '{git_path_escaped}' rev-parse --path-format=absolute --show-toplevel 2>/dev/null || true )
      target_git_dir=$( '{git_path_escaped}' rev-parse --path-format=absolute --git-dir 2>/dev/null || true )
    fi
    if [ -z "$target_repo_root" ] && [ -z "$target_git_dir" ] && path_is_within "$PWD" "$active_repo_root"; then
      target_repo_root="$active_repo_root"
      target_git_dir="$active_git_dir"
    fi
    protection_scope_active=0
    normalized_target_repo_root=$(normalize_scope_dir "$target_repo_root")
    normalized_target_git_dir=$(normalize_scope_dir "$target_git_dir")
    if [ "$normalized_target_git_dir" = "$active_git_dir" ]; then
      protection_scope_active=1
    elif [ -n "$target_repo_root" ] && [ "$normalized_target_repo_root" = "$active_repo_root" ]; then
      protection_scope_active=1
    fi
    if [ "$protection_scope_active" = "1" ]; then
    case "$subcmd" in
      "")
        # `git` with no subcommand is effectively help/version output.
       ;;
     status|log|diff|show|rev-parse|ls-files|describe)
       # Explicitly allowed read-only lookup commands.
       ;;
     stash)
       # Allow only `git stash list`.
       stash_sub=""
       found_stash=0
       for a2 in "$@"; do
         if [ "$found_stash" = "1" ]; then
           case "$a2" in
             -*) ;;
             *) stash_sub="$a2"; break ;;
           esac
         fi
         if [ "$a2" = "stash" ]; then found_stash=1; fi
       done
       if [ "$stash_sub" != "list" ]; then
         echo "Blocked: git stash disabled during agent phase (only 'stash list' allowed)." >&2
         exit 1
       fi
       ;;
     branch)
       # Allow only explicit read-only `git branch` forms.
       found_branch=0
       branch_allows_value=0
       for a2 in "$@"; do
         if [ "$branch_allows_value" = "1" ]; then
           branch_allows_value=0
           continue
         fi
         if [ "$found_branch" = "1" ]; then
           case "$a2" in
             --list|-l|--all|-a|--remotes|-r|--verbose|-v|--vv|--show-current|--column|--no-column|--color|--no-color|--ignore-case|--omit-empty)
               ;;
             --contains|--no-contains|--merged|--no-merged|--points-at|--sort|--format|--abbrev)
               branch_allows_value=1
               ;;
             --contains=*|--no-contains=*|--merged=*|--no-merged=*|--points-at=*|--sort=*|--format=*|--abbrev=*)
               ;;
             *)
               echo "Blocked: git branch disabled during agent phase (read-only forms only; mutating flags like --unset-upstream are blocked)." >&2
               exit 1
               ;;
           esac
         fi
         if [ "$a2" = "branch" ]; then found_branch=1; fi
       done
       ;;
     remote)
       # Allow only list-only forms of `git remote` (no positional args).
       found_remote=0
       for a2 in "$@"; do
         if [ "$found_remote" = "1" ]; then
           case "$a2" in
             -*) ;;
             *)
               echo "Blocked: git remote <subcommand> disabled during agent phase (list-only allowed)." >&2
               exit 1
               ;;
           esac
         fi
         if [ "$a2" = "remote" ]; then found_remote=1; fi
       done
       ;;
      *)
        echo "Blocked: git $subcmd disabled during agent phase (read-only allowlist)." >&2
        exit 1
        ;;
    esac
    fi
  fi
  exec '{git_path_escaped}' "$@"
  "#
    )
}