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
1 change: 1 addition & 0 deletions .gitapex/ssot.json
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@
"script": [
"hooks/check-pr-skill-audit-disclosure.sh",
"hooks/gitapex_check_skill_audit_disclosure_or_waiver.py",
"hooks/gitapex_check_python_precondition.py",
".github/scripts/gitapex_gate_skill_audit_disclosure.py",
".github/scripts/gitapex_compute_skill_audit_flags.py",
".github/scripts/gitapex_skill_description_diff.py",
Expand Down
241 changes: 192 additions & 49 deletions .github/scripts/gitapex_gate_bare_python3_invocation.py

Large diffs are not rendered by default.

124 changes: 124 additions & 0 deletions docs/adr/0003-prefer-uv-resolved-python3-in-hooks-scripts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Prefer uv-resolved python3 in hooks/*.sh, with a bare-python3 fallback

## Status

Accepted (approved by tvna, 2026-09-03)

## Context and Problem Statement

This decision is already implemented, on branch `claude/fix-python-path-resolution-q2e3pv`
(PR #1701, not yet merged as of this writing) -- this is a retrofit
record, written after the change, not before it.

`hooks/*.sh` files are this repository's own agent-harness hook
subprocesses (PreToolUse/Stop, etc.). Several of them invoke a companion
Python checker script. Before this change, that invocation was a bare
`python3 "$script"` call, which resolves the `python3` binary from
whatever `PATH` the *calling* hook context happens to have at
invocation time -- not necessarily this repository's own uv-managed
`.venv`.

Issue #1697 reported the resulting defect directly:
`hooks/check-pr-skill-audit-disclosure.sh`'s precondition probe denied a
real `create_pull_request` call with `"python3 cannot import: pydantic"`,
even though `uv sync --group dev` had already installed pydantic into
this checkout's own `.venv` -- because the ambient `PATH` at hook-
invocation time resolved a different `python3` that could not see that
`.venv`. Issue #1581 raised the same PATH-dependent-interpreter defect
class against a different call site.

A conflicting constraint narrows the fix: per `docs/repository-layout.md`,
only `skills/` is currently deployed to a *consumer* plugin install of
this repository, with `hooks/` deployment stated there as planned for a
future release -- and a consumer install carries no `uv` toolchain or
lockfile of its own. An unconditional switch to `uv run` in every
`hooks/*.sh` file would resolve this repository's own dev-checkout bug
today, but would break every consumer install outright as soon as
`hooks/` deployment ships.

## Considered Options

- Leave every `hooks/*.sh` bare-`python3` call site unchanged (do
nothing).
- Switch every `hooks/*.sh` companion-script invocation to `uv run
--frozen python3` unconditionally.
- Resolve the interpreter through a `python3_cmd` array: prefer `uv run
--frozen [--directory "$plugin_root"] python3` when `command -v uv`
succeeds AND the checkout actually owns `pyproject.toml`/`uv.lock`,
falling back to bare `python3` otherwise.

## Decision Outcome

We will resolve each `hooks/*.sh` companion-Python-script invocation
through the third option: a `command -v uv`-and-lockfile-gated
`python3_cmd` array that prefers `uv run --frozen python3` when this
checkout is a uv-managed dev checkout, and falls back to the pre-existing
bare `python3` otherwise -- because it closes the PATH-dependent
false-deny issue #1697 and #1581 both describe, in exactly the dev
checkout where it can occur, without changing behavior at all for a
consumer plugin install that has no `uv` toolchain to invoke.

The one exception is `hooks/check-pr-skill-audit-disclosure.sh`'s own
tier-1 block, which only ever runs when `.github/scripts/` is present
(i.e., only in this repository's own dev checkout, never a consumer
install) -- there, the invocation uses `uv run --frozen python3`
unconditionally, since the gating condition the other nine files' ten
call sites (`check-bash-safety.sh` has two) need is already guaranteed
by that block's own existing `.github/scripts/`-presence check.

As a durable enforcement mechanism for this decision, we also promoted
`.github/scripts/gitapex_gate_bare_python3_invocation.py`'s own
`hooks/*.sh` shell-variable-indirected scan from WARNING-only (report
only, CI never failed) to HARD-FAIL: a `hooks/*.sh` bare `python3
"$var"` invocation of a `.github/scripts/*.py` target, or of a `hooks/*.py`
target registered in `.gitapex/ssot.json` under a gate whose own
`preconditions.requires_python_packages` is non-empty, now fails CI and
local-preflight.

## Consequences

Good, because the exact PATH-dependent false-deny issue #1697 reported
no longer reproduces in a uv-managed dev checkout, while a consumer
plugin install's own behavior (bare `python3`, unchanged) is completely
unaffected.

Good, because the promoted hard-fail gate makes this decision durable
going forward: a future `hooks/*.sh` call site that reintroduces a bare
`python3 "$var"` of a third-party-dependent target now fails CI, rather
than silently reintroducing this defect class the way the original
regression (#1697, itself a regression from #1566/PR #1675) went
undetected.

Bad, because the `command -v uv` + lockfile-gated `python3_cmd`-array
resolution snippet (~5 lines) is duplicated verbatim across all ten
`hooks/*.sh` call sites (nine files, one of them -- `check-bash-safety.sh`
-- with two call sites), with no automated check that the ten copies stay
in sync. `hooks/` is planned to join `skills/` on the deployed side of
the plugin-redistribution boundary per `docs/repository-layout.md`, so
consolidating this into one sourced helper is not blocked by that
boundary; it simply has not been done yet.

Bad, because the hard-fail gate's own `load_python_dependent_hook_script_names`
helper (which reads `.gitapex/ssot.json` to learn which `hooks/*.py`
targets carry a third-party-package precondition) initially failed
*open* -- returned an empty result rather than a hard failure -- when an
individual `gates` entry's own `preconditions` field was present but
malformed (e.g., a string instead of a mapping), even though the same
entry's `script` list did name a `hooks/*.py` target. This was narrower
than, but the same class as, the whole-file-unreadable case the gate
already treated as a hard failure; an independent review of this branch
confirmed it live (a well-formed `ssot.json` with one corrupted
`preconditions` field, alongside a real bare invocation of the
registered target, produced a false "clean" exit 0). Fixed in a
follow-up commit (`f6bed277`) on this same branch, shortly after this
ADR was first drafted, by extending the same fail-closed treatment to a
malformed per-gate shape, not only a malformed whole-file one -- live
re-verified by a second independent review pass after the fix.

## Confirmation

`.github/scripts/gitapex_gate_bare_python3_invocation.py`'s HARD-FAIL
`hooks/*.sh` shell-variable-indirected scan, run in CI and as part of
local-preflight: a new bare `python3 "$var"` invocation of a
`.github/scripts/*.py` target, or of a registered third-party-dependent
`hooks/*.py` target, fails the check.
26 changes: 24 additions & 2 deletions hooks/check-bash-safety.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,21 @@ if [ ! -f "$classifier" ]; then
deny "Blocked by hooks/check-bash-safety.sh: gitapex_check_bash_safety.py was not found at $classifier (corrupted or incomplete plugin bundle). Failing closed."
fi

# Issue #1697/#1581: prefer this checkout's own uv-managed .venv (uv on
# PATH plus a pyproject.toml/uv.lock at plugin_root) over a bare `python3`
# resolved from the calling shell's own ambient PATH -- closes the same
# PATH-nondeterminism class hooks/check-pr-skill-audit-disclosure.sh's own
# precondition probe hit. Falls back to a bare `python3` for a consumer
# plugin install (only skills/ and hooks/ are ever deployed there --
# docs/repository-layout.md), where no uv toolchain/lockfile exists --
# $classifier is stdlib-only, so a bare python3 has always been a correct
# answer there; this fallback keeps that unchanged.
plugin_root="$(dirname "$script_dir")"
python3_cmd=(python3)
if command -v uv >/dev/null 2>&1 && [ -f "$plugin_root/pyproject.toml" ] && [ -f "$plugin_root/uv.lock" ]; then
python3_cmd=(uv run --frozen --directory "$plugin_root" python3)
fi

# $input is piped on stdin the whole way through, never re-passed as a
# command-line argument -- same ARG_MAX rationale as deny()/warn() above.
# The classifier re-validates tool_input/tool_input.command's own shape
Expand All @@ -130,7 +145,7 @@ classifier_exit=0
# leak into this hook's own stderr channel -- deny()'s JSON envelope below
# is the only thing this hook itself ever writes there, and a stray extra
# line ahead of it would break Claude Code's own JSON parse of that stream.
classifier_output=$(printf '%s' "$input" | python3 "$classifier" 2>/dev/null) || classifier_exit=$?
classifier_output=$(printf '%s' "$input" | "${python3_cmd[@]}" "$classifier" 2>/dev/null) || classifier_exit=$?
if [ "$classifier_exit" -ne 0 ]; then
deny "Blocked by hooks/check-bash-safety.sh: gitapex_check_bash_safety.py exited non-zero ($classifier_exit) instead of returning a decision. Failing closed."
fi
Expand Down Expand Up @@ -160,6 +175,13 @@ if [ "$is_git_push" = "true" ]; then
deny "Blocked by hooks/check-bash-safety.sh: git push requires the outward-artifact-preflight scan, but gitapex_scan_provenance.py was not found at $scan_script."
fi

# Same uv-preferred/bare-python3-fallback rationale as $classifier above,
# keyed on project_dir (this call's own root) rather than plugin_root.
scan_python3_cmd=(python3)
if command -v uv >/dev/null 2>&1 && [ -f "$project_dir/pyproject.toml" ] && [ -f "$project_dir/uv.lock" ]; then
scan_python3_cmd=(uv run --frozen --directory "$project_dir" python3)
fi

# Determine the commit range being pushed. With an upstream, @{u}..HEAD is
# exact. On a first push (`git push -u origin newbranch`) there is no
# upstream, so @{u} errors and the range is empty. Fall back to the
Expand Down Expand Up @@ -187,7 +209,7 @@ if [ "$is_git_push" = "true" ]; then
fi

scan_exit=0
scan_output=$(printf '%s' "$content" | python3 "$scan_script" 2>&1) || scan_exit=$?
scan_output=$(printf '%s' "$content" | "${scan_python3_cmd[@]}" "$scan_script" 2>&1) || scan_exit=$?

# gitapex_scan_provenance.py's own docstring says it "surfaces candidates, it does
# not decide" -- a hard deny here would make this mechanical regex the
Expand Down
17 changes: 16 additions & 1 deletion hooks/check-issue-acm-disclosure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ body=$(printf '%s' "$input" | jq -r '.tool_input.body // empty')
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
check_script="$script_dir/gitapex_check_acm_present_or_waiver.py"

# Issue #1697/#1581: prefer this checkout's own uv-managed .venv over a
# bare `python3` resolved from the calling shell's own ambient PATH --
# see hooks/check-pr-skill-audit-disclosure.sh's own precondition-probe
# fix for the PATH-nondeterminism class this closes. Falls back to a bare
# `python3` for a consumer plugin install (only skills/ and hooks/ are
# ever deployed there -- docs/repository-layout.md), where no uv
# toolchain/lockfile exists -- $check_script is stdlib-only, so a bare
# python3 has always been a correct answer there; this fallback keeps
# that unchanged.
plugin_root="$(dirname "$script_dir")"
python3_cmd=(python3)
if command -v uv >/dev/null 2>&1 && [ -f "$plugin_root/pyproject.toml" ] && [ -f "$plugin_root/uv.lock" ]; then
python3_cmd=(uv run --frozen --directory "$plugin_root" python3)
fi

deny() {
local reason="$1"
jq -n --arg msg "$reason" \
Expand All @@ -52,7 +67,7 @@ if [ ! -f "$check_script" ]; then
deny "Blocked by hooks/check-issue-acm-disclosure.sh: cannot verify ACM disclosure -- gitapex_check_acm_present_or_waiver.py was not found at $check_script (corrupted or incomplete plugin bundle)."
fi

if printf '%s' "$body" | python3 "$check_script" >/dev/null 2>&1; then
if printf '%s' "$body" | "${python3_cmd[@]}" "$check_script" >/dev/null 2>&1; then
exit 0
fi

Expand Down
17 changes: 15 additions & 2 deletions hooks/check-post-review-obligation-tracker.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,20 @@ if [ ! -f "$tracker_script" ]; then
exit 0
fi

if ! command -v python3 >/dev/null 2>&1; then
# Issue #1697/#1581: prefer this checkout's own uv-managed .venv over a
# bare `python3` resolved from the calling shell's own ambient PATH --
# see hooks/check-pr-skill-audit-disclosure.sh's own precondition-probe
# fix for the PATH-nondeterminism class this closes. Falls back to a bare
# `python3` for a consumer plugin install (only skills/ and hooks/ are
# ever deployed there -- docs/repository-layout.md), where no uv
# toolchain/lockfile exists -- $tracker_script is stdlib-only, so a bare
# python3 has always been a correct answer there; this fallback keeps
# that unchanged.
plugin_root="$(dirname "$script_dir")"
python3_cmd=(python3)
if command -v uv >/dev/null 2>&1 && [ -f "$plugin_root/pyproject.toml" ] && [ -f "$plugin_root/uv.lock" ]; then
python3_cmd=(uv run --frozen --directory "$plugin_root" python3)
elif ! command -v python3 >/dev/null 2>&1; then
printf '%s\n' "{\"systemMessage\": \"hooks/check-post-review-obligation-tracker.sh: python3 is not available on PATH. Skipping this cycle's obligation tracking.\"}"
exit 0
fi
Expand All @@ -53,7 +66,7 @@ fi
# no ARG_MAX concern either. Payload-shape validation, tool_name dispatch,
# and any systemMessage worth emitting for a malformed/irrelevant payload
# all happen inside the tracker script itself (see header above).
if ! python3 "$tracker_script" 2>/dev/null; then
if ! "${python3_cmd[@]}" "$tracker_script" 2>/dev/null; then
printf '%s\n' "{\"systemMessage\": \"hooks/check-post-review-obligation-tracker.sh: gitapex_check_post_review_obligation_tracker.py exited non-zero. Review-thread-resolution/mergeable_state tracking for this turn may be incomplete.\"}"
fi

Expand Down
17 changes: 16 additions & 1 deletion hooks/check-post-write-provenance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,25 @@ if [ ! -f "$check_script" ]; then
report "hooks/check-post-write-provenance.sh could not verify the stored PR/issue body: gitapex_check_post_write_provenance.py was not found at $check_script (corrupted or incomplete plugin bundle). The artifact this call just published is UNVERIFIED."
fi

# Issue #1697/#1581: prefer this checkout's own uv-managed .venv over a
# bare `python3` resolved from the calling shell's own ambient PATH --
# see hooks/check-pr-skill-audit-disclosure.sh's own precondition-probe
# fix for the PATH-nondeterminism class this closes. Falls back to a bare
# `python3` for a consumer plugin install (only skills/ and hooks/ are
# ever deployed there -- docs/repository-layout.md), where no uv
# toolchain/lockfile exists -- $check_script is stdlib-only, so a bare
# python3 has always been a correct answer there; this fallback keeps
# that unchanged.
plugin_root="$(dirname "$script_dir")"
python3_cmd=(python3)
if command -v uv >/dev/null 2>&1 && [ -f "$plugin_root/pyproject.toml" ] && [ -f "$plugin_root/uv.lock" ]; then
python3_cmd=(uv run --frozen --directory "$plugin_root" python3)
fi

# $input is piped on stdin the whole way through, never re-passed as a
# command-line argument -- same ARG_MAX rationale as report() above, and
# the same reason a tool-controlled title/body never reaches an argv slot.
if check_output=$(printf '%s' "$input" | python3 "$check_script" 2>&1); then
if check_output=$(printf '%s' "$input" | "${python3_cmd[@]}" "$check_script" 2>&1); then
exit 0
fi

Expand Down
17 changes: 16 additions & 1 deletion hooks/check-pr-duplicate-issue.sh
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,25 @@ if [ ! -f "$check_script" ]; then
deny "Blocked by hooks/check-pr-duplicate-issue.sh: cannot verify duplicate-PR status -- gitapex_check_pr_duplicate_issue.py was not found at $check_script (corrupted or incomplete plugin bundle). Failing closed."
fi

# Issue #1697/#1581: prefer this checkout's own uv-managed .venv over a
# bare `python3` resolved from the calling shell's own ambient PATH --
# see hooks/check-pr-skill-audit-disclosure.sh's own precondition-probe
# fix for the PATH-nondeterminism class this closes. Falls back to a bare
# `python3` for a consumer plugin install (only skills/ and hooks/ are
# ever deployed there -- docs/repository-layout.md), where no uv
# toolchain/lockfile exists -- $check_script is stdlib-only, so a bare
# python3 has always been a correct answer there; this fallback keeps
# that unchanged.
plugin_root="$(dirname "$script_dir")"
python3_cmd=(python3)
if command -v uv >/dev/null 2>&1 && [ -f "$plugin_root/pyproject.toml" ] && [ -f "$plugin_root/uv.lock" ]; then
python3_cmd=(uv run --frozen --directory "$plugin_root" python3)
fi

payload=$(printf '%s' "$input" | jq -c \
'{owner: (.tool_input.owner // ""), repo: (.tool_input.repo // ""), title: (.tool_input.title // ""), body: (.tool_input.body // "")}')

if check_output=$(printf '%s' "$payload" | python3 "$check_script" 2>&1); then
if check_output=$(printf '%s' "$payload" | "${python3_cmd[@]}" "$check_script" 2>&1); then
check_exit=0
else
check_exit=$?
Expand Down
17 changes: 16 additions & 1 deletion hooks/check-pr-issue-acm-disclosure.sh
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,21 @@ if [ ! -f "$check_script" ]; then
deny "Blocked by hooks/check-pr-issue-acm-disclosure.sh: cannot verify the cited issue's ACM/waiver disclosure -- gitapex_check_pr_issue_acm_disclosure.py was not found at $check_script (corrupted or incomplete plugin bundle). Failing closed."
fi

# Issue #1697/#1581: prefer this checkout's own uv-managed .venv over a
# bare `python3` resolved from the calling shell's own ambient PATH --
# see hooks/check-pr-skill-audit-disclosure.sh's own precondition-probe
# fix for the PATH-nondeterminism class this closes. Falls back to a bare
# `python3` for a consumer plugin install (only skills/ and hooks/ are
# ever deployed there -- docs/repository-layout.md), where no uv
# toolchain/lockfile exists -- $check_script is stdlib-only, so a bare
# python3 has always been a correct answer there; this fallback keeps
# that unchanged.
plugin_root="$(dirname "$script_dir")"
python3_cmd=(python3)
if command -v uv >/dev/null 2>&1 && [ -f "$plugin_root/pyproject.toml" ] && [ -f "$plugin_root/uv.lock" ]; then
python3_cmd=(uv run --frozen --directory "$plugin_root" python3)
fi

# Extracts owner/repo/title/body directly from $input in one jq call and
# re-shapes them into the payload the Python checker expects -- $input is
# read via stdin the whole way through, never re-passed as a `--arg`
Expand All @@ -142,7 +157,7 @@ fi
payload=$(printf '%s' "$input" | jq -c \
'{owner: (.tool_input.owner // ""), repo: (.tool_input.repo // ""), title: (.tool_input.title // ""), body: (.tool_input.body // "")}')

if check_output=$(printf '%s' "$payload" | python3 "$check_script" 2>&1); then
if check_output=$(printf '%s' "$payload" | "${python3_cmd[@]}" "$check_script" 2>&1); then
check_exit=0
else
check_exit=$?
Expand Down
Loading
Loading