Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .claude/hooks/git-guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,14 @@
{"hookSpecificOutput": {"hookEventName": "PreToolUse",
"permissionDecision": "deny", "permissionDecisionReason": "..."}}.
Fail-open: any error → exit 0 with no decision (allow).

One exception, a DECISION and not an error (#186): a well-formed event that
lacks the field this guard reads is DENIED. With no `tool_name`, or a Bash
event with no string `tool_input.command`, the guard used to read "" and allow
the command, so a Claude Code update that renamed the field would switch this
guard off without a word. Now it refuses and says why. A Write/Edit/MultiEdit
event with no `file_path` still fails open: that check is advisory (a warning
unless CLAUDE_STRICT_PATHS=1). Unparseable input still fails open as before.
"""

from __future__ import annotations
Expand Down Expand Up @@ -1182,6 +1190,32 @@ def deny(reason: str) -> None:
}}, sys.stdout)


def payload_contract_break(data) -> str | None:
"""What is missing from a well-formed event this guard has to read, or None (#186).

A non-object is not judged here: it is malformed input, which fails open.
Only the fields a DENY depends on are required, so only Bash is checked."""
if not isinstance(data, dict):
return None
tool = data.get("tool_name")
if not isinstance(tool, str) or not tool:
return "an event with no `tool_name`"
if tool == "Bash":
ti = data.get("tool_input")
if not isinstance(ti, dict) or not isinstance(ti.get("command"), str):
return "a Bash event with no string `tool_input.command`"
return None


def contract_denial(guard: str, what: str) -> str:
return (f"Blocked by {guard}: Claude Code sent {what}, so this guard cannot read "
f"the command it exists to check. A Claude Code update may have changed the "
f"hook payload; the guard refuses rather than allow a command it cannot see "
f"(#186). Look for an update to this template's .claude/hooks/. Until then, "
f"run the command yourself in a terminal, or switch this project's hooks off "
f"with \"disableAllHooks\": true in .claude/settings.local.json.")


def _repo_neutral_env() -> dict:
"""The process environment with git's REPOSITORY-SCOPING variables removed.

Expand Down Expand Up @@ -1731,6 +1765,11 @@ def main() -> int:
except (json.JSONDecodeError, EOFError):
return 0

broken = payload_contract_break(data)
if broken:
deny(contract_denial("git-guardrails", broken))
return 0

tool = data.get("tool_name", "")
ti = data.get("tool_input", {}) or {}

Expand Down
40 changes: 40 additions & 0 deletions .claude/hooks/root-of-trust-guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,14 @@
machine's code page could not decode used to be exactly such an error, and so
an allow.

One exception, a DECISION and not an error (#186): a well-formed event that
lacks the field this guard reads is DENIED. With no `tool_name`, or a Bash
event with no string `tool_input.command`, the guard used to read "" and allow,
so a Claude Code update that renamed the field would switch it off without a
word. The check lives in this file, not in the sibling it imports, so a stubbed
or broken git-guardrails.py cannot take it away. Unparseable input still fails
open as above.

Escape hatch: set ALLOW_ROOT_OF_TRUST_WRITE=1 in the ENVIRONMENT OF THE
SESSION (`ALLOW_ROOT_OF_TRUST_WRITE=1 claude`, or export it in the shell
that launches Claude Code). Prefixing the blocked Bash command with it does
Expand Down Expand Up @@ -1981,6 +1989,33 @@ def deny(reason: str) -> None:
}}, sys.stdout)


def payload_contract_break(data) -> str | None:
"""What is missing from a well-formed event this guard has to read, or None (#186).

Kept here rather than imported from git-guardrails.py, so a stubbed sibling
cannot remove it. A non-object is malformed input, which fails open."""
if not isinstance(data, dict):
return None
tool = data.get("tool_name")
if not isinstance(tool, str) or not tool:
return "an event with no `tool_name`"
if tool == "Bash":
ti = data.get("tool_input")
if not isinstance(ti, dict) or not isinstance(ti.get("command"), str):
return "a Bash event with no string `tool_input.command`"
return None


def contract_denial(what: str) -> str:
return (f"Blocked by root-of-trust-guard: Claude Code sent {what}, so this guard "
f"cannot read the command it exists to check. A Claude Code update may have "
f"changed the hook payload; the guard refuses rather than allow a command it "
f"cannot see (#186). Look for an update to this template's .claude/hooks/. "
f"Until then, run the command yourself in a terminal, or switch this "
f"project's hooks off with \"disableAllHooks\": true in "
f".claude/settings.local.json.")


# Bash's THREE quoting characters, and they are the complete set: the escape
# `\`, the single quote, and the double quote. Every other quoting form bash
# has is one of these with a `$` glued to the front (`$'…'`, `$"…"`), and that
Expand Down Expand Up @@ -2118,6 +2153,11 @@ def main() -> int:
except (json.JSONDecodeError, EOFError):
return 0

broken = payload_contract_break(data)
if broken:
deny(contract_denial(broken))
return 0

if data.get("tool_name", "") != "Bash":
return 0

Expand Down
4 changes: 2 additions & 2 deletions .render-stamp
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
guide/workflow-guide.html:0bc283c0b000845b:dd57a16e1ed58502
docs/workflow-guide.html:0bc283c0b000845b:dd57a16e1ed58502
guide/workflow-guide.html:dc552da43a54d9c1:4116a5b9c548c0db
docs/workflow-guide.html:dc552da43a54d9c1:4116a5b9c548c0db
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ If you have forked this template, see the **Upgrading** section at the bottom fo

---

## v2.9.1 — 2026-11-06

A **fix release.** The two guards that protect your work no longer treat a missing field as an empty command.

### Fixed

- **A Claude Code update that renamed a hook field would have switched two guards off without a word** ([#186](https://github.com/pedrohcgs/claude-code-my-workflow/issues/186)).
- `git-guardrails.py` and `root-of-trust-guard.py` read `tool_input.command`. When it was missing they treated the command as empty and allowed it.
- They now deny a well-formed event that has no `tool_name`, or a Bash event with no string `tool_input.command`. The reason names #186 and the way out (`"disableAllHooks": true` in `.claude/settings.local.json` until the hooks are updated).
- The refusal is a deny decision, so it works on every Claude Code version, not only those that honour `onFailure`.
- Recorded per guard:
- unparseable input still fails open;
- a Write/Edit event with no `file_path` still fails open, because that check is advisory;
- `issue-guard.py` is unchanged: it is not a security boundary, and Claude Code starts it only for `gh` commands.
- Twelve battery cases pin it, and five mutants each fail the cases aimed at them.
- The rest of #186, a run log and a session-start check after clo-author 26.09, stays open.
- TROUBLESHOOTING's "Every Bash, Edit and Write is refused" entry names this as a third cause.

**Inventory at release: 62 skills, 18 agents, 37 rules, 11 hooks, 11 gates**
(standing hook battery: 334 cases, seconds to run; portability suite: 245 cases).

---

## v2.9.0 — 2026-11-06

An **adoption release** carrying what the 2026-11-06 audit found worth building or linking. Everything in it is additive: a new skill, new scripts and a template, guidance, and links to outside work, each listed below.
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ authors:
repository-code: "https://github.com/pedrohcgs/claude-code-my-workflow"
url: "https://psantanna.com/claude-code-my-workflow/"
license: MIT
version: 2.9.0
version: 2.9.1
date-released: 2026-11-06
keywords:
- claude-code
Expand Down
3 changes: 2 additions & 1 deletion TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,11 @@ Good — the pre-check caught a P3 (bare `scale=`) or P4 (missing directional ke

### Every Bash, Edit and Write is refused, with a hook error

Since v2.7.0 the guard hooks are registered with `"onFailure": "block"`, so a guard that cannot run blocks the call instead of letting it through. There are two causes; the message says which.
Since v2.7.0 the guard hooks are registered with `"onFailure": "block"`, so a guard that cannot run blocks the call instead of letting it through. There are three causes; the message says which.

- **`can't open file …/.claude/hooks/<name>.py`: the hook script is missing.** Usually a branch switch or an old checkout removed `.claude/hooks/` while the session still holds the settings that point there. Claude cannot repair this from inside the session, because the repair is itself a blocked call. From a terminal, run `git checkout -- .claude/hooks` (or switch back with `git switch -`), then start a new session. Tracked in [#180](https://github.com/pedrohcgs/claude-code-my-workflow/issues/180).
- **`python3: command not found`, or a Microsoft Store message: no working `python3`.** On Windows, the python.org installer provides `python` and `py` but not `python3`, and the `python3` that ships with Windows opens the Store. Make `python3 --version` work in Git Bash, for example with the Microsoft Store's Python, which provides `python3`. Tracked in [#179](https://github.com/pedrohcgs/claude-code-my-workflow/issues/179).
- **`Claude Code sent … so this guard cannot read the command` (only Bash is refused): the hook payload changed.** Since v2.9.1, `git-guardrails` and `root-of-trust-guard` refuse an event that lacks the field they read, rather than read it as an empty command and allow it. Seeing this means a Claude Code update renamed or dropped that field. Look for an update to the template's `.claude/hooks/`. Until then, run the command in a terminal, or switch the project's hooks off with `"disableAllHooks": true` in `.claude/settings.local.json`, and turn them back on once the hooks are updated. Tracked in [#186](https://github.com/pedrohcgs/claude-code-my-workflow/issues/186).

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 Distinguish which missing field blocks edit tools

The parenthetical is true only when tool_input.command is missing. If the payload change removes or renames tool_name, git-guardrails.py rejects the event before checking its tool type, and its matcher also covers Write, Edit, and MultiEdit, so all of those tools are refused as well. In that failure mode this troubleshooting entry misstates the scope of the lockout; distinguish the missing-command case from the missing-tool-name case.

Useful? React with 👍 / 👎.


A launcher that warns and carries on when a hook script is missing, and finds `python`/`py` when `python3` does not work, is ready as a settings patch on #180. `scripts/check-ledger-coverage.py` already understands its `sh -c '…' hook <script>` form.

Expand Down
Loading
Loading