fix(hooks): resolve PATH-dependent bare python3 false-deny (#1697, #1581) - #1701
Merged
Conversation
…rministically hooks/check-pr-skill-audit-disclosure.sh's tier-1 block invoked its precondition probe and the skill-audit-disclosure gate script as bare python3, which resolves from the calling PreToolUse hook's own ambient PATH rather than this checkout's uv-managed .venv. When that PATH lacks the venv (confirmed live in this environment), the probe reports a genuinely-installed third-party package (pydantic) as missing and false-denies create_pull_request/update_pull_request. Route both invocations through uv run --frozen python3 (this block only ever runs inside a dev checkout, guarded by .github/ presence). Also route the tier-2 (SKILL.md-only) check_script invocation the same way, with a command -v uv + lockfile-gated fallback to bare python3, since that path also runs in a consumer plugin install with no uv toolchain. Also default is_importable()'s own python argument to sys.executable instead of a second, independent PATH lookup, so the probe always reflects whichever interpreter actually launched it rather than re-deriving a possibly-different one from PATH. Live-reproduced via a git worktree at the pre-fix commit: under this session's own ambient PATH (uv present, .venv/bin absent), the original code denies (exit 2, "python3 cannot import: pydantic") and the fixed code allows (exit 0). Refs #1697, #1581 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGHXhvkGRDFxGBARLbmh81
…fallback Extends #1697's fix pattern to the nine other hooks/*.sh bare-python3 call sites (check-bash-safety.sh x2, check-issue-acm-disclosure.sh, check-post-review-obligation-tracker.sh, check-post-write-provenance.sh, check-pr-duplicate-issue.sh, check-pr-issue-acm-disclosure.sh, check-pr-title-convention.sh, check-stop-review-obligation.sh) plus check-pr-skill-audit-disclosure.sh's own tier-2 (SKILL.md-only) call site, for consistency with the same PATH-nondeterminism class #1697's own precondition-probe fix closes. All ten scripts these call sites invoke are stdlib-only today (verified by inspecting every import statement), so this is a preventive or consistency fix rather than a currently-observable-failure fix for these nine plus tier-2. Each call site prefers uv run --frozen python3 when uv is on PATH and this checkout has its own pyproject.toml/uv.lock, falling back to a bare python3 otherwise. hooks/ is deployed to consumer plugin installs (see docs/repository-layout.md), which carry neither a uv toolchain nor a lockfile, so an unconditional uv run would have newly broken every such install; this fallback keeps the pre-existing behavior unchanged there. Also closes out #1581's own residual scope on this fix pattern (its stated target, .pre-commit-config.yaml's local-preflight entry, was already fixed by PR #1486/#1485 -- see the comment on #1581 for the live-verified timeline). Refs #1697, #1581 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGHXhvkGRDFxGBARLbmh81
…*.py target gitapex_gate_bare_python3_invocation.py previously only WARNED (report-only, never affected exit code) on a hooks/*.sh bare-python3 invocation of a .github/scripts/*.py target, and never inspected hooks/*.py targets at all -- so it could not have caught #1697's own root cause, a bare invocation of hooks/gitapex_check_python_precondition.py. Promotes the .github/scripts/*.py case from WARNING to HARD-FAIL, and adds a new HARD-FAIL case for a hooks/*.py file registered in .gitapex/ssot.json under a gate whose own preconditions.requires_python_packages is non-empty (a hooks/*.py file that genuinely needs a third-party-dependent, deterministically-resolved interpreter, not every hooks/*.py sibling indiscriminately -- an unregistered hooks/*.py target stays bare-invoked by design, per docs/repository-layout.md). Also registers hooks/gitapex_check_python_precondition.py under the skill-audit-disclosure gate's own script list in .gitapex/ssot.json, since that gate is the one declaring the pydantic precondition this script exists to probe, and it was missing from that list. Live-verified this gate would have caught #1697's own defect before merge: run against a git-worktree checkout of the pre-fix commit's hooks/ directory with the fixed ssot.json, it correctly flags all three original bare invocations in check-pr-skill-audit-disclosure.sh (precondition_script, full_gate, and check_script). Refs #1697 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGHXhvkGRDFxGBARLbmh81
…hook directly local-preflight's detection-logic-property-coverage and function-body-test-coverage gates flagged the previous commit's own gitapex_gate_bare_python3_invocation.py changes: the new load_python_dependent_hook_script_names string-comparison call site had no hypothesis @given property test, and the changed _scan_hook body had no test in the same diff mentioning it by name (existing tests only called it through find_hooks_shell_indirected_invocations's wrapper). Adds direct _scan_hook unit tests for a registered vs. unregistered hooks/*.py target, plus hypothesis property tests for load_python_dependent_hook_script_names covering: a required-package gate contributes its own hooks/*.py basename (never a sibling .github/scripts/*.py entry on the same gate), a gate with no preconditions never contributes one, and an empty requires_python_packages list is treated the same as absent. Refs #1697 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGHXhvkGRDFxGBARLbmh81
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1701 +/- ##
=======================================
Coverage 99.66% 99.66%
=======================================
Files 152 152
Lines 24725 24792 +67
Branches 2967 2981 +14
=======================================
+ Hits 24643 24710 +67
Misses 82 82 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
codecov/patch flagged PR #1701's head commit at 98.36% patch coverage (target 99.66%), missing gitapex_gate_bare_python3_invocation.py:310 -- load_python_dependent_hook_script_names's own early-return when .gitapex/ssot.json's "gates" key is present but not a list (a malformed registry shape distinct from the already-covered missing-file, undecodable, and non-mapping-top-level cases). Refs #1697 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGHXhvkGRDFxGBARLbmh81
…eview An independent evaluating-deterministic-gate-quality review of PR #1701 (dispatched via a fresh, isolated subagent per that skill's own Subagent-dispatch rule) live-confirmed two real defects in this PR's own gitapex_gate_bare_python3_invocation.py changes: 1. Dimension-15 (fail-closed) violation: load_python_dependent_hook_script_names silently degraded to an empty frozenset on a missing/unreadable/malformed .gitapex/ssot.json, which find_hooks_shell_indirected_invocations then read as "nothing registered" -- so a crafted malformed ssot.json alongside a real bare python3 invocation of a registered hooks/*.py target produced a false "No ... bare invocations found" and exit 0. This broke the same file's own established convention (find_bare_invocations/_scan_workflow already treat an unreadable input as a "cannot verify" finding forcing exit_code=1). Fixed: load_python_dependent_hook_script_names now returns None (not an empty frozenset) on any unreadable/malformed registry; main() treats None as a hard failure while still running the .github/scripts/*.py scan against an empty hooks/*.py scope, matching find_bare_invocations's own "still scan what you can, but report the inability to verify" precedent. Live-verified: a malformed ssot.json now exits 1 with "Could not read or parse ..." instead of the prior false-clean exit 0. 2. Missing path-boundary anchor: the registered-hooks/*.py-target regex anchored only on a trailing "$", so an unregistered file whose own name merely ENDS with a registered name as a substring (e.g. "my_other_gitapex_check_python_precondition.py" against registered "gitapex_check_python_precondition.py") would false-positive. Fixed by anchoring on "(?:^|/)" before the name, requiring a real path-component boundary. Live-verified the decoy no longer flags. Both fixes covered by new unit tests (including the exact decoy/malformed inputs above) plus updated existing tests whose expected return value changed from frozenset() to None for the four genuinely-malformed-registry cases (a well-formed-but-empty registry, or one with no matching gates, still correctly returns frozenset()). Refs #1697 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGHXhvkGRDFxGBARLbmh81
Retrofit ADR for PR #1701's own decision (hooks/*.sh companion-script invocations prefer `uv run --frozen python3` when this checkout owns a uv toolchain and lockfile, falling back to the pre-existing bare `python3` otherwise) that fixed issue #1697's PATH-dependent false-deny and closed out issue #1581's residual scope on the same defect class. Approved by tvna, 2026-09-03. Refs #1697, #1581 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGHXhvkGRDFxGBARLbmh81
Second independent review of this branch found a narrower mirror of the already-fixed whole-file fail-open bug: load_python_dependent_hook_script_names returned an empty frozenset (a legitimate-looking "no additional targets") rather than failing closed when a single `gates` entry DID name a `hooks/*.py` script but its own `preconditions` or `requires_python_packages` field was present and malformed (e.g. a string instead of a mapping/list) -- live-reproduced: a well-formed ssot.json with one such corrupted entry, alongside a real bare invocation of the registered target, produced a false "clean" exit 0. Fixed by checking each gate's own `script` list for a hooks/*.py target first; only once one is present does a malformed `preconditions`/ `requires_python_packages` shape return None (fail closed) instead of being skipped. A gate that legitimately omits preconditions, or that names no hooks/*.py script at all, is unaffected and still contributes nothing, as before. Refs #1697 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGHXhvkGRDFxGBARLbmh81
A second independent review of PR #1701 (dispatched to re-verify the per-gate fail-open fix, commit f6bed27) also checked ADR 0003 for factual accuracy against the actual code/docs and found three errors: 1. "Not yet fixed as of this ADR's writing" / "Unknown, pending a follow-up fix" (the per-gate fail-open Consequence) was already stale -- f6bed27, committed on this same branch shortly after the ADR was first drafted, had already fixed it. Updated to state the fix and cite the commit; the reviewer live re-verified the fix holds. 2. Decision Outcome said "the other nine call sites" where Consequences correctly said "ten call sites (nine files, check-bash-safety.sh twice)" -- an internal inconsistency. Independently re-counted via `grep -c 'python3_cmd\[@\]' hooks/*.sh`: 10 occurrences across 9 files, confirming "ten" is correct. Decision Outcome corrected to match. 3. The ADR stated hooks/ deployment to a consumer plugin install as current fact ("only skills/ and hooks/ are ever deployed... hooks/ is entirely on the deployed side"), but docs/repository-layout.md (the doc it cites) describes hooks/ deployment as future/planned ("and, in the future, hooks from hooks/"; "and, later, hooks"), not yet shipped. Corrected Context and the duplication Consequence to describe hooks/ deployment as planned rather than current, and Context's own risk framing to "as soon as hooks/ deployment ships" rather than an already-present break. No change to the Decision Outcome (uv-preferred python3 resolution with a bare-python3 fallback) or Status -- these are factual corrections to an already-Accepted record, not a re-litigation of the decision itself. Refs #1697 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BGHXhvkGRDFxGBARLbmh81
tvna
marked this pull request as draft
September 3, 2026 03:47
tvna
marked this pull request as ready for review
September 3, 2026 03:50
This was referenced Sep 3, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes a PATH-dependent bare
python3false-deny inhooks/check-pr-skill-audit-disclosure.sh's precondition probe (issue#1697, a regression from #1566/PR #1675) and extends the same
deterministic-interpreter fix to the other nine
hooks/*.shbare-python3call sites, closing out #1581's own residual scope on this defect class.
Also hard-fails a durable gate that previously could not have caught
this.
Facts
f274bda): piping a real
create_pull_request-shaped PreToolUsepayload into
hooks/check-pr-skill-audit-disclosure.shunder thissession's own ambient PATH (which has
uvbut not this checkout's own.venv/bin) denies with exit 2,"python3 cannot import: pydantic"--even though
uv sync --group devhad already installed pydantic into.venv.payload now allows with exit 0.
hooks/check-pr-skill-audit-disclosure.shinvoked its preconditionprobe (
hooks/gitapex_check_python_precondition.py) and its tier-1gate script as bare
python3 "$var", which resolves from the callingPreToolUsehook's own ambient PATH rather than this checkout'suv-managed
.venv.is_importable()'s ownpythonargumentadditionally defaulted to the literal string
"python3"(a second,independent PATH lookup), rather than
sys.executable.hooks/*.shbare-python3 call sites(issue gate-proposal: PATH-dependent bare python3 in check-pr-skill-audit-disclosure.sh's precondition probe blocks create_pull_request in some sessions #1697's own Facts section lists them): every script they invoke
is stdlib-only (verified by reading each file's own top-level
importstatements) -- so this is a preventive/consistency fix for those nine,
not a currently-observable-failure fix. Stated here as fact, not
assumed.
(
.pre-commit-config.yaml'slocal-preflightentry) was already fixedby commit
7002c8965e104c60a0a2ff9806d3d6bb2aee149e(PR fix(hooks): route local-preflight through uv run, not bare python3 #1486, merged2026-08-29T23:54:26Z) -- roughly 22 hours before gate-proposal: retro #1576 repair 1: PATH-dependent pre-push hook interpreter #1581 was even filed
(2026-08-30T21:40:02Z). Confirmed directly against the current
.pre-commit-config.yamlandgit log. See the comment left on gate-proposal: retro #1576 repair 1: PATH-dependent pre-push hook interpreter #1581for the full timeline. gate-proposal: retro #1576 repair 1: PATH-dependent pre-push hook interpreter #1581's own body never itself enumerated the
nine other call sites this PR fixes -- those came from gate-proposal: PATH-dependent bare python3 in check-pr-skill-audit-disclosure.sh's precondition probe blocks create_pull_request in some sessions #1697's own
Facts section.
gitapex_gate_bare_python3_invocation.pypreviously WARNED (report-only) on a
.github/scripts/*.pytarget andnever inspected
hooks/*.pytargets at all. Run against a git-worktreecheckout of the pre-fix
hooks/directory with this PR's own fixed.gitapex/ssot.json(which registershooks/gitapex_check_python_precondition.pyunder theskill-audit-disclosuregate), the extended gate correctly flags allthree original bare invocations in
check-pr-skill-audit-disclosure.sh(precondition_script:255,full_gate:301, check_script:366) -- proving it would have caught this
defect before merge.
PR open as commits added new tests), 1 pre-existing failure unrelated
to this change (
test_repository_workflows_are_drift_free, caused bythis session's own shallow git clone lacking full history for an
unrelated
harden-checkoutpin-drift check -- confirmed viagit rev-parse --is-shallow-repository= true).Assumptions
than deferring (see Scope decision below) -- a judgment call, not a
fact.
skills/iscurrently deployed, per
docs/repository-layout.md, withhooks/deployment stated there as planned for a future release) genuinely
carries no
uvtoolchain or lockfile; I did not test an actualconsumer install, only inferred this from that doc and from
hooks/check-issue-acm-disclosure.sh's own existing comment making thesame claim.
Scope decision: all 10 call sites in one PR (not split with #1581)
Issue #1697 asked me to decide and state reasoning rather than pick
silently. Chose to fix all ten
hooks/*.shbare-python3 call sites inthis one PR, and to close out #1581 alongside #1697, because:
resolution) -- fixing nine of them now, with the pattern and tests
already built for the tenth, costs little extra.
command -v uv+ lockfile-gated fallback tobare
python3(not an unconditionaluv run), so none of them cannewly break a consumer plugin install that has no uv toolchain --
the same fail-safe direction
hooks/check-post-review-obligation-tracker.shand
hooks/check-stop-review-obligation.shalready use for a missingpython3itself.gate-proposal: retro #1576 repair 1: PATH-dependent pre-push hook interpreter #1581 was even filed (see Facts) -- so gate-proposal: retro #1576 repair 1: PATH-dependent pre-push hook interpreter #1581's only remaining live
value was the same defect-class awareness gate-proposal: PATH-dependent bare python3 in check-pr-skill-audit-disclosure.sh's precondition probe blocks create_pull_request in some sessions #1697 already carries
forward. Closing it here avoids leaving a stale, already-resolved
issue open indefinitely.
Risk / blast radius
check-pr-skill-audit-disclosure.sh):scoped to a block that only runs when
.github/scripts/is present(this repository's own dev checkout) -- never reaches a consumer
install.
pre-existing bare-
python3behavior whenuv/lockfile are absent, soa consumer install with no uv toolchain is unaffected. If the fallback
condition itself has a bug, the worst case is falling back to the
pre-existing behavior (unchanged), not a new failure mode.
bare-python3
hooks/*.shcall site of a.github/scripts/*.pyor aregistered third-party-dependent
hooks/*.pytarget -- this is theintended effect (issue gate-proposal: PATH-dependent bare python3 in check-pr-skill-audit-disclosure.sh's precondition probe blocks create_pull_request in some sessions #1697 item 3).
Rollback
git revertthe commits on this branch. No schema, data, orexternal-service change; a plain revert restores the exact pre-PR
behavior (including the original bug in
check-pr-skill-audit-disclosure.sh).Verification
Restating issue #1697's own re-verified Acceptance Criteria Map:
check-pr-skill-audit-disclosure.sh's precondition probe resolves the same interpreter regardless of the calling context's ownPATHpython3 "$precondition_script"invocation (and the probe subprocess's own default insidegitapex_check_python_precondition.py) touv run --frozen python3(tier-1 gate invocation too), and defaultis_importable()'s ownpythonargument tosys.executableinstead of a fresh PATH lookuphooks/check-pr-skill-audit-disclosure.sh(tier-1 precondition-probe and full_gate invocations); editedis_importable()/find_missing_modules()inhooks/gitapex_check_python_precondition.py.venvthat itself lacks the required package still correctly denies -- this fix narrows which interpreter is resolved, not the precondition's own pass/fail logichooks/*.shbare-python3call site gets the same deterministic-interpreter treatmentuv run --frozen python3whenuvis on PATH and this checkout has its ownpyproject.toml/uv.lock, falling back to barepython3otherwisehooks/*.shfiles pluscheck-pr-skill-audit-disclosure.sh's tier-2 call site, each with acommand -v uv+lockfile-gated fallbackuvremoved from PATH on multiple hooksis_importable(..., python="python3")) is also fixedsys.executable, falling back to the literal"python3"only ifsys.executableis itself empty (documented CPython edge case)is_importable()/find_missing_modules()signatures inhooks/gitapex_check_python_precondition.pysubprocess.run's own argv to assert the default equalssys.executable, the empty-fallback, and that an explicitpython=still overrides the defaultsys.executableis the process's own known-good interpreter path by constructionCommands run (re-verified against the current head):
Skill audit evidence
checker-script-adversarial-review: RAN -- against.github/scripts/gitapex_gate_bare_python3_invocation.py's own newload_python_dependent_hook_script_names/_scan_hookchanges, acrossthree rounds as findings surfaced (see Merge gate section below):
decoy
hooks/*.pynames as both a substring-prefix and asubstring-suffix of a registered target, the
python3_cmd=(...)array-fallback shape confirmed NOT misdetected as a new bare
invocation, a malformed/missing
.gitapex/ssot.json, and a singlemalformed
gatesentry naming a realhooks/*.pytarget.deterministic-gate-quality: RAN -- iteratively, as the independentreview below progressed, read against
skills/evaluating-deterministic-gate-quality/references/dimensions.mdwith dimension 15 (fail-closed default) as the focus throughout. Two
genuine dimension-15 violations were found and fixed during this PR's
own lifecycle (see Merge gate section below for the full account)
rather than merely disclosed as accepted risk.
defeat-test-disclosure: RAN -- the defeat-case tests cited above arebundled in
tests/test_gitapex_gate_bare_python3_invocation.pyandtests/test_gitapex_gate_bare_python3_invocation_properties.py.Checklist
SKILL.md/design-doc/security-relevant-skill change in this PRevals/*/split.mdchange in this PRSKILL.mdStop-boundary/branch change in this PRArchitecture decision record
docs/adr/0003-prefer-uv-resolved-python3-in-hooks-scripts.md-- retrofitADR recording this PR's own uv-preferred/bare-python3-fallback decision.
Approved by @tvna, 2026-09-03.
Merge gate: independent review
Outer layer (GitHub-native reviewer): requested
copilot-pull-request-reviewer[bot]viarequest_copilot_reviewtwice(once earlier in this PR's lifecycle, once against the current head) --
no review was returned either time within this session's own wait
window. The repository's other two configured checks,
Devin Reviewand
CodeRabbit, both report as skipped by their own configuration("trial expired and no credits remaining"; "manual review required for
this OSS repository"). This layer did not run.
Inner layer (
reviewing-an-artifact, deferred at its own Step 0 toevaluating-deterministic-gate-qualitysince this PR's diff is almostentirely deterministic-gate/hook files, not ordinary application code):
run via a fresh, isolated subagent dispatch (no prior context on this
diff) across three rounds as the branch's head evolved:
.github/scripts/gitapex_gate_bare_python3_invocation.py--(a) a dimension-15 fail-closed violation:
load_python_dependent_hook_script_namessilently degraded to "nothing registered" instead of failing closed on
a missing/unreadable/malformed
.gitapex/ssot.json, live-reproduced(a crafted malformed registry alongside a real bare invocation of a
registered target produced a false "clean" exit 0); (b) a missing
path-boundary anchor in the registered-target regex, letting an
unregistered file whose name merely ends with a registered name as a
substring false-positive. Both fixed and pushed.
round-1 fixes live plus a full-diff re-review): confirmed both fixes
hold in both directions, found a third, narrower mirror of finding
(a) -- a single
gatesentry naming ahooks/*.pyscript but with amalformed
preconditions/requires_python_packagesfield alsosilently skipped instead of failing closed, live-reproduced the same
way. Fixed and pushed (commit f6bed27).
plus an ADR factual-accuracy pass): confirmed the round-2 fix live in
both directions (malformed shapes now return
None/hard-fail;legitimate no-op shapes still correctly contribute nothing, no
overcorrection), and found 3 factual errors in the newly-added ADR
(
docs/adr/0003) -- a stale "not yet fixed" Consequence describing analready-fixed bug, a 9-vs-10 call-site count inconsistency between two
of the ADR's own sections, and a present-tense claim about
hooks/deployment that
docs/repository-layout.md(the doc the ADR itselfcites) actually states as future/planned. All three corrected and
pushed. This round's own verdict: "the gate code itself ... is now
well-formed and well-placed for this defect class."
No further findings against the current head (commit 99ed17d). Per
drafting-a-pr-to-merge's own Step 8 disclosure requirement: thisrecorded verdict is disclosure for a human reader, not a self-certifying
signal for an automated downstream consumer.
Independent review verdict
Related Issue
Closes #1697
Closes #1581
Generated by Claude Code