Skip to content
Closed
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
4 changes: 3 additions & 1 deletion .github/workflows/homebrew.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ on:
release:
types: [published]

permissions: read-all

jobs:
homebrew:
name: Bump Homebrew formula
runs-on: ubuntu-latest
if: ${{ !github.event.release.prerelease }}
steps:
- uses: mislav/bump-homebrew-formula-action@v3
- uses: mislav/bump-homebrew-formula-action@56a283fa15557e9abaa4bdb63b8212abc68e655c # v3
with:
formula-name: git-gtr
formula-path: Formula/git-gtr.rb
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ on:
pull_request:
branches: [main]

permissions: read-all

jobs:
shellcheck:
name: ShellCheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Install ShellCheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
Expand All @@ -24,7 +26,7 @@ jobs:
name: Completions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Verify completion files are up to date
run: ./scripts/generate-completions.sh --check
Expand All @@ -33,7 +35,7 @@ jobs:
name: Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4

- name: Install BATS
run: sudo apt-get update && sudo apt-get install -y bats
Expand Down
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,16 @@ git gtr clean --merged --force --yes # Force-clean and auto-confirm

**Note:** The `--merged` mode auto-detects your hosting provider (GitHub or GitLab) from the `origin` remote URL and requires the corresponding CLI tool (`gh` or `glab`) to be installed and authenticated. For self-hosted instances, set the provider explicitly: `git gtr config set gtr.provider gitlab`.

### `git gtr trust`

Review and approve hook commands defined in the repository's `.gtrconfig` file. Hooks from `.gtrconfig` are **not executed** until explicitly trusted — this prevents malicious contributors from injecting arbitrary shell commands via shared config files.

```bash
git gtr trust # Review and approve .gtrconfig hooks
```

Trust is stored per content hash and must be re-approved if hooks change. Hooks from your local git config (`.git/config`, `~/.gitconfig`) are always trusted.

### Other Commands

- `git gtr doctor` - Health check (verify git, editors, AI tools)
Expand Down Expand Up @@ -390,10 +400,12 @@ git gtr config set gtr.ui.color never
ai = claude
```

**Hook trust:** Hooks defined in `.gtrconfig` require explicit approval before they execute. Run `git gtr trust` after cloning a repository or when `.gtrconfig` hooks change. This protects against malicious hook injection in shared repositories.

**Configuration precedence** (highest to lowest):

1. `git config --local` (`.git/config`) - personal overrides
2. `.gtrconfig` (repo root) - team defaults
2. `.gtrconfig` (repo root) - team defaults (hooks require `git gtr trust`)
3. `git config --global` (`~/.gitconfig`) - user defaults

> For complete configuration reference including all settings, hooks, file copying patterns, and environment variables, see [docs/configuration.md](docs/configuration.md)
Expand Down
3 changes: 3 additions & 0 deletions bin/git-gtr
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ main() {
init)
cmd_init "$@"
;;
trust)
cmd_trust "$@"
;;
version|--version|-v)
echo "git gtr version $GTR_VERSION"
;;
Expand Down
1 change: 1 addition & 0 deletions completions/_git-gtr
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ _git-gtr() {
'config:Manage configuration'
'completion:Generate shell completions'
'init:Generate shell integration for cd support'
'trust:Trust .gtrconfig hooks'
'version:Show version'
'help:Show help'
)
Expand Down
1 change: 1 addition & 0 deletions completions/git-gtr.fish
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ complete -f -c git -n '__fish_git_gtr_using_command completion' -a 'bash zsh fis
complete -f -c git -n '__fish_git_gtr_needs_command' -a init -d 'Generate shell integration for cd support'
complete -f -c git -n '__fish_git_gtr_using_command init' -a 'bash zsh fish' -d 'Shell type'
complete -c git -n '__fish_git_gtr_using_command init' -l as -d 'Custom function name' -r
complete -f -c git -n '__fish_git_gtr_needs_command' -a trust -d 'Trust .gtrconfig hooks'
complete -f -c git -n '__fish_git_gtr_needs_command' -a version -d 'Show version'
complete -f -c git -n '__fish_git_gtr_needs_command' -a help -d 'Show help'

Expand Down
2 changes: 1 addition & 1 deletion completions/gtr.bash
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ _git_gtr() {

# If we're completing the first argument after 'git gtr'
if [ "$cword" -eq 2 ]; then
COMPREPLY=($(compgen -W "new go run copy editor ai rm mv rename ls list clean doctor adapter config completion init help version" -- "$cur"))
COMPREPLY=($(compgen -W "new go run copy editor ai rm mv rename ls list clean doctor adapter config completion init trust help version" -- "$cur"))
return 0
fi

Expand Down
34 changes: 28 additions & 6 deletions lib/adapters.sh
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,10 @@ editor_open() {
target="$workspace"
fi

# $GTR_EDITOR_CMD may contain arguments (e.g., "code --wait")
# Using eval here is necessary to handle multi-word commands properly
eval "$GTR_EDITOR_CMD \"\$target\""
# Split multi-word commands (e.g., "code --wait") into an array for safe execution
local _cmd_arr
read -ra _cmd_arr <<< "$GTR_EDITOR_CMD"
"${_cmd_arr[@]}" "$target"
}

# Globals set by load_ai_adapter: GTR_AI_CMD, GTR_AI_CMD_NAME
Expand All @@ -179,9 +180,10 @@ ai_can_start() {
ai_start() {
local path="$1"
shift
# $GTR_AI_CMD may contain arguments (e.g., "bunx @github/copilot@latest")
# Using eval here is necessary to handle multi-word commands properly
(cd "$path" && eval "$GTR_AI_CMD \"\$@\"")
# Split multi-word commands (e.g., "bunx @github/copilot@latest") into an array for safe execution
local _cmd_arr
read -ra _cmd_arr <<< "$GTR_AI_CMD"
(cd "$path" && "${_cmd_arr[@]}" "$@")
}

# Standard AI adapter builder — used by adapter files that follow the common pattern
Expand Down Expand Up @@ -295,6 +297,15 @@ resolve_workspace_file() {
# Usage: _load_adapter <type> <name> <label> <builtin_list> <path_hint>
_load_adapter() {
local type="$1" name="$2" label="$3" builtin_list="$4" path_hint="$5"

# Reject adapter names containing path traversal characters
case "$name" in
*/* | *..* | *\\*)
log_error "$label name '$name' contains invalid characters"
return 1
;;
esac
Comment on lines +301 to +307
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate only the adapter selector, not the full generic command.

This check runs before generic fallback and rejects any / anywhere in name. That blocks legitimate commands like bunx @github/copilot@latest—the same form called out in the new example on Line 183—even though the executable being resolved is just bunx.

♻️ Proposed fix
 _load_adapter() {
-  local type="$1" name="$2" label="$3" builtin_list="$4" path_hint="$5"
+  local type="$1" name="$2" label="$3" builtin_list="$4" path_hint="$5"
+  local adapter_selector="${name%% *}"
 
   # Reject adapter names containing path traversal characters
-  case "$name" in
+  case "$adapter_selector" in
     */* | *..* | *\\*)
       log_error "$label name '$name' contains invalid characters"
       return 1
       ;;
   esac
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/adapters.sh` around lines 301 - 307, The current validation rejects any
"/" in the entire "$name", which wrongly blocks generic commands like "bunx
`@github/copilot`@latest"; update the check to validate only the adapter selector
portion (e.g., the token that follows the command, not the executable) by
extracting the selector from "$name" (or use the existing selector variable if
present) and run the case pattern against that selector instead of "$name",
keeping the same log_error "$label name '...'" semantics but reporting the
selector; ensure the change targets the block that contains the case "$name"
check so generic fallback still accepts executables with "/" in their arguments.


local adapter_file="$GTR_DIR/adapters/${type}/${name}.sh"

# 1. Try loading explicit adapter file (custom overrides like claude, nano)
Expand Down Expand Up @@ -332,6 +343,17 @@ _load_adapter() {
return 1
fi

# Reject shell metacharacters in config-supplied command names to prevent injection
# Allows multi-word commands (e.g., "code --wait") but blocks shell operators
# shellcheck disable=SC2016 # Literal '$(' pattern match is intentional
case "$name" in
*\;* | *\`* | *'$('* | *\|* | *\&* | *'>'* | *'<'*)
log_error "$label '$name' contains shell metacharacters — refusing to execute"
log_info "Use a simple command name, optionally with flags (e.g., 'code --wait')"
return 1
;;
esac

# Set globals for generic adapter functions
# Note: $name may contain arguments (e.g., "code --wait", "bunx @github/copilot@latest")
if [ "$type" = "editor" ]; then
Expand Down
4 changes: 1 addition & 3 deletions lib/args.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,13 @@ _pa_match_flag() {

# Check if $flag matches any alternative in the pattern
local alt matched=0
local IFS_save="$IFS"
IFS="|"
local IFS="|"
for alt in $line; do
if [ "$flag" = "$alt" ]; then
matched=1
break
fi
done
IFS="$IFS_save"

if [ "$matched" = "1" ]; then
# Derive variable name from the first (canonical) pattern
Expand Down
26 changes: 26 additions & 0 deletions lib/commands/help.sh
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,27 @@ Command palette (gtr cd with no arguments, requires fzf):
EOF
}

_help_trust() {
cat <<'EOF'
git gtr trust - Trust .gtrconfig hooks

Usage: git gtr trust

Reviews and approves hook commands defined in the repository's .gtrconfig
file. Hooks from .gtrconfig are not executed until explicitly trusted.

This prevents malicious contributors from injecting arbitrary shell
commands via shared .gtrconfig files. Trust is stored per content hash
in ~/.config/gtr/trusted/ and must be re-approved if hooks change.

Hooks from your local git config (.git/config, ~/.gitconfig) are always
trusted since you control those files directly.

Examples:
git gtr trust # Review and approve hooks
EOF
}

_help_version() {
cat <<'EOF'
git gtr version - Show version
Expand Down Expand Up @@ -572,6 +593,11 @@ SETUP & MAINTENANCE:
--dry-run, -n: show what would be removed without removing
--force, -f: force removal even if worktree has uncommitted changes or untracked files

trust
Review and approve .gtrconfig hook commands
Hooks from .gtrconfig are not executed until trusted
Trust is re-required when hook content changes

completion <shell>
Generate shell completions (bash, zsh, fish)
Usage: eval "$(git gtr completion zsh)"
Expand Down
49 changes: 39 additions & 10 deletions lib/commands/init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -96,23 +96,31 @@ _init_bash() {

__FUNC___run_post_cd_hooks() {
local dir="$1"
local _gtr_trust_dir="${XDG_CONFIG_HOME:-$HOME/.config}/gtr/trusted"

cd "$dir" && {
local _gtr_hooks _gtr_hook _gtr_seen _gtr_config_file
_gtr_hooks=""
_gtr_seen=""
# Read from git config (local > global > system)
_gtr_hooks="$(git config --get-all gtr.hook.postCd 2>/dev/null)" || true
# Read from .gtrconfig if it exists
# Read from .gtrconfig if it exists — only if trusted
_gtr_config_file="$(git rev-parse --show-toplevel 2>/dev/null)/.gtrconfig"
if [ -f "$_gtr_config_file" ]; then
local _gtr_file_hooks
_gtr_file_hooks="$(git config -f "$_gtr_config_file" --get-all hooks.postCd 2>/dev/null)" || true
if [ -n "$_gtr_file_hooks" ]; then
if [ -n "$_gtr_hooks" ]; then
_gtr_hooks="$_gtr_hooks"$'\n'"$_gtr_file_hooks"
# Verify trust before including .gtrconfig hooks
local _gtr_hook_hash
_gtr_hook_hash="$(git config -f "$_gtr_config_file" --get-regexp '^hooks\.' 2>/dev/null | shasum -a 256 | cut -d' ' -f1)" || true
if [ -n "$_gtr_hook_hash" ] && [ -f "$_gtr_trust_dir/$_gtr_hook_hash" ]; then
if [ -n "$_gtr_hooks" ]; then
_gtr_hooks="$_gtr_hooks"$'\n'"$_gtr_file_hooks"
else
_gtr_hooks="$_gtr_file_hooks"
fi
else
_gtr_hooks="$_gtr_file_hooks"
echo "__FUNC__: Untrusted .gtrconfig hooks skipped — run 'git gtr trust' to approve" >&2
Comment on lines 97 to +123
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

These new shell-integration bodies are still hidden behind the old init cache.

cmd_init() only keys ~/.cache/gtr/init-* on GTR_VERSION (Lines 68-88), and bin/git-gtr still reports 2.6.0. Anyone who already has cached init output will keep getting the pre-trust hook script, so this security gate does not land until the cache is manually cleared or the stamp changes.

Also applies to: 281-309, 468-492

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/commands/init.sh` around lines 97 - 123, The cached init output keyed
only on GTR_VERSION (see cmd_init and the ~/.cache/gtr/init-* stamp) still
serves the old pre-trust hook bodies (and bin/git-gtr still reports 2.6.0), so
update the caching/stamp logic to invalidate or version the init cache whenever
the shell-integration/hook trust behavior changes: either include a new
trust-related stamp (e.g., a HOOKS_TRUST_VERSION or a hash of the new hook
bodies) in the cache key used by cmd_init and related init paths (also update
the other init cache usages around the blocks noted at 281-309 and 468-492), or
bump GTR_VERSION reported by bin/git-gtr so existing caches are treated as stale
and regenerated with the postCd trust checks enabled.

Comment on lines +107 to +123
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The post-cd trust check is looking in the worktree, not the main repo.

Inside a linked worktree, git rev-parse --show-toplevel points at the worktree root. _gtrconfig_path() in lib/config.sh:17-33 intentionally uses --git-common-dir to reach the main repo root, and git gtr trust stores trust against that file. With Lines 108, 293, and 481 still using --show-toplevel, trusted repo-level .gtrconfig hooks will never be found here.

Also applies to: 292-308, 480-492

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/commands/init.sh` around lines 107 - 123, The trust-check is building
_gtr_config_file with git rev-parse --show-toplevel (variable _gtr_config_file)
which points to a worktree root and thus misses the repo-level .gtrconfig;
change the lookup to use the repository common dir (e.g., use git rev-parse
--git-common-dir or call the existing _gtrconfig_path() helper from
lib/config.sh) so _gtr_config_file points at the main repo .gtrconfig before
performing the trust/hash check in the post-cd hook logic (the code block around
_gtr_config_file, _gtr_file_hooks, and _gtr_hook_hash).

fi
fi
fi
Expand Down Expand Up @@ -273,23 +281,31 @@ _init_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" && {
local _gtr_hooks _gtr_hook _gtr_seen _gtr_config_file
_gtr_hooks=""
_gtr_seen=""
# Read from git config (local > global > system)
_gtr_hooks="$(git config --get-all gtr.hook.postCd 2>/dev/null)" || true
# Read from .gtrconfig if it exists
# Read from .gtrconfig if it exists — only if trusted
_gtr_config_file="$(git rev-parse --show-toplevel 2>/dev/null)/.gtrconfig"
if [ -f "$_gtr_config_file" ]; then
local _gtr_file_hooks
_gtr_file_hooks="$(git config -f "$_gtr_config_file" --get-all hooks.postCd 2>/dev/null)" || true
if [ -n "$_gtr_file_hooks" ]; then
if [ -n "$_gtr_hooks" ]; then
_gtr_hooks="$_gtr_hooks"$'\n'"$_gtr_file_hooks"
# Verify trust before including .gtrconfig hooks
local _gtr_hook_hash
_gtr_hook_hash="$(git config -f "$_gtr_config_file" --get-regexp '^hooks\.' 2>/dev/null | shasum -a 256 | cut -d' ' -f1)" || true
if [ -n "$_gtr_hook_hash" ] && [ -f "$_gtr_trust_dir/$_gtr_hook_hash" ]; then
if [ -n "$_gtr_hooks" ]; then
_gtr_hooks="$_gtr_hooks"$'\n'"$_gtr_file_hooks"
else
_gtr_hooks="$_gtr_file_hooks"
fi
else
_gtr_hooks="$_gtr_file_hooks"
echo "__FUNC__: Untrusted .gtrconfig hooks skipped — run 'git gtr trust' to approve" >&2
fi
fi
fi
Expand Down Expand Up @@ -451,17 +467,30 @@ _init_fish() {

function __FUNC___run_post_cd_hooks
set -l dir "$argv[1]"
set -l _gtr_trust_dir "$HOME/.config/gtr/trusted"
if set -q XDG_CONFIG_HOME
set _gtr_trust_dir "$XDG_CONFIG_HOME/gtr/trusted"
end
cd $dir
and begin
set -l _gtr_hooks
set -l _gtr_seen
# Read from git config (local > global > system)
set -l _gtr_git_hooks (git config --get-all gtr.hook.postCd 2>/dev/null)
# Read from .gtrconfig if it exists
# Read from .gtrconfig if it exists — only if trusted
set -l _gtr_config_file (git rev-parse --show-toplevel 2>/dev/null)"/.gtrconfig"
set -l _gtr_file_hooks
if test -f "$_gtr_config_file"
set _gtr_file_hooks (git config -f "$_gtr_config_file" --get-all hooks.postCd 2>/dev/null)
set -l _gtr_candidate_hooks (git config -f "$_gtr_config_file" --get-all hooks.postCd 2>/dev/null)
if test (count $_gtr_candidate_hooks) -gt 0
# Verify trust before including .gtrconfig hooks
set -l _gtr_hook_hash (git config -f "$_gtr_config_file" --get-regexp '^hooks\.' 2>/dev/null | shasum -a 256 | cut -d' ' -f1)
if test -n "$_gtr_hook_hash"; and test -f "$_gtr_trust_dir/$_gtr_hook_hash"
set _gtr_file_hooks $_gtr_candidate_hooks
else
echo "__FUNC__: Untrusted .gtrconfig hooks skipped — run 'git gtr trust' to approve" >&2
end
end
end
# Merge and deduplicate
set _gtr_hooks $_gtr_git_hooks $_gtr_file_hooks
Expand Down
41 changes: 41 additions & 0 deletions lib/commands/trust.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Trust management for .gtrconfig hooks

cmd_trust() {
local config_file
config_file=$(_gtrconfig_path)

if [ -z "$config_file" ] || [ ! -f "$config_file" ]; then
log_info "No .gtrconfig file found in this repository"
return 0
fi

# Show all hook entries from .gtrconfig
local hook_content
hook_content=$(git config -f "$config_file" --get-regexp '^hooks\.' 2>/dev/null) || true

if [ -z "$hook_content" ]; then
log_info "No hooks defined in $config_file"
return 0
fi

if _hooks_are_trusted "$config_file"; then
log_info "Hooks in $config_file are already trusted"
log_info "Current hooks:"
printf '%s\n' "$hook_content" >&2
return 0
fi

log_warn "The following hooks are defined in $config_file:"
echo "" >&2
printf '%s\n' "$hook_content" >&2
echo "" >&2
log_warn "These commands will execute on your machine during gtr operations."

if prompt_yes_no "Trust these hooks?"; then
_hooks_mark_trusted "$config_file"
log_info "Hooks marked as trusted"
Comment on lines +35 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't report trust success when persistence failed.

_hooks_mark_trusted() in lib/hooks.sh:39-48 can fail on mkdir -p or the redirect. Line 37 still prints success unconditionally, so the user can be told the hooks are trusted even though no marker was written.

♻️ Proposed fix
   if prompt_yes_no "Trust these hooks?"; then
-    _hooks_mark_trusted "$config_file"
-    log_info "Hooks marked as trusted"
+    if _hooks_mark_trusted "$config_file"; then
+      log_info "Hooks marked as trusted"
+    else
+      log_error "Failed to persist hook trust state"
+      return 1
+    fi
   else
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if prompt_yes_no "Trust these hooks?"; then
_hooks_mark_trusted "$config_file"
log_info "Hooks marked as trusted"
if prompt_yes_no "Trust these hooks?"; then
if _hooks_mark_trusted "$config_file"; then
log_info "Hooks marked as trusted"
else
log_error "Failed to persist hook trust state"
return 1
fi
else
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/commands/trust.sh` around lines 35 - 37, The success message is printed
unconditionally even if _hooks_mark_trusted() failed; update the trust command
to check the exit status of _hooks_mark_trusted (call it and test its return
code) and only call log_info "Hooks marked as trusted" when _hooks_mark_trusted
returns success; on failure, surface an error via log_error or non-zero exit so
the user knows persistence failed. Ensure you reference the _hooks_mark_trusted
function from the trust flow and propagate its failure rather than always
printing success.

else
log_info "Hooks remain untrusted and will not execute"
fi
}
4 changes: 2 additions & 2 deletions lib/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,9 @@ cfg_default() {
value=$(git config --get "$key" 2>/dev/null || true)
fi

# 4. Fall back to environment variable (POSIX-compliant indirect reference)
# 4. Fall back to environment variable
if [ -z "$value" ] && [ -n "$env_name" ]; then
eval "value=\${${env_name}:-}"
value=$(printenv "$env_name" 2>/dev/null) || true
fi

# 5. Use fallback if still empty
Expand Down
Loading