diff --git a/docs/configuration.md b/docs/configuration.md index 71cad9d..be29785 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,7 +28,7 @@ All configuration is stored via `git config`, making it easy to manage per-repository or globally. You can also use a `.gtrconfig` file for team-shared settings. -**Configuration precedence** (highest to lowest): +**Configuration precedence for single-value settings** (highest to lowest): 1. `git config --local` (`.git/config`) - personal overrides 2. `.gtrconfig` (repo root) - team defaults @@ -37,6 +37,30 @@ All configuration is stored via `git config`, making it easy to manage per-repos 5. Environment variables 6. Default values +Multi-valued copy settings merge values from local, `.gtrconfig`, global, and +system configuration, removing duplicates. For example, a local +`gtr.copy.includeDirs=vendor` and `.gtrconfig` `copy.includeDirs=node_modules` +can cause both directories to be copied; the local value does not replace the +team value. + +Git configuration is a trusted input: GTR runs hooks from local, global, and +system Git configuration without a separate `git gtr trust` approval. Anyone +who can modify that configuration can affect commands run by both GTR and Git +itself. Keep local, global, and system Git configuration files, their included +files, and any symlink targets writable only by people you trust to run commands +as your user. Protect their parent directories as well, so untrusted users +cannot replace files or links. The `.gtrconfig` approval gate does not isolate +users sharing writable Git configuration. + +`git gtr trust` approves executable `.gtrconfig` entries (hooks and editor/AI +defaults). Other settings do not require that approval. Most single-value +settings use the precedence above, and multi-valued copy settings merge across +scopes. UI color is an exception: `.gtrconfig` `ui.color` is not used for runtime +color, and a nonempty `NO_COLOR` overrides Git configuration +(see [UI Settings](#ui-settings)). +For example, `worktrees.dir` selects the worktree destination; honoring this +path does not approve `.gtrconfig` hooks. + For the CLI, a bare repository's root is the bare Git directory itself, including when a command is run from one of its linked worktrees. `.gtrconfig`, `.worktreeinclude`, relative worktree locations, and the default copy source use @@ -469,7 +493,7 @@ Control color output behavior. | Git Config Key | `.gtrconfig` Key | Description | Values | | -------------- | ---------------- | ----------------- | ----------------------------------- | -| `gtr.ui.color` | `ui.color` | Color output mode | `auto` (default), `always`, `never` | +| `gtr.ui.color` | `ui.color` (not used for runtime color) | Color output mode | `auto` (default), `always`, `never` | ```bash # Disable color output @@ -479,9 +503,15 @@ git gtr config set gtr.ui.color never git gtr config set gtr.ui.color always ``` -**Precedence**: `NO_COLOR` env (highest) > `GTR_COLOR` env > `gtr.ui.color` config > auto-detect (TTY). +Color is initialized from `GTR_COLOR`: `always` enables it, `never` disables it, +and `auto` uses TTY detection. Then `gtr.ui.color` is read using Git's native +configuration precedence. A value of `always` or `never` overrides the initial +mode; `auto` leaves the initial mode unchanged. The `.gtrconfig` value is not +used for runtime color, but `git gtr config get gtr.ui.color` and +`git gtr config list` can still display it. -The `NO_COLOR` environment variable ([no-color.org](https://no-color.org)) always wins regardless of other settings. +A nonempty `NO_COLOR` environment variable ([no-color.org](https://no-color.org)) +disables color regardless of either source. --- diff --git a/tests/config_trust_policy.bats b/tests/config_trust_policy.bats new file mode 100644 index 0000000..1a1591b --- /dev/null +++ b/tests/config_trust_policy.bats @@ -0,0 +1,184 @@ +#!/usr/bin/env bats +# Git configuration is user-controlled; .gtrconfig commands require approval. +# Fixture commands expand variables only when invoked by the child shell. +# BATS owns the status/output variables across its test functions. +# shellcheck disable=SC2016,SC2030,SC2031 +load test_helper + +# Create an isolated repository and configuration/cache paths for each test. +setup() { + POLICY_FIXTURE=$(cd -P "$(mktemp -d)" && pwd) + export XDG_CONFIG_HOME="$POLICY_FIXTURE/config" + export XDG_CACHE_HOME="$POLICY_FIXTURE/cache" + export XDG_DATA_HOME="$POLICY_FIXTURE/data" + export GIT_CONFIG_GLOBAL="$POLICY_FIXTURE/global.gitconfig" + export GIT_CONFIG_NOSYSTEM=1 + export POLICY_EVENTS="$POLICY_FIXTURE/events" + : > "$GIT_CONFIG_GLOBAL" + setup_integration_repo + source_gtr_commands +} + +# Remove registered worktrees before deleting the test's configuration fixtures. +teardown() { + teardown_integration_repo + rm -rf "$POLICY_FIXTURE" +} + +# Configure marker hooks for a phase in Git config and unapproved .gtrconfig. +# Usage: configure_policy_hooks +# Writes globals by explicit filename so setup cannot modify user config. +configure_policy_hooks() { + local phase="$1" + git config -f "$GIT_CONFIG_GLOBAL" --add "gtr.hook.$phase" \ + "printf '%s\\n' 'global.$phase' >> \"\$POLICY_EVENTS\"" + git config --local --add "gtr.hook.$phase" \ + "printf '%s\\n' 'local.$phase' >> \"\$POLICY_EVENTS\"" + git config -f "$TEST_REPO/.gtrconfig" --add "hooks.$phase" \ + "printf '%s\\n' 'untrusted.$phase' >> \"\$POLICY_EVENTS\"" +} + +# Verify destination precedence and hook trust across real CLI create/remove. +# Usage: check_policy_lifecycle +# Asserts that Git hooks run, file hooks stay blocked, and no approval is stored. +check_policy_lifecycle() { + local scope="$1" destination="$POLICY_FIXTURE/worktrees with spaces" + local phase result + for phase in postCreate preRemove postRemove; do + configure_policy_hooks "$phase" + done + git config -f "$GIT_CONFIG_GLOBAL" gtr.worktrees.dir "$destination" + if [ "$scope" = local ]; then + # Local configuration must override both team and global destinations. + git config -f "$GIT_CONFIG_GLOBAL" gtr.worktrees.dir "$POLICY_FIXTURE/global-unused" + git config -f "$TEST_REPO/.gtrconfig" worktrees.dir "$POLICY_FIXTURE/team-unused" + git config --local gtr.worktrees.dir "$destination" + fi + + # Use the actual CLI (set -e), with all network operations disabled. + result=$("$PROJECT_ROOT/bin/gtr" new policy-feature --from HEAD --no-fetch --no-copy \ + --porcelain 2> "$POLICY_FIXTURE/create.stderr") + [ "$result" = $'path\t'"$destination/policy-feature"$'\nbranch\tpolicy-feature\nhook_status\tpartial' ] + [ -d "$destination/policy-feature" ] + [ ! -e "$POLICY_FIXTURE/global-unused" ] + [ ! -e "$POLICY_FIXTURE/team-unused" ] + [ "$(cat "$POLICY_EVENTS")" = $'global.postCreate\nlocal.postCreate' ] + + run "$PROJECT_ROOT/bin/gtr" rm policy-feature --yes + [ "$status" -eq 0 ] + [ ! -e "$destination/policy-feature" ] + [ "$(cat "$POLICY_EVENTS")" = $'global.postCreate\nlocal.postCreate\nglobal.preRemove\nlocal.preRemove\nglobal.postRemove\nlocal.postRemove' ] + [ ! -d "$_GTR_TRUST_DIR" ] +} + +@test "local worktrees.dir and Git lifecycle hooks work while untrusted file hooks stay blocked" { + check_policy_lifecycle local +} + +@test "global worktrees.dir and Git lifecycle hooks work while untrusted file hooks stay blocked" { + check_policy_lifecycle global +} + +@test "untrusted worktrees.dir selects a literal destination without approving file hooks" { + local destination="$POLICY_FIXTURE/"'$(touch should-not-run)' + git config -f "$TEST_REPO/.gtrconfig" worktrees.dir "$destination" + git config -f "$TEST_REPO/.gtrconfig" hooks.postCreate 'printf untrusted > "$POLICY_EVENTS"' + + run "$PROJECT_ROOT/bin/gtr" new literal-path --from HEAD --no-fetch --no-copy --porcelain + [ "$status" -eq 0 ] + [[ "$output" == *$'hook_status\tskipped-untrusted'* ]] + [ -d "$destination/literal-path" ] + [ ! -e "$TEST_REPO/should-not-run" ] + [ ! -e "$POLICY_EVENTS" ] + [ ! -d "$_GTR_TRUST_DIR" ] +} + +@test "postCd export dispatcher runs Git hooks while untrusted file hooks stay blocked" { + configure_policy_hooks postCd + git config --local --add gtr.hook.postCd 'export POLICY_LOCAL_EXPORT=local' + git config -f "$TEST_REPO/.gtrconfig" --add hooks.postCd 'export POLICY_LOCAL_EXPORT=untrusted' + + # Run with strict mode in a child shell, as used by the AI launch path. + run bash -e -c ' + source "$GTR_DIR/lib/config.sh" + source "$GTR_DIR/lib/hooks.sh" + run_hooks_export postCd + [ "$POLICY_LOCAL_EXPORT" = local ] + ' + [ "$status" -eq 0 ] + [ "$(cat "$POLICY_EVENTS")" = $'global.postCd\nlocal.postCd' ] + [ ! -d "$_GTR_TRUST_DIR" ] +} + +# Verify a generated shell helper preserves only trusted hooks and exports. +# Usage: check_generated_policy_hooks +# Skips unavailable shells; assertion failures propagate to the BATS test. +check_generated_policy_hooks() { + local shell_name="$1" + command -v "$shell_name" >/dev/null 2>&1 || skip "$shell_name is not installed" + configure_policy_hooks postCd + local trusted_export_hook untrusted_export_hook + case "$shell_name" in + bash|zsh) + trusted_export_hook='export POLICY_TRUSTED_EXPORT=trusted' + untrusted_export_hook='export POLICY_UNTRUSTED_EXPORT=untrusted' + ;; + fish) + trusted_export_hook='set -gx POLICY_TRUSTED_EXPORT trusted' + untrusted_export_hook='set -gx POLICY_UNTRUSTED_EXPORT untrusted' + ;; + esac + git config --local --add gtr.hook.postCd "$trusted_export_hook" + git config -f "$TEST_REPO/.gtrconfig" --add hooks.postCd "$untrusted_export_hook" + export POLICY_INIT="$POLICY_FIXTURE/init.$shell_name" + export POLICY_WORKTREE="$POLICY_FIXTURE/linked worktree" + git worktree add --quiet "$POLICY_WORKTREE" -b policy-cd HEAD + "$PROJECT_ROOT/bin/gtr" init "$shell_name" > "$POLICY_INIT" + + unset POLICY_TRUSTED_EXPORT POLICY_UNTRUSTED_EXPORT + local shell_args=() + if [ "$shell_name" = zsh ]; then + shell_args=(-f) + fi + case "$shell_name" in + bash|zsh) + run "$shell_name" "${shell_args[@]}" -c ' + set -e + # Completion registration is outside this non-interactive hook fixture. + compdef() { :; } + source "$POLICY_INIT" + gtr_run_post_cd_hooks "$POLICY_WORKTREE" + [ "$POLICY_TRUSTED_EXPORT" = trusted ] + [ -z "${POLICY_UNTRUSTED_EXPORT+x}" ] + ' + ;; + fish) + run fish -c ' + source "$POLICY_INIT"; or exit 1 + gtr_run_post_cd_hooks "$POLICY_WORKTREE"; or exit 1 + test "$POLICY_TRUSTED_EXPORT" = trusted; or exit 1 + not set -q POLICY_UNTRUSTED_EXPORT + ' + ;; + esac + [ "$status" -eq 0 ] + [[ "$output" == *"Untrusted .gtrconfig hooks skipped"* ]] + [ "$(cat "$POLICY_EVENTS")" = $'global.postCd\nlocal.postCd' ] + [ ! -d "$_GTR_TRUST_DIR" ] +} + +@test "bash generated postCd runs Git hooks while untrusted file hooks stay blocked" { + check_generated_policy_hooks bash +} + +@test "zsh generated postCd runs Git hooks while untrusted file hooks stay blocked" { + # The helper shell must skip user startup files that could replace fixture config. + export ZDOTDIR="$POLICY_FIXTURE/zsh-startup" + mkdir -p "$ZDOTDIR" + printf 'exit 99\n' > "$ZDOTDIR/.zshenv" + check_generated_policy_hooks zsh +} + +@test "fish generated postCd runs Git hooks while untrusted file hooks stay blocked" { + check_generated_policy_hooks fish +}