Module 7: Remote Control and CLAUDE.md for Teams

3. Remote Approval Flows — Approval Gates for Sensitive Operations

3. Remote Approval Flows — Approval Gates for Sensitive Operations

Description

Monitoring a Claude Code session from your phone is useful, but remote control's real power is in the approval flows: defining which operations need your explicit approval before running. A git push --force, a DROP TABLE, an npm publish — operations that, if they go wrong, are hard or impossible to revert. Without approval flows, Claude Code can run these operations if it has the permissions. With approval flows, the operation pauses, sends you an approval request, and waits for your response before continuing.

This capsule covers the complete design of approval flows: how to define which operations need approval, how to configure gates with PermissionRequest hooks, how to approve from your phone or from another terminal, what happens when no one approves in time (timeout policies), and the tradeoffs between auto-approving everything vs. requiring manual approval for each operation.

By the end, you'll have an approval system that protects your project from destructive operations without slowing down the agent's normal work.

Experimental status (March 2026): Remote approval flows depend on the remote control feature, which is in active development. The PermissionRequest hook patterns described here are functional. The integration with approval from mobile devices is based on the documentation available as of March 2026. Verify current availability at docs.anthropic.com.


The Problem: Operations With No Way Back

Why you need approval gates

Claude Code with broad permissions can do almost anything. That's an advantage when you want productivity — and a risk when the operation is destructive:

Safe operations (auto-approvable):
  ✅ Read files
  ✅ Write new code
  ✅ Run tests
  ✅ Generate documentation
  ✅ git add, git commit

Risky operations (need approval):
  ⚠️ git push --force
  ⚠️ DROP TABLE / DROP DATABASE
  ⚠️ npm publish
  ⚠️ rm -rf in important directories
  ⚠️ Modify production configuration files
  ⚠️ Run database migrations
  ⚠️ Change system file permissions

Without approval flows, you have two extreme options:

  1. Grant all permissions → Productive but dangerous
  2. Restrict everything → Safe but inefficient (Claude asks about every operation)

Approval flows are the middle ground: normal operations pass automatically, sensitive operations pause until you approve them.


PermissionRequest Hooks: The Mechanics

How PermissionRequest works

The PermissionRequest hook triggers when Claude Code needs a permission it doesn't have pre-approved. Your hook decides:

Exit CodeMeaningClaude Code does...
0ApprovedRuns the operation
1Error (decide)Reports to Claude, which decides whether to insist or find an alternative
2RejectedThe operation is canceled

PermissionRequest JSON input

When the hook triggers, it receives information about which operation needs permission:

{
  "hook_event_name": "PermissionRequest",
  "tool_name": "Bash",
  "tool_input": {
    "command": "git push --force origin main"
  },
  "permission_type": "tool_execution",
  "session_id": "abc123"
}

Your script inspects this JSON and decides whether to approve, reject, or escalate to remote approval.


Designing Approval Gates

Level 1: Auto-approve safe operations

The first step is to classify operations by risk:

scripts/hooks/permission-gate.sh:

#!/bin/bash

INPUT=$(cat -)

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')

AUTO_APPROVE_TOOLS=(
    "Read"
    "Grep"
    "Glob"
)

for tool in "${AUTO_APPROVE_TOOLS[@]}"; do
    if [ "$TOOL_NAME" = "$tool" ]; then
        exit 0
    fi
done

if [ "$TOOL_NAME" = "Write" ] || [ "$TOOL_NAME" = "Edit" ]; then
    if echo "$FILE_PATH" | grep -qE "^(src/|tests/|docs/)"; then
        exit 0
    fi
fi

if [ "$TOOL_NAME" = "Bash" ]; then
    SAFE_PATTERNS=(
        "^ls "
        "^cat "
        "^echo "
        "^python -m pytest"
        "^npm test"
        "^npm run lint"
        "^git status"
        "^git log"
        "^git diff"
    )

    for pattern in "${SAFE_PATTERNS[@]}"; do
        if echo "$COMMAND" | grep -qE "$pattern"; then
            exit 0
        fi
    done
fi

echo "REQUIRES_APPROVAL: $TOOL_NAME"
echo "Details: ${COMMAND:-$FILE_PATH}"
exit 1

Level 2: Block destructive operations

Some operations should never run without human review:

scripts/hooks/block-destructive.sh:

#!/bin/bash

INPUT=$(cat -)

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if [ "$TOOL_NAME" != "Bash" ] || [ -z "$COMMAND" ]; then
    exit 0
fi

BLOCKED_PATTERNS=(
    "git push --force"
    "git push.*-f "
    "git reset --hard"
    "DROP DATABASE"
    "DROP TABLE"
    "TRUNCATE TABLE"
    "npm publish"
    "rm -rf /"
    "rm -rf ~"
    "rm -rf \."
    "chmod -R 777"
    "curl.*| sh"
    "wget.*| bash"
)

for pattern in "${BLOCKED_PATTERNS[@]}"; do
    if echo "$COMMAND" | grep -qiE "$pattern"; then
        echo "BLOCKED: Destructive operation detected"
        echo "Pattern: $pattern"
        echo "Command: $COMMAND"
        echo ""
        echo "This operation requires manual approval."
        echo "Use remote control to approve if it's intentional."
        exit 2
    fi
done

exit 0

Level 3: Escalation to remote approval

For operations that aren't destructive but are sensitive, the hook can escalate to remote approval by writing the request to a file the remote control monitors:

scripts/hooks/remote-approval.sh:

#!/bin/bash

INPUT=$(cat -)

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')

SENSITIVE_COMMANDS=(
    "git push"
    "npm install.*-g"
    "pip install"
    "alembic upgrade"
    "alembic downgrade"
    "docker"
    "kubectl"
)

NEEDS_APPROVAL=false
for pattern in "${SENSITIVE_COMMANDS[@]}"; do
    if echo "$COMMAND" | grep -qiE "$pattern"; then
        NEEDS_APPROVAL=true
        break
    fi
done

SENSITIVE_FILES=(
    "package.json"
    "requirements.txt"
    "Dockerfile"
    "docker-compose"
    ".github/workflows"
    "alembic/versions"
)

for pattern in "${SENSITIVE_FILES[@]}"; do
    if echo "$FILE_PATH" | grep -qi "$pattern"; then
        NEEDS_APPROVAL=true
        break
    fi
done

if [ "$NEEDS_APPROVAL" = true ]; then
    APPROVAL_DIR=".claude/approvals"
    mkdir -p "$APPROVAL_DIR"

    APPROVAL_ID="approval-$(date +%s)"
    APPROVAL_FILE="$APPROVAL_DIR/$APPROVAL_ID.json"

    cat > "$APPROVAL_FILE" << EOF
{
  "id": "$APPROVAL_ID",
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
  "tool": "$TOOL_NAME",
  "command": "$COMMAND",
  "file": "$FILE_PATH",
  "status": "pending",
  "timeout_seconds": 300
}
EOF

    echo "APPROVAL_REQUIRED: $APPROVAL_ID"
    echo "Operation: $TOOL_NAME ${COMMAND:-$FILE_PATH}"
    echo "Approve via remote control or create: $APPROVAL_DIR/$APPROVAL_ID.approved"

    TIMEOUT=300
    ELAPSED=0
    INTERVAL=5

    while [ $ELAPSED -lt $TIMEOUT ]; do
        if [ -f "$APPROVAL_DIR/$APPROVAL_ID.approved" ]; then
            echo "APPROVED by remote user"
            rm -f "$APPROVAL_FILE" "$APPROVAL_DIR/$APPROVAL_ID.approved"
            exit 0
        fi

        if [ -f "$APPROVAL_DIR/$APPROVAL_ID.rejected" ]; then
            echo "REJECTED by remote user"
            rm -f "$APPROVAL_FILE" "$APPROVAL_DIR/$APPROVAL_ID.rejected"
            exit 2
        fi

        sleep $INTERVAL
        ELAPSED=$((ELAPSED + INTERVAL))
    done

    echo "TIMEOUT: No approval received in ${TIMEOUT}s"
    rm -f "$APPROVAL_FILE"
    exit 2
fi

exit 0

Approval from Mobile Devices

The complete flow

1. Claude Code tries to run: git push origin feature-branch
   ↓
2. PermissionRequest hook detects "git push" → needs approval
   ↓
3. The hook writes a request to .claude/approvals/approval-xxx.json
   ↓
4. Remote control detects the pending request
   ↓
5. Your phone receives a notification: "Approve git push?"
   ↓
6a. You approve → approval-xxx.approved is created → hook returns exit 0
6b. You reject → approval-xxx.rejected is created → hook returns exit 2
6c. You don't respond → timeout → hook returns exit 2

Approval via remote CLI

From another terminal or device with CLI access:

claude remote approvals --session abc123 --token rc_tk_xxxxx
Pending approvals:
  1. [approval-1710345600] git push origin feature-branch (4m 32s remaining)

Approve: claude remote approve --id approval-1710345600
Reject:  claude remote reject --id approval-1710345600
claude remote approve --session abc123 --token rc_tk_xxxxx --id approval-1710345600

Approval via SDK

import subprocess
import json


def check_pending_approvals(session_id, token):
    result = subprocess.run(
        ["claude", "remote", "approvals",
         "--session", session_id,
         "--token", token,
         "--output-format", "json"],
        capture_output=True, text=True
    )
    if result.returncode != 0:
        return []
    data = json.loads(result.stdout)
    return data.get("pending", [])


def approve_operation(session_id, token, approval_id):
    result = subprocess.run(
        ["claude", "remote", "approve",
         "--session", session_id,
         "--token", token,
         "--id", approval_id],
        capture_output=True, text=True
    )
    return result.returncode == 0


def reject_operation(session_id, token, approval_id):
    result = subprocess.run(
        ["claude", "remote", "reject",
         "--session", session_id,
         "--token", token,
         "--id", approval_id],
        capture_output=True, text=True
    )
    return result.returncode == 0


session = "abc123"
token = "rc_tk_xxxxx"

pending = check_pending_approvals(session, token)
for approval in pending:
    print(f"Pending: {approval['operation']}")
    print(f"  Command: {approval.get('command', 'N/A')}")
    print(f"  Time remaining: {approval.get('remaining_seconds', '?')}s")

    response = input("  Approve? (y/n): ")
    if response.lower() == "y":
        approve_operation(session, token, approval["id"])
        print("  → Approved")
    else:
        reject_operation(session, token, approval["id"])
        print("  → Rejected")

Timeout Policies

What happens when no one approves

If you launch an Agent Team, leave, and an operation needs approval that never comes, the system needs a clear policy:

PolicyBehaviorWhen to use
Timeout → RejectIf no one approves in X minutes, reject the operationSafe default. For destructive operations
Timeout → ApproveIf no one approves in X minutes, approve automaticallyLow-risk operations that can wait
Timeout → FallbackIf no one approves, use a safe alternativeWhen there are less risky options
No timeoutWait indefinitelyCritical operations that MUST be reviewed

Timeout configuration

scripts/hooks/timed-approval.sh:

#!/bin/bash

INPUT=$(cat -)

TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

get_timeout() {
    local cmd="$1"

    if echo "$cmd" | grep -qiE "(DROP|TRUNCATE|--force|publish)"; then
        echo 600  # 10 minutes for destructive operations
    elif echo "$cmd" | grep -qiE "(git push|deploy|migrate)"; then
        echo 300  # 5 minutes for sensitive operations
    else
        echo 120  # 2 minutes for the rest
    fi
}

get_timeout_action() {
    local cmd="$1"

    if echo "$cmd" | grep -qiE "(DROP|TRUNCATE|--force)"; then
        echo "reject"
    elif echo "$cmd" | grep -qiE "(git push|migrate)"; then
        echo "reject"
    else
        echo "approve"
    fi
}

TIMEOUT=$(get_timeout "$COMMAND")
ACTION=$(get_timeout_action "$COMMAND")

APPROVAL_DIR=".claude/approvals"
mkdir -p "$APPROVAL_DIR"
APPROVAL_ID="approval-$(date +%s)-$$"
APPROVAL_FILE="$APPROVAL_DIR/$APPROVAL_ID.json"

cat > "$APPROVAL_FILE" << EOF
{
  "id": "$APPROVAL_ID",
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
  "tool": "$TOOL_NAME",
  "command": "$COMMAND",
  "status": "pending",
  "timeout_seconds": $TIMEOUT,
  "timeout_action": "$ACTION"
}
EOF

echo "APPROVAL NEEDED: $TOOL_NAME"
echo "Command: $COMMAND"
echo "Timeout: ${TIMEOUT}s (action: $ACTION)"

ELAPSED=0
while [ $ELAPSED -lt $TIMEOUT ]; do
    if [ -f "$APPROVAL_DIR/$APPROVAL_ID.approved" ]; then
        echo "APPROVED"
        rm -f "$APPROVAL_FILE" "$APPROVAL_DIR/$APPROVAL_ID.approved"
        exit 0
    fi

    if [ -f "$APPROVAL_DIR/$APPROVAL_ID.rejected" ]; then
        echo "REJECTED"
        rm -f "$APPROVAL_FILE" "$APPROVAL_DIR/$APPROVAL_ID.rejected"
        exit 2
    fi

    sleep 5
    ELAPSED=$((ELAPSED + 5))
done

rm -f "$APPROVAL_FILE"

if [ "$ACTION" = "approve" ]; then
    echo "TIMEOUT → Auto-approved (low risk)"
    exit 0
else
    echo "TIMEOUT → Rejected (high risk, no approval received)"
    exit 2
fi

Complete Configuration in settings.json

A production-ready approval flow setup:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/hooks/block-destructive.sh"
          }
        ]
      }
    ],
    "PermissionRequest": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "./scripts/hooks/permission-gate.sh"
          }
        ]
      }
    ]
  },
  "remote": {
    "enabled": true,
    "require_auth": true,
    "allowed_operations": ["monitor", "approve", "reject"],
    "log_remote_actions": true
  }
}

With this configuration:

  • PreToolUse blocks destructive operations immediately (exit 2)
  • PermissionRequest classifies the remaining operations into auto-approvable vs. requires approval
  • Remote control is enabled to approve/reject from another device

Production Safety: Advanced Patterns

Pattern 1: Approval by environment

Different approval levels depending on the environment:

#!/bin/bash

INPUT=$(cat -)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

ENV=$(echo "$COMMAND" | grep -oE "(production|staging|development)" | head -1)
ENV=${ENV:-development}

case "$ENV" in
    production)
        echo "BLOCKED: Production operations require manual approval"
        echo "Use the deploy portal for production operations"
        exit 2
        ;;
    staging)
        echo "APPROVAL_REQUIRED: Staging operation"
        # Wait for approval with a 5-minute timeout
        exit 1
        ;;
    development)
        exit 0
        ;;
esac

Pattern 2: Double approval for critical operations

Some operations need approval from two people:

#!/bin/bash

INPUT=$(cat -)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

is_critical() {
    echo "$COMMAND" | grep -qiE "(DROP DATABASE|npm publish|deploy.*production)"
}

if is_critical; then
    APPROVAL_DIR=".claude/approvals"
    APPROVAL_ID="critical-$(date +%s)"

    mkdir -p "$APPROVAL_DIR"
    echo "0" > "$APPROVAL_DIR/$APPROVAL_ID.count"

    cat > "$APPROVAL_DIR/$APPROVAL_ID.json" << EOF
{
  "id": "$APPROVAL_ID",
  "type": "critical",
  "requires": 2,
  "current": 0,
  "command": "$COMMAND",
  "approvers": []
}
EOF

    echo "CRITICAL OPERATION: Requires 2 approvals"
    echo "Command: $COMMAND"

    TIMEOUT=900  # 15 minutes
    ELAPSED=0

    while [ $ELAPSED -lt $TIMEOUT ]; do
        if [ -f "$APPROVAL_DIR/$APPROVAL_ID.count" ]; then
            COUNT=$(cat "$APPROVAL_DIR/$APPROVAL_ID.count")
            if [ "$COUNT" -ge 2 ]; then
                echo "APPROVED: 2 approvals received"
                rm -f "$APPROVAL_DIR/$APPROVAL_ID.json" "$APPROVAL_DIR/$APPROVAL_ID.count"
                exit 0
            fi
        fi

        if [ -f "$APPROVAL_DIR/$APPROVAL_ID.rejected" ]; then
            echo "REJECTED"
            rm -f "$APPROVAL_DIR/$APPROVAL_ID.json" "$APPROVAL_DIR/$APPROVAL_ID.count" "$APPROVAL_DIR/$APPROVAL_ID.rejected"
            exit 2
        fi

        sleep 5
        ELAPSED=$((ELAPSED + 5))
    done

    echo "TIMEOUT: 2 approvals were not reached"
    rm -f "$APPROVAL_DIR/$APPROVAL_ID.json" "$APPROVAL_DIR/$APPROVAL_ID.count"
    exit 2
fi

exit 0

Pattern 3: Approval audit log

Record all approval decisions for auditing:

#!/bin/bash

AUDIT_LOG=".claude/logs/approval-audit.log"
mkdir -p "$(dirname "$AUDIT_LOG")"

log_audit() {
    local action=$1
    local tool=$2
    local command=$3
    local approver=${4:-"system"}
    local timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

    echo "$timestamp | $action | $tool | $approver | $command" >> "$AUDIT_LOG"
}

INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -qiE "(git push|npm publish|deploy|migrate)"; then
    log_audit "REQUESTED" "$TOOL_NAME" "$COMMAND"

    # ... approval logic ...

    # If approved:
    log_audit "APPROVED" "$TOOL_NAME" "$COMMAND" "remote-user"
    exit 0

    # If rejected:
    # log_audit "REJECTED" "$TOOL_NAME" "$COMMAND" "remote-user"
    # exit 2
fi

log_audit "AUTO_APPROVED" "$TOOL_NAME" "$COMMAND"
exit 0

Troubleshooting

"The approval never reaches the hook"

Cause: The hook is waiting for a .approved file but the remote control uses a different notification mechanism.

Solution: Verify how your version of remote control communicates approvals. If it uses files, confirm the path. If it uses an API, adjust the hook to query via curl:

APPROVED=$(curl -s "http://localhost:3500/approvals/$APPROVAL_ID/status" 2>/dev/null)
if [ "$APPROVED" = "approved" ]; then
    exit 0
fi

"The timeout is too short for nightly operations"

Cause: The 5-minute default isn't enough if you launch a batch job before sleeping.

Solution: Configure longer timeouts for batch jobs:

if [ -f ".claude/batch-mode" ]; then
    TIMEOUT=28800  # 8 hours for batch mode
else
    TIMEOUT=300    # 5 minutes for normal mode
fi

Before launching a batch:

touch .claude/batch-mode
# ... run batch ...
rm .claude/batch-mode

"The approval hook blocks Claude Code completely"

Cause: The hook waits with sleep inside a loop, and the timeout is very long, making Claude Code hang.

Solution: Make sure the total timeout is reasonable. For PreToolUse hooks, keep timeouts short (< 5 minutes). If you need longer times, use PermissionRequest instead of PreToolUse, since it's designed for longer waits.

"Approvals get lost between restarts"

Cause: The approval files in .claude/approvals/ are created but the hook waiting for them already finished (the session restarted).

Solution: Add cleanup of stale approvals in the SessionStart hook:

find .claude/approvals/ -name "*.json" -mmin +60 -delete 2>/dev/null

"I don't know which operations should need approval"

Cause: You don't have a clear classification of operations by risk.

Solution: Start conservative — require approval for everything except reading. After a week, review the audit log and relax the operations that are always approved:

# See which operations are always approved
grep "APPROVED" .claude/logs/approval-audit.log | \
    awk -F'|' '{print $3}' | sort | uniq -c | sort -rn

Comparison: Auto-Approve vs Manual Approval

AspectAuto-Approve EverythingManual ApprovalApproval Gates (hybrid)
SpeedMaximum — no interruptionsMinimum — every operation waitsHigh — only sensitive ones wait
SecurityLow — destructive operations passMaximum — everything is reviewedHigh — risk classification
SupervisionNoneConstant (you must be there)Selective (remote control)
Ideal forPrototyping, personal projectsCritical production, complianceTeams, real projects
RiskHigh — one error destroys workLow — but high frictionControlled — risk/speed balance
With remote controlNot neededEssentialPerfect complement
Developer fatigueNoneHigh (approval fatigue)Low (you only approve what matters)

Recommendation: Start with approval gates (hybrid). Auto-approve reading and writing in src/ and tests/. Require approval for git push, publishing, migrations, and system operations. Adjust based on the audit log after the first week.


Exercises

Exercise 1: Basic risk classifier (Easy)

Create a script that classifies operations into three levels: safe (exit 0), moderate (exit 1 with a warning), dangerous (exit 2, blocked). Classify at least 5 operations in each level.

See solution

scripts/hooks/risk-classifier.sh:

#!/bin/bash

INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty')

# SAFE: reading operations and normal development
if [ "$TOOL_NAME" = "Read" ] || [ "$TOOL_NAME" = "Grep" ] || [ "$TOOL_NAME" = "Glob" ]; then
    exit 0
fi

if echo "$COMMAND" | grep -qE "^(ls|cat|echo|pwd|which|python -m pytest|npm test)"; then
    exit 0
fi

if echo "$FILE_PATH" | grep -qE "^(src/|tests/|docs/)"; then
    exit 0
fi

# DANGEROUS: destructive and irreversible operations
if echo "$COMMAND" | grep -qiE "(rm -rf|DROP|TRUNCATE|--force|publish|mkfs|dd if=)"; then
    echo "DANGEROUS: $COMMAND"
    exit 2
fi

if echo "$FILE_PATH" | grep -qiE "(\.env|credentials|secrets|\.ssh|\.aws)"; then
    echo "DANGEROUS: access to sensitive file $FILE_PATH"
    exit 2
fi

# MODERATE: everything else (git push, installs, configs)
echo "MODERATE: $TOOL_NAME ${COMMAND:-$FILE_PATH}"
exit 1

Exercise 2: Timeout configurable via file (Easy)

Create a .claude/approval-config.json file that defines timeouts by operation type, and a script that reads that configuration.

See solution

.claude/approval-config.json:

{
  "timeouts": {
    "git_push": 300,
    "npm_publish": 600,
    "database_migration": 600,
    "file_delete": 120,
    "default": 180
  },
  "timeout_action": {
    "git_push": "reject",
    "npm_publish": "reject",
    "database_migration": "reject",
    "file_delete": "reject",
    "default": "approve"
  }
}

scripts/hooks/configurable-timeout.sh:

#!/bin/bash

INPUT=$(cat -)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

CONFIG_FILE=".claude/approval-config.json"
if [ ! -f "$CONFIG_FILE" ]; then
    exit 0
fi

get_operation_type() {
    local cmd="$1"
    if echo "$cmd" | grep -qiE "git push"; then echo "git_push"
    elif echo "$cmd" | grep -qiE "npm publish"; then echo "npm_publish"
    elif echo "$cmd" | grep -qiE "(alembic|migrate)"; then echo "database_migration"
    elif echo "$cmd" | grep -qiE "rm -r"; then echo "file_delete"
    else echo "default"
    fi
}

OP_TYPE=$(get_operation_type "$COMMAND")
TIMEOUT=$(jq -r ".timeouts.$OP_TYPE // .timeouts.default" "$CONFIG_FILE")
ACTION=$(jq -r ".timeout_action.$OP_TYPE // .timeout_action.default" "$CONFIG_FILE")

echo "Operation: $OP_TYPE"
echo "Timeout: ${TIMEOUT}s"
echo "Timeout action: $ACTION"

# ... approval-waiting logic using $TIMEOUT and $ACTION ...
exit 0

Exercise 3: Audit log with analysis (Medium)

Create a system that: (1) records each approval decision in a CSV log, and (2) a Python script that analyzes the log and generates statistics: most-approved operations, most-rejected, average response time.

See solution

scripts/hooks/audit-logger.sh:

#!/bin/bash

INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

AUDIT_CSV=".claude/logs/approval-audit.csv"
mkdir -p "$(dirname "$AUDIT_CSV")"

if [ ! -f "$AUDIT_CSV" ]; then
    echo "timestamp,action,tool,command,response_time_s" > "$AUDIT_CSV"
fi

START_TIME=$(date +%s)

# ... approval logic ...

END_TIME=$(date +%s)
RESPONSE_TIME=$((END_TIME - START_TIME))

echo "$(date -u +%Y-%m-%dT%H:%M:%SZ),APPROVED,$TOOL_NAME,$COMMAND,$RESPONSE_TIME" >> "$AUDIT_CSV"
exit 0

scripts/analyze-approvals.py:

#!/usr/bin/env python3
"""Analyzes the approval audit log."""

import csv
from collections import Counter
from pathlib import Path

AUDIT_FILE = Path(".claude/logs/approval-audit.csv")


def analyze():
    if not AUDIT_FILE.exists():
        print("No audit log found.")
        return

    rows = []
    with open(AUDIT_FILE) as f:
        reader = csv.DictReader(f)
        rows = list(reader)

    if not rows:
        print("Audit log empty.")
        return

    actions = Counter(r["action"] for r in rows)
    tools = Counter(r["tool"] for r in rows)

    times = [int(r["response_time_s"]) for r in rows
             if r["response_time_s"].isdigit()]
    avg_time = sum(times) / len(times) if times else 0

    print(f"Total decisions: {len(rows)}")
    print(f"\nBy action:")
    for action, count in actions.most_common():
        print(f"  {action}: {count}")

    print(f"\nBy tool:")
    for tool, count in tools.most_common(5):
        print(f"  {tool}: {count}")

    print(f"\nAvg response time: {avg_time:.1f}s")


if __name__ == "__main__":
    analyze()

Exercise 4: Approval flow with fallback (Medium)

Design an approval flow where, if the original operation is rejected, the hook suggests a safer alternative. For example: if git push --force is rejected, suggest git push --force-with-lease.

See solution

scripts/hooks/approval-with-fallback.sh:

#!/bin/bash

INPUT=$(cat -)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

suggest_fallback() {
    local cmd="$1"

    if echo "$cmd" | grep -qE "git push --force"; then
        echo "Safe alternative: $(echo "$cmd" | sed 's/--force/--force-with-lease/')"
    elif echo "$cmd" | grep -qE "rm -rf"; then
        echo "Safe alternative: Move to .trash/ instead of deleting"
    elif echo "$cmd" | grep -qE "DROP TABLE"; then
        echo "Safe alternative: Rename the table with a _deprecated_ prefix"
    elif echo "$cmd" | grep -qE "git reset --hard"; then
        echo "Safe alternative: git stash to preserve changes"
    else
        echo "No suggested alternative"
    fi
}

if echo "$COMMAND" | grep -qiE "(--force|rm -rf|DROP TABLE|reset --hard)"; then
    FALLBACK=$(suggest_fallback "$COMMAND")
    echo "RISKY OPERATION: $COMMAND"
    echo ""
    echo "$FALLBACK"
    echo ""
    echo "The original operation was blocked."
    echo "Claude can use the safe alternative without approval."
    exit 1
fi

exit 0

With exit 1, Claude receives the hook's message and can decide to use the suggested alternative. It's more flexible than exit 2 (total block) because it gives the agent options.

Exercise 5: Complete approval flow simulation (Hard)

Without real remote control, simulate a complete approval flow with three scripts: (1) a hook that writes approval requests to files, (2) an "approval server" that reads those requests and presents them to the user, (3) a response script that creates the .approved or .rejected files.

See solution

Script 1 — Hook (scripts/hooks/sim-approval-hook.sh):

#!/bin/bash
INPUT=$(cat -)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')

if echo "$COMMAND" | grep -qiE "(git push|npm publish|migrate)"; then
    APPROVAL_DIR=".claude/approvals"
    mkdir -p "$APPROVAL_DIR"
    ID="sim-$(date +%s)"
    echo "{\"id\":\"$ID\",\"command\":\"$COMMAND\",\"status\":\"pending\"}" > "$APPROVAL_DIR/$ID.json"
    echo "Waiting for approval: $ID"
    for i in $(seq 1 60); do
        if [ -f "$APPROVAL_DIR/$ID.approved" ]; then
            rm -f "$APPROVAL_DIR/$ID.json" "$APPROVAL_DIR/$ID.approved"
            exit 0
        fi
        if [ -f "$APPROVAL_DIR/$ID.rejected" ]; then
            rm -f "$APPROVAL_DIR/$ID.json" "$APPROVAL_DIR/$ID.rejected"
            exit 2
        fi
        sleep 1
    done
    rm -f "$APPROVAL_DIR/$ID.json"
    exit 2
fi
exit 0

Script 2 — Approval server (scripts/approval-server.sh):

#!/bin/bash
APPROVAL_DIR=".claude/approvals"
echo "Approval Server - Watching $APPROVAL_DIR"
while true; do
    for f in "$APPROVAL_DIR"/*.json 2>/dev/null; do
        [ -f "$f" ] || continue
        ID=$(jq -r '.id' "$f")
        CMD=$(jq -r '.command' "$f")
        STATUS=$(jq -r '.status' "$f")
        if [ "$STATUS" = "pending" ]; then
            echo ""
            echo "PENDING: $ID"
            echo "Command: $CMD"
            echo "→ Approve: touch $APPROVAL_DIR/$ID.approved"
            echo "→ Reject:  touch $APPROVAL_DIR/$ID.rejected"
        fi
    done
    sleep 2
done

Script 3 — Responder (scripts/respond-approval.sh):

#!/bin/bash
APPROVAL_DIR=".claude/approvals"
ID=$1
ACTION=$2

if [ -z "$ID" ] || [ -z "$ACTION" ]; then
    echo "Usage: ./respond-approval.sh <approval-id> <approve|reject>"
    exit 1
fi

if [ "$ACTION" = "approve" ]; then
    touch "$APPROVAL_DIR/$ID.approved"
    echo "Approved: $ID"
elif [ "$ACTION" = "reject" ]; then
    touch "$APPROVAL_DIR/$ID.rejected"
    echo "Rejected: $ID"
fi

Usage (3 terminals):

# Terminal 1: Claude Code with the hook
# Terminal 2: ./scripts/approval-server.sh
# Terminal 3: ./scripts/respond-approval.sh sim-1710345600 approve

Summary

  • Approval flows define which Claude Code operations need human approval before running
  • They use PermissionRequest and PreToolUse hooks with exit codes: 0 (approve), 1 (escalate/decide), 2 (reject)
  • Operations are classified into safe (auto-approve), sensitive (requires approval), and destructive (block)
  • Approval can be done via remote CLI, web interface, or programmatic SDK
  • Timeout policies define what happens when no one approves: reject (safe), approve (low risk), or fallback (alternative)
  • The escalation pattern uses files: the hook writes a .json request, waits for an .approved or .rejected
  • Audit logs record all approval decisions for traceability and analysis
  • Advanced patterns include approval by environment, double approval for critical operations, and fallback with safe alternatives
  • Experimental status (March 2026): the complete integration with mobile remote control is in active development

Additional Resources

  1. Claude Code Hooks (Anthropic Docs) — Documentation of PermissionRequest and other hooks
  2. Claude Code Settings — Hook and permission configuration
  3. Claude Code Security — Security and permission model
  4. Claude Code CLI Reference — Permission and approval flags
  5. Claude Code Best Practices — Security best practices in automation
  6. Claude Code Overview — General context of the permission model
  7. OWASP Secure Coding — Security principles applicable to approval flows
  8. Anthropic Safety — Anthropic's security framework

Next capsule: In capsule 04 you'll design CLAUDE.md as a team standard — the section structure, the merge hierarchy (global < user < project < subdirectory), enforcement with hooks that validate compliance, and a production-ready template. You go from controlling individual operations (approval flows) to establishing the rules the whole team follows (CLAUDE.md as a constitution).