#!/usr/bin/env bash
# gstack-safe-git — run one allowlisted, read-only Git query for audits that
# must not execute project-controlled code (/deslop-shared-libs).
#
# Usage: gstack-safe-git [-C
] [args...]
#
# Every invocation runs `git` with this fixed prefix; callers cannot add or
# override it:
# GIT_OPTIONAL_LOCKS=0 GIT_NO_LAZY_FETCH=1 GIT_TERMINAL_PROMPT=0
# git --no-pager --no-lazy-fetch --no-replace-objects
# -c core.fsmonitor=false -c log.showSignature=false -c diff.submodule=short
#
# Only query shapes that cannot run clean/process filters, textconv or external
# diff drivers, signature verifiers, pagers, transports, or index/ref writes are
# forwarded. log/show/diff always get --no-ext-diff --no-textconv; diff is only
# between two explicit object IDs. Everything else is refused with exit 2 and
# a one-line message naming the allowed forms. Git's own exit status passes
# through unchanged, including 129 when this Git lacks --no-lazy-fetch.
#
# The script sources nothing and executes only `git` from PATH.
set -euo pipefail
ALLOWED='allowed: rev-parse, symbolic-ref [--short] [, branch --show-current, remote [-v | get-url ], config --get|--get-all|--get-regexp , log, show, ls-tree, cat-file, rev-list, merge-base, for-each-ref, show-ref, grep, diff [-- ...], ls-files --cached --others --exclude-standard -z [-- ...]'
refuse() {
echo "gstack-safe-git: refused: $1; $ALLOWED" >&2
exit 2
}
dir_args=()
if [ "${1:-}" = "-C" ]; then
[ $# -ge 2 ] || refuse "-C needs a directory"
dir_args=(-C "$2")
shift 2
fi
[ $# -ge 1 ] || refuse "no subcommand"
sub=$1
shift
case "$sub" in
-*) refuse "global option '$sub' (only a leading -C is accepted; the safety -c settings are fixed)" ;;
rev-parse|symbolic-ref|branch|remote|config|log|show|ls-tree|cat-file|rev-list|merge-base|for-each-ref|show-ref|grep|diff|ls-files) ;;
*) refuse "'$sub' is not an allowlisted read" ;;
esac
for arg in "$@"; do
[ "$arg" = "--" ] && break
case "$arg" in
--output|--output=*) refuse "'$arg' writes files" ;;
--ext-diff|--textconv|--filters|--path|--path=*) refuse "'$arg' can run configured diff drivers or filters" ;;
--show-signature|*%G*|*'%(signature'*) refuse "'$arg' runs a signature verifier" ;;
--no-index|--recurse-submodules) refuse "'$arg' reads outside the repository's committed objects" ;;
esac
done
positional_before_dashdash() {
local count=0 arg
for arg in "$@"; do
[ "$arg" = "--" ] && break
case "$arg" in -*) ;; *) count=$((count + 1)) ;; esac
done
echo "$count"
}
extra=()
case "$sub" in
rev-parse|ls-tree|cat-file|rev-list|merge-base|for-each-ref|show-ref) ;;
log|show) extra=(--no-ext-diff --no-textconv) ;;
grep)
for arg in "$@"; do
[ "$arg" = "--" ] && break
case "$arg" in
-O*|--open-files-in-pager*) refuse "'$arg' launches a pager program" ;;
esac
done
;;
symbolic-ref)
for arg in "$@"; do
case "$arg" in
-q|--quiet|--short|--no-recurse) ;;
-*) refuse "symbolic-ref '$arg' is not a read" ;;
esac
done
[ "$(positional_before_dashdash "$@")" = 1 ] || refuse "symbolic-ref reads exactly one ref"
;;
branch)
[ "$*" = "--show-current" ] || refuse "branch is limited to 'branch --show-current'"
;;
remote)
case "$*" in
''|-v|--verbose) ;;
*)
[ "${1:-}" = "get-url" ] || refuse "remote is limited to listing and get-url"
shift_count=0
for arg in "${@:2}"; do
case "$arg" in
--push|--all) ;;
-*) refuse "remote get-url '$arg'" ;;
*) shift_count=$((shift_count + 1)) ;;
esac
done
[ "$shift_count" = 1 ] || refuse "remote get-url takes one remote name"
;;
esac
;;
config)
case "${1:-}" in
--get|--get-all|--get-regexp) ;;
*) refuse "config is limited to --get, --get-all and --get-regexp" ;;
esac
[ $# -ge 2 ] && [ $# -le 3 ] || refuse "config reads take a key and an optional value pattern"
for arg in "${@:2}"; do
case "$arg" in -*) refuse "config '$arg'" ;; esac
done
;;
diff)
ids=0
for arg in "$@"; do
[ "$arg" = "--" ] && break
case "$arg" in
--cached|--staged|--merge-base|--merge-base=*) refuse "diff '$arg' compares the index or derived revisions" ;;
-*) ;;
*)
[[ "$arg" =~ ^[0-9a-fA-F]{7,64}$ ]] || refuse "diff operand '$arg' is not an explicit object ID (put paths after --)"
ids=$((ids + 1))
;;
esac
done
[ "$ids" = 2 ] || refuse "diff needs exactly two explicit committed object IDs, never the worktree or index"
extra=(--no-ext-diff --no-textconv)
;;
ls-files)
nul=0
for arg in "$@"; do
[ "$arg" = "--" ] && break
case "$arg" in
-z) nul=1 ;;
--cached|--others|--exclude-standard|--stage) ;;
*) refuse "ls-files '$arg' (the overlay form is 'ls-files --cached --others --exclude-standard -z [-- ...]')" ;;
esac
done
[ "$nul" = 1 ] || refuse "ls-files output must be NUL-delimited with -z"
;;
esac
unset GIT_EXTERNAL_DIFF GIT_CONFIG_PARAMETERS GIT_CONFIG_COUNT
export GIT_OPTIONAL_LOCKS=0 GIT_NO_LAZY_FETCH=1 GIT_TERMINAL_PROMPT=0
exec git --no-pager --no-lazy-fetch --no-replace-objects \
-c core.fsmonitor=false -c log.showSignature=false -c diff.submodule=short \
${dir_args[@]+"${dir_args[@]}"} "$sub" ${extra[@]+"${extra[@]}"} "$@"
]