const WRAPPER_MARKER: &str = "RALPH_AGENT_PHASE_GIT_WRAPPER";
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('\'', "'\\''"))
}
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}' "$@"
"#
)
}