#!/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[@]}"} "$@"