Skip to content

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:

{"decision": "block", "reason": "ruff found lint errors: ..."}

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

  1. Open .claude/settings.json. Note defaultMode is "default".
  2. Try three rounds of "Add a comment to @src/routes/quizzes.py explaining that GET /quizzes returns an empty list when no quizzes exist.":

    • default, asks before writing
    • acceptEdits, writes without asking
    • plan, 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 with Shift+Tab.

  3. Reset to "default". After any direct edit to settings.json, run /exit and relaunch claude — settings are not hot-reloaded.

Part 2: PreToolUse Hook: Guard live/

  1. 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()
    
  2. Wire it into .claude/settings.json (replace the entire file):
    {
      "permissions": { "defaultMode": "default" },
      "hooks": {
        "PreToolUse": [
          {
            "matcher": "Write|Edit",
            "hooks": [{ "type": "command", "command": "uv run python .claude/hooks/guard_live.py" }]
          }
        ],
        "PostToolUse": []
      }
    }
    
    Run /exit and relaunch claude.
  3. Test:
    • "Edit live/quiz-001.json and add a test question."denied
    • "Add a comment to @src/routes/quizzes.py explaining that GET /quizzes returns an empty list when no quizzes exist."allowed

Part 3: PostToolUse Hook: Auto-Lint

  1. 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()
    
  2. Update .claude/settings.json (replace the entire file):
    {
      "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" }]
          }
        ]
      }
    }
    
    Run /exit and relaunch claude.
  3. 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.json has both hooks configured

← Lab 03 overview · Exercise 2: MCP →