From e2d08b5b5084ca516e0ed6db54707a962dc27be6 Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Tue, 27 Oct 2026 10:29:55 -0700 Subject: [PATCH 1/3] fix: bind command trust to repository identity (VULN-566) --- .github/workflows/lint.yml | 4 +-- README.md | 4 ++- docs/configuration.md | 2 ++ lib/commands/help.sh | 10 +++++-- lib/commands/init.sh | 36 +++++++++++++++++++++--- lib/commands/trust.sh | 5 ++-- lib/hooks.sh | 27 ++++++++++++++---- tests/cmd_trust.bats | 31 +++++++++++++++++++-- tests/hooks.bats | 57 ++++++++++++++++++++++++++++++++++++++ tests/init.bats | 55 +++++++++++++++++++++++++++++++++--- 10 files changed, 209 insertions(+), 22 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 20c3c79..8d377d0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -44,8 +44,8 @@ jobs: with: persist-credentials: false - - name: Install BATS - run: sudo apt-get update && sudo apt-get install -y bats + - name: Install BATS and shells for integration tests + run: sudo apt-get update && sudo apt-get install -y bats fish zsh - name: Run tests run: bats tests/ diff --git a/README.md b/README.md index c7b88f8..fa3defa 100644 --- a/README.md +++ b/README.md @@ -410,7 +410,9 @@ Review and approve executable commands defined in the repository's `.gtrconfig` git gtr trust # Review and approve .gtrconfig commands ``` -Trust is stored per repository path plus executable command definitions and must be re-approved if hooks or editor/AI defaults change. 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 your local git config (`.git/config`, `~/.gitconfig`) are always trusted. + +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. ### Other Commands diff --git a/docs/configuration.md b/docs/configuration.md index 4f6211f..90c243c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,6 +71,8 @@ Create a `.gtrconfig` file in your repository root to share configuration across > [!IMPORTANT] > Hooks and editor/AI defaults from `.gtrconfig` are executable command entries. They are ignored until you review them with `git gtr trust`; changing those entries requires re-approval. +Approval is bound to the repository path and the filesystem identity of its common Git directory. A replacement or fresh clone at the same path needs new approval, as do approvals created by older versions that only used the path. Normal commits and linked worktrees retain approval. This is command-definition trust: scripts, dependencies, and tools invoked by approved commands are not hashed or pinned, so their contents can change within the trusted repository. Review delegated code and trust its contributors before approving. + --- ## Worktree Settings diff --git a/lib/commands/help.sh b/lib/commands/help.sh index ed0bab1..47ec6f7 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -474,8 +474,14 @@ used until explicitly trusted. This prevents malicious contributors from injecting arbitrary shell commands via shared .gtrconfig files. Trust is stored per repository -path plus executable command definitions in ~/.config/gtr/trusted/ and -must be re-approved if those commands change. +path, common Git directory filesystem identity, and executable command +definitions in ~/.config/gtr/trusted/. Replacing a repository at the same +path or changing those commands requires new approval. Older path-only +approvals require renewal. + +Approval includes scripts and programs invoked by these commands. Their +contents are not hashed or pinned; review delegated code and trust its +contributors before approving. Hooks and defaults from your local git config (.git/config, ~/.gitconfig) are always trusted since you control those files directly. diff --git a/lib/commands/init.sh b/lib/commands/init.sh index e526744..2c9c450 100644 --- a/lib/commands/init.sh +++ b/lib/commands/init.sh @@ -68,7 +68,7 @@ cmd_init() { # Generate output (cached to ~/.cache/gtr/, auto-invalidates on shell integration changes) local cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/gtr" local cache_file="$cache_dir/init-${func_name}.${shell}" - local cache_schema="${GTR_INIT_CACHE_VERSION:-6}" + local cache_schema="${GTR_INIT_CACHE_VERSION:-7}" local cache_stamp="# gtr-cache: version=${GTR_VERSION:-unknown} init=${cache_schema} func=$func_name shell=$shell" # Return cached output if version matches @@ -131,13 +131,26 @@ __SHELL_PRELUDE__ printf '%s/%s\n' "$_gtr_repo_root" "$(basename "$_gtr_config_file")" } +__FUNC___hooks_repo_identity() { +__SHELL_PRELUDE__ + local _gtr_repo_root="$1" + local _gtr_common_dir _gtr_identity + _gtr_common_dir="$(git -C "$_gtr_repo_root" rev-parse --git-common-dir 2>/dev/null)" || return 1 + _gtr_common_dir="$(cd "$_gtr_repo_root" && cd "$_gtr_common_dir" && pwd -P)" || return 1 + _gtr_identity="$(LC_ALL=C stat -c '%d:%i:%W' "$_gtr_common_dir" 2>/dev/null)" || + _gtr_identity="$(LC_ALL=C stat -f '%d:%i:%B' "$_gtr_common_dir" 2>/dev/null)" || return 1 + [ -n "$_gtr_identity" ] || return 1 + printf '%s\n%s\n' "$_gtr_common_dir" "$_gtr_identity" +} + __FUNC___hooks_current_trust_key() { __SHELL_PRELUDE__ local _gtr_config_file="$1" - local _gtr_hook_hash _gtr_repo_root + local _gtr_hook_hash _gtr_repo_root _gtr_repo_identity _gtr_hook_hash="$(__FUNC___hooks_current_content_hash "$_gtr_config_file" 2>/dev/null)" || return 1 _gtr_repo_root="$(__FUNC___hooks_repo_root "$_gtr_config_file" 2>/dev/null)" || return 1 - printf '%s\n%s\n' "$_gtr_repo_root" "$_gtr_hook_hash" | shasum -a 256 | cut -d' ' -f1 + _gtr_repo_identity="$(__FUNC___hooks_repo_identity "$_gtr_repo_root" 2>/dev/null)" || return 1 + printf 'v2\n%s\n%s\n%s\n' "$_gtr_repo_root" "$_gtr_repo_identity" "$_gtr_hook_hash" | shasum -a 256 | cut -d' ' -f1 } __FUNC___hooks_marker_matches_config() { @@ -200,13 +213,28 @@ function __FUNC___hooks_canonical_config_path printf '%s/%s\n' "$_gtr_repo_root" (basename "$_gtr_config_file") end +function __FUNC___hooks_repo_identity + # Keep the identity algorithm identical to the CLI and POSIX wrappers. + command sh -c ' + common_dir=$(git -C "$1" rev-parse --git-common-dir 2>/dev/null) || exit 1 + common_dir=$(cd "$1" && cd "$common_dir" && pwd -P) || exit 1 + identity=$(LC_ALL=C stat -c "%d:%i:%W" "$common_dir" 2>/dev/null) || + identity=$(LC_ALL=C stat -f "%d:%i:%B" "$common_dir" 2>/dev/null) || exit 1 + [ -n "$identity" ] || exit 1 + printf "%s\\n%s\\n" "$common_dir" "$identity" + ' sh "$argv[1]" +end + function __FUNC___hooks_current_trust_key set -l _gtr_config_file "$argv[1]" set -l _gtr_hook_hash (__FUNC___hooks_current_content_hash "$_gtr_config_file" 2>/dev/null) test -n "$_gtr_hook_hash"; or return 1 set -l _gtr_repo_root (__FUNC___hooks_repo_root "$_gtr_config_file" 2>/dev/null) test -n "$_gtr_repo_root"; or return 1 - printf '%s\n%s\n' "$_gtr_repo_root" "$_gtr_hook_hash" | shasum -a 256 | cut -d' ' -f1 + set -l _gtr_repo_identity (__FUNC___hooks_repo_identity "$_gtr_repo_root" 2>/dev/null | string collect) + test $pipestatus[1] -eq 0; or return 1 + test -n "$_gtr_repo_identity"; or return 1 + printf 'v2\n%s\n%s\n%s\n' "$_gtr_repo_root" "$_gtr_repo_identity" "$_gtr_hook_hash" | shasum -a 256 | cut -d' ' -f1 end function __FUNC___hooks_marker_matches_config diff --git a/lib/commands/trust.sh b/lib/commands/trust.sh index 01294c7..db435d9 100644 --- a/lib/commands/trust.sh +++ b/lib/commands/trust.sh @@ -37,13 +37,14 @@ cmd_trust() { printf '%s\n' "$hook_content" >&2 echo "" >&2 log_warn "These commands will execute on your machine during gtr operations." + log_warn "Approval includes scripts and programs they invoke; their contents are not pinned." if prompt_yes_no "Trust these commands?"; then if _hooks_write_trust_marker "$trust_path" "$config_file"; then local current_trust_path current_trust_path=$(_hooks_current_trust_path "$config_file") || true - if [ -n "$current_trust_path" ] && [ "$current_trust_path" != "$trust_path" ]; then - log_warn "Executable commands changed during review; current commands remain untrusted" + if [ "$current_trust_path" != "$trust_path" ]; then + log_warn "Executable commands or repository identity changed during review; current commands remain untrusted" return 1 fi log_info "Executable commands marked as trusted" diff --git a/lib/hooks.sh b/lib/hooks.sh index 4622a1c..c4f0b89 100644 --- a/lib/hooks.sh +++ b/lib/hooks.sh @@ -8,9 +8,10 @@ # files. # # Trust state is stored in ~/.config/gtr/trusted/ -# where is a SHA-256 of the canonical repo root plus trusted content hash. -# Trust is scoped to repo identity + trusted .gtrconfig definitions, not repo -# snapshot state. +# where binds the canonical repo root, Git directory identity and trusted +# content hash. Replacing a repository at the same path requires new approval. +# Trust approves command definitions, including any code they delegate to; it +# does not pin script contents, dependencies or repository snapshot state. _GTR_TRUST_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/gtr/trusted" @@ -56,6 +57,21 @@ _hooks_repo_root() { ) } +# Identify this repository instance, rather than just its reusable path. +# Use the common Git directory so normal commits and linked worktrees do not +# invalidate approval. Birth time supplements device/inode where available. +# Do not use a repository-controlled config value or file as an identity token. +_hooks_repo_identity() { + local repo_root="$1" + local common_dir identity + common_dir=$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null) || return 1 + common_dir=$(cd "$repo_root" && cd "$common_dir" && pwd -P) || return 1 + identity=$(LC_ALL=C stat -c '%d:%i:%W' "$common_dir" 2>/dev/null) || + identity=$(LC_ALL=C stat -f '%d:%i:%B' "$common_dir" 2>/dev/null) || return 1 + [ -n "$identity" ] || return 1 + printf '%s\n%s\n' "$common_dir" "$identity" +} + # Resolve the canonical path for a .gtrconfig file # Usage: _hooks_canonical_config_path _hooks_canonical_config_path() { @@ -70,10 +86,11 @@ _hooks_canonical_config_path() { _hooks_reviewed_trust_key() { local config_file="$1" local hook_content="$2" - local hash repo_root + local hash repo_root repo_identity hash=$(_hooks_content_hash "$hook_content") || return 1 repo_root=$(_hooks_repo_root "$config_file") || return 1 - printf '%s\n%s\n' "$repo_root" "$hash" | shasum -a 256 | cut -d' ' -f1 + repo_identity=$(_hooks_repo_identity "$repo_root") || return 1 + printf 'v2\n%s\n%s\n%s\n' "$repo_root" "$repo_identity" "$hash" | shasum -a 256 | cut -d' ' -f1 } # Compute the repo-scoped trust key for the current trusted command content diff --git a/tests/cmd_trust.bats b/tests/cmd_trust.bats index a7c40ea..1ef76fc 100644 --- a/tests/cmd_trust.bats +++ b/tests/cmd_trust.bats @@ -77,7 +77,7 @@ EOF [ "$rc" -eq 1 ] [ -f "$reviewed_trust_path" ] ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" - grep -F "Executable commands changed during review; current commands remain untrusted" "$output_file" + grep -F "Executable commands or repository identity changed during review; current commands remain untrusted" "$output_file" } @test "cmd_trust writes the reviewed marker when hooks change during confirmation" { @@ -99,9 +99,36 @@ EOF run cmd_trust [ "$status" -eq 1 ] - [[ "$output" == *"Executable commands changed during review; current commands remain untrusted"* ]] + [[ "$output" == *"Executable commands or repository identity changed during review; current commands remain untrusted"* ]] [ -f "$reviewed_marker" ] run _hooks_are_trusted "$TEST_REPO/.gtrconfig" [ "$status" -eq 1 ] } + +@test "cmd_trust rejects repository replacement during confirmation with identical definitions" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'sh ./bootstrap.sh' + printf 'echo original\n' > "$TEST_REPO/bootstrap.sh" + prompt_yes_no() { + mv "$TEST_REPO/.git" "$TEST_REPO/original-git" + git -C "$TEST_REPO" init --quiet + printf 'echo replacement\n' > "$TEST_REPO/bootstrap.sh" + return 0 + } + run cmd_trust + [ "$status" -eq 1 ] + [[ "$output" == *"repository identity changed during review"* ]] + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" +} + +@test "cmd_trust fails closed when repository identity disappears during confirmation" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'echo harmless' + prompt_yes_no() { + mv "$TEST_REPO/.git" "$TEST_REPO/original-git" + return 0 + } + run cmd_trust + [ "$status" -eq 1 ] + [[ "$output" == *"repository identity changed during review"* ]] + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" +} diff --git a/tests/hooks.bats b/tests/hooks.bats index a94543e..72e880b 100644 --- a/tests/hooks.bats +++ b/tests/hooks.bats @@ -138,6 +138,7 @@ EOF @test "repo-specific trust markers differ for the same hook content" { local second_repo="$BATS_TMPDIR/second-repo" mkdir -p "$second_repo" + git -C "$second_repo" init --quiet cat > "$TEST_REPO/.gtrconfig" <<'EOF' [hooks] @@ -156,6 +157,62 @@ EOF [ "$first_path" != "$second_path" ] } +@test "replacement repository cannot reuse trust for identical definitions and changed delegated code" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCreate 'sh ./bootstrap.sh' + printf 'echo original > hook-result\n' > "$TEST_REPO/bootstrap.sh" + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + local original_hash original_path + original_hash=$(_hooks_file_hash "$TEST_REPO/.gtrconfig") + original_path=$(_hooks_trust_path "$TEST_REPO/.gtrconfig") + run_hooks_in postCreate "$TEST_REPO" + [ "$(cat "$TEST_REPO/hook-result")" = original ] + + # Keep the old Git directory alive so this fixture cannot reuse its inode. + mv "$TEST_REPO/.git" "$TEST_REPO/original-git" + git -C "$TEST_REPO" init --quiet + printf 'echo replacement > hook-result\n' > "$TEST_REPO/bootstrap.sh" + rm "$TEST_REPO/hook-result" + [ "$(_hooks_file_hash "$TEST_REPO/.gtrconfig")" = "$original_hash" ] + run_hooks_in postCreate "$TEST_REPO" + [ ! -f "$TEST_REPO/hook-result" ] + [ "$(_hooks_trust_path "$TEST_REPO/.gtrconfig")" != "$original_path" ] + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" +} + +@test "legacy path-only approval cannot approve a repository instance" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'echo harmless' + local legacy_key + legacy_key=$(printf '%s\n%s\n' "$TEST_REPO" "$(_hooks_file_hash "$TEST_REPO/.gtrconfig")" | + shasum -a 256 | cut -d' ' -f1) + mkdir -p "$_GTR_TRUST_DIR" + printf '%s\n' "$TEST_REPO/.gtrconfig" > "$_GTR_TRUST_DIR/$legacy_key" + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" +} + +@test "definition trust survives delegated script changes in the same repository" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCreate 'sh ./bootstrap.sh' + printf 'echo original\n' > "$TEST_REPO/bootstrap.sh" + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + printf 'echo updated\n' > "$TEST_REPO/bootstrap.sh" + _hooks_are_trusted "$TEST_REPO/.gtrconfig" +} + +@test "repository identity stays stable across commits and linked worktrees" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCreate 'echo harmless' + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + git -C "$TEST_REPO" commit --allow-empty -m second --quiet + create_test_worktree identity-test + (cd "$TEST_WT_PATH" && _hooks_are_trusted "$TEST_REPO/.gtrconfig") +} + +@test "trust fails closed when repository identity cannot be resolved" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'echo harmless' + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + _hooks_repo_identity() { return 1; } + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" + ! _hooks_mark_trusted "$TEST_REPO/.gtrconfig" +} + @test "run_hooks executes single hook" { git config --add gtr.hook.postCreate 'touch "$REPO_ROOT/hook-ran"' run_hooks postCreate REPO_ROOT="$TEST_REPO" diff --git a/tests/init.bats b/tests/init.bats index 2d759fa..d66e65b 100644 --- a/tests/init.bats +++ b/tests/init.bats @@ -181,6 +181,53 @@ require_runtime_shell() { command -v "$shell_name" >/dev/null 2>&1 || skip "$shell_name is not installed" } +# Use real generated hooks and a harmless delegated script. Keep the definitions +# and canonical path identical when replacing the Git directory. +run_generated_replacement_repo_hooks() ( + set -e + local shell_name="$1" fixture + fixture=$(mktemp -d) + trap 'rm -rf "$fixture"' EXIT + export REPO_FIXTURE="$fixture/repo with spaces" + export INIT_FIXTURE="$fixture/init.$shell_name" + export XDG_CONFIG_HOME="$fixture/config" + mkdir -p "$REPO_FIXTURE" + git -C "$REPO_FIXTURE" init --quiet + git config -f "$REPO_FIXTURE/.gtrconfig" hooks.postCd 'sh "$REPO_ROOT/bootstrap.sh"' + printf 'echo original > "$REPO_ROOT/hook-result"\n' > "$REPO_FIXTURE/bootstrap.sh" + source "$PROJECT_ROOT/lib/hooks.sh" + _hooks_mark_trusted "$REPO_FIXTURE/.gtrconfig" + cmd_init "$shell_name" > "$INIT_FIXTURE" + "$shell_name" -c 'source "$INIT_FIXTURE"; gtr_run_post_cd_hooks "$REPO_FIXTURE"' + [ "$(cat "$REPO_FIXTURE/hook-result")" = original ] + mv "$REPO_FIXTURE/.git" "$fixture/original-git" + git -C "$REPO_FIXTURE" init --quiet + printf 'echo replacement > "$REPO_ROOT/hook-result"\n' > "$REPO_FIXTURE/bootstrap.sh" + rm "$REPO_FIXTURE/hook-result" + "$shell_name" -c 'source "$INIT_FIXTURE"; gtr_run_post_cd_hooks "$REPO_FIXTURE"' + [ ! -f "$REPO_FIXTURE/hook-result" ] +) + +@test "bash generated hooks reject replacement repo with identical definitions" { + run run_generated_replacement_repo_hooks bash + [ "$status" -eq 0 ] + [[ "$output" == *"Untrusted .gtrconfig hooks skipped"* ]] +} + +@test "zsh generated hooks reject replacement repo with identical definitions" { + require_runtime_shell zsh + run run_generated_replacement_repo_hooks zsh + [ "$status" -eq 0 ] + [[ "$output" == *"Untrusted .gtrconfig hooks skipped"* ]] +} + +@test "fish generated hooks reject replacement repo with identical definitions" { + require_runtime_shell fish + run run_generated_replacement_repo_hooks fish + [ "$status" -eq 0 ] + [[ "$output" == *"Untrusted .gtrconfig hooks skipped"* ]] +} + # ── Default function name ──────────────────────────────────────────────────── @test "bash output defines gtr() function by default" { @@ -952,7 +999,7 @@ require_runtime_shell() { @test "cache invalidates old default shell integration schema" { mkdir -p "$XDG_CACHE_HOME/gtr" - printf '%s\n%s\n' '# gtr-cache: version=test init=5 func=gtr shell=bash' old-cache > "$XDG_CACHE_HOME/gtr/init-gtr.bash" + printf '%s\n%s\n' '# gtr-cache: version=test init=6 func=gtr shell=bash' old-cache > "$XDG_CACHE_HOME/gtr/init-gtr.bash" run cmd_init bash @@ -960,19 +1007,19 @@ require_runtime_shell() { [ "$output" != "old-cache" ] local stamp stamp="$(head -1 "$XDG_CACHE_HOME/gtr/init-gtr.bash")" - [[ "$stamp" == *"init=6"* ]] + [[ "$stamp" == *"init=7"* ]] } @test "documented bash setup regenerates old schema cache" { mkdir -p "$XDG_CACHE_HOME/gtr" local _gtr_init="$XDG_CACHE_HOME/gtr/init-gtr.bash" - printf '%s\n%s\n' '# gtr-cache: version=test init=5 func=gtr shell=bash' old-cache > "$_gtr_init" + printf '%s\n%s\n' '# gtr-cache: version=test init=6 func=gtr shell=bash' old-cache > "$_gtr_init" cmd_init bash >/dev/null local stamp stamp="$(head -1 "$_gtr_init")" - [[ "$stamp" == *"init=6"* ]] + [[ "$stamp" == *"init=7"* ]] } @test "cache uses --as func name in cache key" { From 0532397a4df86acc1adf09935e9131a1346322a9 Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Tue, 27 Oct 2026 10:43:53 -0700 Subject: [PATCH 2/3] fix: reject unavailable birth times and preserve nanoseconds --- README.md | 2 ++ docs/configuration.md | 2 ++ lib/commands/help.sh | 3 +++ lib/commands/init.sh | 16 ++++++++++------ lib/hooks.sh | 11 +++++++---- tests/hooks.bats | 26 ++++++++++++++++++++++++++ tests/init.bats | 7 +++++++ 7 files changed, 57 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fa3defa..882cbd8 100644 --- a/README.md +++ b/README.md @@ -414,6 +414,8 @@ Trust is bound to the repository path, the filesystem identity of its common Git 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. +Trust requires a known filesystem birth time for the Git directory. If `stat` cannot provide it, `.gtrconfig` executable commands remain untrusted. + ### Other Commands - `git gtr doctor` - Health check (verify git, editors, AI tools) diff --git a/docs/configuration.md b/docs/configuration.md index 90c243c..e3d48f7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,6 +73,8 @@ Create a `.gtrconfig` file in your repository root to share configuration across Approval is bound to the repository path and the filesystem identity of its common Git directory. A replacement or fresh clone at the same path needs new approval, as do approvals created by older versions that only used the path. Normal commits and linked worktrees retain approval. This is command-definition trust: scripts, dependencies, and tools invoked by approved commands are not hashed or pinned, so their contents can change within the trusted repository. Review delegated code and trust its contributors before approving. +The identity includes device, inode, and birth time with nanosecond precision. If the filesystem or `stat` implementation cannot provide a known birth time, approval fails closed and `.gtrconfig` executable commands remain disabled. + --- ## Worktree Settings diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 47ec6f7..bc355c6 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -483,6 +483,9 @@ Approval includes scripts and programs invoked by these commands. Their contents are not hashed or pinned; review delegated code and trust its contributors before approving. +Trust requires a known filesystem birth time for the Git directory. +Without it, .gtrconfig executable commands remain untrusted. + Hooks and defaults from your local git config (.git/config, ~/.gitconfig) are always trusted since you control those files directly. diff --git a/lib/commands/init.sh b/lib/commands/init.sh index 2c9c450..26d3096 100644 --- a/lib/commands/init.sh +++ b/lib/commands/init.sh @@ -137,9 +137,11 @@ __SHELL_PRELUDE__ local _gtr_common_dir _gtr_identity _gtr_common_dir="$(git -C "$_gtr_repo_root" rev-parse --git-common-dir 2>/dev/null)" || return 1 _gtr_common_dir="$(cd "$_gtr_repo_root" && cd "$_gtr_common_dir" && pwd -P)" || return 1 - _gtr_identity="$(LC_ALL=C stat -c '%d:%i:%W' "$_gtr_common_dir" 2>/dev/null)" || - _gtr_identity="$(LC_ALL=C stat -f '%d:%i:%B' "$_gtr_common_dir" 2>/dev/null)" || return 1 - [ -n "$_gtr_identity" ] || return 1 + _gtr_identity="$(LC_ALL=C stat -c '%d:%i:%.9W' "$_gtr_common_dir" 2>/dev/null)" || + _gtr_identity="$(LC_ALL=C stat -f '%d:%i:%.9FB' "$_gtr_common_dir" 2>/dev/null)" || return 1 + case "${_gtr_identity##*:}" in + ''|0|0.*|*[!0-9.]*) return 1 ;; + esac printf '%s\n%s\n' "$_gtr_common_dir" "$_gtr_identity" } @@ -218,9 +220,11 @@ function __FUNC___hooks_repo_identity command sh -c ' common_dir=$(git -C "$1" rev-parse --git-common-dir 2>/dev/null) || exit 1 common_dir=$(cd "$1" && cd "$common_dir" && pwd -P) || exit 1 - identity=$(LC_ALL=C stat -c "%d:%i:%W" "$common_dir" 2>/dev/null) || - identity=$(LC_ALL=C stat -f "%d:%i:%B" "$common_dir" 2>/dev/null) || exit 1 - [ -n "$identity" ] || exit 1 + identity=$(LC_ALL=C stat -c "%d:%i:%.9W" "$common_dir" 2>/dev/null) || + identity=$(LC_ALL=C stat -f "%d:%i:%.9FB" "$common_dir" 2>/dev/null) || exit 1 + case "${identity##*:}" in + ""|0|0.*|*[!0-9.]*) exit 1 ;; + esac printf "%s\\n%s\\n" "$common_dir" "$identity" ' sh "$argv[1]" end diff --git a/lib/hooks.sh b/lib/hooks.sh index c4f0b89..d0dea28 100644 --- a/lib/hooks.sh +++ b/lib/hooks.sh @@ -59,16 +59,19 @@ _hooks_repo_root() { # Identify this repository instance, rather than just its reusable path. # Use the common Git directory so normal commits and linked worktrees do not -# invalidate approval. Birth time supplements device/inode where available. +# invalidate approval. Require a known birth time and preserve nanoseconds to +# distinguish rapid inode reuse. Filesystems without birth times fail closed. # Do not use a repository-controlled config value or file as an identity token. _hooks_repo_identity() { local repo_root="$1" local common_dir identity common_dir=$(git -C "$repo_root" rev-parse --git-common-dir 2>/dev/null) || return 1 common_dir=$(cd "$repo_root" && cd "$common_dir" && pwd -P) || return 1 - identity=$(LC_ALL=C stat -c '%d:%i:%W' "$common_dir" 2>/dev/null) || - identity=$(LC_ALL=C stat -f '%d:%i:%B' "$common_dir" 2>/dev/null) || return 1 - [ -n "$identity" ] || return 1 + identity=$(LC_ALL=C stat -c '%d:%i:%.9W' "$common_dir" 2>/dev/null) || + identity=$(LC_ALL=C stat -f '%d:%i:%.9FB' "$common_dir" 2>/dev/null) || return 1 + case "${identity##*:}" in + ''|0|0.*|*[!0-9.]*) return 1 ;; + esac printf '%s\n%s\n' "$common_dir" "$identity" } diff --git a/tests/hooks.bats b/tests/hooks.bats index 72e880b..133938a 100644 --- a/tests/hooks.bats +++ b/tests/hooks.bats @@ -189,6 +189,32 @@ EOF ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" } +@test "deleting and recreating the Git directory cannot reuse approval" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'sh ./bootstrap.sh' + printf 'echo original\n' > "$TEST_REPO/bootstrap.sh" + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + local original_identity iteration=0 + original_identity=$(_hooks_repo_identity "$TEST_REPO") + while [ "$iteration" -lt 10 ]; do + rm -rf "$TEST_REPO/.git" + git -C "$TEST_REPO" init --quiet + printf 'echo replacement\n' > "$TEST_REPO/bootstrap.sh" + [ "$(_hooks_repo_identity "$TEST_REPO")" != "$original_identity" ] + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" + iteration=$((iteration + 1)) + done +} + +@test "unknown or unavailable Git directory birth times fail closed" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'echo harmless' + stat() { printf '%s\n' "$fixture_stat"; } + local fixture_stat + for fixture_stat in '1:2:0.000000000' '1:2:0' '1:2:-' '1:2:-1.000000000'; do + ! _hooks_repo_identity "$TEST_REPO" + ! _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + done +} + @test "definition trust survives delegated script changes in the same repository" { git config -f "$TEST_REPO/.gtrconfig" hooks.postCreate 'sh ./bootstrap.sh' printf 'echo original\n' > "$TEST_REPO/bootstrap.sh" diff --git a/tests/init.bats b/tests/init.bats index d66e65b..f37d446 100644 --- a/tests/init.bats +++ b/tests/init.bats @@ -200,6 +200,13 @@ run_generated_replacement_repo_hooks() ( cmd_init "$shell_name" > "$INIT_FIXTURE" "$shell_name" -c 'source "$INIT_FIXTURE"; gtr_run_post_cd_hooks "$REPO_FIXTURE"' [ "$(cat "$REPO_FIXTURE/hook-result")" = original ] + # Verify every generated identity helper fails closed without birth times. + mkdir "$fixture/bin" + printf '#!/bin/sh\necho 1:2:0.000000000\n' > "$fixture/bin/stat" + chmod +x "$fixture/bin/stat" + if PATH="$fixture/bin:$PATH" "$shell_name" -c 'source "$INIT_FIXTURE"; gtr_hooks_repo_identity "$REPO_FIXTURE"'; then + return 1 + fi mv "$REPO_FIXTURE/.git" "$fixture/original-git" git -C "$REPO_FIXTURE" init --quiet printf 'echo replacement > "$REPO_ROOT/hook-result"\n' > "$REPO_FIXTURE/bootstrap.sh" From d96e0d817a27a6fbd0190dc5c5bb7ab8d13b4f92 Mon Sep 17 00:00:00 2001 From: Howon Lee Date: Tue, 27 Oct 2026 11:01:58 -0700 Subject: [PATCH 3/3] fix: snapshot repository identity before command review --- lib/commands/trust.sh | 7 ++++++- lib/hooks.sh | 14 +++++++++----- tests/cmd_trust.bats | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/lib/commands/trust.sh b/lib/commands/trust.sh index db435d9..41298ea 100644 --- a/lib/commands/trust.sh +++ b/lib/commands/trust.sh @@ -10,6 +10,11 @@ cmd_trust() { return 0 fi + # Bind the definitions to the repository present before they are read. + local repo_root repo_identity + repo_root=$(_hooks_repo_root "$config_file") || return 1 + repo_identity=$(_hooks_repo_identity "$repo_root") || return 1 + # Show all trusted command entries from .gtrconfig local hook_content hook_content=$(_hooks_read_definitions "$config_file") || true @@ -27,7 +32,7 @@ cmd_trust() { fi local trust_path - trust_path=$(_hooks_reviewed_trust_path "$config_file" "$hook_content") || { + trust_path=$(_hooks_reviewed_trust_path "$config_file" "$hook_content" "$repo_identity") || { log_error "Failed to compute trust marker for $config_file" return 1 } diff --git a/lib/hooks.sh b/lib/hooks.sh index d0dea28..17d6205 100644 --- a/lib/hooks.sh +++ b/lib/hooks.sh @@ -85,14 +85,17 @@ _hooks_canonical_config_path() { } # Compute the repo-scoped trust key for reviewed trusted command content -# Usage: _hooks_reviewed_trust_key +# Usage: _hooks_reviewed_trust_key [repo_identity] _hooks_reviewed_trust_key() { local config_file="$1" local hook_content="$2" - local hash repo_root repo_identity + local repo_identity="${3:-}" + local hash repo_root hash=$(_hooks_content_hash "$hook_content") || return 1 repo_root=$(_hooks_repo_root "$config_file") || return 1 - repo_identity=$(_hooks_repo_identity "$repo_root") || return 1 + if [ -z "$repo_identity" ]; then + repo_identity=$(_hooks_repo_identity "$repo_root") || return 1 + fi printf 'v2\n%s\n%s\n%s\n' "$repo_root" "$repo_identity" "$hash" | shasum -a 256 | cut -d' ' -f1 } @@ -111,12 +114,13 @@ _hooks_trust_key() { } # Resolve the trust marker path for reviewed trusted command content -# Usage: _hooks_reviewed_trust_path +# Usage: _hooks_reviewed_trust_path [repo_identity] _hooks_reviewed_trust_path() { local config_file="$1" local hook_content="$2" + local repo_identity="${3:-}" local trust_key - trust_key=$(_hooks_reviewed_trust_key "$config_file" "$hook_content") || return 1 + trust_key=$(_hooks_reviewed_trust_key "$config_file" "$hook_content" "$repo_identity") || return 1 printf '%s/%s\n' "$_GTR_TRUST_DIR" "$trust_key" } diff --git a/tests/cmd_trust.bats b/tests/cmd_trust.bats index 1ef76fc..1a85fea 100644 --- a/tests/cmd_trust.bats +++ b/tests/cmd_trust.bats @@ -132,3 +132,23 @@ EOF [[ "$output" == *"repository identity changed during review"* ]] ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" } + +@test "cmd_trust snapshots identity before reading definitions" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'sh ./bootstrap.sh' + printf 'echo original\n' > "$TEST_REPO/bootstrap.sh" + _hooks_read_definitions() { + local definitions + definitions=$(git config -f "$1" --get-regexp '^hooks\.') || return 1 + if [ ! -d "$TEST_REPO/original-git" ]; then + mv "$TEST_REPO/.git" "$TEST_REPO/original-git" + git -C "$TEST_REPO" init --quiet + printf 'echo replacement\n' > "$TEST_REPO/bootstrap.sh" + fi + printf '%s\n' "$definitions" + } + prompt_yes_no() { return 0; } + run cmd_trust + [ "$status" -eq 1 ] + [[ "$output" == *"repository identity changed during review"* ]] + [ ! -f "$(_hooks_current_trust_path "$TEST_REPO/.gtrconfig")" ] +}