Skip to content

Secret-path guard hook refuses when its script is missing - #920

Merged
seathatflowsinourveins merged 2 commits into
mainfrom
claude/cc-native-practice-guard-fail-closed-20261009
Oct 9, 2026
Merged

seathatflowsinourveins merged 2 commits into
mainfrom
claude/cc-native-practice-guard-fail-closed-20261009

Conversation

@seathatflowsinourveins

@seathatflowsinourveins seathatflowsinourveins commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Scope

  • What this PR changes, in one or two sentences: the template's secret-path guard hook now refuses (exit 2) when its script is missing, instead of letting every Bash call through, and the guard record gets a dated addendum declaring the one changed test contract.
  • Base commit: 52ed48881115adb072385456060a80e70e57e3d8
  • Lane: lane:foundation
  • Owned paths touched: adoption/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

  • Claude Code 2.1.295 hooks: a PreToolUse command hook that exits 2 blocks the call on every client version; onFailure: "block" (new in 2.1.295) blocks other failures but never fires on exit 0. Source: anthropics/claude-code@602df92bf481ed904533e95c09f740f40aab5aed CHANGELOG.md, 2.1.295 entry; https://code.claude.com/docs/en/hooks (exit-code semantics), read 2026-10-09; the probe cases in docs/decisions/2026-10-08-guard-hook-fails-closed.md.
  • Install order: adoption/bootstrap.md:262-263 (the claude-profile step installs the guard before the claude-settings step) and the new-WSL renderer's admitted command words, tools/adoption/new_wsl_client_config.py BASE_COMMAND_WORDS at this base.
  • Found by the Claude Code native practice review of 2026-10-09 (verify pass, slot hooks); the command center ruled the change and applied the same command to its host at 2026-10-09T06:20:30Z.

Evidence-class table

Claim Evidence class Command / receipt
A missing guard script now blocks with exit 2 and a refusal local_integration tests.test_install_claude_profile test_rendered_hook_blocks_before_and_after_install; manual run of the rendered command in an empty temporary HOME: missing rc=2
An installed guard still blocks credential reads and allows ordinary commands local_integration the same test's after-install assertions, unchanged
The new command passes the new-WSL renderer's practice-piece check local_integration tests.test_new_wsl_client_config (the first draft using { ... exit 2; } failed it; the final form runs only [, python3 and jq)
The refusal needs no Python: `jq -n ... halt_error(2)` exits 2 with the message (jq 1.8.1) local_integration
The host's user settings run the same command native_proven (command center) command-center host-change record of 2026-10-09T06:20:30Z, tested in isolated temporary HOME directories

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
Ran 785 tests at c63f8482; FAILED (failures=1, skipped=15)
  the one failure, OverlayTests.test_the_installed_client_knows_no_notification_type_without_a_decision,
  fails identically on clean main at this base (the installed 2.1.295 client lists 'plugin_notification');
  it reads the installed client and is unrelated to this change
$ python3 -E -s -B -m unittest tests.test_apply_claude_settings tests.test_install_claude_profile tests.test_new_wsl_client_config tests.test_render_config tests.test_secret_path_guard tests.test_skills_manifest tests.test_upstream_surface_watch   # at 983b28f3
Ran 666 tests; OK (skipped=9)
$ python3 -E -s -B scripts/validate.py
{"components": 70, "hashed_files": 10921, "profiles": 4, "receipts": 239, "status": "passed"}

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

  • New/changed GitHub Actions are pinned to a full commit SHA with a version comment (no floating tags). (none changed)
  • New/changed workflows declare top-level permissions: {} and grant each job only what it needs. (none changed)
  • No secrets are printed, logged or committed; no new required secret was added without a documented owner.
  • No new paid hosting, subscription or billing surface was introduced.
  • Peer-owned untracked files and worktrees were preserved (not deleted, moved or overwritten).

🤖 Generated with Claude Code

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]>
@seathatflowsinourveins seathatflowsinourveins added the lane:foundation Foundation lane: Claude/Codex setup, hosts, memory, RAG, research, workers label Oct 9, 2026
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]>
@seathatflowsinourveins
seathatflowsinourveins marked this pull request as ready for review October 9, 2026 07:27
@seathatflowsinourveins
seathatflowsinourveins merged commit 22e3ef6 into main Oct 9, 2026
36 checks passed
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-09T07:41:00.697377Z 983b28f Draft marked ready
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@seathatflowsinourveins
seathatflowsinourveins deleted the claude/cc-native-practice-guard-fail-closed-20261009 branch October 9, 2026 07:28

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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)'",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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)'",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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 👍 / 👎.

seathatflowsinourveins added a commit that referenced this pull request Oct 9, 2026
### 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lane:foundation Foundation lane: Claude/Codex setup, hosts, memory, RAG, research, workers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant