You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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):
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.
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.
…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>
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
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
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.
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.
Two things worth a reviewer's attention specifically:
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.
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)
@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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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
The reason will be displayed to describe this comment to others. Learn more.
🟠 Highhooks/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.
🚀 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.
…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>
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>
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:
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.
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>
The reason will be displayed to describe this comment to others. Learn more.
🟡 MediumAGENTS.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.
The reason will be displayed to describe this comment to others. Learn more.
🟠 Highbrainlayer/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.
🚀 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.
…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>
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_python3 — does PATH decide? An absolute path is never bare. It names something.
is_system_python — is 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)
The reason will be displayed to describe this comment to others. Learn more.
🟡 Mediumbrainlayer/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.
The reason will be displayed to describe this comment to others. Learn more.
🟠 Highbrainlayer/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().
🚀 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()`.
…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>
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
size:LTight-loop PR size: over 400 hand-written lines changed; canon 9 needs a one-line why
1 participant
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.
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.jsonaspython3 <script>. PATH thereforechose the interpreter — and with it, the
brainlayerlibrary every hook fire imported.On the M4
python3fronts the framework python at/Library/Frameworks/Python.framework/Versions/3.13/bin/python3, whosesite-packages/_brainlayer.pthinjects~/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:
Any agent that runs
git checkoutat~/Gits/brainlayerre-breaksimport brainlayerfor everyhook, 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, notCellar/<version>, because a rendered command outlives its keg — samereasoning the launchd plists already use.
src/brainlayer/hook_python.py(new) — resolves the keg python, honoursBRAINLAYER_HOOK_PYTHON(which must exist), and refuses a silent PATH fallback, raising
HookPythonUnresolvednamingeverything it looked for. That is the fail-closed stance
scripts/launchd/install.shalreadytakes 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 barepython3/env python3shebang orhook 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
Deployment half applied to
~/.claude/settings.json(backed up first; four command stringschanged, 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_DBpointed at an APFS clone so nothing touched the canonical DB (confirmed: 0 rowswritten for the probe session):
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_ms0 → 98.9 ms only oncedetect_correctionis called,sklearnabsent fromsys.modulesafter importingsemantic_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 behindhooks/—session-cleanup.pyby 23 linesincluding a real body change (it still carries the
zikaron.dbfallback). Not synced here: this PRis unmerged, and that body drift is a separate deployment decision. It does not weaken the fix —
the
settings.jsoncommand 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
python3on PATH resolves to (including a live checkout via_brainlayer.pth) by pinning all seven hook scripts to/opt/homebrew/opt/brainlayer/libexec/venv/bin/pythonin both shebangs and documentedsettings.jsonexpectations.Adds
brainlayer.hook_python: resolves the keg interpreter (or an absoluteBRAINLAYER_HOOK_PYTHONthat exists and is not site-wide), never falls back to PATH, renders hook commands viarender_hook_command(), and lints~/.claude/settings.jsonwith a fail-closed affirmative gate (is_pinned_interpreter) that flags barepython3,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.pylocks shebangs, resolver override rules, wrapper parsing, and CLI exit codes;AGENTS.mddocuments 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 CLIpython3shebangs in all hook scripts with the absolute BrainLayer keg virtual-environment interpreter path.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.resolve_hook_pythonno longer falls back to PATH; it raisesHookPythonUnresolvedwhen no valid override or keg candidate exists.Macroscope summarized e18ef70.