Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"matcher": "Edit|Write|MultiEdit|Bash",
"hooks": [
{
"type": "command",
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ jobs:
- name: Install Claude Code CLI (official plugin validator)
run: npm install -g @anthropic-ai/claude-code@2.1.258

# Unit tests for the enforcement tooling itself (the PreToolUse guard and
# the checks). Stdlib unittest, no extra dependency, about a second.
- name: Test enforcement tooling
run: python -m unittest discover -s checks/tests

- name: Run enforcement suite
env:
# No update checks or telemetry from a CI-only validator run.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `skills-claude-ai/` — Claude.ai Agent Skill ports of product-ideation and product-flow (issue #12): bundled prompts with skill-relative paths, conversation-scoped artifact ledger with `product-dev-artifacts.md` export replacing the `.product-dev/` registry

### Changed
- PreToolUse guard (ADR 0012 amendment, 2026-10-12): appending to the end of an existing ADR is now allowed, so amendments and graduation-log entries no longer need the kill-switch; the guard also covers MultiEdit and Bash writes; rejections name moves an agent can actually take. Tests in `checks/tests/`, run in CI
- product-ideation: the escape hatch now respects the first explicit request to move on, recording the remaining unvalidated assumptions instead of requiring a second override
- ADR 0002: Superseded — MCP server removed; plugin is the sole delivery mechanism

Expand Down
21 changes: 17 additions & 4 deletions checks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ Rule (ADR 0012 §5): 7+ consecutive clean days OR one real catch. Then:

1. Flip `"status": "warn"` → `"block"` for the check in
`enforcement.config.json` and set `"graduated"` to the date.
2. Append a dated entry to the **Graduation log** in the owning ADR's
Enforcement section (use `PRODUCT_DEV_SKIP_ADR_GUARD=1` — this is a
sanctioned amendment).
2. Append a `## Graduation log entry (YYYY-MM-DD)` section to the end of the
owning ADR, naming the check and the basis. Appends pass the guard; no
kill-switch needed.
3. Add a CHANGELOG note.

Demotion (block → warn) is always allowed; log it the same three ways.
Expand All @@ -56,4 +56,17 @@ Configured in `.claude/settings.json`, scripts in `checks/hooks/`:
| Hook | Does | Kill-switch |
|------|------|-------------|
| Stop-gate | Runs the suite when Claude finishes a turn; BLOCKERs bounce the turn back with the report | `PRODUCT_DEV_SKIP_STOP_GATE=1` |
| PreToolUse guard | Blocks Edit/Write on existing `docs/adrs/*.md` (append-only; new ADR files always allowed) and on the Pattern Guide | `PRODUCT_DEV_SKIP_ADR_GUARD=1` |
| PreToolUse guard | On Edit, Write, MultiEdit, and Bash: blocks rewrites of existing `docs/adrs/NNNN-*.md` and any edit of the Pattern Guide. Appending to the end of an ADR and creating a new ADR file are always allowed | `PRODUCT_DEV_SKIP_ADR_GUARD=1` |

The kill-switches are environment variables of the Claude Code process: the
person launching the session can set them, an agent cannot. An agent blocked
by the guard appends, writes a new ADR, or stops and asks.

The Bash coverage is a heuristic (redirects, `sed -i`, `tee`, `rm`, `mv`,
inline scripts that write). It is a guard rail, not a security boundary.

## Tests

```bash
python -m unittest discover -s checks/tests
```
241 changes: 199 additions & 42 deletions checks/hooks/pretooluse_guard.py
Original file line number Diff line number Diff line change
@@ -1,70 +1,227 @@
#!/usr/bin/env python3
"""PreToolUse guard: protect ADR-governed paths from Edit/Write.
"""PreToolUse guard: protect ADR-governed paths from rewrites.

Protected (see ADR 0012):
- docs/adrs/*.md that already exist — accepted ADRs are append-only history.
Creating a NEW ADR file is always allowed (that is the supersession path).
Protected (see ADR 0012 and its 2026-10-12 amendment):
- docs/adrs/NNNN-*.md that already exist — ADRs are append-only history.
* Creating a NEW ADR file is always allowed (the supersession path).
* APPENDING to an existing ADR is allowed: an Edit/Write/MultiEdit whose
result keeps every existing character and only adds text after it. That
is how amendments, graduation-log entries, and supersession notes land.
* Anything else (rewriting prose, changing a Status line) is blocked.
- docs/updates/00_ENHANCEMENT_PATTERN.md — the Pattern Guide is the spec
(ADR 0009); changing it requires an ADR.

Kill-switch: PRODUCT_DEV_SKIP_ADR_GUARD=1 disables this hook entirely
(use for sanctioned amendments: Enforcement-section appends, graduation-log
entries, supersession notes).
Covers Edit, Write, MultiEdit, and Bash. The Bash coverage is a heuristic
that catches the ordinary ways a shell command rewrites a file (redirection,
sed -i, tee, mv, rm, an inline interpreter script). It is a guard rail, not a
security boundary: review of the diff is what catches a determined rewrite.

Kill-switch: PRODUCT_DEV_SKIP_ADR_GUARD=1 disables this hook entirely. It is
an environment variable of the Claude Code process, so only the person who
launches the session can set it — an agent cannot.
"""
import glob
import json
import os
import re
import sys
from pathlib import Path

PATTERN_GUIDE = Path("docs/updates/00_ENHANCEMENT_PATTERN.md")
ADR_DIR = Path("docs/adrs")
ADR_FILE = re.compile(r"^\d{4}-.+\.md$")

OWNER_ONLY = (
"This needs the repo owner: stop and ask them to make the edit, or to "
"relaunch the session with PRODUCT_DEV_SKIP_ADR_GUARD=1. Do not route "
"around this guard with another tool."
)
ADR_MOVES = (
"Legal moves you can take now: (1) APPEND — an edit that leaves all "
"existing text unchanged and adds a dated section at the end of the file "
"(amendment, graduation-log entry, supersession note); (2) write a NEW "
"docs/adrs/NNNN-*.md that supersedes or amends this one. Not available to "
"you: rewriting decision prose or changing a Status line. " + OWNER_ONLY
)

def main():
if os.environ.get("PRODUCT_DEV_SKIP_ADR_GUARD") == "1":
return 0

def protected_kind(target, project_dir):
"""Return 'adr', 'guide', or None for a path."""
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return 0
file_path = (payload.get("tool_input") or {}).get("file_path")
rel = target.resolve().relative_to(project_dir)
except ValueError:
return None # outside the project; not ours to police
if rel == PATTERN_GUIDE:
return "guide"
if rel.parent == ADR_DIR and ADR_FILE.match(rel.name):
return "adr"
return None


def is_pure_append(before, after):
return after != before and after.startswith(before)


def apply_edits(content, edits):
"""Simulate Edit/MultiEdit; return the resulting text, or None if an edit
would not apply (the tool itself will reject it — nothing to guard)."""
for e in edits:
old, new = e.get("old_string"), e.get("new_string")
if not isinstance(old, str) or not isinstance(new, str) or old == "" or old not in content:
return None
content = content.replace(old, new) if e.get("replace_all") else content.replace(old, new, 1)
return content


def block(message):
print("BLOCKED: " + message, file=sys.stderr)
return 2


def guard_file_tool(tool_name, tool_input, project_dir):
file_path = tool_input.get("file_path")
if not file_path:
return 0

project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR") or Path.cwd()).resolve()
target = Path(file_path)
if not target.is_absolute():
target = project_dir / target
target = target.resolve()
try:
rel = target.relative_to(project_dir)
except ValueError:
return 0 # outside the project; not ours to police
kind = protected_kind(target, project_dir)
if kind is None:
return 0
rel = target.resolve().relative_to(project_dir)

if rel == PATTERN_GUIDE:
print(
"BLOCKED: docs/updates/00_ENHANCEMENT_PATTERN.md is the prompt-pattern "
"spec (ADR 0009) and is protected (ADR 0012). Legal moves: propose the "
"change in a new ADR that amends ADR 0009, or for a sanctioned edit rerun "
"with kill-switch PRODUCT_DEV_SKIP_ADR_GUARD=1.",
file=sys.stderr,
if kind == "guide":
return block(
f"{rel} is the prompt-pattern spec (ADR 0009) and is protected "
"(ADR 0012). Legal move you can take now: propose the change in a "
"NEW docs/adrs/NNNN-*.md that amends ADR 0009. Editing the guide "
"itself is not available to you. " + OWNER_ONLY
)
return 2

if rel.parent == ADR_DIR and rel.suffix == ".md" and target.exists():
print(
f"BLOCKED: {rel} is an existing ADR — accepted ADRs are append-only "
"history (ADR 0012). Legal moves: (1) supersede by WRITING A NEW "
"docs/adrs/NNNN-*.md file (always allowed) and adding a supersession "
"note, (2) append an Enforcement-schema amendment or graduation-log "
"entry via kill-switch PRODUCT_DEV_SKIP_ADR_GUARD=1. Never rewrite "
"decision prose.",
file=sys.stderr,
)
return 2

return 0
if not target.exists():
return 0 # new ADR file: always allowed

before = target.read_text()
if tool_name == "Write":
after = tool_input.get("content")
elif tool_name == "MultiEdit":
after = apply_edits(before, tool_input.get("edits") or [])
else:
after = apply_edits(before, [tool_input])
if after is None:
return 0 # edit does not apply; the tool will report that itself
if isinstance(after, str) and is_pure_append(before, after):
return 0
return block(
f"{rel} is an existing ADR — ADRs are append-only history (ADR 0012), "
"and this edit changes or removes existing text. " + ADR_MOVES
)


# ------------------------------------------------------------------ Bash

REDIRECT = re.compile(r"(?<![<>&\d])\d?>{1,2}\|?\s*([^\s;|&()<>]+)")
SEGMENT_SPLIT = re.compile(r"\|\||&&|[;|&\n]")
DESTRUCTIVE = {"rm", "mv", "tee", "truncate", "unlink", "shred"}
IN_PLACE_EDITORS = {"sed", "gsed", "perl", "ruby"}
DEST_ONLY = {"cp", "install", "ln", "rsync"}
GIT_REWRITERS = {"rm", "mv", "checkout", "restore"}
INTERPRETERS = {"python", "python3", "node", "ruby", "perl", "php", "deno", "bun"}
INLINE_WRITE = re.compile(
r"write_text|write_bytes|\.write\(|open\([^)]*,\s*['\"][wax+]|writeFile|appendFile"
r"|File\.write|os\.remove|os\.rename|os\.replace|shutil\.|\.unlink\("
)
PROTECTED_MENTION = re.compile(r"docs/adrs|00_ENHANCEMENT_PATTERN")


def strip_quotes(token):
return token.strip("\"'`")


def existing_protected(token, bases, project_dir):
"""Existing protected files named by one shell token (globs expanded).
A new ADR file does not exist yet, so it never matches."""
token = strip_quotes(token)
if not token or not PROTECTED_MENTION.search(token):
return []
hits = []
for base in bases:
candidate = token if os.path.isabs(token) else str(base / token)
for match in glob.glob(candidate) or [candidate]:
p = Path(match)
if p.is_file() and protected_kind(p, project_dir):
hits.append(p.resolve().relative_to(project_dir))
return hits


def bash_violations(command, bases, project_dir):
if not PROTECTED_MENTION.search(command):
return []
hits = []
for m in REDIRECT.finditer(command):
hits += existing_protected(m.group(1), bases, project_dir)
for segment in SEGMENT_SPLIT.split(command):
tokens = [strip_quotes(t) for t in segment.split()]
if not tokens:
continue
names = {os.path.basename(t) for t in tokens}
paths = [p for t in tokens for p in existing_protected(t, bases, project_dir)]
in_place = any(re.match(r"^-[a-zA-Z]*i", t) or t.startswith("--in-place") for t in tokens)
if names & DESTRUCTIVE:
hits += paths
elif names & IN_PLACE_EDITORS and in_place:
hits += paths
elif "git" in names and names & GIT_REWRITERS:
hits += paths
elif names & DEST_ONLY:
hits += existing_protected(tokens[-1], bases, project_dir)
elif "dd" in names:
for t in tokens:
if t.startswith("of="):
hits += existing_protected(t[3:], bases, project_dir)
# An inline interpreter script (python -c, a heredoc) that names a
# protected path and contains a write call. The path sits inside code, so
# it cannot be resolved token by token.
words = {os.path.basename(strip_quotes(t)) for t in command.split()}
if not hits and words & INTERPRETERS and INLINE_WRITE.search(command):
hits.append(Path("an inline script that names docs/adrs or the Pattern Guide and writes files"))
return sorted(set(hits), key=str)


def guard_bash(tool_input, project_dir, cwd):
command = tool_input.get("command")
if not isinstance(command, str):
return 0
bases = [project_dir] if cwd in (None, project_dir) else [cwd, project_dir]
hits = bash_violations(command, bases, project_dir)
if not hits:
return 0
listed = ", ".join(str(h) for h in hits)
return block(
"this shell command would rewrite protected ADR-governed content "
f"({listed}). Existing ADRs are append-only history and the Pattern "
"Guide is the ADR 0009 spec (ADR 0012). Use the Edit tool instead. "
+ ADR_MOVES
)


def main():
if os.environ.get("PRODUCT_DEV_SKIP_ADR_GUARD") == "1":
return 0
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
return 0
if not isinstance(payload, dict):
return 0
tool_input = payload.get("tool_input") or {}
tool_name = payload.get("tool_name") or ""
project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR") or Path.cwd()).resolve()

if tool_name == "Bash":
cwd = Path(payload["cwd"]).resolve() if payload.get("cwd") else None
return guard_bash(tool_input, project_dir, cwd)
return guard_file_tool(tool_name, tool_input, project_dir)


if __name__ == "__main__":
Expand Down
Loading
Loading