Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 2 additions & 0 deletions bin/git-gtr
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 30 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,36 @@ that root, never its parent directory. The default worktree location is a siblin
named `<bare-directory-name>-worktrees`. Files stored only in Git history are not
copy sources; use `git gtr copy --from <worktree>` 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)
Expand Down
35 changes: 33 additions & 2 deletions lib/commands/init.sh
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand Down Expand Up @@ -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=""
Expand Down Expand Up @@ -520,13 +547,15 @@ _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
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=""
Expand Down Expand Up @@ -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]"
Expand All @@ -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)
Expand Down
169 changes: 169 additions & 0 deletions lib/config_safety.sh
Original file line number Diff line number Diff line change
@@ -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; }
}
9 changes: 7 additions & 2 deletions lib/hooks.sh
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -240,6 +244,7 @@ _hooks_mark_trusted() {
# Usage: _hooks_get_trusted <phase>
_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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading