diff --git a/.github/workflows/homebrew.yml b/.github/workflows/homebrew.yml index 9636d59..41601f8 100644 --- a/.github/workflows/homebrew.yml +++ b/.github/workflows/homebrew.yml @@ -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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8507681..7ea0886 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index d1a7ff4..99c28ee 100644 --- a/README.md +++ b/README.md @@ -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 repository path plus hook definitions 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) @@ -359,6 +369,14 @@ git gtr config set gtr.editor.default cursor # Set your AI tool (aider, auggie, claude, codex, continue, copilot, cursor, gemini, opencode) git gtr config set gtr.ai.default claude +# Override-backed adapters may include flags +git gtr config set gtr.editor.default "nano -w" +git gtr config set gtr.ai.default "claude --continue" + +# Generic fallbacks may use other safe PATH commands +git gtr config set gtr.editor.default "code --wait" +git gtr config set gtr.ai.default "bunx @github/copilot@latest" + # Copy env files to new worktrees git gtr config add gtr.copy.include "**/.env.example" @@ -390,10 +408,14 @@ 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. + +**Adapter safety:** Generic `gtr.editor.default` and `gtr.ai.default` values must resolve to safe PATH commands. Filesystem paths such as `./tool` and shell wrapper forms such as `sh -c ...` are rejected. Override-backed adapters like `claude`, `cursor`, and `nano` may include additional flags, for example `claude --continue` or `nano -w`. + **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) diff --git a/adapters/ai/claude.sh b/adapters/ai/claude.sh index 83feec5..4515c48 100644 --- a/adapters/ai/claude.sh +++ b/adapters/ai/claude.sh @@ -42,6 +42,7 @@ ai_can_start() { ai_start() { local path="$1" shift + local configured_args=("${GTR_AI_CMD_ARGS[@]}") local claude_cmd claude_cmd="$(find_claude_executable)" @@ -57,5 +58,5 @@ ai_start() { return 1 fi - (cd "$path" && "$claude_cmd" "$@") + (cd "$path" && "$claude_cmd" "${configured_args[@]}" "$@") } diff --git a/adapters/ai/cursor.sh b/adapters/ai/cursor.sh index 8ea910c..dad75c1 100644 --- a/adapters/ai/cursor.sh +++ b/adapters/ai/cursor.sh @@ -11,6 +11,7 @@ ai_can_start() { ai_start() { local path="$1" shift + local configured_args=("${GTR_AI_CMD_ARGS[@]}") if ! ai_can_start; then log_error "Cursor not found. Install from https://cursor.com" @@ -25,9 +26,9 @@ ai_start() { # Try cursor-agent first, then fallback to cursor CLI commands if command -v cursor-agent >/dev/null 2>&1; then - (cd "$path" && cursor-agent "$@") + (cd "$path" && cursor-agent "${configured_args[@]}" "$@") elif command -v cursor >/dev/null 2>&1; then # Try various Cursor CLI patterns (implementation varies by version) - (cd "$path" && cursor cli "$@") 2>/dev/null || (cd "$path" && cursor "$@") + (cd "$path" && cursor cli "${configured_args[@]}" "$@") 2>/dev/null || (cd "$path" && cursor "${configured_args[@]}" "$@") fi } diff --git a/adapters/editor/nano.sh b/adapters/editor/nano.sh index 41016d6..66436fe 100755 --- a/adapters/editor/nano.sh +++ b/adapters/editor/nano.sh @@ -10,12 +10,18 @@ editor_can_open() { # Usage: editor_open path editor_open() { local path="$1" + local configured_args=("${GTR_EDITOR_CMD_ARGS[@]}") if ! editor_can_open; then log_error "Nano not found. Usually pre-installed on Unix systems." return 1 fi + if [ "${#configured_args[@]}" -gt 0 ]; then + (cd "$path" && nano "${configured_args[@]}") + return $? + fi + # Open nano in the directory (just cd there, nano doesn't open directories) log_info "Opening shell in $path (nano doesn't support directory mode)" (cd "$path" && exec "$SHELL") diff --git a/bin/git-gtr b/bin/git-gtr index ef19932..9ec06f1 100755 --- a/bin/git-gtr +++ b/bin/git-gtr @@ -100,6 +100,9 @@ main() { init) cmd_init "$@" ;; + trust) + cmd_trust "$@" + ;; version|--version|-v) echo "git gtr version $GTR_VERSION" ;; diff --git a/completions/_git-gtr b/completions/_git-gtr index e0a5ba3..ddb340c 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -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' ) diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish index 7fdc786..f3b436c 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -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' diff --git a/completions/gtr.bash b/completions/gtr.bash index 13314cf..f9a80d6 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -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 diff --git a/lib/adapters.sh b/lib/adapters.sh index 91c3c08..a3e554a 100644 --- a/lib/adapters.sh +++ b/lib/adapters.sh @@ -150,7 +150,8 @@ EOF # Generic adapter functions (used when no explicit adapter file exists) # These will be overridden if an adapter file is sourced -# Globals set by load_editor_adapter: GTR_EDITOR_CMD, GTR_EDITOR_CMD_NAME +# Globals set by load_editor_adapter: +# GTR_EDITOR_CMD, GTR_EDITOR_CMD_NAME, GTR_EDITOR_CMD_ARGS editor_can_open() { command -v "$GTR_EDITOR_CMD_NAME" >/dev/null 2>&1 } @@ -166,12 +167,11 @@ 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\"" + _run_configured_command "$GTR_EDITOR_CMD_NAME" "${GTR_EDITOR_CMD_ARGS[@]}" "$target" } -# Globals set by load_ai_adapter: GTR_AI_CMD, GTR_AI_CMD_NAME +# Globals set by load_ai_adapter: +# GTR_AI_CMD, GTR_AI_CMD_NAME, GTR_AI_CMD_ARGS ai_can_start() { command -v "$GTR_AI_CMD_NAME" >/dev/null 2>&1 } @@ -179,9 +179,182 @@ 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 \"\$@\"") + (cd "$path" && _run_configured_command "$GTR_AI_CMD_NAME" "${GTR_AI_CMD_ARGS[@]}" "$@") +} + +# Assign an array to a caller-provided variable name. +# Bash 3.2 has no namerefs, so this uses a safely quoted eval assignment. +_set_array_var() { + local var_name="$1" + shift + + case "$var_name" in + [a-zA-Z_][a-zA-Z0-9_]*) ;; + *) return 1 ;; + esac + + local assignment="${var_name}=(" + local item + for item in "$@"; do + assignment="${assignment}$(printf '%q ' "$item")" + done + assignment="${assignment})" + + eval "$assignment" +} + +# Split a config-supplied command string without shell evaluation. +# Usage: _parse_configured_command +_parse_configured_command() { + local out_var="$1" + local command_string="$2" + local length="${#command_string}" + local i=0 char="" token="" state="normal" escaped=0 token_started=0 + local parsed_tokens=() + + while [ "$i" -lt "$length" ]; do + char="${command_string:$i:1}" + + case "$state" in + normal) + if [ "$escaped" -eq 1 ]; then + token="${token}${char}" + token_started=1 + escaped=0 + else + case "$char" in + "\\") + escaped=1 + token_started=1 + ;; + " " | $'\t' | $'\n') + if [ "$token_started" -eq 1 ]; then + parsed_tokens+=("$token") + token="" + token_started=0 + fi + ;; + "'") + state="single" + token_started=1 + ;; + '"') + state="double" + token_started=1 + ;; + *) + token="${token}${char}" + token_started=1 + ;; + esac + fi + ;; + single) + if [ "$char" = "'" ]; then + state="normal" + else + token="${token}${char}" + fi + ;; + double) + if [ "$escaped" -eq 1 ]; then + token="${token}${char}" + token_started=1 + escaped=0 + else + case "$char" in + "\\") + escaped=1 + token_started=1 + ;; + '"') + state="normal" + ;; + *) + token="${token}${char}" + token_started=1 + ;; + esac + fi + ;; + esac + + i=$((i + 1)) + done + + [ "$escaped" -eq 0 ] || return 1 + [ "$state" = "normal" ] || return 1 + + if [ "$token_started" -eq 1 ]; then + parsed_tokens+=("$token") + fi + + [ "${#parsed_tokens[@]}" -gt 0 ] || return 1 + _set_array_var "$out_var" "${parsed_tokens[@]}" +} + +_configured_command_uses_path_arg() { + local arg="$1" + case "$arg" in + /* | ./* | ../* | ~* | *\\*) + return 0 + ;; + esac + return 1 +} + +_configured_command_is_wrapper() { + local cmd_name="$1" + case "$cmd_name" in + sh | bash | zsh | dash | ksh | fish | env | eval | source | . | python | python3 | node | ruby | perl | php | lua | pwsh | powershell) + return 0 + ;; + esac + return 1 +} + +# Reject raw shell syntax before argv parsing. +_configured_command_has_safe_syntax() { + local command_string="$1" + + # Reject shell metacharacters in config-supplied command names to prevent injection. + # shellcheck disable=SC2016 # Literal '$(' pattern match is intentional + case "$command_string" in + *\;* | *\`* | *'$('* | *\|* | *\&* | *'>'* | *'<'*) + return 1 + ;; + esac +} + +_configured_command_is_safe() { + [ "$#" -gt 0 ] || return 1 + + local cmd_name="$1" + shift + + case "$cmd_name" in + */* | *\\*) + return 1 + ;; + esac + + if _configured_command_is_wrapper "$cmd_name" && [ "$#" -gt 0 ]; then + return 1 + fi + + local arg + for arg in "$@"; do + if _configured_command_uses_path_arg "$arg"; then + return 1 + fi + done +} + +# Run a config-supplied command argv that has already been parsed and validated. +# Usage: _run_configured_command +_run_configured_command() { + [ "$#" -gt 0 ] || return 1 + "$@" } # Standard AI adapter builder — used by adapter files that follow the common pattern @@ -295,14 +468,42 @@ resolve_workspace_file() { # Usage: _load_adapter