Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
114 changes: 92 additions & 22 deletions lib/commands/init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 key<LF>value<NUL>; 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__
Expand All @@ -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
}

Expand All @@ -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"
Expand All @@ -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() {
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions lib/commands/trust.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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."
Expand Down
6 changes: 4 additions & 2 deletions lib/config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
60 changes: 47 additions & 13 deletions lib/hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,44 @@ _GTR_TRUST_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/gtr/trusted"
# Usage: _hooks_read_definitions <config_file>
_hooks_read_definitions() {
local config_file="$1"
# Hex keeps NUL record boundaries and trailing value newlines in shell variables.
# Git emits key<LF>value<NUL>; 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 <hook_content>
_hooks_content_hash() {
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading