diff --git a/docs/configuration.md b/docs/configuration.md index f822b7a..d6a0df8 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. +Command definitions are captured once for trust verification and consumption, with NUL-delimited records preserving multiline values. The version 3 trust format invalidates older approvals; review commands again with `git gtr trust` after upgrading. + 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. --- diff --git a/lib/commands/init.sh b/lib/commands/init.sh index 26d3096..77e75b3 100644 --- a/lib/commands/init.sh +++ b/lib/commands/init.sh @@ -90,10 +90,51 @@ cmd_init() { fi } +# This template is exercised against the CLI's snapshot helpers in tests. +_init_emit_snapshot_helpers() { + cat <<'SNAPSHOT' +__FUNC___hooks_read_definitions() { + local config_file="$1" + # Hex keeps NUL record boundaries and trailing value newlines in shell variables. + # Git emits keyvalue; unlike line records this encoding is injective. + local hook_content + hook_content=$(git config -z -f "$config_file" --get-regexp '^hooks\.|^defaults\.editor$|^defaults\.ai$' 2>/dev/null | od -An -v -tx1 | tr -d ' \n') + [ -n "$hook_content" ] || return 1 + printf '%s\n' "$hook_content" +} + +# Select values only from the captured definitions, never from the pathname. +# An empty key displays every record for the trust confirmation. +__FUNC___hooks_definitions_get() { + local definitions="$1" key="${2:-}" mode="${3:-all}" record record_key value last_value="" + key=$(printf '%s' "$key" | tr '[:upper:]' '[:lower:]') + while IFS= read -r -d '' record; do + record_key="${record%%$'\n'*}" + value="" + case "$record" in *$'\n'*) value="${record#*$'\n'}" ;; esac + if [ -z "$key" ]; then + printf '%s = %q\n' "$record_key" "$value" + elif [ "$record_key" = "$key" ]; then + if [ "$mode" = last ]; then + last_value="$value" + else + printf '%s\n' "$value" + fi + fi + done < <(printf '%b' "$(printf '%s' "$definitions" | sed 's/../\\x&/g')") + if [ "$mode" = last ]; then printf '%s\n' "$last_value"; fi +} + +SNAPSHOT +} + _init_emit_posix_trust_helpers() { local shell_prelude="$1" local template + # Keep the snapshot encoding and extraction identical to the CLI. + _init_emit_snapshot_helpers + template=$(cat <<'EOF' __FUNC___gtrconfig_path() { __SHELL_PRELUDE__ @@ -112,8 +153,11 @@ __SHELL_PRELUDE__ __FUNC___hooks_current_content_hash() { __SHELL_PRELUDE__ local _gtr_config_file="$1" - local _gtr_hook_defs - _gtr_hook_defs="$(git config -f "$_gtr_config_file" --get-regexp '^hooks\.|^defaults\.editor$|^defaults\.ai$' 2>/dev/null)" || return 1 + local _gtr_hook_defs="${2:-}" + if [ "$#" -lt 2 ]; then + _gtr_hook_defs="$(__FUNC___hooks_read_definitions "$_gtr_config_file")" || return 1 + fi + [ -n "$_gtr_hook_defs" ] || return 1 printf '%s\n' "$_gtr_hook_defs" | shasum -a 256 | cut -d' ' -f1 } @@ -134,12 +178,14 @@ __SHELL_PRELUDE__ __FUNC___hooks_repo_identity() { __SHELL_PRELUDE__ local _gtr_repo_root="$1" - local _gtr_common_dir _gtr_identity + local _gtr_common_dir _gtr_identity _gtr_birth_time _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:%.9W' "$_gtr_common_dir" 2>/dev/null)" || + _gtr_identity="$(LC_ALL=C TZ=UTC stat -c '%d:%i:%W:%w' "$_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 + _gtr_birth_time="${_gtr_identity#*:*:}" + _gtr_birth_time="${_gtr_birth_time%%:*}" + case "$_gtr_birth_time" in ''|0|0.*|*[!0-9.]*) return 1 ;; esac printf '%s\n%s\n' "$_gtr_common_dir" "$_gtr_identity" @@ -149,10 +195,14 @@ __FUNC___hooks_current_trust_key() { __SHELL_PRELUDE__ local _gtr_config_file="$1" 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 + local _gtr_definitions="${2:-}" + if [ "$#" -lt 2 ]; then + _gtr_definitions="$(__FUNC___hooks_read_definitions "$_gtr_config_file")" || return 1 + fi + _gtr_hook_hash="$(__FUNC___hooks_current_content_hash "$_gtr_config_file" "$_gtr_definitions" 2>/dev/null)" || return 1 _gtr_repo_root="$(__FUNC___hooks_repo_root "$_gtr_config_file" 2>/dev/null)" || return 1 _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 + printf 'v3\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() { @@ -171,7 +221,7 @@ __SHELL_PRELUDE__ local _gtr_config_file="$1" local _gtr_trust_dir="$2" local _gtr_trust_key - _gtr_trust_key="$(__FUNC___hooks_current_trust_key "$_gtr_config_file" 2>/dev/null)" || return 1 + _gtr_trust_key="$(__FUNC___hooks_current_trust_key "$_gtr_config_file" "${3:-}" 2>/dev/null)" || return 1 __FUNC___hooks_marker_matches_config "$_gtr_trust_dir/$_gtr_trust_key" "$_gtr_config_file" } EOF @@ -181,6 +231,13 @@ EOF } _init_emit_fish_trust_helpers() { + local snapshot_helpers + snapshot_helpers=$(_init_emit_snapshot_helpers | sed 's/__FUNC___hooks_/_hooks_/g') + # Quote the helper source for a Fish single-quoted string. + snapshot_helpers="${snapshot_helpers//\\/\\\\}" + snapshot_helpers="${snapshot_helpers//\'/\\\'}" + printf "function __FUNC___hooks_read_definitions\n command bash -c '%s\n_hooks_read_definitions \"\$1\"' bash \"\$argv[1]\"\nend\n" "$snapshot_helpers" + printf "function __FUNC___hooks_definitions_get\n command bash -c '%s\n_hooks_definitions_get \"\$1\" \"\$2\"' bash \"\$argv[1]\" \"\$argv[2]\"\nend\n" "$snapshot_helpers" cat <<'FISH' function __FUNC___gtrconfig_path set -l _gtr_git_common_dir (git rev-parse --git-common-dir 2>/dev/null) @@ -197,10 +254,12 @@ end function __FUNC___hooks_current_content_hash set -l _gtr_config_file "$argv[1]" - set -l _gtr_hook_defs (git config -f "$_gtr_config_file" --get-regexp '^hooks\.|^defaults\.editor$|^defaults\.ai$' 2>/dev/null | shasum -a 256 | cut -d' ' -f1) - test $pipestatus[1] -eq 0; or return 1 + set -l _gtr_hook_defs "$argv[2]" + if test (count $argv) -lt 2 + set _gtr_hook_defs (__FUNC___hooks_read_definitions "$_gtr_config_file") + end test -n "$_gtr_hook_defs"; or return 1 - printf '%s\n' "$_gtr_hook_defs" + printf '%s\n' "$_gtr_hook_defs" | shasum -a 256 | cut -d' ' -f1 end function __FUNC___hooks_repo_root @@ -220,9 +279,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:%.9W" "$common_dir" 2>/dev/null) || + identity=$(LC_ALL=C TZ=UTC stat -c "%d:%i:%W:%w" "$common_dir" 2>/dev/null) || identity=$(LC_ALL=C stat -f "%d:%i:%.9FB" "$common_dir" 2>/dev/null) || exit 1 - case "${identity##*:}" in + birth_time="${identity#*:*:}" + birth_time="${birth_time%%:*}" + case "$birth_time" in ""|0|0.*|*[!0-9.]*) exit 1 ;; esac printf "%s\\n%s\\n" "$common_dir" "$identity" @@ -231,14 +292,18 @@ 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) + set -l _gtr_definitions "$argv[2]" + if test (count $argv) -lt 2 + set _gtr_definitions (__FUNC___hooks_read_definitions "$_gtr_config_file") + end + set -l _gtr_hook_hash (__FUNC___hooks_current_content_hash "$_gtr_config_file" "$_gtr_definitions" 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 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 + printf 'v3\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 @@ -253,7 +318,7 @@ end function __FUNC___hooks_are_trusted set -l _gtr_config_file "$argv[1]" set -l _gtr_trust_dir "$argv[2]" - set -l _gtr_trust_key (__FUNC___hooks_current_trust_key "$_gtr_config_file" 2>/dev/null) + set -l _gtr_trust_key (__FUNC___hooks_current_trust_key "$_gtr_config_file" "$argv[3]" 2>/dev/null) test -n "$_gtr_trust_key"; or return 1 __FUNC___hooks_marker_matches_config "$_gtr_trust_dir/$_gtr_trust_key" "$_gtr_config_file" end @@ -281,10 +346,12 @@ __FUNC___run_post_cd_hooks() { _gtr_config_file="$(__FUNC___gtrconfig_path 2>/dev/null)" || true 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 + local _gtr_definitions + _gtr_definitions="$(__FUNC___hooks_read_definitions "$_gtr_config_file")" || true + _gtr_file_hooks="$(__FUNC___hooks_definitions_get "$_gtr_definitions" hooks.postCd)" if [ -n "$_gtr_file_hooks" ]; then # Verify trust before including .gtrconfig hooks - if __FUNC___hooks_are_trusted "$_gtr_config_file" "$_gtr_trust_dir"; then + if __FUNC___hooks_are_trusted "$_gtr_config_file" "$_gtr_trust_dir" "$_gtr_definitions"; then if [ -n "$_gtr_hooks" ]; then _gtr_hooks="$_gtr_hooks"$'\n'"$_gtr_file_hooks" else @@ -466,10 +533,12 @@ __FUNC___run_post_cd_hooks() { _gtr_config_file="$(__FUNC___gtrconfig_path 2>/dev/null)" || true 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 + local _gtr_definitions + _gtr_definitions="$(__FUNC___hooks_read_definitions "$_gtr_config_file")" || true + _gtr_file_hooks="$(__FUNC___hooks_definitions_get "$_gtr_definitions" hooks.postCd)" if [ -n "$_gtr_file_hooks" ]; then # Verify trust before including .gtrconfig hooks - if __FUNC___hooks_are_trusted "$_gtr_config_file" "$_gtr_trust_dir"; then + if __FUNC___hooks_are_trusted "$_gtr_config_file" "$_gtr_trust_dir" "$_gtr_definitions"; then if [ -n "$_gtr_hooks" ]; then _gtr_hooks="$_gtr_hooks"$'\n'"$_gtr_file_hooks" else @@ -654,10 +723,11 @@ function __FUNC___run_post_cd_hooks set -l _gtr_config_file (__FUNC___gtrconfig_path 2>/dev/null) set -l _gtr_file_hooks if test -f "$_gtr_config_file" - set -l _gtr_candidate_hooks (git config -f "$_gtr_config_file" --get-all hooks.postCd 2>/dev/null) + set -l _gtr_definitions (__FUNC___hooks_read_definitions "$_gtr_config_file") + set -l _gtr_candidate_hooks (__FUNC___hooks_definitions_get "$_gtr_definitions" hooks.postCd) if test (count $_gtr_candidate_hooks) -gt 0 # Verify trust before including .gtrconfig hooks - if __FUNC___hooks_are_trusted "$_gtr_config_file" "$_gtr_trust_dir" + if __FUNC___hooks_are_trusted "$_gtr_config_file" "$_gtr_trust_dir" "$_gtr_definitions" set _gtr_file_hooks $_gtr_candidate_hooks else echo "__FUNC__: Untrusted .gtrconfig hooks skipped — run 'git gtr trust' to approve" >&2 diff --git a/lib/commands/trust.sh b/lib/commands/trust.sh index 41298ea..0b00d5f 100644 --- a/lib/commands/trust.sh +++ b/lib/commands/trust.sh @@ -24,10 +24,10 @@ cmd_trust() { return 0 fi - if _hooks_are_trusted "$config_file"; then + if _hooks_snapshot_is_trusted "$config_file" "$hook_content"; then log_info "Executable commands in $config_file are already trusted" log_info "Current trusted commands:" - printf '%s\n' "$hook_content" >&2 + _hooks_definitions_get "$hook_content" >&2 return 0 fi @@ -39,7 +39,7 @@ cmd_trust() { log_warn "The following executable commands are defined in $config_file:" echo "" >&2 - printf '%s\n' "$hook_content" >&2 + _hooks_definitions_get "$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." diff --git a/lib/config.sh b/lib/config.sh index 77ec269..8a5f57c 100644 --- a/lib/config.sh +++ b/lib/config.sh @@ -489,9 +489,11 @@ cfg_default_trusted_file() { if [ -z "$value" ] && [ -n "$file_key" ]; then config_file=$(_gtrconfig_path) if [ -n "$config_file" ] && [ -f "$config_file" ]; then - file_value=$(git config -f "$config_file" --get "$file_key" 2>/dev/null || true) + local definitions + definitions=$(_hooks_read_definitions "$config_file") || true + file_value=$(_hooks_definitions_get "$definitions" "$file_key" last) if [ -n "$file_value" ]; then - if _hooks_are_trusted "$config_file"; then + if _hooks_snapshot_is_trusted "$config_file" "$definitions"; then value="$file_value" else log_warn "Untrusted .gtrconfig $file_key skipped — run: git gtr trust" diff --git a/lib/hooks.sh b/lib/hooks.sh index 17d6205..128f33d 100644 --- a/lib/hooks.sh +++ b/lib/hooks.sh @@ -19,12 +19,44 @@ _GTR_TRUST_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/gtr/trusted" # Usage: _hooks_read_definitions _hooks_read_definitions() { local config_file="$1" + # Hex keeps NUL record boundaries and trailing value newlines in shell variables. + # Git emits keyvalue; unlike line records this encoding is injective. local hook_content - hook_content=$(git config -f "$config_file" --get-regexp '^hooks\.|^defaults\.editor$|^defaults\.ai$' 2>/dev/null) || true + hook_content=$(git config -z -f "$config_file" --get-regexp '^hooks\.|^defaults\.editor$|^defaults\.ai$' 2>/dev/null | od -An -v -tx1 | tr -d ' \n') [ -n "$hook_content" ] || return 1 printf '%s\n' "$hook_content" } +# Select values only from the captured definitions, never from the pathname. +# An empty key displays every record for the trust confirmation. +_hooks_definitions_get() { + local definitions="$1" key="${2:-}" mode="${3:-all}" record record_key value last_value="" + key=$(printf '%s' "$key" | tr '[:upper:]' '[:lower:]') + while IFS= read -r -d '' record; do + record_key="${record%%$'\n'*}" + value="" + case "$record" in *$'\n'*) value="${record#*$'\n'}" ;; esac + if [ -z "$key" ]; then + printf '%s = %q\n' "$record_key" "$value" + elif [ "$record_key" = "$key" ]; then + if [ "$mode" = last ]; then + last_value="$value" + else + printf '%s\n' "$value" + fi + fi + done < <(printf '%b' "$(printf '%s' "$definitions" | sed 's/../\\x&/g')") + if [ "$mode" = last ]; then printf '%s\n' "$last_value"; fi +} + +# Validate a captured snapshot against its repository-scoped marker. +_hooks_snapshot_is_trusted() { + local config_file="$1" definitions="$2" trust_path + [ -n "$definitions" ] || return 0 + trust_path=$(_hooks_reviewed_trust_path "$config_file" "$definitions") || return 1 + _hooks_marker_matches_config "$trust_path" "$config_file" +} + # Compute a content hash for trusted command definitions. # Usage: _hooks_content_hash _hooks_content_hash() { @@ -61,15 +93,18 @@ _hooks_repo_root() { # Use the common Git directory so normal commits and linked worktrees do not # invalidate approval. Require a known birth time and preserve nanoseconds to # distinguish rapid inode reuse. Filesystems without birth times fail closed. +# GNU %W is whole seconds even with precision; %w retains birth nanoseconds. # 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 + local common_dir identity birth_time 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:%.9W' "$common_dir" 2>/dev/null) || + identity=$(LC_ALL=C TZ=UTC stat -c '%d:%i:%W:%w' "$common_dir" 2>/dev/null) || identity=$(LC_ALL=C stat -f '%d:%i:%.9FB' "$common_dir" 2>/dev/null) || return 1 - case "${identity##*:}" in + birth_time="${identity#*:*:}" + birth_time="${birth_time%%:*}" + case "$birth_time" in ''|0|0.*|*[!0-9.]*) return 1 ;; esac printf '%s\n%s\n' "$common_dir" "$identity" @@ -96,7 +131,7 @@ _hooks_reviewed_trust_key() { 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 + printf 'v3\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 @@ -164,8 +199,7 @@ _hooks_are_trusted() { local hook_content trust_path hook_content=$(_hooks_read_definitions "$config_file") || return 0 - trust_path=$(_hooks_reviewed_trust_path "$config_file" "$hook_content") || return 1 - _hooks_marker_matches_config "$trust_path" "$config_file" + _hooks_snapshot_is_trusted "$config_file" "$hook_content" } # Write a trust marker that matches the reviewed command definitions @@ -217,15 +251,15 @@ _hooks_get_trusted() { local file_hooks="" if [ -n "$config_file" ] && [ -f "$config_file" ]; then - if _hooks_are_trusted "$config_file"; then - file_hooks=$(git config -f "$config_file" --get-all "hooks.$phase" 2>/dev/null) || true - else - local untrusted_hooks - untrusted_hooks=$(git config -f "$config_file" --get-all "hooks.$phase" 2>/dev/null) || true - if [ -n "$untrusted_hooks" ]; then + local definitions + definitions=$(_hooks_read_definitions "$config_file") || true + file_hooks=$(_hooks_definitions_get "$definitions" "hooks.$phase") + if ! _hooks_snapshot_is_trusted "$config_file" "$definitions"; then + if [ -n "$file_hooks" ]; then log_warn "Untrusted .gtrconfig hooks for '$phase' phase — skipping" log_warn "Review hooks in $config_file, then run: git gtr trust" fi + file_hooks="" fi fi diff --git a/tests/hooks.bats b/tests/hooks.bats index 133938a..0eadb0a 100644 --- a/tests/hooks.bats +++ b/tests/hooks.bats @@ -52,7 +52,7 @@ teardown() { EOF local expected - expected="$(git config -f "$TEST_REPO/.gtrconfig" --get-regexp '^hooks\.|^defaults\.editor$|^defaults\.ai$' 2>/dev/null | shasum -a 256 | cut -d' ' -f1)" + expected="$(git config -f "$TEST_REPO/.gtrconfig" -z --get-regexp '^hooks\.|^defaults\.editor$|^defaults\.ai$' 2>/dev/null | od -An -v -tx1 | tr -d ' \n' | { encoded=$(cat); printf '%s\n' "$encoded"; } | shasum -a 256 | cut -d' ' -f1)" [ "$(_hooks_file_hash "$TEST_REPO/.gtrconfig")" = "$expected" ] } @@ -64,7 +64,7 @@ EOF EOF local expected - expected="$(git config -f "$TEST_REPO/.gtrconfig" --get-regexp '^hooks\.|^defaults\.editor$|^defaults\.ai$' 2>/dev/null | shasum -a 256 | cut -d' ' -f1)" + expected="$(git config -f "$TEST_REPO/.gtrconfig" -z --get-regexp '^hooks\.|^defaults\.editor$|^defaults\.ai$' 2>/dev/null | od -An -v -tx1 | tr -d ' \n' | { encoded=$(cat); printf '%s\n' "$encoded"; } | shasum -a 256 | cut -d' ' -f1)" [ "$(_hooks_file_hash "$TEST_REPO/.gtrconfig")" = "$expected" ] } @@ -392,3 +392,85 @@ EOF [ "$(head -1 "$TEST_REPO/order")" = "first" ] [ "$(tail -1 "$TEST_REPO/order")" = "third" ] } + +@test "trust encoding distinguishes multiline defaults from added hook records" { + git config -f "$TEST_REPO/.gtrconfig" defaults.ai $'echo safe\nhooks.postcd echo attacker' + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + git config -f "$TEST_REPO/.gtrconfig" defaults.ai 'echo safe' + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'echo attacker' + ! _hooks_are_trusted "$TEST_REPO/.gtrconfig" +} + +@test "hook execution uses the captured definitions after config replacement" { + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'echo approved' + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + eval "$(declare -f _hooks_snapshot_is_trusted | sed '1s/_hooks_snapshot_is_trusted/_original_snapshot_is_trusted/')" + _hooks_snapshot_is_trusted() { + _original_snapshot_is_trusted "$@" || return 1 + git config -f "$1" hooks.postCd 'echo attacker' + } + run _hooks_get_trusted postCd + [ "$status" -eq 0 ] + [ "$output" = 'echo approved' ] +} + +@test "trusted defaults are selected from the snapshot whose marker is checked" { + git config -f "$TEST_REPO/.gtrconfig" defaults.ai 'echo approved' + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + eval "$(declare -f _hooks_snapshot_is_trusted | sed '1s/_hooks_snapshot_is_trusted/_original_snapshot_is_trusted/')" + _hooks_snapshot_is_trusted() { + git config -f "$1" defaults.ai 'echo attacker' + _original_snapshot_is_trusted "$@" + } + run cfg_default_trusted_file gtr.ai.default GTR_AI_DEFAULT fallback defaults.ai + [ "$status" -eq 0 ] + [ "$output" = 'echo approved' ] +} + +@test "generated shells hash and extract the same multiline snapshot" { + source "$PROJECT_ROOT/lib/commands/init.sh" + git config -f "$TEST_REPO/.gtrconfig" defaults.ai $'echo safe\n\n' + git config -f "$TEST_REPO/.gtrconfig" hooks.postCd 'echo approved' + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + local snapshot expected shell script + snapshot=$(_hooks_read_definitions "$TEST_REPO/.gtrconfig") + expected=$(_hooks_current_trust_key "$TEST_REPO/.gtrconfig") + for shell in bash zsh fish; do + command -v "$shell" >/dev/null || continue + script="$BATS_TEST_TMPDIR/helpers.$shell" + if [ "$shell" = fish ]; then + _init_emit_fish_trust_helpers | sed 's/__FUNC__/gtr/g' > "$script" + printf '\ngtr_hooks_current_trust_key "$argv[1]"\ngtr_hooks_definitions_get "$argv[2]" hooks.postCd\n' >> "$script" + else + _init_emit_posix_trust_helpers '' | sed 's/__FUNC__/gtr/g' > "$script" + printf '\ngtr_hooks_current_trust_key "$1"\ngtr_hooks_definitions_get "$2" hooks.postCd\n' >> "$script" + fi + run "$shell" "$script" "$TEST_REPO/.gtrconfig" "$snapshot" + [ "$status" -eq 0 ] + [ "$output" = "$expected"$'\n''echo approved' ] + done +} + +@test "snapshot defaults retain git config last-value and boolean semantics" { + git config -f "$TEST_REPO/.gtrconfig" --add defaults.ai first + git config -f "$TEST_REPO/.gtrconfig" --add defaults.ai last + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + [ "$(cfg_default_trusted_file gtr.ai.default '' fallback defaults.ai)" = last ] + printf '[defaults]\n ai\n' > "$TEST_REPO/.gtrconfig" + _hooks_mark_trusted "$TEST_REPO/.gtrconfig" + [ "$(cfg_default_trusted_file gtr.ai.default '' fallback defaults.ai)" = fallback ] +} + +@test "GNU repository identity retains birth nanoseconds within the same second" { + local birth_fraction=123456789 first second + stat() { + case "$*" in + *%W:%w*) printf '1:2:1793140000:2026-10-27 23:00:00.%s +0000\n' "$birth_fraction" ;; + *) printf '1:2:1793140000.000000000\n' ;; + esac + } + first=$(_hooks_repo_identity "$TEST_REPO") + birth_fraction=987654321 + second=$(_hooks_repo_identity "$TEST_REPO") + [ "$first" != "$second" ] +} diff --git a/tests/init.bats b/tests/init.bats index f37d446..afcffcf 100644 --- a/tests/init.bats +++ b/tests/init.bats @@ -1043,3 +1043,43 @@ run_generated_replacement_repo_hooks() ( [ -f "$XDG_CACHE_HOME/gtr/init-gtr.${sh}" ] done } + +run_generated_snapshot_replacement_hooks() ( + set -e + local shell_name="$1" fixture + fixture=$(mktemp -d) + trap 'rm -rf "$fixture"' EXIT + export REPO_FIXTURE="$fixture/repo" + export INIT_FIXTURE="$fixture/init.$shell_name" + export XDG_CONFIG_HOME="$fixture/config" + export REAL_GIT + REAL_GIT=$(command -v git) + mkdir -p "$REPO_FIXTURE" "$fixture/bin" + git -C "$REPO_FIXTURE" init --quiet + git config -f "$REPO_FIXTURE/.gtrconfig" hooks.postCd 'echo approved > "$REPO_ROOT/result"' + source "$PROJECT_ROOT/lib/hooks.sh" + _hooks_mark_trusted "$REPO_FIXTURE/.gtrconfig" + cmd_init "$shell_name" > "$INIT_FIXTURE" + cat > "$fixture/bin/git" <<'WRAPPER' +#!/usr/bin/env bash +if [ "$1" = config ] && [ "${2:-}" = -z ] && [ "${4:-}" = "$REPO_FIXTURE/.gtrconfig" ]; then + "$REAL_GIT" "$@" > "$REPO_FIXTURE/captured" + "$REAL_GIT" config -f "$REPO_FIXTURE/.gtrconfig" hooks.postCd 'echo attacker > "$REPO_ROOT/result"' + cat "$REPO_FIXTURE/captured" +else + exec "$REAL_GIT" "$@" +fi +WRAPPER + chmod +x "$fixture/bin/git" + PATH="$fixture/bin:$PATH" "$shell_name" -c 'source "$INIT_FIXTURE"; gtr_run_post_cd_hooks "$REPO_FIXTURE"' + [ "$(cat "$REPO_FIXTURE/result")" = approved ] +) + +@test "generated shells execute the captured approved snapshot after pathname replacement" { + local shell_name + for shell_name in bash zsh fish; do + command -v "$shell_name" >/dev/null || continue + run run_generated_snapshot_replacement_hooks "$shell_name" + [ "$status" -eq 0 ] + done +}