ralph_workflow/git_helpers/
script.rs1const WRAPPER_MARKER: &str = "RALPH_AGENT_PHASE_GIT_WRAPPER";
8
9pub(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
24pub(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}