Exercise 1: Permissions & Hooks¶
In Lab 02, CLAUDE.md asked Claude not to write to
live/. Now you will enforce it.
Lab: Lab 03 overview
Theory: Permission Modes
| Mode | Behavior |
|---|---|
default |
Ask on first use of each tool type |
acceptEdits |
Auto-accept file edits, ask for bash |
plan |
Read-only analysis, no modifications |
auto |
Auto-approve with safety checks |
bypassPermissions |
Skip all prompts (use with caution) |
Configure in .claude/settings.json or override per-session: claude --permission-mode <name> / Shift+Tab.
Further reading: Permission modes docs
Theory: Hooks
What it is. Shell commands that run automatically before or after Claude's tool calls. PreToolUse fires before (can block). PostToolUse fires after (can send feedback).
Configuration in .claude/settings.json:
{
"hooks": {
"PreToolUse": [{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "uv run python .claude/hooks/guard_live.py" }]
}]
}
}
PreToolUse: deny by printing:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "live/ is protected"
}
}
PostToolUse: send feedback:
Gotcha. Scripts must exit 0. Non-zero = hook error, not a deny.
Further reading: Hooks reference · Hooks guide
Hands-on¶
Part 1: Try Permission Modes¶
- Open
.claude/settings.json. NotedefaultModeis"default". -
Try three rounds of "Add a comment to @src/routes/quizzes.py explaining that
GET /quizzesreturns an empty list when no quizzes exist.":default, asks before writingacceptEdits, writes without askingplan, analyzes but does not edit
The current permission mode is shown in the status bar at the bottom of the Claude Code interface (e.g.
accept edits on,plan mode on). Watch it change as you cycle withShift+Tab. -
Reset to
"default". After any direct edit tosettings.json, run/exitand relaunchclaude— settings are not hot-reloaded.
Part 2: PreToolUse Hook: Guard live/¶
- Create
.claude/hooks/guard_live.py:import json import sys def main(): data = json.load(sys.stdin) tool_input = data.get("tool_input", {}) file_path = tool_input.get("file_path", "") or tool_input.get("path", "") if file_path.startswith("live/") or "/live/" in file_path: print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": "live/ is protected during quiz play", } })) if __name__ == "__main__": main() - Wire it into
.claude/settings.json(replace the entire file):Run{ "permissions": { "defaultMode": "default" }, "hooks": { "PreToolUse": [ { "matcher": "Write|Edit", "hooks": [{ "type": "command", "command": "uv run python .claude/hooks/guard_live.py" }] } ], "PostToolUse": [] } }/exitand relaunchclaude. - Test:
- "Edit live/quiz-001.json and add a test question." → denied
- "Add a comment to @src/routes/quizzes.py explaining that
GET /quizzesreturns an empty list when no quizzes exist." → allowed
Part 3: PostToolUse Hook: Auto-Lint¶
- Create
.claude/hooks/lint_python.py:"""PostToolUse hook: run ruff on edited Python files after a write.""" from __future__ import annotations import json import subprocess import sys from pathlib import Path def main() -> None: data = json.load(sys.stdin) tool_input = data.get("tool_input", {}) file_path = tool_input.get("file_path", "") or tool_input.get("path", "") if not file_path or not str(file_path).endswith(".py"): return path = Path(file_path) if not path.is_file(): return result = subprocess.run( ["uv", "run", "ruff", "check", str(path)], capture_output=True, text=True, ) if result.returncode != 0: out = (result.stdout + result.stderr).strip() print(json.dumps({"decision": "block", "reason": f"ruff found issues:\n{out}"})) if __name__ == "__main__": main() - Update
.claude/settings.json(replace the entire file):Run{ "permissions": { "defaultMode": "default" }, "hooks": { "PreToolUse": [ { "matcher": "Write|Edit", "hooks": [{ "type": "command", "command": "uv run python .claude/hooks/guard_live.py" }] } ], "PostToolUse": [ { "matcher": "Write|Edit", "hooks": [{ "type": "command", "command": "uv run python .claude/hooks/lint_python.py" }] } ] } }/exitand relaunchclaude. - Test: "Add a function to @src/utils.py that uses a bare except clause." Watch: Claude writes it → ruff catches it → Claude fixes it automatically.
Checkpoint
- [x]
live/write is blocked (denied, with reason visible) - [x] Lint hook fires after Python writes
- [x]
.claude/settings.jsonhas both hooks configured