From d795314e35dc165530d52e785225c71b4882000d Mon Sep 17 00:00:00 2001 From: Shi Qi Date: Wed, 4 Nov 2026 12:04:46 -0800 Subject: [PATCH 1/2] fix: reject unsafe Git configuration before running GTR --- README.md | 9 +- bin/git-gtr | 2 + docs/configuration.md | 30 ++++++ lib/commands/init.sh | 35 ++++++- lib/config_safety.sh | 169 ++++++++++++++++++++++++++++++++ lib/hooks.sh | 9 +- tests/config_safety.bats | 202 +++++++++++++++++++++++++++++++++++++++ tests/init.bats | 12 +-- 8 files changed, 457 insertions(+), 11 deletions(-) create mode 100644 lib/config_safety.sh create mode 100644 tests/config_safety.bats diff --git a/README.md b/README.md index f5086fa..46440e8 100644 --- a/README.md +++ b/README.md @@ -404,7 +404,14 @@ Review and approve executable commands defined in the repository's `.gtrconfig` git gtr trust # Review and approve .gtrconfig commands ``` -Trust is bound to the repository path, the filesystem identity of its common Git directory, and executable command definitions. Replacing or recloning a repository at the same path, or changing hooks or editor/AI defaults, requires approval again. Older path-only approvals also require renewal. Hooks and defaults from your local git config (`.git/config`, `~/.gitconfig`) are always trusted. +Trust is bound to the repository path, the filesystem identity of its common Git directory, and executable command definitions. Replacing or recloning a repository at the same path, or changing hooks or editor/AI defaults, requires approval again. Older path-only approvals also require renewal. + +Hooks and defaults from Git configuration do not require `.gtrconfig` approval, +but GTR first checks configuration ownership and write permissions. It refuses +configuration files, included files, symlink targets, or parent directories that +another OS user can modify or replace. Shared group-writable Git configuration +must be secured before running GTR; `git gtr trust` does not bypass this check. +See [configuration safety](docs/configuration.md#configuration-safety). Approval covers command definitions and the behavior they delegate to. It does not hash or pin scripts, dependencies, or tools: for example, approving `sh ./scripts/setup.sh` allows subsequent changes to that script within the same repository. Review delegated code and trust the repository's contributors before approving commands. diff --git a/bin/git-gtr b/bin/git-gtr index 903b725..146d81e 100755 --- a/bin/git-gtr +++ b/bin/git-gtr @@ -34,6 +34,8 @@ resolve_script_dir() { . "$GTR_DIR/lib/ui.sh" . "$GTR_DIR/lib/args.sh" . "$GTR_DIR/lib/config.sh" +. "$GTR_DIR/lib/config_safety.sh" +_gtr_config_check || exit 1 _ui_apply_color_config . "$GTR_DIR/lib/platform.sh" . "$GTR_DIR/lib/core.sh" diff --git a/docs/configuration.md b/docs/configuration.md index 71cad9d..06a00e8 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -44,6 +44,36 @@ that root, never its parent directory. The default worktree location is a siblin named `-worktrees`. Files stored only in Git history are not copy sources; use `git gtr copy --from ` to copy from a checkout. +### Configuration safety + +Before using native Git configuration, GTR checks that configuration files and +their paths are owned by the current user or root and cannot be written or +replaced by other users. The check includes local, global, system, and linked +worktree configuration, included files, symlinks, and parent directories. It +rejects group/other write permissions and, on macOS, write-capable ACL grants. +Trusted-owner sticky directories such as `/tmp` are allowed when they protect +an existing entry owned by the current user or root. Unreadable permissions or +configuration fail closed. + +An unsafe source stops the command before GTR creates a worktree, honors a +configured destination, or launches configured commands. Hook dispatch and +generated Bash/Zsh/Fish `postCd` helpers also check the destination worktree's +configuration. A `.gtrconfig` approval never overrides unsafe Git permissions. + +The error names the path that needs attention. For example, if your own +`.git/config` is group-writable, remove group/other write access: + +```bash +chmod go-w .git/config +``` + +Secure any reported parent directories, included files, or ACLs as well; do not +change ownership or permissions on files you do not manage. Shared repositories +that deliberately grant other users write access need separate private clones +for GTR use. After upgrading, reload the documented shell setup or restart your +shell so cached integration is regenerated; already-loaded functions retain +their old behavior until reloaded. + --- ## Team Configuration (.gtrconfig) diff --git a/lib/commands/init.sh b/lib/commands/init.sh index 878402c..221e2d8 100644 --- a/lib/commands/init.sh +++ b/lib/commands/init.sh @@ -1,5 +1,16 @@ #!/usr/bin/env bash +# The generators are also sourced directly, independently of the CLI loader. +if ! command -v _gtr_config_check >/dev/null 2>&1; then + if [ -n "${ZSH_VERSION:-}" ]; then + # shellcheck disable=SC2296,SC2298,SC1091 + . "${${(%):-%x}:h}/../config_safety.sh" + else + # shellcheck source=lib/config_safety.sh + . "${BASH_SOURCE[0]%/*}/../config_safety.sh" + fi +fi + # Init command (generate shell integration for cd support) cmd_init() { local shell="" func_name="gtr" @@ -69,8 +80,8 @@ cmd_init() { local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/gtr" local cache_file="$cache_dir/init-${func_name}.${shell}" # Bump when generated helpers change, even if GTR_VERSION is unchanged. - # Schema 7 can contain PR207's v2 trust helpers; schema 8 requires v3 snapshots. - local cache_schema="${GTR_INIT_CACHE_VERSION:-8}" + # Schema 9 adds filesystem checks before using native Git configuration. + local cache_schema="${GTR_INIT_CACHE_VERSION:-9}" local cache_stamp="# gtr-cache: version=${GTR_VERSION:-unknown} init=${cache_schema} func=$func_name shell=$shell" # Return cached output if version matches @@ -92,6 +103,20 @@ cmd_init() { fi } +# Run the exact CLI validator in Bash for every generated shell. Embedding the +# functions keeps cached integration independent of the installation pathname. +_init_emit_config_safety_helper() { + local shell_name="$1" validator + validator=$(typeset -f _config_safety_error _config_safety_node _config_safety_path _gtr_config_check) + validator="$validator"$'\n_gtr_config_check' + if [ "$shell_name" = fish ]; then + validator=$(printf '%s' "$validator" | sed -e 's/\\/\\\\/g' -e "s/'/\\\\'/g") + printf "function __FUNC___check_git_config\n command bash -c '%s'\nend\n" "$validator" + else + printf '__FUNC___check_git_config() {\n command bash -c %q\n}\n' "$validator" + fi +} + # This template is exercised against the CLI's snapshot helpers in tests. _init_emit_snapshot_helpers() { cat <<'SNAPSHOT' @@ -334,12 +359,14 @@ _init_bash() { # Setup: see git gtr help init BASH _init_emit_posix_trust_helpers "" + _init_emit_config_safety_helper bash cat <<'BASH' __FUNC___run_post_cd_hooks() { local dir="$1" local _gtr_trust_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtr/trusted" cd "$dir" && { + __FUNC___check_git_config || return 1 local _gtr_hooks _gtr_hook _gtr_seen _gtr_config_file _gtr_hooks="" _gtr_seen="" @@ -520,6 +547,7 @@ _init_zsh() { # Setup: see git gtr help init ZSH _init_emit_posix_trust_helpers " emulate -L zsh" + _init_emit_config_safety_helper zsh cat <<'ZSH' __FUNC___run_post_cd_hooks() { emulate -L zsh @@ -527,6 +555,7 @@ __FUNC___run_post_cd_hooks() { local _gtr_trust_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtr/trusted" cd "$dir" && { + __FUNC___check_git_config || return 1 local _gtr_hooks _gtr_hook _gtr_seen _gtr_config_file _gtr_hooks="" _gtr_seen="" @@ -709,6 +738,7 @@ _init_fish() { # git gtr init fish | source FISH _init_emit_fish_trust_helpers + _init_emit_config_safety_helper fish cat <<'FISH' function __FUNC___run_post_cd_hooks set -l dir "$argv[1]" @@ -718,6 +748,7 @@ function __FUNC___run_post_cd_hooks end cd $dir and begin + __FUNC___check_git_config; or return 1 set -l _gtr_hooks set -l _gtr_seen # Read from git config (local > global > system) diff --git a/lib/config_safety.sh b/lib/config_safety.sh new file mode 100644 index 0000000..d6625ec --- /dev/null +++ b/lib/config_safety.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# Git configuration can select commands as well as worktree destinations. +# Fail closed when another OS user can modify a config file or replace its path. +# These functions also run in Bash inside generated Bash/Zsh/Fish integration. + +_config_safety_error() { + printf 'gtr: Unsafe Git configuration: %s (%s). Fix ownership/write permissions before running GTR.\n' "$1" "$2" >&2 + return 1 +} + +# Check a single path component without following symlinks. The caller walks +# both the link's parent chain and its target. Dynamic locals belong to +# _gtr_config_check; never accept a caller-supplied cached validation result. +_config_safety_node() { + local path="$1" metadata owner mode cached acl + _config_safety_sticky=0 + for cached in "${_config_safety_checked[@]}"; do + [ "$cached" != "$path" ] || return 0 + done + metadata=$(LC_ALL=C stat -c '%u %a' -- "$path" 2>/dev/null) || + metadata=$(LC_ALL=C stat -f '%u %p' -- "$path" 2>/dev/null) || + { _config_safety_error "$path" 'cannot inspect permissions'; return 1; } + owner="${metadata%% *}" + mode="${metadata#* }" + case "$owner:$mode" in + *[!0-9:]*|:*) _config_safety_error "$path" 'unknown ownership or permissions'; return 1 ;; + esac + case "$mode" in + ''|*[!0-7]*) _config_safety_error "$path" 'unknown permissions'; return 1 ;; + esac + if [ "$owner" != 0 ] && [ "$owner" != "$_config_safety_uid" ]; then + _config_safety_error "$path" 'owned by another user'; return 1 + fi + # Symlink mode bits do not control who can replace the link. + [ ! -L "$path" ] || return 0 + # Git commonly uses the null device to disable a configuration scope. Its + # writable device contents cannot supply configuration; parents were checked. + if [ "$path" = /dev/null ] && [ -c "$path" ]; then return 0; fi + if [ "$((8#$mode & 0022))" -ne 0 ]; then + # A trusted owner's sticky directory (e.g. /tmp) protects existing entries + # owned by this user/root. Missing children are handled separately below. + if [ -d "$path" ] && [ "$((8#$mode & 01000))" -ne 0 ]; then + _config_safety_sticky=1 + else + _config_safety_error "$path" 'writable by group or other users'; return 1 + fi + fi + # POSIX ACL write access is bounded by the group-class mode bits. macOS ACLs + # are independent of those bits; conservatively reject write-capable grants. + if [ "$_config_safety_os" = Darwin ]; then + acl=$(LC_ALL=C ls -lde "$path" 2>/dev/null) || + { _config_safety_error "$path" 'cannot inspect ACL'; return 1; } + if printf '%s\n' "$acl" | sed '1d' | grep -Eq '^[[:space:]]*[0-9]+:.* allow (.*[, ])?(write|append|delete|add_file|add_subdirectory|delete_child|writeattr|writeextattr|writesecurity|chown)(,|$| )'; then + _config_safety_error "$path" 'write-capable ACL'; return 1 + fi + fi + if [ "$_config_safety_sticky" -eq 0 ]; then + _config_safety_checked+=("$path") + fi +} + +# Resolve one component at a time: canonicalizing first would hide a writable +# symlink or an unsafe parent of a link whose target is otherwise protected. +_config_safety_path() { + local pending="$1" current=/ component candidate target links=0 parent_sticky=0 + case "$pending" in /*) ;; *) pending="$(pwd -P)/$pending" ;; esac + _config_safety_node / || return 1 + while [ -n "$pending" ]; do + pending="${pending#/}" + component="${pending%%/*}" + if [ "$component" = "$pending" ]; then pending=""; else pending="${pending#*/}"; fi + case "$component" in + ''|.) continue ;; + ..) + current="${current%/*}"; current="${current:-/}" + _config_safety_node "$current" || return 1 + parent_sticky="$_config_safety_sticky" + continue + ;; + esac + candidate="${current%/}/$component" + if [ ! -e "$candidate" ] && [ ! -L "$candidate" ]; then + # Missing files under private directories cannot be introduced by another + # user. Sticky directories do not protect names that do not yet exist. + [ "$parent_sticky" -eq 0 ] || + { _config_safety_error "$candidate" 'unprotected missing path'; return 1; } + return 0 + fi + _config_safety_node "$candidate" || return 1 + if [ -L "$candidate" ]; then + links=$((links + 1)) + [ "$links" -le 40 ] || { _config_safety_error "$candidate" 'symlink loop'; return 1; } + target=$(readlink "$candidate") || return 1 + case "$target" in /*) current=/ ;; esac + pending="$target${pending:+/$pending}" + else + current="$candidate" + parent_sticky="$_config_safety_sticky" + fi + done +} + +# Inspect all native Git configuration origins, including includes and linked +# worktree config. Reading configuration/repository paths does not run hooks. +# Use NUL records to preserve whitespace in paths; do not parse Git's quoting. +_gtr_config_check() { + local _config_safety_uid _config_safety_os _config_safety_sticky=0 + local _config_safety_checked=() + local origin entry path key value root include_dir complete=0 + _config_safety_uid=$(id -u) || return 1 + _config_safety_os=$(uname -s) || return 1 + + # Check primary sources even when empty or absent, so a concurrent writer + # cannot introduce entries into a previously empty, unreported origin. + if [ -n "${GIT_CONFIG_GLOBAL:-}" ]; then + _config_safety_path "$GIT_CONFIG_GLOBAL" || return 1 + else + _config_safety_path "$HOME/.gitconfig" || return 1 + _config_safety_path "${XDG_CONFIG_HOME:-$HOME/.config}/git/config" || return 1 + fi + if [ "${GIT_CONFIG_NOSYSTEM:-0}" != 1 ]; then + _config_safety_path "${GIT_CONFIG_SYSTEM:-/etc/gitconfig}" || return 1 + fi + if [ -n "${GIT_CONFIG:-}" ]; then + _config_safety_path "$GIT_CONFIG" || return 1 + fi + if root=$(git rev-parse --git-common-dir 2>/dev/null); then + _config_safety_path "$root/config" || return 1 + root=$(git rev-parse --absolute-git-dir 2>/dev/null) || return 1 + _config_safety_path "$root/config.worktree" || return 1 + if root=$(git rev-parse --show-toplevel 2>/dev/null); then + _config_safety_path "$root/.git" || return 1 + fi + fi + while IFS= read -r -d '' origin && IFS= read -r -d '' entry; do + case "$origin" in + file:*) + path="${origin#file:}" + _config_safety_path "$path" || return 1 + key="${entry%%$'\n'*}" + # Even an empty included file can later supply commands. Check include + # destinations as well as the origins of records actually returned. + case "$key" in + include.path|includeif.*.path) + value="${entry#*$'\n'}" + # Let Git expand ~/ paths, ~user paths, and installation prefixes. + # The sentinel preserves any trailing newlines in the pathname. + value=$(git -c "gtr.internalSafetyPath=$value" config --path --get gtr.internalSafetyPath && printf '.') || return 1 + value="${value%.}" + value="${value%$'\n'}" + case "$value" in + '') _config_safety_error "$path" 'empty include path'; return 1 ;; + /*) ;; + *) + case "$path" in */*) include_dir="${path%/*}" ;; *) include_dir=. ;; esac + value="$include_dir/$value" + ;; + esac + _config_safety_path "$value" || return 1 + ;; + esac + ;; + 'command line:'|'standard input:') ;; + gtr-config-end) complete=1 ;; + *) _config_safety_error "$origin" 'unknown config origin'; return 1 ;; + esac + done < <(git config --null --show-origin --list && printf 'gtr-config-end\0\0') + [ "$complete" -eq 1 ] || { _config_safety_error 'Git config' 'cannot read configuration'; return 1; } +} diff --git a/lib/hooks.sh b/lib/hooks.sh index 128f33d..f1cc5ec 100644 --- a/lib/hooks.sh +++ b/lib/hooks.sh @@ -1,6 +1,10 @@ #!/usr/bin/env bash # Hook execution system +# Hook dispatch can enter a different linked worktree after CLI startup. +# shellcheck source=lib/config_safety.sh +. "${BASH_SOURCE[0]%/*}/config_safety.sh" + # ── Hook trust model ──────────────────────────────────────────────────── # Hooks and executable defaults from .gtrconfig files (committed to # repositories) require explicit user approval before use. This prevents @@ -240,6 +244,7 @@ _hooks_mark_trusted() { # Usage: _hooks_get_trusted _hooks_get_trusted() { local phase="$1" + _gtr_config_check || return 1 # Always include hooks from git config (user controls their own .git/config) local git_hooks @@ -313,7 +318,7 @@ run_hooks() { # Get hooks, filtering untrusted .gtrconfig hooks local hooks - hooks=$(_hooks_get_trusted "$phase") + hooks=$(_hooks_get_trusted "$phase") || return 1 if [ -z "$hooks" ]; then # No hooks configured for this phase @@ -398,7 +403,7 @@ run_hooks_export() { shift local hooks - hooks=$(_hooks_get_trusted "$phase") + hooks=$(_hooks_get_trusted "$phase") || return 1 if [ -z "$hooks" ]; then return 0 diff --git a/tests/config_safety.bats b/tests/config_safety.bats new file mode 100644 index 0000000..1c0aff5 --- /dev/null +++ b/tests/config_safety.bats @@ -0,0 +1,202 @@ +#!/usr/bin/env bats +# Public entry points must reject Git configuration writable by another user. +# BATS owns status/output across test functions. +# shellcheck disable=SC2016,SC2030,SC2031 +load test_helper + +setup() { + SAFETY_FIXTURE=$(cd -P "$(mktemp -d)" && pwd) + export XDG_CONFIG_HOME="$SAFETY_FIXTURE/config" + export XDG_CACHE_HOME="$SAFETY_FIXTURE/cache" + export XDG_DATA_HOME="$SAFETY_FIXTURE/data" + export GIT_CONFIG_GLOBAL="$SAFETY_FIXTURE/global.gitconfig" + export GIT_CONFIG_NOSYSTEM=1 + export SAFETY_MARKER="$SAFETY_FIXTURE/executed" + : > "$GIT_CONFIG_GLOBAL" + setup_integration_repo + git config gtr.worktrees.dir "$SAFETY_FIXTURE/attacker destination" + git config gtr.hook.postCreate 'printf executed > "$SAFETY_MARKER"' + git config gtr.hook.postCd 'printf executed > "$SAFETY_MARKER"' +} + +teardown() { + teardown_integration_repo + rm -rf "$SAFETY_FIXTURE" +} + +# Exercise both injected settings through the real CLI, without network access. +assert_unsafe_create_blocked() { + run "$PROJECT_ROOT/bin/gtr" new unsafe-config --from HEAD --no-fetch --no-copy + [ "$status" -ne 0 ] + [[ "$output" == *"Unsafe Git configuration"* ]] + [ ! -e "$SAFETY_MARKER" ] + [ ! -e "$SAFETY_FIXTURE/attacker destination" ] + ! git show-ref --verify --quiet refs/heads/unsafe-config +} + +@test "group-writable local Git config cannot execute hooks or redirect worktrees" { + chmod 0660 "$TEST_REPO/.git/config" + assert_unsafe_create_blocked +} + +@test "normally protected Git config still supports hooks and worktrees.dir" { + chmod 0600 "$TEST_REPO/.git/config" + run "$PROJECT_ROOT/bin/gtr" new protected-config --from HEAD --no-fetch --no-copy + [ "$status" -eq 0 ] + [ -d "$SAFETY_FIXTURE/attacker destination/protected-config" ] + [ "$(cat "$SAFETY_MARKER")" = executed ] +} + +# Generate while safe, then change permissions: cached helpers must recheck. +assert_unsafe_shell_blocked() { + local shell_name="$1" init_file="$SAFETY_FIXTURE/init" + command -v "$shell_name" >/dev/null 2>&1 || skip "$shell_name is not installed" + "$PROJECT_ROOT/bin/gtr" init "$shell_name" > "$init_file" + chmod 0660 "$TEST_REPO/.git/config" + export SAFETY_INIT="$init_file" SAFETY_REPO="$TEST_REPO" + case "$shell_name" in + bash|zsh) + run "$shell_name" -f -c 'compdef() { :; }; source "$SAFETY_INIT"; gtr_run_post_cd_hooks "$SAFETY_REPO"' + ;; + fish) + run fish --no-config -c 'source "$SAFETY_INIT"; gtr_run_post_cd_hooks "$SAFETY_REPO"' + ;; + esac + [ "$status" -ne 0 ] + [[ "$output" == *"Unsafe Git configuration"* ]] + [ ! -e "$SAFETY_MARKER" ] +} + +@test "cached bash postCd helper refuses newly writable Git config" { + assert_unsafe_shell_blocked bash +} + +@test "cached zsh postCd helper refuses newly writable Git config" { + assert_unsafe_shell_blocked zsh +} + +@test "cached fish postCd helper refuses newly writable Git config" { + assert_unsafe_shell_blocked fish +} + +@test "a read-only config in a group-writable Git directory is rejected" { + chmod 0600 "$TEST_REPO/.git/config" + chmod 0770 "$TEST_REPO/.git" + assert_unsafe_create_blocked +} + +@test "world-writable config is rejected" { + chmod 0606 "$TEST_REPO/.git/config" + assert_unsafe_create_blocked +} + +@test "writable included config is rejected even through a relative include" { + git config -f "$TEST_REPO/.git/shared config" gtr.hook.postCreate 'printf included > "$SAFETY_MARKER"' + git config include.path 'shared config' + chmod 0660 "$TEST_REPO/.git/shared config" + assert_unsafe_create_blocked +} + +@test "empty writable includes cannot acquire commands after validation" { + : > "$TEST_REPO/.git/empty config" + git config include.path 'empty config' + chmod 0660 "$TEST_REPO/.git/empty config" + assert_unsafe_create_blocked +} + +@test "unsafe symlink targets and their parents are rejected" { + mkdir "$SAFETY_FIXTURE/shared" + git config -f "$SAFETY_FIXTURE/shared/included" gtr.hook.postCreate 'printf linked > "$SAFETY_MARKER"' + ln -s "$SAFETY_FIXTURE/shared/included" "$TEST_REPO/.git/linked-config" + git config include.path linked-config + chmod 0770 "$SAFETY_FIXTURE/shared" + assert_unsafe_create_blocked +} + +@test "symlink parent permissions are checked before following a safe target" { + mkdir "$SAFETY_FIXTURE/shared" + git config -f "$SAFETY_FIXTURE/protected" gtr.hook.postCreate 'printf linked > "$SAFETY_MARKER"' + ln -s "$SAFETY_FIXTURE/protected" "$SAFETY_FIXTURE/shared/link" + git config include.path "$SAFETY_FIXTURE/shared/link" + chmod 0770 "$SAFETY_FIXTURE/shared" + assert_unsafe_create_blocked +} + +@test "protected includes and symlinks preserve normal hook execution" { + git config -f "$SAFETY_FIXTURE/protected" gtr.hook.postCreate 'printf included > "$SAFETY_MARKER"' + ln -s "$SAFETY_FIXTURE/protected" "$TEST_REPO/.git/linked-config" + git config include.path linked-config + run "$PROJECT_ROOT/bin/gtr" new safe-includes --from HEAD --no-fetch --no-copy + [ "$status" -eq 0 ] + [ "$(cat "$SAFETY_MARKER")" = included ] +} + +@test "group-writable global config is rejected" { + chmod 0660 "$GIT_CONFIG_GLOBAL" + assert_unsafe_create_blocked +} + +@test "group-writable system config is rejected" { + export GIT_CONFIG_NOSYSTEM=0 GIT_CONFIG_SYSTEM="$SAFETY_FIXTURE/system" + : > "$GIT_CONFIG_SYSTEM" + chmod 0660 "$GIT_CONFIG_SYSTEM" + assert_unsafe_create_blocked +} + +@test "linked worktree startup checks its common Git config" { + git worktree add --quiet "$SAFETY_FIXTURE/linked" -b linked HEAD + chmod 0660 "$TEST_REPO/.git/config" + cd "$SAFETY_FIXTURE/linked" + assert_unsafe_create_blocked +} + +@test "hook dispatch rechecks the target worktree-specific config" { + git config extensions.worktreeConfig true + git worktree add --quiet "$SAFETY_FIXTURE/linked" -b linked HEAD + git -C "$SAFETY_FIXTURE/linked" config --worktree gtr.hook.postCd 'printf worktree > "$SAFETY_MARKER"' + local config_file + config_file=$(git -C "$SAFETY_FIXTURE/linked" rev-parse --git-path config.worktree) + chmod 0660 "$config_file" + source_gtr_libs + run run_hooks_in postCd "$SAFETY_FIXTURE/linked" + [ "$status" -ne 0 ] + [[ "$output" == *"Unsafe Git configuration"* ]] + [ ! -e "$SAFETY_MARKER" ] +} + +@test "export dispatcher refuses unsafe Git config" { + chmod 0660 "$TEST_REPO/.git/config" + source_gtr_libs + run run_hooks_export postCd + [ "$status" -ne 0 ] + [ ! -e "$SAFETY_MARKER" ] +} + +@test "bare repository config is checked before creation" { + git clone --quiet --bare "$TEST_REPO" "$SAFETY_FIXTURE/bare.git" + git -C "$SAFETY_FIXTURE/bare.git" config gtr.worktrees.dir "$SAFETY_FIXTURE/attacker destination" + chmod 0660 "$SAFETY_FIXTURE/bare.git/config" + cd "$SAFETY_FIXTURE/bare.git" + assert_unsafe_create_blocked +} + +@test "macOS write ACLs cannot bypass mode-bit checks" { + [ "$(uname -s)" = Darwin ] || skip "macOS ACL syntax" + chmod +a 'everyone allow write' "$TEST_REPO/.git/config" + assert_unsafe_create_blocked +} + +@test "the null device can still disable global Git config" { + export GIT_CONFIG_GLOBAL=/dev/null + run "$PROJECT_ROOT/bin/gtr" new null-config --from HEAD --no-fetch --no-copy + [ "$status" -eq 0 ] + [ "$(cat "$SAFETY_MARKER")" = executed ] +} + +@test "Git include expansion preserves a newline in the target filename" { + local included="$SAFETY_FIXTURE/with newline"$'\n' + git config -f "$included" gtr.hook.postCreate 'printf included > "$SAFETY_MARKER"' + git config include.path "$included" + chmod 0660 "$included" + assert_unsafe_create_blocked +} diff --git a/tests/init.bats b/tests/init.bats index 9966d7d..b56d7c4 100644 --- a/tests/init.bats +++ b/tests/init.bats @@ -1074,19 +1074,19 @@ run_generated_v2_trust_upgrade() ( [[ "$stamp" == *"init=2"* ]] } -@test "init invalidates schema 6 and PR207 schema 7 at the same CLI version" { +@test "init invalidates older schemas at the same CLI version" { local shell_name func_name schema cache_file mkdir -p "$XDG_CACHE_HOME/gtr" for shell_name in bash zsh fish; do for func_name in gtr gwtr; do - for schema in 7 6; do + for schema in 8 7 6; do cache_file="$XDG_CACHE_HOME/gtr/init-$func_name.$shell_name" printf '# gtr-cache: version=test init=%s func=%s shell=%s\nold-cache\n' \ "$schema" "$func_name" "$shell_name" > "$cache_file" run cmd_init "$shell_name" --as "$func_name" [ "$status" -eq 0 ] [[ "$output" != *old-cache* ]] - [[ "$(head -1 "$cache_file")" == *"init=8"* ]] + [[ "$(head -1 "$cache_file")" == *"init=9"* ]] done done done @@ -1115,15 +1115,15 @@ run_documented_init_upgrade() ( if [ "$shell_name" = zsh ]; then snippet='compdef() { :; }'$'\n'"$snippet" fi - for schema in 6 7 8; do + for schema in 6 7 8 9; do version="$cli_version" - [ "$schema" != 8 ] || version=old-version + [ "$schema" != 9 ] || version=old-version printf '# gtr-cache: version=%s init=%s func=gtr shell=%s\n' \ "$version" "$schema" "$shell_name" > "$cache_file" printf '%s\n' 'echo stale > "$STALE_CACHE_MARKER"' >> "$cache_file" "$shell_name" -c "$snippet" [ ! -f "$STALE_CACHE_MARKER" ] - [[ "$(head -1 "$cache_file")" == *"version=$cli_version init=8 "* ]] + [[ "$(head -1 "$cache_file")" == *"version=$cli_version init=9 "* ]] done done ) From 18f0914ab61cf0418b1932571009cd3f1e3df0c9 Mon Sep 17 00:00:00 2001 From: Shi Qi Date: Wed, 4 Nov 2026 12:09:04 -0800 Subject: [PATCH 2/2] test: isolate CI from runner Git configuration --- .github/workflows/lint.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6d5d289..11794d6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -49,6 +49,11 @@ jobs: run: | export PATH="$PWD/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH" export XDG_CACHE_HOME="$RUNNER_TEMP/gtr-cache" + # Exercise the CLI with private config, not the runner image's + # system config under its shared writable application directories. + export GIT_CONFIG_NOSYSTEM=1 + export GIT_CONFIG_GLOBAL="$RUNNER_TEMP/gtr-global.gitconfig" + : > "$GIT_CONFIG_GLOBAL" /bin/bash --version git gtr --version ./bin/gtr --version @@ -68,4 +73,13 @@ jobs: run: sudo apt-get update && sudo apt-get install -y bats fish zsh - name: Run tests - run: bats tests/ + run: | + # Tests write global settings and XDG state; isolate them from the + # runner image. Permission regressions supply their own unsafe files. + export GIT_CONFIG_NOSYSTEM=1 + export GIT_CONFIG_GLOBAL="$RUNNER_TEMP/gtr-global.gitconfig" + export XDG_CONFIG_HOME="$RUNNER_TEMP/gtr-config" + export XDG_CACHE_HOME="$RUNNER_TEMP/gtr-cache" + export XDG_DATA_HOME="$RUNNER_TEMP/gtr-data" + : > "$GIT_CONFIG_GLOBAL" + bats tests/