Skip to content

fix(hooks): pin every BrainLayer hook to the keg python, never bare python3 (L) - #790

Merged
EtanHey merged 5 commits into
mainfrom
wt/hooks-pin-keg-python
Sep 5, 2026
Merged

fix(hooks): pin every BrainLayer hook to the keg python, never bare python3 (L)#790
EtanHey merged 5 commits into
mainfrom
wt/hooks-pin-keg-python

Conversation

@EtanHey

@EtanHey EtanHey commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Size: L (554 hand-written lines: +547/-7). Canon 9 one-line why: the module and the test
that gates it are one indivisible change — the test file is 259 of those lines, and splitting it
off would ship an untested interpreter resolver, which is the exact shape of the bug being fixed.
The other 7 files are one-line shebang edits.

The bug

BrainLayer's hooks are wired into ~/.claude/settings.json as python3 <script>. PATH therefore
chose the interpreter — and with it, the brainlayer library every hook fire imported.

On the M4 python3 fronts the framework python at
/Library/Frameworks/Python.framework/Versions/3.13/bin/python3, whose
site-packages/_brainlayer.pth injects ~/Gits/brainlayer/src: a live checkout, not a keg.
While that checkout held a 09-02 snapshot, hooks ran weeks-old library code under a 1.5.15 CLI
with no import error, no version mismatch and no log line.

The checkout has since been repaired, which fixes the symptom and not the mechanism:

$ git show 5bd8d818:src/brainlayer/__init__.py | grep __version__
__version__ = "1.5.12"      # the SAME file bare `python3` imports, one commit ago

Any agent that runs git checkout at ~/Gits/brainlayer re-breaks import brainlayer for every
hook, silently. The root does not self-stabilise. Naming the interpreter is what makes hooks
immune to it.

The change

  • hooks/*.py (7) — shebang #!/usr/bin/env python3#!/opt/homebrew/opt/brainlayer/libexec/venv/bin/python.
    The opt/ symlink, not Cellar/<version>, because a rendered command outlives its keg — same
    reasoning the launchd plists already use.
  • src/brainlayer/hook_python.py (new) — resolves the keg python, honours BRAINLAYER_HOOK_PYTHON
    (which must exist), and refuses a silent PATH fallback, raising HookPythonUnresolved naming
    everything it looked for. That is the fail-closed stance scripts/launchd/install.sh already
    takes when a keg is present. It also lints a settings file:
    python -m brainlayer.hook_python ~/.claude/settings.json.
  • tests/test_hook_python.py (new, 46 tests) — fails on any bare python3/env python3 shebang or
    hook command. It matches only the seven basenames this repo owns, so other repos' hooks are never
    reported, and it sees through the Stop hook's stop-telemetry.mjs … -- <python> <script> wrapper.
    Written red-first: 15 failed → 45 passed + 1 skipped.
  • AGENTS.md## Hook Interpreter (pinned, never PATH).

Measured

                 bare python3 (framework)          keg python
version          1.5.15                            1.5.15
file             ~/Gits/brainlayer/src/…           Cellar/1.5.15/…/site-packages/brainlayer/…
__build_sha__    None                              51a72a061f2d05306c5de3a8d0c5cc9c2ca38d83
stderr per fire  253 B RequestsDependencyWarning   none

Deployment half applied to ~/.claude/settings.json (backed up first; four command strings
changed, every other byte identical — verified by asserting the parsed before/after trees differ in
exactly 4 leaves).

End-to-end, using the exact command strings now configured, realistic JSON payloads, with
BRAINLAYER_DB pointed at an APFS clone so nothing touched the canonical DB (confirmed: 0 rows
written for the probe session):

[OK] SessionStart     session-cleanup.py           exit=0   47ms  (timeout 2000)
[OK] SessionStart     brainlayer-session-start.py  exit=0   73ms  (timeout 2000)  848ch injected
[OK] Stop             brainbar-stop-index.py       exit=0   87ms  (timeout 5000)
[OK] UserPromptSubmit brainlayer-prompt-search.py  exit=0  225ms  (timeout 1000)  328ch injected

Injected output is byte-identical to before the change. The Stop hook's shim was checked
differentially — the same command with a bogus interpreter exits 127, so the shim really execs the
path it is handed.

#782's deferred-sklearn form is what loads under the keg python: no heavy dep at module load
(sklearn, torch, numpy, brainlayer.pipeline, …), lazy_import_ms 0 → 98.9 ms only once
detect_correction is called, sklearn absent from sys.modules after importing semantic_style.

Not a speedup claim: warm medians over 5 runs are 262 ms vs 233 ms (UserPromptSubmit) and
69 ms vs 69 ms (SessionStart). The wins here are the __build_sha__ and the absent stderr warning,
not latency.

Known drift, reported not silently fixed

The installed ~/.claude/hooks/ copies are behind hooks/session-cleanup.py by 23 lines
including a real body change (it still carries the zikaron.db fallback). Not synced here: this PR
is unmerged, and that body drift is a separate deployment decision. It does not weaken the fix —
the settings.json command names the interpreter, which overrides the shebang entirely.

Receipt: docs.local/plans/2026-09-06/hooks-pin-keg-python-receipt.md.

@coderabbitai review

🤖 Generated with Claude Code


Note

Medium Risk
Hooks and lint now fail closed when the keg path is missing or misconfigured; ARM-specific shebangs fail loudly on Intel until commands are re-rendered via render_hook_command().

Overview
Stops BrainLayer hooks from importing whatever python3 on PATH resolves to (including a live checkout via _brainlayer.pth) by pinning all seven hook scripts to /opt/homebrew/opt/brainlayer/libexec/venv/bin/python in both shebangs and documented settings.json expectations.

Adds brainlayer.hook_python: resolves the keg interpreter (or an absolute BRAINLAYER_HOOK_PYTHON that exists and is not site-wide), never falls back to PATH, renders hook commands via render_hook_command(), and lints ~/.claude/settings.json with a fail-closed affirmative gate (is_pinned_interpreter) that flags bare python3, env python3, site-wide pythons, wrapper shapes (e.g. Stop shim after --), and other unrecognized runners—only hooks whose basenames this repo owns.

tests/test_hook_python.py locks shebangs, resolver override rules, wrapper parsing, and CLI exit codes; AGENTS.md documents the Hook Interpreter policy.

Reviewed by Cursor Bugbot for commit e18ef70. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Pin BrainLayer hook shebangs to keg python, add linter CLI

  • Replaces bare python3 shebangs in all hook scripts with the absolute BrainLayer keg virtual-environment interpreter path.
  • Adds hook_python.py with a resolver, interpreter classifiers, and a settings linter. The resolver checks an explicit override env var, then the first existing keg candidate. It rejects relative, system-wide, and missing interpreters.
  • Adds a CLI entry point (main) that validates a Claude settings JSON file, prints unpinned hook findings, and returns status 1 for findings, 2 for invalid settings, 0 when clean.
  • Adds tests in test_hook_python.py covering shebangs, interpreter classification, resolver, linter, and CLI.
  • Behavioral Change: resolve_hook_python no longer falls back to PATH; it raises HookPythonUnresolved when no valid override or keg candidate exists.

Macroscope summarized e18ef70.

…python3`

XS. Hooks were wired into `~/.claude/settings.json` as `python3 <script>`, so PATH
chose the interpreter and therefore the `brainlayer` library every hook fire imported.
On the M4, `python3` fronts the framework python at
`/Library/Frameworks/Python.framework/Versions/3.13/bin/python3`, whose
`site-packages/_brainlayer.pth` injects `~/Gits/brainlayer/src` — a live checkout any
agent can move with one `git checkout`. While it held a 09-02 snapshot, hooks executed
weeks-old library code under a 1.5.15 CLI with no import error, no version mismatch and
no log line. The checkout has since been repaired, which fixes today's symptom and not
the mechanism: the very same file says `1.5.12` at the previous commit.

So the interpreter is named outright:

- `hooks/*.py` shebangs become `/opt/homebrew/opt/brainlayer/libexec/venv/bin/python` —
  the `opt/` symlink, not a `Cellar/<version>` path, because a rendered command outlives
  the keg it was rendered against (same reasoning as the launchd plists).
- `src/brainlayer/hook_python.py` resolves it, honours `BRAINLAYER_HOOK_PYTHON` (which
  must exist), and REFUSES a silent PATH fallback — the fail-closed stance
  `scripts/launchd/install.sh` already takes when a keg is present. It also lints a
  settings file: `python -m brainlayer.hook_python ~/.claude/settings.json`.
- `tests/test_hook_python_pin.py` fails on any bare `python3`/`env python3` shebang or
  hook command. The lint matches on the seven script basenames this repo owns, so hooks
  belonging to other repos are never touched, and it sees through the Stop hook's
  `stop-telemetry.mjs … -- <python> <script>` wrapper.

Measured before/after on the M4 (deployment half applied to `~/.claude/settings.json`
from a backup, four command strings changed, every other byte identical):

  bare python3 -> brainlayer 1.5.15 @ ~/Gits/brainlayer/src, __build_sha__ None
  keg  python  -> brainlayer 1.5.15 @ Cellar/1.5.15/…, __build_sha__ 51a72a0

All four configured hooks run end-to-end under the keg python against an APFS clone of
the canonical DB: exit 0, within their configured timeouts (warm), byte-identical
injected output (848ch SessionStart, 328ch UserPromptSubmit). #782's deferred-sklearn
form is what loads: no heavy dep at module load, `lazy_import_ms` 0 -> 98.9ms only once
`detect_correction` is called.

Co-authored-by: brainlayerClaude worker running claude-opus-5 <noreply@anthropic.com>
@cursor

cursor Bot commented Sep 5, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_bf9e45e5-6cd5-4df5-8a8e-a30d7652ac0e)

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 33 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Team

Run ID: bae02a7a-78ac-421a-b6f7-93133a051883

📥 Commits

Reviewing files that changed from the base of the PR and between da325b8 and e18ef70.

📒 Files selected for processing (10)
  • AGENTS.md
  • hooks/brainbar-postcompact.py
  • hooks/brainbar-prompt-capture.py
  • hooks/brainbar-stop-index.py
  • hooks/brainlayer-prompt-search.py
  • hooks/brainlayer-session-start.py
  • hooks/post-commit.py
  • hooks/session-cleanup.py
  • src/brainlayer/hook_python.py
  • tests/test_hook_python.py

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

BrainLayer ratchet

Every Value below was measured by this run. A row this machine cannot measure says n/a — <reason> instead of a number; baselines in Notes name their own machine, method and date and were not measured here.

Row Status Value (measured by this run) Method Notes
commit provenance 🟢 GREEN measured e18ef70f87ea == PR head · checkout 38b9e0dedfc0 commit graph + live PR head · in-process · runner Which commit this whole table is about. On a pull_request event the checkout is GitHub's synthetic merge ref, whose sha is not on the PR — #759's table printed 13fa724278bf while that PR's head was 4632f979 — so this row names the PR-head parent instead, the sha a reviewer can actually see. The comparison sha is read live from repos/{owner}/{repo}/pulls/{n} when the table is collected, not taken from the event payload, because the payload cannot know the run has been overtaken. Residual window, stated rather than papered over: a push landing between that read and the comment being posted is not caught here — the run for that push refreshes the table.
baseline attestation 🟢 GREEN baseline f421d1a7c5e6 matches the main attestation (run 33994370548 · main aaaf09c00bf2 · 2026-09-05T21:54:26Z) main attestation artifact via Actions API · in-process · runner What every comparison is measured AGAINST, and who says so. The baseline fields of tests/fixtures/sprint_gate/corpus.json (queries, latency_baseline_ms, thresholds) are compared to the ratchet-attestation artifact of the latest successful push or (no-input) workflow_dispatch run of ratchet-attest.yml on main, fetched through the Actions API — a PR run cannot write to another run's artifacts. A field that differs is RED unless that main run measured the new value; today no runner-side collector measures any baseline field, so today the baseline cannot move by PR at all, and this row says so instead of a hand edit passing. Boundary: the comparator is this PR's checkout of ci_ratchet_table.py, diff-reviewable, not tamper-proof.
provenance 🟢 GREEN stamped 38b9e0dedfc0 == HEAD, tree clean wheel stamp · in-process · runner Sha half of #749 keg-mode provenance: a keg built from this wheel can answer __build_sha__. The helper-age and served-process predicates need a running BrainBar and are measured only by scripts/sprint_gate.py on an installed Mac. The sha here is the checkout's — the merge ref on a PR — because that is what publish.yml stamps at release time; the PR-head sha this table describes is the one in commit provenance above.
fallback replay debt ⚪ n/a n/a — no fallback queue on this machine: the pending memories live in ~/Gits/*/docs.local/decisions, and docs.local/ is gitignored, so a runner checkout has no copy of them to count docs.local walk · machine with the fallback queue intended_brain_store: true with no chunk_id means a memory reached disk and never reached the DB, so it answers no brain_search. Budget: 0. Any pending or unparseable file is a finding, never a band -- 122 of these sat from 2026-06-28 to 2026-09-05 because nothing counted them where a reader would look. Measured by walking the tree, so it is only ever measured on a machine that HAS the tree.
mapped bytes ⚪ n/a n/a — no BrainBar daemon at /tmp/brainbar.sock: this row needs the daemon, its hybrid helper and the indexed corpus running together, and no GitHub-hosted runner has them (macOS included) — only a self-hosted Darwin/arm64 runner on an installed Mac would socket · installed Mac Baseline 26.2 GB — installed Mac, socket, 2026-09-03, after R2 drained 15,070 → 0. Up from 16.8 GB because the drain left more vectors mapped under the same cap: the change is the drain, not a leak. Not measured by this run.
search p50/p95 ⚪ n/a n/a — no BrainBar daemon at /tmp/brainbar.sock: this row needs the daemon, its hybrid helper and the indexed corpus running together, and no GitHub-hosted runner has them (macOS included) — only a self-hosted Darwin/arm64 runner on an installed Mac would socket · installed Mac Margin p50: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Margin p95: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Calibrated on MacBook-Pro.local at 2026-09-01T08:42:22Z under active_sprint_load (tests/fixtures/sprint_gate/corpus.json). Not measured by this run.
idle CPU ⚪ n/a n/a — no BrainBar daemon at /tmp/brainbar.sock: this row needs the daemon, its hybrid helper and the indexed corpus running together, and no GitHub-hosted runner has them (macOS included) — only a self-hosted Darwin/arm64 runner on an installed Mac would ps sampling · installed Mac Ceiling: average CPU < 30% over a 60 s window (resource_budget in scripts/sprint_gate.py), ratified and kept as a hard budget. Margin daemon: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Margin helper: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Margin watcher: margin unmeasured — 0 of the 5 attested green main runs it needs; no verdict is rendered from fewer. Needs the BrainBar daemon, helper and watcher actually running. Not measured by this run.
signature_valid ⚪ n/a n/a — the macOS signature-parity job is trigger-gated and did not run on this PR: it touches no release or signing path (pyproject.toml, scripts/release-*, scripts/brainlayer-version-check.sh, publish.yml, ratchet.yml) and carries no ratchet:signatures label — a GitHub macOS runner bills at ~10× Linux minutes and rebuilds the keg venv from source codesign · installed keg scripts/release-verify-signatures.sh <keg> codesign-verifies every *.so/*.dylib under libexec/venv. The macOS parity job installs the published tap formula (etanhey/layers/brainlayer), so this row measures the release path — formula, published sdist and Homebrew's relocation — and not this PR's tree. Release-time baseline for the same keg on a different machine: 442 valid / 0 invalid — installed Mac (M4 Max), brew --prefix brainlayer 1.5.11, 2026-09-03.

🟢 GREEN measured, within budget · 🔴 RED measured, out of budget — a finding to clear before merge · ⚪ n/a not measurable on this machine, never guessed.

No RED rows.

Measured on Linux/x86_64 · measured e18ef70f87ea · PR head e18ef70f87ea · checkout 38b9e0dedfc0 · run · updated 2026-09-05 22:00:42 UTC

@EtanHey EtanHey added the size:L Tight-loop PR size: over 400 hand-written lines changed; canon 9 needs a one-line why label Sep 5, 2026
@deepsource-io

deepsource-io Bot commented Sep 5, 2026

Copy link
Copy Markdown

DeepSource Code Review

We reviewed changes in da325b8...e18ef70 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
Python Sep 5, 2026 10:00p.m. Review ↗
Swift Sep 5, 2026 10:00p.m. Review ↗
JavaScript Sep 5, 2026 10:00p.m. Review ↗
Shell Sep 5, 2026 10:00p.m. Review ↗
Secrets Sep 5, 2026 10:00p.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

@EtanHey

EtanHey commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review

Two things worth a reviewer's attention specifically:

  1. is_bare_python3 clears a path only on the libexec/venv segment. That is deliberate — it
    is the one structural marker a brainlayer keg always has and a framework/system/.venv python
    never does. If you can think of a real path that has it and is not a keg, that is a finding.
  2. resolve_hook_python raises instead of returning python3. A caller that wraps it in a
    try/except and falls back to PATH would reopen the exact hole. Nothing in this PR does; it is
    worth checking that no future caller can.

Not in scope here and reported rather than fixed: the installed ~/.claude/hooks/ copies are behind
hooks/ (session-cleanup.py by 23 lines, including the zikaron.db fallback it still carries).
Syncing those is a deployment decision for the lane, not this PR.

The .pth neutralization that was briefed alongside this was cancelled by the lead mid-task and is
NOT in this PR: _brainlayer.pth is what bin/brainlayer (an editable-install shim) resolves
through, so truncating it would break Etan's interactive brainlayer command, and removing the shim
needs sudo we do not have.


brainlayer lane worker seat, running claude-opus-5 (1M)

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown

@EtanHey I will review PR #790. I will check the libexec/venv path invariant and all resolve_hook_python call paths for a PATH fallback.

⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment thread tests/test_hook_python.py Outdated
settings = _settings("/usr/bin/env python3 /Users/x/.claude/hooks/session-cleanup.py")
assert len(find_unpinned_hook_commands(settings)) == 1

def test_empty_settings_is_clean(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method doesn't use the class instance and could be converted into a static method


The method doesn't use its bound instance. Decorate this method with @staticmethod decorator, so that Python does not have to instantiate a bound method for every instance of this class thereby saving memory and computation. Read more about staticmethods here.

Comment thread tests/test_hook_python.py Outdated
assert len(findings) == 1
assert findings[0].script == "brainbar-stop-index.py"

def test_env_python3_is_flagged_too(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method doesn't use the class instance and could be converted into a static method


The method doesn't use its bound instance. Decorate this method with @staticmethod decorator, so that Python does not have to instantiate a bound method for every instance of this class thereby saving memory and computation. Read more about staticmethods here.

Comment thread tests/test_hook_python.py Outdated
class TestCli:
"""`python -m brainlayer.hook_python <settings.json>` — a hand-runnable lint."""

def test_exits_one_on_an_unpinned_hook(self, tmp_path, capsys):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method doesn't use the class instance and could be converted into a static method


The method doesn't use its bound instance. Decorate this method with @staticmethod decorator, so that Python does not have to instantiate a bound method for every instance of this class thereby saving memory and computation. Read more about staticmethods here.

Comment thread src/brainlayer/hook_python.py Outdated

def shebang_of(path) -> str | None:
"""Return the file's shebang line (stripped), or None when it has none."""
with open(path, "rb") as handle:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

External variable 'path' used in file path


Python's open() function can take in a relative or absolute path and read its file contents. If a user is provided direct access to the path that is opened, it can have serious security risks.

Comment thread tests/test_hook_python.py Outdated


class TestShebangs:
def test_hooks_dir_is_not_empty(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method doesn't use the class instance and could be converted into a static method


The method doesn't use its bound instance. Decorate this method with @staticmethod decorator, so that Python does not have to instantiate a bound method for every instance of this class thereby saving memory and computation. Read more about staticmethods here.

Comment thread tests/test_hook_python.py Outdated


class TestFindUnpinnedHookCommands:
def test_flags_bare_python3_on_a_brainlayer_hook(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method doesn't use the class instance and could be converted into a static method


The method doesn't use its bound instance. Decorate this method with @staticmethod decorator, so that Python does not have to instantiate a bound method for every instance of this class thereby saving memory and computation. Read more about staticmethods here.

Comment thread tests/test_hook_python.py Outdated
assert findings[0].script == "brainlayer-prompt-search.py"
assert findings[0].event == "UserPromptSubmit"

def test_accepts_a_pinned_command(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method doesn't use the class instance and could be converted into a static method


The method doesn't use its bound instance. Decorate this method with @staticmethod decorator, so that Python does not have to instantiate a bound method for every instance of this class thereby saving memory and computation. Read more about staticmethods here.

Comment thread tests/test_hook_python.py Outdated
settings = _settings(f"{DEFAULT_KEG_PYTHON} /Users/x/.claude/hooks/brainlayer-prompt-search.py")
assert find_unpinned_hook_commands(settings) == []

def test_ignores_hooks_this_repo_does_not_own(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method doesn't use the class instance and could be converted into a static method


The method doesn't use its bound instance. Decorate this method with @staticmethod decorator, so that Python does not have to instantiate a bound method for every instance of this class thereby saving memory and computation. Read more about staticmethods here.

Comment thread tests/test_hook_python.py Outdated
settings = _settings("python3 /Users/x/.claude/hooks/tdd-guard.py")
assert find_unpinned_hook_commands(settings) == []

def test_sees_through_a_wrapper_command(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method doesn't use the class instance and could be converted into a static method


The method doesn't use its bound instance. Decorate this method with @staticmethod decorator, so that Python does not have to instantiate a bound method for every instance of this class thereby saving memory and computation. Read more about staticmethods here.

Comment thread tests/test_hook_python.py Outdated
class TestLiveSettings:
"""The deployment half. Skipped where there is no settings.json to check."""

def test_installed_brainlayer_hooks_are_pinned(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Method doesn't use the class instance and could be converted into a static method


The method doesn't use its bound instance. Decorate this method with @staticmethod decorator, so that Python does not have to instantiate a bound method for every instance of this class thereby saving memory and computation. Read more about staticmethods here.

@EtanHey EtanHey changed the title fix(hooks): pin every BrainLayer hook to the keg python, never bare python3 (XS) fix(hooks): pin every BrainLayer hook to the keg python, never bare python3 (L) Sep 5, 2026
Comment thread src/brainlayer/hook_python.py Outdated
Comment thread hooks/post-commit.py
@@ -1,4 +1,4 @@
#!/usr/bin/env python3
#!/opt/homebrew/opt/brainlayer/libexec/venv/bin/python

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High hooks/post-commit.py:1

On Intel Homebrew and non-Homebrew installations, every successful git commit fails to run the post-commit hook because .git/hooks/post-commit invokes the nonexistent /opt/homebrew/.../python interpreter directly. Use a portable interpreter lookup or have the installer rewrite this path for the target installation.

Suggested change
#!/opt/homebrew/opt/brainlayer/libexec/venv/bin/python
#!/usr/bin/env python3
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @hooks/post-commit.py around line 1:

On Intel Homebrew and non-Homebrew installations, every successful `git commit` fails to run the post-commit hook because `.git/hooks/post-commit` invokes the nonexistent `/opt/homebrew/.../python` interpreter directly. Use a portable interpreter lookup or have the installer rewrite this path for the target installation.

Comment thread src/brainlayer/hook_python.py Outdated
…f-use

20 major-severity DeepSource findings, all the same rule: these test methods never
touch `self`. The repo already answers this shape with `@staticmethod` (see
`tests/test_prompt_search_cap.py::TestImportCost`), so this follows the existing
convention rather than suppressing the check.

`@staticmethod` is placed OUTERMOST above `@pytest.mark.parametrize`, so parametrize
decorates the plain function and never has to set marks on a `staticmethod` object —
which is not writable on every Python this repo's CI runs (3.11, 3.12, 3.13).

46 collected, 45 passed + 1 skipped — unchanged from before.

Co-authored-by: brainlayerClaude worker running claude-opus-5 <noreply@anthropic.com>
@cursor

cursor Bot commented Sep 5, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_a820b56a-b2e0-4f66-b5c3-8a7a2a114315)

Both are real, both were red before the fix, and both would have let a PATH-resolved
interpreter pass as pinned — this module's whole bug, arriving through its own doors.

1. `BRAINLAYER_HOOK_PYTHON=python3` was accepted. `os.path.exists("python3")` is true
   whenever the cwd happens to hold one, so the existence check returned it,
   `render_hook_command` emitted `python3 <script>`, and the hook process resolved it
   through PATH. A relative override is now refused outright, naming why.

2. `python3 -u <script>` was reported as PINNED. The lint read the token immediately
   before the script — `-u` — and `is_bare_python3("-u")` is False. Skipping tokens that
   start with `-` is not sufficient either: `-X utf8` is an option WITH an argument, and
   `utf8` does not start with `-`. The interpreter is now found by what it looks like,
   walking back for the first python-shaped token, with an adjacent-token fallback so an
   unrecognised runner is still reported rather than passing as pinned.

Also documents in AGENTS.md that the shebangs name the ARM Homebrew prefix on purpose:
the four hooks that matter are invoked as `<python> <script>` from settings.json, which
overrides the shebang; on an Intel prefix a direct run fails loudly with "bad
interpreter", which is the intended failure and not a silent wrong-library run.

53 passed + 1 skipped (was 45 + 1). Live settings re-checked: still clean.

Co-authored-by: brainlayerClaude worker running claude-opus-5 <noreply@anthropic.com>
@cursor

cursor Bot commented Sep 5, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_fe808c68-26ae-4879-b941-77434624f73b)

@EtanHey

EtanHey commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Review round 1 answered — 10241852.

Both correctness findings were real, and both were red before the fix. They are the same class
of bug this PR exists to close, arriving through the module's own escape hatches:

  1. BRAINLAYER_HOOK_PYTHON=python3 was accepted. os.path.exists("python3") is true whenever
    the cwd happens to hold one, so the existence check returned it, render_hook_command emitted
    python3 <script>, and the hook process resolved it through PATH. A relative override is now
    refused outright, naming why. Test: test_a_relative_override_is_refused, parametrized over
    python3, python, ./venv/bin/python, bin/python, with all four actually present in the cwd
    so the old code really would have returned them.

  2. python3 -u <script> was reported as PINNED. The lint read the token immediately before the
    script — -u — and is_bare_python3("-u") is False. Worth noting that the obvious fix (skip
    tokens starting with -) is also wrong: -X utf8 is an option with an argument, and utf8
    does not start with -. That case was in my first fix attempt and failed. The interpreter is now
    found by what it looks like — walking back for the first python-shaped token — with an
    adjacent-token fallback so an unrecognised runner is reported rather than passing as pinned.
    Tests cover -u, -X utf8 -u, env python3 -u, and a pinned interpreter with -u staying clean.

53 passed + 1 skipped, up from 45 + 1. The live ~/.claude/settings.json was re-checked after the
change and is still clean.

On hooks/post-commit.py:1 — answered, not changed. The finding is fair but the fix belongs to
an installer that does not exist yet. Three measured points:

  • The four hooks that matter are invoked as <python> <script> from settings.json, which
    overrides the shebang entirely. The shebang only decides what happens on a direct run.
  • post-commit.py is the only one run directly, and nothing in this repo installs it as a git
    hook
    .git/hooks/post-commit does not exist and .githooks/ does not reference it.
  • On an Intel prefix that shebang fails loudly with "bad interpreter". That is the intended failure
    mode: the alternative — falling back to PATH — is precisely the silent wrong-library bug being
    fixed here. is_bare_python3 already accepts /usr/local/opt/brainlayer/libexec/venv/bin/python,
    and render_hook_command() exists to render per-machine when an installer calls it.

Documented that reasoning in AGENTS.md so the next reader does not have to re-derive it.

The 20 no-self-use findings were on the superseded commit 738ba46b and were fixed in 413940d5
by following the repo's existing convention (tests/test_prompt_search_cap.py::TestImportCost),
with @staticmethod placed outermost above @pytest.mark.parametrize so parametrize never has to
set marks on a staticmethod object.


brainlayer lane worker seat, running claude-opus-5 (1M)

One minor finding, "external variable 'path' used in file path". The audit fires on any
non-literal `open()`. `shebang_of` reads the first line of a hook script this repo ships;
the path is never user input, and a helper that could not be passed a path would be
useless. Suppressed with the reason inline and in the docstring rather than contorting
the signature to satisfy a false positive.

Co-authored-by: brainlayerClaude worker running claude-opus-5 <noreply@anthropic.com>
Comment thread AGENTS.md
code under a 1.5.15 CLI, silently: no import error, no version mismatch, no log line.
- `src/brainlayer/hook_python.py` resolves it and refuses a silent PATH fallback (same fail-closed
stance as `scripts/launchd/install.sh`). Lint a settings file by hand with
`python -m brainlayer.hook_python ~/.claude/settings.json`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium AGENTS.md:315

The documented lint command resolves python through PATH, so it can run against the stale interpreter this section warns about and either fail to import hook_python or incorrectly validate settings with old code. Document the resolved keg interpreter here instead.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @AGENTS.md around line 315:

The documented lint command resolves `python` through `PATH`, so it can run against the stale interpreter this section warns about and either fail to import `hook_python` or incorrectly validate settings with old code. Document the resolved keg interpreter here instead.

Comment thread src/brainlayer/hook_python.py Outdated
Comment on lines +150 to +151
if os.path.exists(override):
return override

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High brainlayer/hook_python.py:150

resolve_hook_python returns any existing BRAINLAYER_HOOK_PYTHON path, including directories and non-executable files, so render_hook_command emits an unusable executable and hook startup fails instead of raising HookPythonUnresolved. Validate that the override is an executable regular file.

Suggested change
if os.path.exists(override):
return override
if os.path.isfile(override) and os.access(override, os.X_OK):
return override
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/brainlayer/hook_python.py around lines 150-151:

`resolve_hook_python` returns any existing `BRAINLAYER_HOOK_PYTHON` path, including directories and non-executable files, so `render_hook_command` emits an unusable executable and hook startup fails instead of raising `HookPythonUnresolved`. Validate that the override is an executable regular file.

@cursor

cursor Bot commented Sep 5, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_fa48c32f-b3e9-4faa-9f2e-f2f7ca1b1d4f)

…il-open

Review round 1. Both HIGHs are the same class, and it is the class this PR exists to
kill: the module's stated stance is fail-closed, and these were two places it failed
open instead. Both reproduced before the fix.

HIGH 1 — a set-but-missing BRAINLAYER_HOOK_PYTHON fell through to the keg candidates.
Measured: resolve_hook_python(env={OVERRIDE: missing}, candidates=(keg,)) returned the
keg. Setting that variable is a deliberate operator choice; silently substituting a
different interpreter for a typo'd one is the same silent substitution refused
everywhere else. It now RAISES, and says so. test_env_override_must_exist could not
catch this because it passed candidates=(), so there was nothing to fall through to —
the new test supplies a keg and asserts the error does not name it.

HIGH 2 — the "unrecognised runner" branch passed instead of reporting. The gate was a
blacklist (`if is_bare_python3(interpreter)`), and is_bare_python3 is False for '',
'--' and 'run'. Measured: a script-only command, `uv run <script>`, and — worst — the
Stop shim with its pin dropped all linted CLEAN. That last one is the single command
shape _brainlayer_script_in goes out of its way to parse. The gate is now affirmative:
`if not is_pinned_interpreter(interpreter)`, so anything unrecognised is REPORTED, with
a reason. A lint that answers "fine" to a shape it does not understand is not a gate.

MEDIUM — is_bare_python3 called any absolute non-`libexec/venv` python unpinned, so
resolve_hook_python accepted an operator's /tmp/myvenv/bin/python while the linter
failed the command built from it. One predicate was answering two questions. Split:
is_bare_python3 = does PATH decide (an absolute path is never bare); is_system_python =
is this site-wide, i.e. is its site-packages where a global .pth lives — which is the
actual hazard, since naming the framework python absolutely closes the PATH hole and
leaves the .pth hole open; is_pinned_interpreter = the gate. The hatch now refuses a
site-wide override too, so hatch and linter agree in both directions.

MEDIUM — an empty interpreter token read as pinned, so a settings command that was only
a script path passed. Closed by the same affirmative gate.

Also: '--' and 'run' were being explained as "a relative path", which they are not, and
'./bin/python' as "resolved by PATH", which it is not — it is resolved against the cwd.
is_bare_python3 now requires no directory component, and _why_unpinned distinguishes
PATH / cwd / site-wide / no-interpreter / unrecognised.

87 passed + 1 skipped, up from 53 + 1. Live ~/.claude/settings.json re-linted: clean.
The pre-change backup still exits 1 naming all four.

Co-authored-by: brainlayerClaude worker running claude-opus-5 <noreply@anthropic.com>
@cursor

cursor Bot commented Sep 5, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_678d4b08-8a53-4056-9d44-e8f293262236)

@EtanHey

EtanHey commented Sep 5, 2026

Copy link
Copy Markdown
Owner Author

Review round 1 — all six answered. New head e18ef70f.

Every finding reproduced before the fix. The lead's framing was right and it changed the shape of
the fix: both HIGHs are the same class, and it is the class this PR exists to kill. My module's
stated stance is fail-closed, and these were two places it failed open.

HIGH @156-162 — set-but-missing override fell through — FIXED

Reproduced: resolve_hook_python(env={BRAINLAYER_HOOK_PYTHON: "/absolutely/missing/python"}, candidates=(keg,))
returned the keg. Now raises:

BRAINLAYER_HOOK_PYTHON='/absolutely/missing/python' does not exist. Refusing to silently substitute another interpreter for an override that was set on purpose — fix the path or unset BRAINLAYER_HOOK_PYTHON to use the keg.

Your diagnosis of why the old test missed it was exact: test_env_override_must_exist passed
candidates=(), so there was nothing to fall through to. The new test supplies a real keg and
asserts the error does not name it — it fails if the keg is ever silently substituted again.

HIGH @236-239 — unrecognised runner passed instead of reporting — FIXED

The gate was a blacklist (if is_bare_python3(interpreter)), and that is False for '', '--' and
'run'. Reproduced — all three returned zero findings:

command before after
<script> alone 0 1 — no interpreter in the command — the script runs under its shebang, which PATH may resolve
stop-telemetry.mjs … -- <script> 0 1 — '--' is not a recognisable python interpreter
uv run <script> 0 1 — 'run' is not a recognisable python interpreter

The Stop case is the one that stings: that wrapper is the single command shape
_brainlayer_script_in goes out of its way to parse, and it could drop its pin and still lint clean.
The gate is now affirmative — if not is_pinned_interpreter(interpreter) — and every finding carries
a reason, so an unrecognised shape produces an actionable report rather than a bare verdict.

MEDIUM @109-111 — hatch and linter disagreed — FIXED, by splitting the predicate

One function was answering two questions. Now three:

  • is_bare_python3does PATH decide? An absolute path is never bare. It names something.
  • is_system_pythonis it site-wide? /usr/bin, /usr/local/bin, /opt/homebrew/bin, any
    Python.framework. This is the real hazard and it is not a style preference: naming the framework
    python absolutely closes the PATH hole and leaves the .pth hole wide open — and the .pth is
    what put a live checkout on every hook's import path in the first place.
  • is_pinned_interpreter — the gate both feed.

So /tmp/myvenv/bin/python is now accepted by the hatch and the linter, and I tightened the
other direction too: the hatch now refuses a site-wide override, because otherwise an operator
could create a configuration this module's own gate rejects. The contract is "explicitly named", not
"Homebrew-shaped" — that is your main() point, taken.

MEDIUM @99 — empty token read as pinned — FIXED by the same affirmative gate.

MEDIUM @118-121 — test gaps — FIXED. 87 passed + 1 skipped, up from 53 + 1. Both HIGHs are

pinned by tests that fail on the old code, plus is_system_python/is_pinned_interpreter tables.

One thing I found while fixing these: two reason strings were wrong. '--' and 'run' were
being explained as "a relative path", which they are not, and './bin/python' as "resolved by
PATH"
, which it is not — it is resolved against the cwd. is_bare_python3 now requires no
directory component, and _why_unpinned distinguishes PATH / cwd / site-wide / no-interpreter /
unrecognised. A gate whose explanation is wrong teaches the next reader the wrong model.

LOW @50-53 — linuxbrew — this repo does not target Ubuntu. Closing.

Measured, not assumed: there is no Formula/ (only Casks/); nothing in README.md or docs/
documents a Linux install; and the whole delivery chain is macOS — the keg's libexec/venv, the
launchd plists, BrainBar (Swift), and the macos-15 release and signature-parity jobs. Ubuntu
appears only as a unit-test runner (test (3.11/3.12/3.13) on ubuntu-latest), where these
tests string-compare and never touch a real interpreter. Apple-Silicon-Homebrew-only is correct, and
a Linux Homebrew install fails closed with a message naming what it looked for — not a silent PATH
regression. I did add /home/linuxbrew/.linuxbrew/bin to _SYSTEM_BIN_DIRS so that if anyone ever
does run there, a linuxbrew system python is classified honestly rather than mistaken for a pin.

CI on the previous head 27ab0e3c settled 0 fail / 15 pass / 3 skip, pytest matrix included.


brainlayer lane worker seat, running claude-opus-5 (1M)

env: Mapping[str, str] | None = None,
) -> str:
"""Render the `settings.json` command string for one hook script."""
interpreter = python or resolve_hook_python(env=env)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium brainlayer/hook_python.py:270

render_hook_command(script, python="python3") emits a PATH-resolved command, and python="/usr/bin/python3" bypasses the site-wide interpreter rejection. Validate explicit python values with is_pinned_interpreter before rendering, and reject invalid values.

-    interpreter = python or resolve_hook_python(env=env)
+    if python is not None and not is_pinned_interpreter(python):
+        raise HookPythonUnresolved(f"explicit hook interpreter is not pinned: {python!r}")
+    interpreter = resolve_hook_python(env=env) if python is None else python
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/brainlayer/hook_python.py around line 270:

`render_hook_command(script, python="python3")` emits a PATH-resolved command, and `python="/usr/bin/python3"` bypasses the site-wide interpreter rejection. Validate explicit `python` values with `is_pinned_interpreter` before rendering, and reject invalid values.

) -> str:
"""Render the `settings.json` command string for one hook script."""
interpreter = python or resolve_hook_python(env=env)
return f"{shlex.quote(interpreter) if ' ' in interpreter else interpreter} {script_path}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High brainlayer/hook_python.py:271

render_hook_command emits an unquoted script_path, so paths containing spaces are split into multiple arguments and paths containing shell metacharacters can be parsed incorrectly. The interpreter is also only quoted when it contains a literal space, so other special characters are unsafe; quote both tokens unconditionally with shlex.quote().

Suggested change
return f"{shlex.quote(interpreter) if ' ' in interpreter else interpreter} {script_path}"
return f"{shlex.quote(interpreter)} {shlex.quote(script_path)}"
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/brainlayer/hook_python.py around line 271:

`render_hook_command` emits an unquoted `script_path`, so paths containing spaces are split into multiple arguments and paths containing shell metacharacters can be parsed incorrectly. The interpreter is also only quoted when it contains a literal space, so other special characters are unsafe; quote both tokens unconditionally with `shlex.quote()`.

@EtanHey
EtanHey merged commit a9fc609 into main Sep 5, 2026
18 checks passed
@EtanHey
EtanHey deleted the wt/hooks-pin-keg-python branch September 5, 2026 22:30
EtanHey added a commit that referenced this pull request Sep 5, 2026
…out rule (XS) (#793)

#789 landed the root-checkout rule with the honest state at the time: no keg-python PR existed,
and the pin lived only in `~/.claude/settings.json` as machine config. #790 merged 36 minutes
later and changed that, so the section now says what is actually true.

- For HOOKS the rule is now defence in depth: `hooks/*.py` shebangs name the keg python via the
  `opt/` symlink, `src/brainlayer/hook_python.py` resolves it and refuses a silent PATH fallback,
  and it lints a settings file. A `git checkout` at the root can no longer re-aim a hook.
- For everything else the rule is still primary. Verified after #790 merged: `_brainlayer.pth` is
  untouched and bare `python3 -c "import brainlayer"` still resolves to
  `<root>/src/brainlayer/__init__.py`. #790 closed the worst consumer, not the mechanism.
- New warning bullet, because this is a live "merged is not deployed" case: `~/.claude/hooks/*.py`
  are real files, not symlinks into this repo, so #790's shebang edit does not reach them by
  merging. Measured 2026-09-06, three deployed hook copies still begin `#!/usr/bin/env python3`;
  they are safe only because settings.json names the interpreter ahead of the script path. The
  section tells the reader to run the `hook_python` lint before believing the pin is live.

Follow-up PR rather than a commit on the merged branch, per canon 9. Docs-only; no code.

Agent: brainlayerClaude-8a49f7a0 (claude-opus-5[1m])

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:L Tight-loop PR size: over 400 hand-written lines changed; canon 9 needs a one-line why

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant