Skip to main content

ralph_workflow/git_helpers/
script.rs

1//! Git wrapper script generation for agent-phase commit protection.
2//!
3//! The wrapper script is installed in a temp directory that is prepended to PATH.
4//! When the marker file exists, the wrapper blocks mutating git commands
5//! (commit, push, tag) while allowing read-only commands (status, log, diff).
6
7const WRAPPER_MARKER: &str = "RALPH_AGENT_PHASE_GIT_WRAPPER";
8
9/// Escape a path for safe use in a POSIX shell single-quoted string.
10///
11/// Single quotes in POSIX shells cannot contain literal single quotes.
12/// The standard workaround is to end the quote, add an escaped quote, and restart the quote.
13/// This function rejects paths with newlines since they can't be safely handled.
14pub(crate) fn escape_shell_single_quoted(path: &str) -> std::io::Result<String> {
15    if path.contains('\n') || path.contains('\r') {
16        return Err(std::io::Error::new(
17            std::io::ErrorKind::InvalidInput,
18            "git path contains newline characters, cannot create safe shell wrapper",
19        ));
20    }
21    Ok(path.replace('\'', "'\\''"))
22}
23
24/// Generate the git wrapper script content.
25///
26/// When protections are active, the wrapper enforces a strict allowlist of
27/// read-only subcommands and blocks everything else.
28///
29/// Protections are considered active when either:
30/// - `<git-dir>/ralph/no_agent_commit` exists (absolute path embedded at install time), OR
31/// - `<git-dir>/ralph/git-wrapper-dir.txt` exists (defense-in-depth against marker deletion).
32///
33/// `git_path_escaped`, `marker_path_escaped`, and `track_file_path_escaped` must already be
34/// shell-single-quote-escaped absolute paths.
35pub(crate) fn make_wrapper_content(
36    git_path_escaped: &str,
37    marker_path_escaped: &str,
38    track_file_path_escaped: &str,
39    active_repo_root_escaped: &str,
40    active_git_dir_escaped: &str,
41) -> String {
42    format!(
43        r#"#!/usr/bin/env bash
44  set -euo pipefail
45  # {WRAPPER_MARKER} - generated by ralph
46  # NOTE: `command git` still routes through this PATH wrapper because `command`
47  # only skips shell functions and aliases, not PATH entries. This wrapper is a
48  # real file in PATH, so it is always invoked for any `git` command.
49  marker='{marker_path_escaped}'
50  track_file='{track_file_path_escaped}'
51  active_repo_root='{active_repo_root_escaped}'
52  active_git_dir='{active_git_dir_escaped}'
53  path_is_within() {{
54    local candidate="$1"
55    local scope_root="$2"
56    [[ "$candidate" == "$scope_root" || "$candidate" == "$scope_root"/* ]]
57  }}
58  normalize_scope_dir() {{
59    local candidate="$1"
60    if [ -z "$candidate" ] || [ ! -d "$candidate" ]; then
61      printf '%s\n' "$candidate"
62      return
63    fi
64    if canonical=$(cd "$candidate" 2>/dev/null && pwd -P); then
65      printf '%s\n' "$canonical"
66    else
67      printf '%s\n' "$candidate"
68    fi
69  }}
70  # Treat either the marker or the wrapper track file as an active agent-phase signal.
71  # This makes the wrapper resilient if an agent deletes the marker mid-run.
72  if [ -f "$marker" ] || [ -f "$track_file" ]; then
73    # Unset environment variables that could be used to bypass the wrapper
74    # by pointing git at a different repository or exec path.
75    unset GIT_DIR
76    unset GIT_WORK_TREE
77    unset GIT_EXEC_PATH
78    subcmd=""
79    repo_args=()
80    repo_arg_pending=0
81    skip_next=0
82     for arg in "$@"; do
83       if [ "$repo_arg_pending" = "1" ]; then
84         repo_args+=("$arg")
85         repo_arg_pending=0
86         continue
87       fi
88       if [ "$skip_next" = "1" ]; then
89         skip_next=0
90         continue
91       fi
92       case "$arg" in
93        -C|--git-dir|--work-tree)
94          repo_args+=("$arg")
95          repo_arg_pending=1
96          ;;
97       --git-dir=*|--work-tree=*|-C=*)
98         repo_args+=("$arg")
99         ;;
100       --namespace|-c|--config|--exec-path)
101         skip_next=1
102         ;;
103       --namespace=*|--exec-path=*|-c=*|--config=*)
104         ;;
105       -*)
106         ;;
107       *)
108         subcmd="$arg"
109         break
110         ;;
111      esac
112    done
113    target_repo_root=""
114    target_git_dir=""
115    if [ "${{#repo_args[@]}}" -gt 0 ]; then
116      target_repo_root=$( '{git_path_escaped}' "${{repo_args[@]}}" rev-parse --path-format=absolute --show-toplevel 2>/dev/null || true )
117      target_git_dir=$( '{git_path_escaped}' "${{repo_args[@]}}" rev-parse --path-format=absolute --git-dir 2>/dev/null || true )
118    else
119      target_repo_root=$( '{git_path_escaped}' rev-parse --path-format=absolute --show-toplevel 2>/dev/null || true )
120      target_git_dir=$( '{git_path_escaped}' rev-parse --path-format=absolute --git-dir 2>/dev/null || true )
121    fi
122    if [ -z "$target_repo_root" ] && [ -z "$target_git_dir" ] && path_is_within "$PWD" "$active_repo_root"; then
123      target_repo_root="$active_repo_root"
124      target_git_dir="$active_git_dir"
125    fi
126    protection_scope_active=0
127    normalized_target_repo_root=$(normalize_scope_dir "$target_repo_root")
128    normalized_target_git_dir=$(normalize_scope_dir "$target_git_dir")
129    if [ "$normalized_target_git_dir" = "$active_git_dir" ]; then
130      protection_scope_active=1
131    elif [ -n "$target_repo_root" ] && [ "$normalized_target_repo_root" = "$active_repo_root" ]; then
132      protection_scope_active=1
133    fi
134    if [ "$protection_scope_active" = "1" ]; then
135    case "$subcmd" in
136      "")
137        # `git` with no subcommand is effectively help/version output.
138       ;;
139     status|log|diff|show|rev-parse|ls-files|describe)
140       # Explicitly allowed read-only lookup commands.
141       ;;
142     stash)
143       # Allow only `git stash list`.
144       stash_sub=""
145       found_stash=0
146       for a2 in "$@"; do
147         if [ "$found_stash" = "1" ]; then
148           case "$a2" in
149             -*) ;;
150             *) stash_sub="$a2"; break ;;
151           esac
152         fi
153         if [ "$a2" = "stash" ]; then found_stash=1; fi
154       done
155       if [ "$stash_sub" != "list" ]; then
156         echo "Blocked: git stash disabled during agent phase (only 'stash list' allowed)." >&2
157         exit 1
158       fi
159       ;;
160     branch)
161       # Allow only explicit read-only `git branch` forms.
162       found_branch=0
163       branch_allows_value=0
164       for a2 in "$@"; do
165         if [ "$branch_allows_value" = "1" ]; then
166           branch_allows_value=0
167           continue
168         fi
169         if [ "$found_branch" = "1" ]; then
170           case "$a2" in
171             --list|-l|--all|-a|--remotes|-r|--verbose|-v|--vv|--show-current|--column|--no-column|--color|--no-color|--ignore-case|--omit-empty)
172               ;;
173             --contains|--no-contains|--merged|--no-merged|--points-at|--sort|--format|--abbrev)
174               branch_allows_value=1
175               ;;
176             --contains=*|--no-contains=*|--merged=*|--no-merged=*|--points-at=*|--sort=*|--format=*|--abbrev=*)
177               ;;
178             *)
179               echo "Blocked: git branch disabled during agent phase (read-only forms only; mutating flags like --unset-upstream are blocked)." >&2
180               exit 1
181               ;;
182           esac
183         fi
184         if [ "$a2" = "branch" ]; then found_branch=1; fi
185       done
186       ;;
187     remote)
188       # Allow only list-only forms of `git remote` (no positional args).
189       found_remote=0
190       for a2 in "$@"; do
191         if [ "$found_remote" = "1" ]; then
192           case "$a2" in
193             -*) ;;
194             *)
195               echo "Blocked: git remote <subcommand> disabled during agent phase (list-only allowed)." >&2
196               exit 1
197               ;;
198           esac
199         fi
200         if [ "$a2" = "remote" ]; then found_remote=1; fi
201       done
202       ;;
203      *)
204        echo "Blocked: git $subcmd disabled during agent phase (read-only allowlist)." >&2
205        exit 1
206        ;;
207    esac
208    fi
209  fi
210  exec '{git_path_escaped}' "$@"
211  "#
212    )
213}