s03: Permission — Check Permissions Before Execution

s01 → s02 → s03s04 → s05 → ... → s20

"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 (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. (Teaching demo: simple string matching is not a reliable security mechanism — command variants and shell expansion can bypass it. CC's approach is in the appendix.)

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.

PERMISSION_RULES = [
    {
        "tools": ["write_file", "edit_file"],
        "check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
        "message": "Writing outside workspace",
    },
    {
        "tools": ["bash"],
        "check": lambda args: 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 response.content:
    if block.type == "tool_use":
        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 all temporary files in /tmp (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)

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.

Dive into CC Source Code

The following is based on a review of CC source code types/permissions.ts, utils/permissions/permissions.ts, toolExecution.ts, utils/permissions/yoloClassifier.ts, tools/AgentTool/forkSubagent.ts.

1. PermissionResult: Not 3, but 4

The teaching version's three gates (deny → ask → allow) don't fully correspond to CC. CC's PermissionResult has 4 behaviors (types/permissions.ts:241-266):

behaviorMeaningTeaching Version Equivalent
allowAllow directlyGate 3 passes
denyDeny directlyGate 1 matches
askShow dialog to userGate 2 matches
passthroughTool doesn't express opinion, passes to generic pipelineNot in teaching version

2. Production Verification Stages

CC's tool calls don't go through three gates — they go through multiple stages distributed across checkPermissionsAndCallTool() (toolExecution.ts:599-1745), hooks, hasPermissionsToUseToolInner() (utils/permissions/permissions.ts:1158-1310), and classifier logic:

  1. Zod schema validation (toolExecution.ts:614-680) — parameter type checking
  2. validateInput() (toolExecution.ts:682-733) — tool-level semantic validation
  3. backfillObservableInput() (toolExecution.ts:784) — backfill legacy fields
  4. PreToolUse hooks (toolExecution.ts:800-862) — hooks can return allow/deny/ask
  5. resolveHookPermissionDecision() (toolExecution.ts:921-931) — coordinate hook + pipeline decisions
  6. hasPermissionsToUseToolInner() (permissions.ts:1158-1310) — multi-layer rule check:
    • Entire tool disabled by deny rule → deny
    • Entire tool flagged by ask rule → ask
    • tool.checkPermissions() tool's own judgment
    • Tool itself returns deny → deny
    • requiresUserInteraction()ask
    • Content-related ask rules → ask (not bypassable)
    • Security check violation → ask (not bypassable)
    • bypassPermissions mode → allow
    • Entire tool allowed by allow rule → allow
    • passthrough → converted to ask

3. Deny List: Not One File, but 8 Sources

CC doesn't have a single deny list. Permission rules come from 8 sources (types/permissions.ts:54-62):

SourceConfiguration Location
userSettings~/.claude/settings.json
projectSettings.claude/settings.json
localSettingssettings.local.json
flagSettingsFeature flags
policySettingsEnterprise management policy
cliArg--allowedTools / --deniedTools
commandInline command
sessionIn-session temporary authorization

Each rule format: { toolName: "Bash", ruleBehavior: "deny", ruleContent: "npm publish:*" }. Rules from multiple sources are merged, with higher-priority sources overriding lower ones (low to high: user < project < local < flag < policy, plus cliArg, command, session).

4. What is isDestructive()

In CC, isDestructive (Tool.ts:405-406) is purely for UI display — showing a [destructive] label in the tool list. It doesn't participate in permission decisions. All tools return false by default. Only ExitWorktree (on remove) and MCP tools (depending on annotations.destructiveHint) override it.

5. YoloClassifier (Auto-Approval)

In CC's auto mode, it doesn't pop a dialog every time. classifyYoloAction (utils/permissions/yoloClassifier.ts:1012) sends the tool call + conversation context to a classifier LLM to judge safety. It first tries acceptEdits mode simulation (permissions.ts:620-656, if acceptEdits allows → auto-approve), then checks the safe tool whitelist (permissions.ts:658-686), and finally calls the classifier. If the classifier rejects too many times in a row → falls back to manual approval.

6. Permission Bubbling

A sub-Agent's (forked via AgentTool) permissionMode is set to 'bubble' (forkSubagent.ts:50). This means permission dialogs bubble up to the parent Agent's terminal, rather than being silently denied in the sub-Agent. The Bash classifier continues running during this process — displaying the permission dialog while judging in the background whether auto-approval is possible.

The Teaching Version's Simplification Is Intentional

  • Multi-stage pipeline → 3 gates: dramatically lower barrier to understanding
  • 8 rule sources → 1 local DENY_LIST: manageable concept count
  • isDestructive → omitted (teaching version has no UI layer, and it doesn't participate in permission decisions in CC either)
  • YoloClassifier → omitted (depends on additional LLM calls and telemetry)
  • Permission bubbling → omitted (s15 covers multi-Agent)