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:
- Grant all permissions → Productive but dangerous
- 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 Code | Meaning | Claude Code does... |
|---|---|---|
0 | Approved | Runs the operation |
1 | Error (decide) | Reports to Claude, which decides whether to insist or find an alternative |
2 | Rejected | The 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:
| Policy | Behavior | When to use |
|---|---|---|
| Timeout → Reject | If no one approves in X minutes, reject the operation | Safe default. For destructive operations |
| Timeout → Approve | If no one approves in X minutes, approve automatically | Low-risk operations that can wait |
| Timeout → Fallback | If no one approves, use a safe alternative | When there are less risky options |
| No timeout | Wait indefinitely | Critical 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
| Aspect | Auto-Approve Everything | Manual Approval | Approval Gates (hybrid) |
|---|---|---|---|
| Speed | Maximum — no interruptions | Minimum — every operation waits | High — only sensitive ones wait |
| Security | Low — destructive operations pass | Maximum — everything is reviewed | High — risk classification |
| Supervision | None | Constant (you must be there) | Selective (remote control) |
| Ideal for | Prototyping, personal projects | Critical production, compliance | Teams, real projects |
| Risk | High — one error destroys work | Low — but high friction | Controlled — risk/speed balance |
| With remote control | Not needed | Essential | Perfect complement |
| Developer fatigue | None | High (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
.jsonrequest, waits for an.approvedor.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
- Claude Code Hooks (Anthropic Docs) — Documentation of PermissionRequest and other hooks
- Claude Code Settings — Hook and permission configuration
- Claude Code Security — Security and permission model
- Claude Code CLI Reference — Permission and approval flags
- Claude Code Best Practices — Security best practices in automation
- Claude Code Overview — General context of the permission model
- OWASP Secure Coding — Security principles applicable to approval flows
- 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).