Learn Claude Code
s03

Permission

Tools & Execution

Check Permissions Before Execution

192 LOC5 toolsPermission gate
Dangerous actions need a harness decision point before the shell runs.

s01 → s02 → s03s04 → s05 → ... → s16 → s17

"Check permissions before executing" — The permission pipeline decides which operations need approval.

Harness Layer: Permission — a gate before tool execution.


The Problem

s02's Agent has 5 tools. File tools are protected by safe_path, but bash is unrestricted. Ask it to "clean up the project," and it might run rm -rf /.

Safety can't rely on trusting the model — it needs code: a check before every tool execution.


The Solution

Permission Overview

s02's loop is fully preserved. The only change is inserting check_permission() before tool execution — each tool call passes through three gates in a fixed order: hard deny first, then soft ask, and if neither matches, allow.

The three gates correspond to three decisions:

GatePurposeOn Match
1. Deny ListPermanently forbidden operations (rm -rf /, sudo)Denied immediately, not executed
2. Rule MatchingContext-dependent operations (reading/writing outside workspace, rm files)Passed to Gate 3
3. User ApprovalAfter Gate 2 matches, pauses for user confirmationUser decides allow or deny

None of the three gates match → execute directly. Most routine operations take this path.


How It Works

Permission Pipeline

Gate 1: A hard deny list. Check first; if matched, return a block message. This list uses simple string matching to show where the permission gate sits; it is not a complete security boundary.

DENY_LIST = [
    "rm -rf /", "sudo", "shutdown", "reboot",
    "mkfs", "dd if=", "> /dev/sda",
]

def check_deny_list(command: str) -> str | None:
    for pattern in DENY_LIST:
        if pattern in command:
            return f"Blocked: '{pattern}' is on the deny list"
    return None

Gate 2: Rule matching — describes "when to ask the user." Each rule specifies a tool and a check condition.

import re

DESTRUCTIVE_COMMAND_WORD = re.compile(
    r"(?i)(?:^|[;&|()\n])\s*(?:rm|del)(?=\s|$|[;&|()])"
)

def contains_destructive_command(command: str) -> bool:
    return bool(DESTRUCTIVE_COMMAND_WORD.search(command))

PERMISSION_RULES = [
    {
        "tools": ["read_file", "write_file", "edit_file"],
        "check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
        "message": "Access outside workspace",
    },
    {
        "tools": ["bash"],
        "check": lambda args: contains_destructive_command(args.get("command", "")) or any(
            kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]
        ),
        "message": "Potentially destructive command",
    },
]

def check_rules(tool_name: str, args: dict) -> str | None:
    for rule in PERMISSION_RULES:
        if tool_name in rule["tools"] and rule["check"](args):
            return rule["message"]
    return None

Gate 3: After a rule matches, pause for user input.

def ask_user(tool_name: str, args: dict, reason: str) -> str:
    print(f"\n⚠  {reason}")
    print(f"   Tool: {tool_name}({args})")
    choice = input("   Allow? [y/N] ").strip().lower()
    return "allow" if choice in ("y", "yes") else "deny"

All three gates chained together, inserted before tool execution:

def check_permission(block) -> bool:
    # Gate 1: Hard deny
    if block.name == "bash":
        reason = check_deny_list(block.input.get("command", ""))
        if reason:
            print(f"\n⛔ {reason}")
            return False

    # Gate 2 + 3: Rule matching → User approval
    reason = check_rules(block.name, block.input)
    if reason:
        decision = ask_user(block.name, block.input, reason)
        if decision == "deny":
            return False

    return True

# In agent_loop — s02's loop with just one line added:
for block in tool_calls:
    if not check_permission(block):           # ← NEW
        results.append({... "content": "Permission denied."})
        continue
    output = TOOL_HANDLERS[block.name](**block.input)  # s02 original
    results.append(...)

Changes from s02

ComponentBefore (s02)After (s03)
Security modelNone (trust the model)Three-gate permission pipeline
New functionscheck_deny_list, check_rules, ask_user, check_permission
LoopExecutes all tools directlyInserts check_permission() before execution

Try It

cd learn-claude-code
python s03_permission/code.py

Try these prompts:

  1. Create a file called test.txt in the current directory (should pass through)
  2. Delete the file test.txt (bash + rm triggers Gate 2)
  3. What files are in the current directory? (read-only, all pass)
  4. Try to write a file to /etc/something (writing outside workspace triggers Gate 2)
  5. On Windows, del test.txt and DEL test.txt trigger Gate 2, while model, delimiter, and echo del test.txt do not.

What to watch for: Which operations pass through? Which need your confirmation? Which are denied outright?


What's Next

Permission checks are in place — but every check is hardcoded as check_permission() inside the loop. What if you want to add logging before and after each tool execution? What if you want to auto-trigger a git commit after certain operations? Scattering this extension logic throughout the loop makes it bloat.

→ s04 Hooks: Add hooks to the loop. Extension logic hangs on hooks; the loop stays clean.