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
4 changes: 2 additions & 2 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,11 @@ 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.

Trust requires a known filesystem birth time for the Git directory. If `stat` cannot provide it, `.gtrconfig` executable commands remain untrusted.

### Other Commands

Expand Down
4 changes: 4 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ 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.

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
Expand Down
13 changes: 11 additions & 2 deletions lib/commands/help.sh
Original file line number Diff line number Diff line change
Expand Up @@ -474,8 +474,17 @@ 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.

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.
Expand Down
40 changes: 36 additions & 4 deletions lib/commands/init.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -131,13 +131,28 @@ __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:%.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"
}

__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() {
Expand Down Expand Up @@ -200,13 +215,30 @@ 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:%.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

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
Expand Down
12 changes: 9 additions & 3 deletions lib/commands/trust.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
Expand All @@ -37,13 +42,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"
Expand Down
38 changes: 31 additions & 7 deletions lib/hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
# files.
#
# Trust state is stored in ~/.config/gtr/trusted/<key>
# where <key> 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 <key> 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"

Expand Down Expand Up @@ -56,6 +57,24 @@ _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. 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:%.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"
}

# Resolve the canonical path for a .gtrconfig file
# Usage: _hooks_canonical_config_path <config_file>
_hooks_canonical_config_path() {
Expand All @@ -66,14 +85,18 @@ _hooks_canonical_config_path() {
}

# Compute the repo-scoped trust key for reviewed trusted command content
# Usage: _hooks_reviewed_trust_key <config_file> <hook_content>
# Usage: _hooks_reviewed_trust_key <config_file> <hook_content> [repo_identity]
_hooks_reviewed_trust_key() {
local config_file="$1"
local hook_content="$2"
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
printf '%s\n%s\n' "$repo_root" "$hash" | shasum -a 256 | cut -d' ' -f1
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
}

# Compute the repo-scoped trust key for the current trusted command content
Expand All @@ -91,12 +114,13 @@ _hooks_trust_key() {
}

# Resolve the trust marker path for reviewed trusted command content
# Usage: _hooks_reviewed_trust_path <config_file> <hook_content>
# Usage: _hooks_reviewed_trust_path <config_file> <hook_content> [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"
}

Expand Down
51 changes: 49 additions & 2 deletions tests/cmd_trust.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -99,9 +99,56 @@ 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"
}

@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")" ]
}
Loading
Loading