Repository navigation
Secret-path guard hook refuses when its script is missing - #920
seathatflowsinourveins merged 2 commits into
Conversation
The template's guard command exited 0 when no guard script was installed, so onFailure never fired and a removed guard let every Bash call through. It now runs the guard when present and otherwise exits 2 with a refusal, using only the command words the new-WSL renderer admits. Exit 2 blocks on every client version. One test contract changes, declared in the guard record's addendum. Co-Authored-By: Claude Opus 5.5 <[email protected]>
The CC read of #920 found that the refusal path returned 1 when python3 could not run. The refusal now uses jq's halt_error(2), one of the command words the new-WSL renderer admits, so a missing guard is refused with exit 2 even when Python is unavailable. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 983b28f343
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| { | ||
| "type": "command", | ||
| "command": "f=\"${HOME}/.claude/hooks/secret_path_guard.py\"; [ ! -f \"$$f\" ] || exec python3 \"$$f\"", | ||
| "command": "f=\"${HOME}/.claude/hooks/secret_path_guard.py\"; [ -f \"$$f\" ] && exec python3 \"$$f\"; exec jq -n --arg f \"$$f\" '\"Refused: the secret-path guard is not installed at \\($$f)\\n\" | halt_error(2)'", |
There was a problem hiding this comment.
Migrate the old guard command instead of appending a duplicate
When an existing host that installed the previous template reruns the documented claude-settings or full-profile update, apply_claude_settings.py deduplicates hooks only by their complete shell words and retains unmatched base hooks. Because this line changes the entire command string, merging the parent template into this version preserves the old fail-open command and appends this new command; reproducing that upgrade yields two secret_path_guard.py hook declarations, so every subsequent Bash tool call runs the large Python guard twice and leaves the obsolete command permanently in live settings. Add an exact migration for the previously shipped command and test the old-template-to-new-template merge.
Useful? React with 👍 / 👎.
| { | ||
| "type": "command", | ||
| "command": "f=\"${HOME}/.claude/hooks/secret_path_guard.py\"; [ ! -f \"$$f\" ] || exec python3 \"$$f\"", | ||
| "command": "f=\"${HOME}/.claude/hooks/secret_path_guard.py\"; [ -f \"$$f\" ] && exec python3 \"$$f\"; exec jq -n --arg f \"$$f\" '\"Refused: the secret-path guard is not installed at \\($$f)\\n\" | halt_error(2)'", |
There was a problem hiding this comment.
Preserve dollar escaping for the default unrendered template
When apply_claude_settings.py is invoked without --template, its declared default is this raw template and the applier performs no string.Template substitution. In the resulting live command, shell "$$f" expands to the shell PID followed by f, while the single-quoted jq filter receives the syntactically invalid $$f; even with the guard installed, reproducing this path returns jq compile-error status 3 instead of running the guard. Clients honoring the declared onFailure: "block" therefore block every Bash call, while clients at the older pinned floor treat the non-2 result as non-blocking and silently disable the guard, consistent with the documented handling of other exit codes. Either reject the raw default or make this command valid both before and after template rendering.
Useful? React with 👍 / 👎.
### Scope - What this PR changes, in one or two sentences: the Claude settings template and the repository's project settings now start the secret-path guard with `/usr/bin/python3` instead of the first `python3` on `PATH`, so a broken or inactive interpreter shim can no longer make every guarded Bash call exit 1; the new-WSL renderer admits that one absolute path as a practice-hook command word, with tests that it still refuses every other interpreter path. - Base commit: `22e3ef6ffd53b09f3cb335c46f7c696093521274` (main after #920) - Lane: `lane:foundation` - Owned paths touched: `adoption/templates/claude.settings.template.json` (one command), `.claude/settings.json` (the project guard command, folded in at the command center's request), `tools/adoption/new_wsl_client_config.py` (`BASE_COMMAND_WORDS` and its comment), `tests/test_install_claude_profile.py`, `tests/test_new_wsl_client_config.py`, `docs/decisions/2026-10-08-guard-hook-fails-closed.md` (append-only addendum), `manifests/evidence.json` (re-registration). Successor to #920, scoped by the command center on 2026-10-09: the interpreter pin, the allowlist entry for that one path, the jq refusal and missing-script exit 2 kept, and a test with no mise shim on `PATH`. ### SOTA sources - Claude Code hooks: exit code 2 blocks a PreToolUse call and other non-zero codes are non-blocking (https://code.claude.com/docs/en/hooks, read 2026-10-09); `onFailure: "block"` added in 2.1.295 (`anthropics/claude-code@602df92bf481ed904533e95c09f740f40aab5aed` CHANGELOG.md line 5: "a hook that can't start, times out, or exits with an unexpected code blocks the action instead of letting it through"). - POSIX Shell Command Language, 2.8.2 Exit Status for Commands: a command that is not found exits 127 (https://pubs.opengroup.org/onlinepubs/9799919799/utilities/V3_chap02.html). - jq 1.7 manual, `halt_error(exit_code)` (https://jqlang.org/manual/v1.7/), unchanged from #920. - Python `ast.parse(..., feature_version=...)` (https://docs.python.org/3/library/ast.html#ast.parse), used to check the guard parses as 3.9 through 3.14. - `docs/decisions/2026-10-08-guard-hook-fails-closed.md` and the Claude Code native practice record of 2026-10-09 (#923, slot `hooks`). ### Evidence-class table | Claim | Evidence class | Command / receipt | | --- | --- | --- | | Unpinned, a failing `python3` first on `PATH` makes the guard return 1 for a credential-file read and for `git status`; pinned, 2 and 0 | `local_integration` | rendered template + installed guard in a temporary home, `PATH` = a directory holding only `jq` and a failing `python3` stub | | The rendered hook blocks before and after install with no working `python3` on `PATH` | `local_integration` | new `test_rendered_hook_runs_the_system_interpreter_without_a_working_python3_on_path`; fails when the template is unpinned | | The renderer admits `/usr/bin/python3` and refuses `/usr/local/bin/python3`, `/usr/bin/python3.14`, `/usr/bin/python`, `/bin/python3`, `/tmp/python3`, `./python3`, `/usr/bin//python3`, `/usr/bin/../bin/python3`, `/usr/bin/env` | `local_integration` | new `test_a_practice_hook_may_name_only_the_pinned_system_interpreter_by_absolute_path`; fails when the allowlist entry is removed, and `--check` on the shipped template then reports the guard | | The project hook in `.claude/settings.json` takes the same form: a checkout without the script gives 2 and the refusal; the repository's guard gives 2 for a credential-file read and 0 for `git status`, behind the same failing-`python3` `PATH` | `local_integration` | new `test_project_hook_runs_the_system_interpreter_and_refuses_a_missing_script`; fails when the hook is unpinned and when its refusal is removed | | The guard uses the standard library only and parses as Python 3.9 to 3.14 | `source_review` | imports at `scripts/hooks/secret_path_guard.py:108-115`; `ast.parse` with `feature_version` 3.9 through 3.14, run by `/usr/bin/python3` 3.14.4; the suite ran under 3.13.16 | ### Local commands run ``` $ python3 -E -s -B -m unittest tests.test_apply_claude_settings tests.test_currency_due_notice tests.test_install_claude_profile tests.test_new_wsl_client_config tests.test_observability_tool_names tests.test_observability_writer_identity tests.test_render_config tests.test_secret_path_guard tests.test_skills_manifest tests.test_token_lanes_session_start tests.test_upstream_surface_watch tests.test_windows_terminal_defaults tests.test_adoption_status tests.test_credential_status tests.test_adoption_docs_consistency Ran 1016 tests; FAILED (failures=2, skipped=16) K4GuardTests.test_k4_hook_process_and_hints failed its 3-second timing assertion under load; it starts the guard with sys.executable, not a settings command, and passed 3 of 3 alone and in the full tests.test_secret_path_guard rerun (90 tests OK) the one failure is the pre-existing F10, OverlayTests.test_the_installed_client_knows_no_notification_type_without_a_decision (plugin_notification on the installed 2.1.295); it fails the same way on clean main and is owned by the currency floor PR #922 $ python3 -E -s -B -m unittest tests.test_new_wsl_definitive_defaults tests.test_new_wsl_profile tests.test_new_wsl_handbook tests.test_new_wsl_gateway_composition tests.test_new_wsl_mcp_conformance_lifecycle tests.test_adoption_docs_consistency tests.test_adoption_contract Ran 314 tests; OK (skipped=4) $ python3 -E -s -B tools/adoption/new_wsl_client_config.py --check check passed (two pre-existing mcp-inspector warnings) $ python3 -E -s -B scripts/validate.py {"components": 70, "hashed_files": 10939, "profiles": 4, "receipts": 239, "status": "passed"} $ python3 -E -s -B scripts/evidence_manifest.py --check {"files": 10939, "status": "passed"} $ python3 -E -s -B scripts/validate_convergence.py --all-recorded --root . --json valid: true $ python3 -E -s -B scripts/component_matrix.py --write && python3 -E -s -B scripts/new_host_grand_list.py --write written; no diff in the generated reports ``` ### Decision record `docs/decisions/2026-10-08-guard-hook-fails-closed.md`, new addendum "the guard runs the system interpreter": the measurement, the allowlist scope, the project hook and the limits. A host without `/usr/bin/python3`, or a missing script with no `jq` on `PATH`, exits 127, which `onFailure: "block"` turns into a block from 2.1.295 (the floor raise is the currency lane's change). After this lands, the command center aligns the host's user settings to the rendered form; until then the host keeps its own pinned form. ### Host evidence Not applicable: no file under `evidence/hosts/` changes. ### Checklist - [x] New/changed GitHub Actions are pinned to a full commit SHA with a version comment (no floating tags). (none changed) - [x] New/changed workflows declare top-level `permissions: {}` and grant each job only what it needs. (none changed) - [x] No secrets are printed, logged or committed; no new required secret was added without a documented owner. - [x] No new paid hosting, subscription or billing surface was introduced. - [x] Peer-owned untracked files and worktrees were preserved (not deleted, moved or overwritten). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Scope
52ed48881115adb072385456060a80e70e57e3d8lane:foundationadoption/templates/claude.settings.template.json(one hook command),tests/test_install_claude_profile.py(one test method),docs/decisions/2026-10-08-guard-hook-fails-closed.md(append-only addendum),manifests/evidence.json(re-registered hashes of those three files).SOTA sources
onFailure: "block"(new in 2.1.295) blocks other failures but never fires on exit 0. Source:anthropics/claude-code@602df92bf481ed904533e95c09f740f40aab5aedCHANGELOG.md, 2.1.295 entry; https://code.claude.com/docs/en/hooks (exit-code semantics), read 2026-10-09; the probe cases indocs/decisions/2026-10-08-guard-hook-fails-closed.md.adoption/bootstrap.md:262-263(theclaude-profilestep installs the guard before theclaude-settingsstep) and the new-WSL renderer's admitted command words,tools/adoption/new_wsl_client_config.pyBASE_COMMAND_WORDSat this base.hooks); the command center ruled the change and applied the same command to its host at 2026-10-09T06:20:30Z.Evidence-class table
local_integrationtests.test_install_claude_profiletest_rendered_hook_blocks_before_and_after_install; manual run of the rendered command in an empty temporary HOME:missing rc=2local_integrationlocal_integrationtests.test_new_wsl_client_config(the first draft using{ ... exit 2; }failed it; the final form runs only[,python3andjq)local_integrationnative_proven(command center)Local commands run
Decision record
docs/decisions/2026-10-08-guard-hook-fails-closed.md, Addendum (2026-10-09). Overturn: a supported install route that applies the settings before the guard file exists (then that route installs the guard first). Raising the pinned client floor from 2.1.284 to 2.1.295 is a separate change owned by the currency lane.Host evidence
Not applicable: no file under
evidence/hosts/changes.Checklist
permissions: {}and grant each job only what it needs. (none changed)🤖 Generated with Claude Code