
Damage Control
- 16 installs
- 17 repo stars
- Updated March 28, 2026
- cfircoo/claude-code-toolkit
Automate damage control in your development workflow
About
damage-control provides specialized automation for your workflow. Integrate it during build to automate key development tasks and improve team efficiency.
- Damage Control
- Automation
- Workflow
Damage Control by the numbers
- 16 all-time installs (skills.sh)
- Ranked #11,040 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cfircoo/claude-code-toolkit --skill damage-controlAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 16 |
|---|---|
| repo stars | ★ 17 |
| Last updated | March 28, 2026 |
| Repository | cfircoo/claude-code-toolkit ↗ |
What it does
Automate damage control in your development workflow
Files
<objective> Defense-in-depth protection system for Claude Code. Uses PreToolUse hooks to intercept and validate tool calls before execution, blocking dangerous commands and protecting sensitive files. </objective>
<protection_levels>
| Level | Read | Write | Edit | Delete | Use Case |
|---|---|---|---|---|---|
| zeroAccessPaths | No | No | No | No | Secrets, credentials, .env files |
| readOnlyPaths | Yes | No | No | No | System configs, lock files, build artifacts |
| noDeletePaths | Yes | Yes | Yes | No | Important project files, .git/, LICENSE |
</protection_levels>
<how_it_works> PreToolUse hooks intercept tool calls at three points:
1. Bash Hook - Evaluates commands against regex patterns and path restrictions 2. Edit Hook - Validates file paths before modifications 3. Write Hook - Checks paths before file creation
Exit codes:
0= Allow operation0+ JSON = Ask for confirmation (triggers dialog)2= Block operation (stderr fed back to Claude)
Ask patterns: Some operations trigger confirmation dialogs instead of blocking:
git checkout -- .(discards changes)git stash drop(deletes stash)DELETE FROM table WHERE id=X(SQL with specific ID)
</how_it_works>
<quick_start> Interactive installation:
/damage-control installOr ask Claude:
"Install damage control security hooks"
"Set up protection for my project"
</quick_start>
<intake> What would you like to do?
1. Install - Set up damage control hooks (global, project, or personal) 2. Modify - Add/remove protected paths or blocked commands 3. Test - Validate hooks are working correctly 4. List - View all active protections across all levels
Wait for response before proceeding. </intake>
<routing>
| Response | Workflow |
|---|---|
| 1, "install", "setup", "deploy" | workflows/install.md |
| 2, "modify", "add", "remove", "change" | workflows/modify.md |
| 3, "test", "verify", "check" | workflows/test.md |
| 4, "list", "view", "show" | workflows/list.md |
Direct command routing (skip menu):
- "add ~/.credentials to zero access" → Execute directly, then restart reminder
- "block npm publish command" → Execute directly, then restart reminder
- "protect /secrets folder" → Execute directly, then restart reminder
After reading the workflow, follow it exactly. </routing>
<blocked_commands_summary> Destructive file operations:
rm -rf,rm --recursive,sudo rmchmod 777,chown -R root
Git destructive:
git reset --hard,git push --force(not --force-with-lease)git clean -fd,git stash clear,git filter-branch
Cloud destructive:
- AWS:
terminate-instances,delete-db-instance,delete-stack - GCP:
projects delete,instances delete,clusters delete - Docker:
system prune -a,volume rm - Kubernetes:
delete namespace,delete all --all
Database destructive:
DELETE FROM table;(no WHERE clause)DROP TABLE,DROP DATABASE,TRUNCATE TABLEredis-cli FLUSHALL,dropdb
See scripts/patterns.yaml for complete list. </blocked_commands_summary>
<settings_locations>
| Level | Settings Path | Hooks Path | Scope |
|---|---|---|---|
| Global | ~/.claude/settings.json | ~/.claude/hooks/damage-control/ | All projects |
| Project | .claude/settings.json | .claude/hooks/damage-control/ | Team-shared |
| Personal | .claude/settings.local.json | .claude/hooks/damage-control/ | Just you |
</settings_locations>
<runtime_requirements> Python with UV (Recommended):
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"TypeScript with Bun (Alternative):
# macOS/Linux
curl -fsSL https://bun.sh/install | bash && bun add yaml
# Windows
powershell -c "irm bun.sh/install.ps1 | iex" && bun add yaml</runtime_requirements>
<critical_reminder> IMPORTANT: After any installation or modification:
Restart your agent for changes to take effect.
Hooks are only loaded at agent startup. Run /hooks after restart to verify. </critical_reminder>
<workflows_index>
| Workflow | Purpose |
|---|---|
| workflows/install.md | Interactive installation at any settings level |
| workflows/modify.md | Add/remove protected paths and blocked commands |
| workflows/test.md | Validate all hooks are working correctly |
| workflows/list.md | View all active protections |
</workflows_index>
<scripts_index>
| Script | Purpose |
|---|---|
| scripts/bash-tool-damage-control.py | PreToolUse hook for Bash commands |
| scripts/edit-tool-damage-control.py | PreToolUse hook for Edit tool |
| scripts/write-tool-damage-control.py | PreToolUse hook for Write tool |
| scripts/test-damage-control.py | Test runner for hook validation |
| scripts/patterns.yaml | Security patterns and protected paths |
| scripts/settings-template.json | Hook configuration template |
</scripts_index>
<success_criteria> A working damage-control installation has:
- Hooks installed at chosen level (global/project/personal)
patterns.yamlcopied alongside hook scriptssettings.jsonupdated with PreToolUse hook configuration- UV (or Bun) runtime installed
- Agent restarted to load hooks
- Verified with
/hookscommand showing damage-control hooks - Tested with
rm -rf /tmp/test(should be blocked)
</success_criteria>
# /// script
# requires-python = ">=3.8"
# dependencies = ["pyyaml"]
# ///
"""
Claude Code Security Firewall - Python/UV Implementation
=========================================================
Blocks dangerous commands before execution via PreToolUse hook.
Loads patterns from patterns.yaml for easy customization.
Exit codes:
0 = Allow command (or JSON output with permissionDecision)
2 = Block command (stderr fed back to Claude)
JSON output for ask patterns:
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "ask", "permissionDecisionReason": "..."}}
"""
import json
import sys
import re
import os
import fnmatch
from pathlib import Path
from typing import Tuple, List, Dict, Any
import yaml
def is_glob_pattern(pattern: str) -> bool:
"""Check if pattern contains glob wildcards."""
return '*' in pattern or '?' in pattern or '[' in pattern
def glob_to_regex(glob_pattern: str) -> str:
"""Convert a glob pattern to a regex pattern for matching in commands."""
# Escape special regex chars except * and ?
result = ""
for char in glob_pattern:
if char == '*':
result += r'[^\s/]*' # Match any chars except whitespace and path sep
elif char == '?':
result += r'[^\s/]' # Match single char except whitespace and path sep
elif char in r'\.^$+{}[]|()':
result += '\\' + char
else:
result += char
return result
# ============================================================================
# OPERATION PATTERNS - Edit these to customize what operations are blocked
# ============================================================================
# {path} will be replaced with the escaped path at runtime
# Operations blocked for READ-ONLY paths (all modifications)
WRITE_PATTERNS = [
(r'>\s*{path}', "write"),
(r'\btee\s+(?!.*-a).*{path}', "write"),
]
APPEND_PATTERNS = [
(r'>>\s*{path}', "append"),
(r'\btee\s+-a\s+.*{path}', "append"),
(r'\btee\s+.*-a.*{path}', "append"),
]
EDIT_PATTERNS = [
(r'\bsed\s+-i.*{path}', "edit"),
(r'\bperl\s+-[^\s]*i.*{path}', "edit"),
(r'\bawk\s+-i\s+inplace.*{path}', "edit"),
]
MOVE_COPY_PATTERNS = [
(r'\bmv\s+.*\s+{path}', "move"),
(r'\bcp\s+.*\s+{path}', "copy"),
]
DELETE_PATTERNS = [
(r'\brm\s+.*{path}', "delete"),
(r'\bunlink\s+.*{path}', "delete"),
(r'\brmdir\s+.*{path}', "delete"),
(r'\bshred\s+.*{path}', "delete"),
]
PERMISSION_PATTERNS = [
(r'\bchmod\s+.*{path}', "chmod"),
(r'\bchown\s+.*{path}', "chown"),
(r'\bchgrp\s+.*{path}', "chgrp"),
]
TRUNCATE_PATTERNS = [
(r'\btruncate\s+.*{path}', "truncate"),
(r':\s*>\s*{path}', "truncate"),
]
# Combined patterns for read-only paths (block ALL modifications)
READ_ONLY_BLOCKED = (
WRITE_PATTERNS +
APPEND_PATTERNS +
EDIT_PATTERNS +
MOVE_COPY_PATTERNS +
DELETE_PATTERNS +
PERMISSION_PATTERNS +
TRUNCATE_PATTERNS
)
# Patterns for no-delete paths (block ONLY delete operations)
NO_DELETE_BLOCKED = DELETE_PATTERNS
# ============================================================================
# CONFIGURATION LOADING
# ============================================================================
def get_config_path() -> Path:
"""Get path to patterns.yaml, checking multiple locations."""
# 1. Check project hooks directory (installed location)
project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
if project_dir:
project_config = Path(project_dir) / ".claude" / "hooks" / "damage-control" / "patterns.yaml"
if project_config.exists():
return project_config
# 2. Check script's own directory (installed location)
script_dir = Path(__file__).parent
local_config = script_dir / "patterns.yaml"
if local_config.exists():
return local_config
# 3. Check skill root directory (development location)
skill_root = script_dir.parent.parent / "patterns.yaml"
if skill_root.exists():
return skill_root
return local_config # Default, even if it doesn't exist
def load_config() -> Dict[str, Any]:
"""Load patterns from YAML config file."""
config_path = get_config_path()
if not config_path.exists():
print(f"Warning: Config not found at {config_path}", file=sys.stderr)
return {"bashToolPatterns": [], "zeroAccessPaths": [], "readOnlyPaths": [], "noDeletePaths": []}
with open(config_path, "r") as f:
return yaml.safe_load(f) or {}
# ============================================================================
# PATH CHECKING
# ============================================================================
def check_path_patterns(command: str, path: str, patterns: List[Tuple[str, str]], path_type: str) -> Tuple[bool, str]:
"""Check command against a list of patterns for a specific path.
Supports both:
- Literal paths: ~/.bashrc, /etc/hosts (prefix matching)
- Glob patterns: *.lock, *.md, src/* (glob matching)
"""
if is_glob_pattern(path):
# Glob pattern - convert to regex for command matching
glob_regex = glob_to_regex(path)
for pattern_template, operation in patterns:
# For glob patterns, we check if the operation + glob appears in command
# e.g., "rm *.lock" should match DELETE_PATTERNS with *.lock
try:
# Build a regex that matches: operation ... glob_pattern
# Extract the command prefix from pattern_template (e.g., '\brm\s+.*' from '\brm\s+.*{path}')
cmd_prefix = pattern_template.replace("{path}", "")
if cmd_prefix and re.search(cmd_prefix + glob_regex, command, re.IGNORECASE):
return True, f"Blocked: {operation} operation on {path_type} {path}"
except re.error:
continue
else:
# Original literal path matching (prefix-based)
expanded = os.path.expanduser(path)
escaped_expanded = re.escape(expanded)
escaped_original = re.escape(path)
for pattern_template, operation in patterns:
# Check both expanded path (/Users/x/.ssh/) and original tilde form (~/.ssh/)
pattern_expanded = pattern_template.replace("{path}", escaped_expanded)
pattern_original = pattern_template.replace("{path}", escaped_original)
try:
if re.search(pattern_expanded, command) or re.search(pattern_original, command):
return True, f"Blocked: {operation} operation on {path_type} {path}"
except re.error:
continue
return False, ""
def check_command(command: str, config: Dict[str, Any]) -> Tuple[bool, bool, str]:
"""Check if command should be blocked or requires confirmation.
Returns: (blocked, ask, reason)
- blocked=True, ask=False: Block the command
- blocked=False, ask=True: Show confirmation dialog
- blocked=False, ask=False: Allow the command
"""
patterns = config.get("bashToolPatterns", [])
zero_access_paths = config.get("zeroAccessPaths", [])
read_only_paths = config.get("readOnlyPaths", [])
no_delete_paths = config.get("noDeletePaths", [])
# 1. Check against patterns from YAML (may block or ask)
for item in patterns:
pattern = item.get("pattern", "")
reason = item.get("reason", "Blocked by pattern")
should_ask = item.get("ask", False)
try:
if re.search(pattern, command, re.IGNORECASE):
if should_ask:
return False, True, reason # Ask for confirmation
else:
return True, False, f"Blocked: {reason}" # Block
except re.error:
continue
# 2. Check for ANY access to zero-access paths (including reads)
for zero_path in zero_access_paths:
if is_glob_pattern(zero_path):
# Convert glob to regex for command matching
glob_regex = glob_to_regex(zero_path)
try:
if re.search(glob_regex, command, re.IGNORECASE):
return True, False, f"Blocked: zero-access pattern {zero_path} (no operations allowed)"
except re.error:
continue
else:
# Original literal path matching
expanded = os.path.expanduser(zero_path)
escaped_expanded = re.escape(expanded)
escaped_original = re.escape(zero_path)
# Check both expanded path (/Users/x/.ssh/) and original tilde form (~/.ssh/)
if re.search(escaped_expanded, command) or re.search(escaped_original, command):
return True, False, f"Blocked: zero-access path {zero_path} (no operations allowed)"
# 3. Check for modifications to read-only paths (reads allowed)
for readonly in read_only_paths:
blocked, reason = check_path_patterns(command, readonly, READ_ONLY_BLOCKED, "read-only path")
if blocked:
return True, False, reason
# 4. Check for deletions on no-delete paths (read/write/edit allowed)
for no_delete in no_delete_paths:
blocked, reason = check_path_patterns(command, no_delete, NO_DELETE_BLOCKED, "no-delete path")
if blocked:
return True, False, reason
return False, False, ""
# ============================================================================
# MAIN
# ============================================================================
def main() -> None:
config = load_config()
# Read hook input from stdin
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"Error reading input: {e}", file=sys.stderr)
sys.exit(1)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
# Only check Bash commands
if tool_name != "Bash":
sys.exit(0)
command = tool_input.get("command", "")
if not command:
sys.exit(0)
# Runtime check: block git commit/push while on main/master (allow tag pushes, gh pr create)
first_cmd = command.split('&&')[0].split('||')[0].split(';')[0].strip()
is_git_push_or_commit = re.match(r'^git\s+(push|commit)\b', first_cmd)
is_tag_push = re.search(r'\bgit\s+push\s+\S+\s+v[\d.]', first_cmd) or re.search(r'\bgit\s+tag\b', first_cmd)
if is_git_push_or_commit and not is_tag_push:
try:
import subprocess
branch = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True, text=True, timeout=5
).stdout.strip()
if branch in ("main", "master"):
op = "push" if "push" in command else "commit"
print(f"SECURITY: Blocked: git {op} while on '{branch}' branch — create a feature branch first", file=sys.stderr)
print(f"Command: {command[:100]}{'...' if len(command) > 100 else ''}", file=sys.stderr)
sys.exit(2)
except Exception:
pass # If we can't detect branch, fall through to pattern checks
# Check the command
is_blocked, should_ask, reason = check_command(command, config)
if is_blocked:
print(f"SECURITY: {reason}", file=sys.stderr)
print(f"Command: {command[:100]}{'...' if len(command) > 100 else ''}", file=sys.stderr)
sys.exit(2)
elif should_ask:
# Output JSON to trigger confirmation dialog
output = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": reason
}
}
print(json.dumps(output))
sys.exit(0)
else:
sys.exit(0)
if __name__ == "__main__":
main()
# /// script
# requires-python = ">=3.8"
# dependencies = ["pyyaml"]
# ///
"""
Claude Code Edit Tool Damage Control
=====================================
Blocks edits to protected files via PreToolUse hook on Edit tool.
Loads zeroAccessPaths and readOnlyPaths from patterns.yaml.
Exit codes:
0 = Allow edit
2 = Block edit (stderr fed back to Claude)
"""
import json
import sys
import os
import fnmatch
from pathlib import Path
from typing import Dict, Any, List, Tuple, Optional
import yaml
def is_glob_pattern(pattern: str) -> bool:
"""Check if pattern contains glob wildcards."""
return '*' in pattern or '?' in pattern or '[' in pattern
def match_path(file_path: str, pattern: str) -> bool:
"""Match file path against pattern, supporting both prefix and glob matching."""
expanded_pattern = os.path.expanduser(pattern)
normalized = os.path.normpath(file_path)
expanded_normalized = os.path.expanduser(normalized)
if is_glob_pattern(pattern):
# Glob pattern matching (case-insensitive for security)
basename = os.path.basename(expanded_normalized)
basename_lower = basename.lower()
pattern_lower = pattern.lower()
expanded_pattern_lower = expanded_pattern.lower()
# Match against basename for patterns like *.pem, .env*
if fnmatch.fnmatch(basename_lower, expanded_pattern_lower):
return True
if fnmatch.fnmatch(basename_lower, pattern_lower):
return True
# Also try full path match for patterns like /path/*.pem
if fnmatch.fnmatch(expanded_normalized.lower(), expanded_pattern_lower):
return True
return False
else:
# Prefix matching (original behavior for directories)
if expanded_normalized.startswith(expanded_pattern) or expanded_normalized == expanded_pattern.rstrip('/'):
return True
return False
def get_config_path() -> Path:
"""Get path to patterns.yaml, checking multiple locations."""
# 1. Check project hooks directory (installed location)
project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
if project_dir:
project_config = Path(project_dir) / ".claude" / "hooks" / "damage-control" / "patterns.yaml"
if project_config.exists():
return project_config
# 2. Check script's own directory (installed location)
script_dir = Path(__file__).parent
local_config = script_dir / "patterns.yaml"
if local_config.exists():
return local_config
# 3. Check skill root directory (development location)
skill_root = script_dir.parent.parent / "patterns.yaml"
if skill_root.exists():
return skill_root
return local_config # Default, even if it doesn't exist
def load_config() -> Dict[str, Any]:
"""Load config from YAML."""
config_path = get_config_path()
if not config_path.exists():
return {"zeroAccessPaths": [], "readOnlyPaths": []}
with open(config_path, "r") as f:
config = yaml.safe_load(f) or {}
return config
def check_path(file_path: str, config: Dict[str, Any]) -> Tuple[bool, str]:
"""Check if file_path is blocked. Returns (blocked, reason)."""
# Check zero-access paths first (no access at all)
for zero_path in config.get("zeroAccessPaths", []):
if match_path(file_path, zero_path):
return True, f"zero-access path {zero_path} (no operations allowed)"
# Check read-only paths (edits not allowed)
for readonly in config.get("readOnlyPaths", []):
if match_path(file_path, readonly):
return True, f"read-only path {readonly}"
return False, ""
def main() -> None:
config = load_config()
# Read hook input from stdin
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
sys.exit(1)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
# Only check Edit tool
if tool_name != "Edit":
sys.exit(0)
file_path = tool_input.get("file_path", "")
if not file_path:
sys.exit(0)
# Check if file is blocked
blocked, reason = check_path(file_path, config)
if blocked:
print(f"SECURITY: Blocked edit to {reason}: {file_path}", file=sys.stderr)
sys.exit(2)
sys.exit(0)
if __name__ == "__main__":
main()
# Claude Code Security Patterns
# =============================
# Add patterns here to block dangerous commands.
# Each script (py, sh, ts) loads this file.
# These patterns are matched against Bash tool commands only
bashToolPatterns:
# ---------------------------------------------------------------------------
# DESTRUCTIVE FILE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\brm\s+(-[^\s]*)*-[rRf]'
reason: rm with recursive or force flags
- pattern: '\brm\s+-[rRf]'
reason: rm with recursive or force flags
- pattern: '\brm\s+--recursive'
reason: rm with --recursive flag
- pattern: '\brm\s+--force'
reason: rm with --force flag
- pattern: '\bsudo\s+rm\b'
reason: sudo rm
- pattern: '\brmdir\s+--ignore-fail-on-non-empty'
reason: rmdir ignore-fail
# ---------------------------------------------------------------------------
# PERMISSION CHANGES
# ---------------------------------------------------------------------------
- pattern: '\bchmod\s+(-[^\s]+\s+)*777\b'
reason: chmod 777 (world writable)
- pattern: '\bchmod\s+-[Rr].*777'
reason: recursive chmod 777
- pattern: '\bchown\s+-[Rr].*\broot\b'
reason: recursive chown to root
# ---------------------------------------------------------------------------
# GIT DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bgit\s+reset\s+--hard\b'
reason: git reset --hard (use --soft or stash)
- pattern: '\bgit\s+clean\s+(-[^\s]*)*-[fd]'
reason: git clean with force/directory flags
# Note: This blocks --force but NOT --force-with-lease
- pattern: '\bgit\s+push\s+.*--force(?!-with-lease)'
reason: git push --force (use --force-with-lease)
- pattern: '\bgit\s+push\s+(-[^\s]*)*-f\b'
reason: git push -f (use --force-with-lease)
- pattern: '^git\s+push\b.*\b(origin|upstream)\s+(main|master)\b'
reason: Pushing directly to main/master — create a feature branch instead
- pattern: '^git\s+push\s*$'
reason: Bare git push blocked — specify remote and branch explicitly (e.g. git push origin feature-branch)
- pattern: '\bgit\s+stash\s+clear\b'
reason: git stash clear (deletes ALL stashes)
- pattern: '\bgit\s+reflog\s+expire\b'
reason: git reflog expire (destroys recovery mechanism)
- pattern: '\bgit\s+gc\s+.*--prune=now'
reason: git gc --prune=now (can lose dangling commits)
- pattern: '\bgit\s+filter-branch\b'
reason: git filter-branch (rewrites entire history)
# ---------------------------------------------------------------------------
# GIT OPERATIONS REQUIRING CONFIRMATION (ask: true)
# ---------------------------------------------------------------------------
- pattern: '\bgit\s+checkout\s+--\s*\.'
reason: Discards all uncommitted changes
ask: true
- pattern: '\bgit\s+restore\s+\.'
reason: Discards all uncommitted changes
ask: true
- pattern: '\bgit\s+stash\s+drop\b'
reason: Permanently deletes a stash
ask: true
- pattern: '\bgit\s+branch\s+(-[^\s]*)*-D'
reason: Force deletes branch (even if unmerged)
ask: true
- pattern: '\bgit\s+push\s+\S+\s+--delete\b'
reason: Deletes remote branch
ask: true
- pattern: '\bgit\s+push\s+\S+\s+:\S+'
reason: Deletes remote branch (old syntax)
ask: true
# ---------------------------------------------------------------------------
# SYSTEM-LEVEL DESTRUCTION
# ---------------------------------------------------------------------------
- pattern: '\bmkfs\.'
reason: filesystem format command
- pattern: '\bdd\s+.*of=/dev/'
reason: dd writing to device
# ---------------------------------------------------------------------------
# PROCESS DESTRUCTION
# ---------------------------------------------------------------------------
- pattern: '\bkill\s+-9\s+-1\b'
reason: kill all processes
- pattern: '\bkillall\s+-9\b'
reason: killall -9
- pattern: '\bpkill\s+-9\b'
reason: pkill -9
# ---------------------------------------------------------------------------
# HISTORY/SHELL MANIPULATION
# ---------------------------------------------------------------------------
- pattern: '\bhistory\s+-c\b'
reason: clearing shell history
# ---------------------------------------------------------------------------
# ENVIRONMENT VARIABLE EXPOSURE (blocks reading secrets from env)
# ---------------------------------------------------------------------------
- pattern: '\bprintenv\b'
reason: printenv exposes environment variables (may contain secrets)
- pattern: '\benv\b(?!\s+\S+=)'
reason: env command exposes environment variables (may contain secrets)
- pattern: '\bexport\s+-p\b'
reason: export -p exposes all exported variables
- pattern: '\bexport\s*$'
reason: export without args exposes all exported variables
- pattern: '\bdeclare\s+-x\b'
reason: declare -x exposes exported variables
- pattern: '\bset\s*$'
reason: set without args exposes shell variables
- pattern: '\bcompgen\s+-e\b'
reason: compgen -e lists environment variable names
- pattern: '\becho\s+\$\{?\w*[A-Z_]+\w*\}?'
reason: echo with env variable (may expose secrets)
ask: true
# ---------------------------------------------------------------------------
# AWS CLI DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\baws\s+s3\s+rm\s+.*--recursive'
reason: aws s3 rm --recursive (deletes all objects)
- pattern: '\baws\s+s3\s+rb\s+.*--force'
reason: aws s3 rb --force (force removes bucket)
- pattern: '\baws\s+ec2\s+terminate-instances\b'
reason: aws ec2 terminate-instances
- pattern: '\baws\s+rds\s+delete-db-instance\b'
reason: aws rds delete-db-instance
- pattern: '\baws\s+cloudformation\s+delete-stack\b'
reason: aws cloudformation delete-stack (deletes infrastructure)
- pattern: '\baws\s+dynamodb\s+delete-table\b'
reason: aws dynamodb delete-table
- pattern: '\baws\s+eks\s+delete-cluster\b'
reason: aws eks delete-cluster
- pattern: '\baws\s+lambda\s+delete-function\b'
reason: aws lambda delete-function
- pattern: '\baws\s+iam\s+delete-role\b'
reason: aws iam delete-role
- pattern: '\baws\s+iam\s+delete-user\b'
reason: aws iam delete-user
# ---------------------------------------------------------------------------
# GCP (gcloud) DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bgcloud\s+projects\s+delete\b'
reason: gcloud projects delete (DELETES ENTIRE PROJECT)
- pattern: '\bgcloud\s+compute\s+instances\s+delete\b'
reason: gcloud compute instances delete
- pattern: '\bgcloud\s+sql\s+instances\s+delete\b'
reason: gcloud sql instances delete
- pattern: '\bgcloud\s+container\s+clusters\s+delete\b'
reason: gcloud container clusters delete (GKE)
- pattern: '\bgcloud\s+storage\s+rm\s+.*-r'
reason: gcloud storage rm -r (recursive delete)
- pattern: '\bgcloud\s+functions\s+delete\b'
reason: gcloud functions delete
- pattern: '\bgcloud\s+iam\s+service-accounts\s+delete\b'
reason: gcloud iam service-accounts delete
# ---------------------------------------------------------------------------
# FIREBASE DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bfirebase\s+projects:delete\b'
reason: firebase projects:delete (deletes entire project)
- pattern: '\bfirebase\s+firestore:delete\s+.*--all-collections'
reason: firebase firestore:delete --all-collections (wipes all data)
- pattern: '\bfirebase\s+database:remove\b'
reason: firebase database:remove (wipes Realtime DB)
- pattern: '\bfirebase\s+hosting:disable\b'
reason: firebase hosting:disable
- pattern: '\bfirebase\s+functions:delete\b'
reason: firebase functions:delete
# ---------------------------------------------------------------------------
# VERCEL DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bvercel\s+remove\s+.*--yes'
reason: vercel remove --yes (removes deployment)
- pattern: '\bvercel\s+projects\s+rm\b'
reason: vercel projects rm (deletes project)
- pattern: '\bvercel\s+env\s+rm\s+.*--yes'
reason: vercel env rm --yes (removes env variables)
# ---------------------------------------------------------------------------
# NETLIFY DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bnetlify\s+sites:delete\b'
reason: netlify sites:delete (deletes entire site)
- pattern: '\bnetlify\s+functions:delete\b'
reason: netlify functions:delete
# ---------------------------------------------------------------------------
# CLOUDFLARE (wrangler) DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bwrangler\s+delete\b'
reason: wrangler delete (deletes Worker)
- pattern: '\bwrangler\s+r2\s+bucket\s+delete\b'
reason: wrangler r2 bucket delete
- pattern: '\bwrangler\s+kv:namespace\s+delete\b'
reason: wrangler kv:namespace delete
- pattern: '\bwrangler\s+d1\s+delete\b'
reason: wrangler d1 delete (deletes database)
- pattern: '\bwrangler\s+queues\s+delete\b'
reason: wrangler queues delete
# ---------------------------------------------------------------------------
# DOCKER DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bdocker\s+system\s+prune\s+.*-a'
reason: docker system prune -a (removes all unused data)
- pattern: '\bdocker\s+rm\s+.*-f.*\$\(docker\s+ps'
reason: docker rm -f $(docker ps) (force removes containers)
- pattern: '\bdocker\s+rmi\s+.*-f'
reason: docker rmi -f (force removes images)
- pattern: '\bdocker\s+volume\s+rm\b'
reason: docker volume rm (data loss)
- pattern: '\bdocker\s+volume\s+prune\b'
reason: docker volume prune (removes unused volumes)
# ---------------------------------------------------------------------------
# KUBERNETES (kubectl) DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bkubectl\s+delete\s+namespace\b'
reason: kubectl delete namespace
- pattern: '\bkubectl\s+delete\s+all\s+--all'
reason: kubectl delete all --all
- pattern: '\bkubectl\s+delete\s+.*--all\s+--all-namespaces'
reason: kubectl delete across all namespaces
- pattern: '\bhelm\s+uninstall\b'
reason: helm uninstall (removes release)
# ---------------------------------------------------------------------------
# DATABASE CLI DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bredis-cli\s+FLUSHALL'
reason: redis-cli FLUSHALL (wipes ALL data)
- pattern: '\bredis-cli\s+FLUSHDB'
reason: redis-cli FLUSHDB (wipes database)
- pattern: '\bmongosh.*dropDatabase'
reason: MongoDB dropDatabase
- pattern: '\bmongo.*dropDatabase'
reason: MongoDB dropDatabase
- pattern: '\bdropdb\b'
reason: PostgreSQL dropdb
- pattern: '\bmysqladmin\s+drop\b'
reason: MySQL drop database
# ---------------------------------------------------------------------------
# INFRASTRUCTURE AS CODE DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bterraform\s+destroy\b'
reason: terraform destroy (destroys all infrastructure)
- pattern: '\bpulumi\s+destroy\b'
reason: pulumi destroy (destroys all resources)
- pattern: '\bserverless\s+remove\b'
reason: serverless remove (removes stack)
- pattern: '\bsls\s+remove\b'
reason: sls remove (removes stack)
- pattern: '\bsam\s+delete\b'
reason: sam delete (deletes SAM application)
# ---------------------------------------------------------------------------
# HEROKU DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bheroku\s+apps:destroy\b'
reason: heroku apps:destroy
- pattern: '\bheroku\s+pg:reset\b'
reason: heroku pg:reset (resets database)
# ---------------------------------------------------------------------------
# OTHER CLOUD PLATFORMS DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bfly\s+apps\s+destroy\b'
reason: fly apps destroy (Fly.io)
- pattern: '\bfly\s+destroy\b'
reason: fly destroy (Fly.io)
- pattern: '\bdoctl\s+compute\s+droplet\s+delete\b'
reason: doctl droplet delete (DigitalOcean)
- pattern: '\bdoctl\s+databases\s+delete\b'
reason: doctl databases delete (DigitalOcean)
- pattern: '\bsupabase\s+db\s+reset\b'
reason: supabase db reset
# ---------------------------------------------------------------------------
# GITHUB CLI DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bgh\s+repo\s+delete\b'
reason: gh repo delete (deletes repository)
# ---------------------------------------------------------------------------
# PACKAGE REGISTRY DESTRUCTIVE OPERATIONS
# ---------------------------------------------------------------------------
- pattern: '\bnpm\s+unpublish\b'
reason: npm unpublish (removes package from registry)
# ---------------------------------------------------------------------------
# SQL DESTRUCTIVE OPERATIONS (catastrophic - no WHERE clause)
# ---------------------------------------------------------------------------
- pattern: 'DELETE\s+FROM\s+\w+\s*;'
reason: DELETE without WHERE clause (will delete ALL rows)
- pattern: 'DELETE\s+FROM\s+\w+\s*$'
reason: DELETE without WHERE clause (will delete ALL rows)
- pattern: 'DELETE\s+\*\s+FROM'
reason: DELETE * (will delete ALL rows)
- pattern: '\bTRUNCATE\s+TABLE\b'
reason: TRUNCATE TABLE (will delete ALL rows)
- pattern: '\bDROP\s+TABLE\b'
reason: DROP TABLE
- pattern: '\bDROP\s+DATABASE\b'
reason: DROP DATABASE
# ---------------------------------------------------------------------------
# SQL OPERATIONS REQUIRING CONFIRMATION (ask: true)
# ---------------------------------------------------------------------------
- pattern: '\bDELETE\s+FROM\s+\w+\s+WHERE\b.*\bid\s*='
reason: SQL DELETE with specific ID
ask: true
# ---------------------------------------------------------------------------
# ZERO ACCESS PATHS - No read, write, or any access allowed
# ---------------------------------------------------------------------------
# These contain secrets/credentials - block ALL operations including reads
# Enforced by: Bash, Edit, Write tools
# Supports glob patterns: *.pem, .env*, *-credentials.json
zeroAccessPaths:
# ---------------------------------------------------------------------------
# ENVIRONMENT FILES (HIGH RISK - contain secrets)
# ---------------------------------------------------------------------------
- ".env"
- ".env.*"
- ".env*.local"
- "*.env"
# ---------------------------------------------------------------------------
# SSH KEYS AND CONFIG
# ---------------------------------------------------------------------------
- "~/.ssh/"
# ---------------------------------------------------------------------------
# GPG KEYS
# ---------------------------------------------------------------------------
- "~/.gnupg/"
# ---------------------------------------------------------------------------
# CLOUD PROVIDER CREDENTIALS
# ---------------------------------------------------------------------------
# AWS
- "~/.aws/"
# GCP
- "~/.config/gcloud/"
- "*-credentials.json"
- "*serviceAccount*.json"
- "*service-account*.json"
# Azure
- "~/.azure/"
# Kubernetes
- "kubeconfig"
- "*-secret.yaml"
- "secrets.yaml"
# Docker
- "~/.docker/"
# ---------------------------------------------------------------------------
# SSL/TLS CERTIFICATES AND PRIVATE KEYS
# ---------------------------------------------------------------------------
- "*.pem"
- "*.key"
- "*.p12"
- "*.pfx"
# ---------------------------------------------------------------------------
# TERRAFORM STATE (contains secrets in plaintext!)
# ---------------------------------------------------------------------------
- "*.tfstate"
- "*.tfstate.backup"
- ".terraform/"
# ---------------------------------------------------------------------------
# PLATFORM TOKENS (Vercel, Netlify, etc.)
# ---------------------------------------------------------------------------
- ".vercel/"
- ".netlify/"
# ---------------------------------------------------------------------------
# FIREBASE/SUPABASE
# ---------------------------------------------------------------------------
- "firebase-adminsdk*.json"
- "serviceAccountKey.json"
- ".supabase/"
# ---------------------------------------------------------------------------
# PACKAGE MANAGER AUTH & CREDENTIALS
# ---------------------------------------------------------------------------
- "~/.netrc"
- "~/.npmrc"
- "~/.pypirc"
- "~/.git-credentials"
- ".git-credentials"
# ---------------------------------------------------------------------------
# DATABASE DUMPS (may contain production data)
# ---------------------------------------------------------------------------
- "dump.sql"
- "backup.sql"
- "*.dump"
# ---------------------------------------------------------------------------
# READ-ONLY PATHS - Can read, but not write/edit/delete
# ---------------------------------------------------------------------------
# Safe to read but should never be modified by AI
# Enforced by: Bash, Edit, Write tools
# Supports glob patterns: *.lock, *.min.js
readOnlyPaths:
# ---------------------------------------------------------------------------
# SYSTEM DIRECTORIES
# ---------------------------------------------------------------------------
- /etc/
- /usr/
- /bin/
- /sbin/
- /boot/
- /root/
# ---------------------------------------------------------------------------
# KUBERNETES CONFIG (read-only for kubectl get/describe)
# ---------------------------------------------------------------------------
- ~/.kube/
# ---------------------------------------------------------------------------
# SHELL HISTORY FILES
# ---------------------------------------------------------------------------
- ~/.bash_history
- ~/.zsh_history
- ~/.node_repl_history
# ---------------------------------------------------------------------------
# SHELL CONFIG FILES
# ---------------------------------------------------------------------------
- ~/.bashrc
- ~/.zshrc
- ~/.profile
- ~/.bash_profile
# ---------------------------------------------------------------------------
# LOCK FILES - Never manually edit, use package managers
# ---------------------------------------------------------------------------
- "package-lock.json"
- "yarn.lock"
- "pnpm-lock.yaml"
- "Gemfile.lock"
- "poetry.lock"
- "Pipfile.lock"
- "composer.lock"
- "Cargo.lock"
- "go.sum"
- "flake.lock"
- "bun.lockb"
- "uv.lock"
- "npm-shrinkwrap.json"
- "*.lock"
- "*.lockb"
# ---------------------------------------------------------------------------
# MINIFIED/COMPILED FILES - Generated, don't edit
# ---------------------------------------------------------------------------
- "*.min.js"
- "*.min.css"
- "*.bundle.js"
- "*.chunk.js"
# ---------------------------------------------------------------------------
# BUILD ARTIFACTS - Generated directories, don't edit
# ---------------------------------------------------------------------------
- dist/
- build/
- out/
- .next/
- .nuxt/
- .output/
- node_modules/
- __pycache__/
- .venv/
- venv/
- target/
# ---------------------------------------------------------------------------
# NO-DELETE PATHS - Can read/write/edit, but not delete
# ---------------------------------------------------------------------------
# Protect important files from accidental deletion
# Enforced by: Bash tool only (Edit/Write don't delete files)
# Supports glob patterns: *.md, LICENSE*
noDeletePaths:
# ---------------------------------------------------------------------------
# CLAUDE CODE CONFIGURATION
# ---------------------------------------------------------------------------
- ~/.claude/
- CLAUDE.md
# ---------------------------------------------------------------------------
# LICENSE AND LEGAL FILES
# ---------------------------------------------------------------------------
- "LICENSE"
- "LICENSE.*"
- "COPYING"
- "COPYING.*"
- "NOTICE"
- "PATENTS"
# ---------------------------------------------------------------------------
# PROJECT DOCUMENTATION
# ---------------------------------------------------------------------------
- "README.md"
- "README.*"
- "CONTRIBUTING.md"
- "CHANGELOG.md"
- "CODE_OF_CONDUCT.md"
- "SECURITY.md"
# ---------------------------------------------------------------------------
# GIT DIRECTORY
# ---------------------------------------------------------------------------
- .git/
- .gitignore
- .gitattributes
- .gitmodules
# ---------------------------------------------------------------------------
# CI/CD CONFIGURATION
# ---------------------------------------------------------------------------
- .github/
- .gitlab-ci.yml
- .circleci/
- Jenkinsfile
- .travis.yml
- azure-pipelines.yml
# ---------------------------------------------------------------------------
# DOCKER CONFIGURATION
# ---------------------------------------------------------------------------
- Dockerfile
- "Dockerfile.*"
- docker-compose.yml
- "docker-compose.*.yml"
- .dockerignore
# ---------------------------------------------------------------------------
# COMMON PROJECT DIRECTORIES (uncomment to enable)
# ---------------------------------------------------------------------------
# - src/
# - lib/
# - app/
# - tests/
# - docs/
# /// script
# requires-python = ">=3.8"
# dependencies = ["pyyaml"]
# ///
"""
Claude Code Read Tool Damage Control
=====================================
Blocks reads from zero-access files via PreToolUse hook on Read tool.
Loads zeroAccessPaths from patterns.yaml.
Note: readOnlyPaths are NOT blocked - they allow reading but not writing.
Exit codes:
0 = Allow read
2 = Block read (stderr fed back to Claude)
"""
import json
import sys
import os
import fnmatch
from pathlib import Path
from typing import Dict, Any, Tuple
import yaml
def is_glob_pattern(pattern: str) -> bool:
"""Check if pattern contains glob wildcards."""
return '*' in pattern or '?' in pattern or '[' in pattern
def match_path(file_path: str, pattern: str) -> bool:
"""Match file path against pattern, supporting both prefix and glob matching."""
expanded_pattern = os.path.expanduser(pattern)
normalized = os.path.normpath(file_path)
expanded_normalized = os.path.expanduser(normalized)
if is_glob_pattern(pattern):
# Glob pattern matching (case-insensitive for security)
basename = os.path.basename(expanded_normalized)
basename_lower = basename.lower()
pattern_lower = pattern.lower()
expanded_pattern_lower = expanded_pattern.lower()
# Match against basename for patterns like *.pem, .env*
if fnmatch.fnmatch(basename_lower, expanded_pattern_lower):
return True
if fnmatch.fnmatch(basename_lower, pattern_lower):
return True
# Also try full path match for patterns like /path/*.pem
if fnmatch.fnmatch(expanded_normalized.lower(), expanded_pattern_lower):
return True
return False
else:
# Prefix matching (original behavior for directories)
if expanded_normalized.startswith(expanded_pattern) or expanded_normalized == expanded_pattern.rstrip('/'):
return True
return False
def get_config_path() -> Path:
"""Get path to patterns.yaml, checking multiple locations."""
# 1. Check project hooks directory (installed location)
project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
if project_dir:
project_config = Path(project_dir) / ".claude" / "hooks" / "damage-control" / "patterns.yaml"
if project_config.exists():
return project_config
# 2. Check script's own directory (installed location)
script_dir = Path(__file__).parent
local_config = script_dir / "patterns.yaml"
if local_config.exists():
return local_config
# 3. Check skill root directory (development location)
skill_root = script_dir.parent.parent / "patterns.yaml"
if skill_root.exists():
return skill_root
return local_config # Default, even if it doesn't exist
def load_config() -> Dict[str, Any]:
"""Load config from YAML."""
config_path = get_config_path()
if not config_path.exists():
return {"zeroAccessPaths": []}
with open(config_path, "r") as f:
config = yaml.safe_load(f) or {}
return config
def check_path(file_path: str, config: Dict[str, Any]) -> Tuple[bool, str]:
"""Check if file_path is blocked for reading. Returns (blocked, reason)."""
# Only check zero-access paths (completely blocked from all operations)
# readOnlyPaths are allowed to be read - that's the point of "read-only"
for zero_path in config.get("zeroAccessPaths", []):
if match_path(file_path, zero_path):
return True, f"zero-access path {zero_path} (no operations allowed)"
return False, ""
def main() -> None:
config = load_config()
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
sys.exit(1)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
# Only check Read tool
if tool_name != "Read":
sys.exit(0)
file_path = tool_input.get("file_path", "")
if not file_path:
sys.exit(0)
blocked, reason = check_path(file_path, config)
if blocked:
print(f"SECURITY: Blocked read from {reason}: {file_path}", file=sys.stderr)
sys.exit(2)
sys.exit(0)
if __name__ == "__main__":
main()
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "uv run ~/.claude/hooks/damage-control/bash-tool-damage-control.py",
"timeout": 5
}
]
},
{
"matcher": "Edit",
"hooks": [
{
"type": "command",
"command": "uv run ~/.claude/hooks/damage-control/edit-tool-damage-control.py",
"timeout": 5
}
]
},
{
"matcher": "Write",
"hooks": [
{
"type": "command",
"command": "uv run ~/.claude/hooks/damage-control/write-tool-damage-control.py",
"timeout": 5
}
]
},
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "uv run ~/.claude/hooks/damage-control/read-tool-damage-control.py",
"timeout": 5
}
]
}
]
},
"permissions": {
"deny": [
"Bash(rm -rf /*:*)",
"Bash(rm -rf ~/*:*)",
"Bash(sudo rm -rf:*)",
"Bash(mkfs:*)",
"Bash(dd if=* of=/dev/*:*)"
],
"ask": [
"Bash(git push --force:*)",
"Bash(git reset --hard:*)"
]
}
}
# /// script
# requires-python = ">=3.8"
# dependencies = ["pyyaml"]
# ///
"""
Damage Control Test Runner - Python/UV
=======================================
Tests damage control hooks via CLI or interactive mode.
Usage:
# Interactive mode - test Bash, Edit, Write hooks interactively
uv run test-damage-control.py -i
uv run test-damage-control.py --interactive
# CLI mode - test a single command
uv run test-damage-control.py <hook> <tool_name> <command_or_path> [--expect-blocked|--expect-allowed]
Examples:
# Interactive mode
uv run test-damage-control.py -i
# Test bash hook blocks rm -rf
uv run test-damage-control.py bash Bash "rm -rf /tmp" --expect-blocked
# Test edit hook blocks zero-access path
uv run test-damage-control.py edit Edit "~/.ssh/id_rsa" --expect-blocked
# Test bash allows safe command
uv run test-damage-control.py bash Bash "ls -la" --expect-allowed
Exit codes:
0 = Test passed (expectation matched)
1 = Test failed (expectation not matched)
"""
import subprocess
import json
import sys
import os
import re
from pathlib import Path
from typing import Dict, Any, List, Tuple, Optional
import yaml
# Import patterns and utilities from the bash tool script (avoid duplication)
# Using importlib to import from hyphenated filename
import importlib.util
import fnmatch
spec = importlib.util.spec_from_file_location(
"bash_tool",
Path(__file__).parent / "bash-tool-damage-control.py"
)
bash_tool = importlib.util.module_from_spec(spec)
spec.loader.exec_module(bash_tool)
READ_ONLY_BLOCKED = bash_tool.READ_ONLY_BLOCKED
NO_DELETE_BLOCKED = bash_tool.NO_DELETE_BLOCKED
def is_glob_pattern(pattern: str) -> bool:
"""Check if pattern contains glob wildcards."""
return '*' in pattern or '?' in pattern or '[' in pattern
def match_path(file_path: str, pattern: str) -> bool:
"""Match file path against pattern, supporting both prefix and glob matching."""
expanded_pattern = os.path.expanduser(pattern)
normalized = os.path.normpath(file_path)
expanded_normalized = os.path.expanduser(normalized)
if is_glob_pattern(pattern):
# Glob pattern matching (case-insensitive for security)
basename = os.path.basename(expanded_normalized)
basename_lower = basename.lower()
pattern_lower = pattern.lower()
expanded_pattern_lower = expanded_pattern.lower()
# Match against basename for patterns like *.pem, .env*
if fnmatch.fnmatch(basename_lower, expanded_pattern_lower):
return True
if fnmatch.fnmatch(basename_lower, pattern_lower):
return True
# Also try full path match for patterns like /path/*.pem
if fnmatch.fnmatch(expanded_normalized.lower(), expanded_pattern_lower):
return True
return False
else:
# Prefix matching (original behavior for directories)
if expanded_normalized.startswith(expanded_pattern) or expanded_normalized == expanded_pattern.rstrip('/'):
return True
return False
def glob_to_regex(glob_pattern: str) -> str:
"""Convert a glob pattern to a regex pattern for matching in commands."""
result = ""
for char in glob_pattern:
if char == '*':
result += r'[^\s/]*' # Match any chars except whitespace and path sep
elif char == '?':
result += r'[^\s/]' # Match single char except whitespace and path sep
elif char in r'\.^$+{}[]|()':
result += '\\' + char
else:
result += char
return result
# ============================================================================
# CONFIG LOADING
# ============================================================================
def get_script_dir() -> Path:
return Path(__file__).parent
def get_config_path() -> Path:
"""Get path to patterns.yaml, checking multiple locations."""
script_dir = get_script_dir()
# 1. Check script's own directory (installed location)
local_config = script_dir / "patterns.yaml"
if local_config.exists():
return local_config
# 2. Check skill root directory (development location)
skill_root = script_dir.parent.parent / "patterns.yaml"
if skill_root.exists():
return skill_root
return local_config # Default, even if it doesn't exist
def load_config() -> Dict[str, Any]:
"""Load patterns from YAML config file."""
config_path = get_config_path()
if not config_path.exists():
return {"bashToolPatterns": [], "zeroAccessPaths": [], "readOnlyPaths": [], "noDeletePaths": []}
with open(config_path, "r") as f:
return yaml.safe_load(f) or {}
# ============================================================================
# DIRECT CHECKING (for interactive mode - no subprocess needed)
# ============================================================================
def check_bash_command(command: str, config: Dict[str, Any]) -> Tuple[bool, List[str]]:
"""Check bash command against patterns. Returns (blocked, list of reasons)."""
reasons = []
# 1. Check bashToolPatterns
for item in config.get("bashToolPatterns", []):
pattern = item.get("pattern", "")
reason = item.get("reason", "Blocked by pattern")
try:
if re.search(pattern, command, re.IGNORECASE):
reasons.append(reason)
except re.error:
continue
# 2. Check zeroAccessPaths (any access blocked) - supports glob patterns
for zero_path in config.get("zeroAccessPaths", []):
if is_glob_pattern(zero_path):
# Convert glob to regex for command matching
glob_regex = glob_to_regex(zero_path)
try:
if re.search(glob_regex, command, re.IGNORECASE):
reasons.append(f"zero-access pattern: {zero_path}")
except re.error:
continue
else:
# Original literal path matching
expanded = os.path.expanduser(zero_path)
escaped = re.escape(expanded)
if re.search(escaped, command) or re.search(re.escape(zero_path), command):
reasons.append(f"zero-access path: {zero_path}")
# 3. Check readOnlyPaths (modifications blocked)
for readonly in config.get("readOnlyPaths", []):
expanded = os.path.expanduser(readonly)
escaped = re.escape(expanded)
for pattern_template, operation in READ_ONLY_BLOCKED:
pattern = pattern_template.replace("{path}", escaped)
try:
if re.search(pattern, command):
reasons.append(f"{operation} on read-only path: {readonly}")
except re.error:
continue
# 4. Check noDeletePaths (deletions blocked)
for no_delete in config.get("noDeletePaths", []):
expanded = os.path.expanduser(no_delete)
escaped = re.escape(expanded)
for pattern_template, operation in NO_DELETE_BLOCKED:
pattern = pattern_template.replace("{path}", escaped)
try:
if re.search(pattern, command):
reasons.append(f"{operation} on no-delete path: {no_delete}")
except re.error:
continue
return len(reasons) > 0, reasons
def check_file_path(file_path: str, config: Dict[str, Any]) -> Tuple[bool, List[str]]:
"""Check file path for Edit/Write tools. Returns (blocked, list of reasons)."""
reasons = []
# Check zeroAccessPaths - supports glob patterns
for zero_path in config.get("zeroAccessPaths", []):
if match_path(file_path, zero_path):
reasons.append(f"zero-access path: {zero_path}")
# Check readOnlyPaths - supports glob patterns
for readonly in config.get("readOnlyPaths", []):
if match_path(file_path, readonly):
reasons.append(f"read-only path: {readonly}")
return len(reasons) > 0, reasons
# ============================================================================
# INTERACTIVE MODE
# ============================================================================
def print_banner():
"""Print interactive mode banner."""
print("\n" + "=" * 60)
print(" Damage Control Interactive Tester")
print("=" * 60)
print(" Test commands and paths against security patterns.")
print(" Type 'quit' or 'q' to exit.")
print("=" * 60 + "\n")
def prompt_tool_selection() -> Optional[str]:
"""Prompt user to select which tool to test."""
print("Select tool to test:")
print(" [1] Bash - Test shell commands")
print(" [2] Edit - Test file paths for edit operations")
print(" [3] Write - Test file paths for write operations")
print(" [q] Quit")
print()
while True:
choice = input("Tool [1/2/3/q]> ").strip().lower()
if choice in ('q', 'quit'):
return None
elif choice == '1' or choice == 'bash':
return 'Bash'
elif choice == '2' or choice == 'edit':
return 'Edit'
elif choice == '3' or choice == 'write':
return 'Write'
else:
print("Invalid choice. Enter 1, 2, 3, or q.")
def run_interactive_mode():
"""Run interactive testing mode."""
config = load_config()
print_banner()
# Show loaded config summary
bash_patterns = len(config.get("bashToolPatterns", []))
zero_paths = len(config.get("zeroAccessPaths", []))
readonly_paths = len(config.get("readOnlyPaths", []))
nodelete_paths = len(config.get("noDeletePaths", []))
print(f"Loaded: {bash_patterns} bash patterns, {zero_paths} zero-access, {readonly_paths} read-only, {nodelete_paths} no-delete paths\n")
while True:
tool = prompt_tool_selection()
if tool is None:
print("\nGoodbye!")
break
print()
if tool == 'Bash':
prompt_text = "Command> "
else:
prompt_text = "Path> "
# Get input
try:
user_input = input(prompt_text).strip()
except (EOFError, KeyboardInterrupt):
print("\nGoodbye!")
break
if not user_input or user_input.lower() in ('q', 'quit'):
print("\nGoodbye!")
break
# Test the input
if tool == 'Bash':
blocked, reasons = check_bash_command(user_input, config)
else:
blocked, reasons = check_file_path(user_input, config)
# Print result
print()
if blocked:
print(f"\033[91mBLOCKED\033[0m - {len(reasons)} pattern(s) matched:")
for reason in reasons:
print(f" - {reason}")
else:
print(f"\033[92mALLOWED\033[0m - No dangerous patterns matched")
print()
# ============================================================================
# CLI MODE HELPERS
# ============================================================================
def get_hook_path(hook_type: str) -> Path:
"""Get path to hook script."""
hooks = {
"bash": "bash-tool-damage-control.py",
"edit": "edit-tool-damage-control.py",
"write": "write-tool-damage-control.py",
}
if hook_type not in hooks:
print(f"Error: Unknown hook type '{hook_type}'. Use: {list(hooks.keys())}")
sys.exit(1)
return get_script_dir() / hooks[hook_type]
def build_tool_input(tool_name: str, value: str) -> dict:
"""Build tool_input based on tool type."""
if tool_name == "Bash":
return {"command": value}
elif tool_name in ("Edit", "Write"):
# Expand ~ for paths
return {"file_path": os.path.expanduser(value)}
else:
return {"command": value}
def run_test(hook_type: str, tool_name: str, value: str, expectation: str) -> bool:
"""Run a single test and return True if passed.
expectation can be: "blocked" or "allowed" (exit code based)
"""
hook_path = get_hook_path(hook_type)
tool_input = build_tool_input(tool_name, value)
input_json = json.dumps({
"tool_name": tool_name,
"tool_input": tool_input
})
try:
result = subprocess.run(
["uv", "run", str(hook_path)],
input=input_json,
capture_output=True,
text=True,
timeout=10
)
exit_code = result.returncode
stdout = result.stdout.strip()
stderr = result.stderr.strip()
except subprocess.TimeoutExpired:
print("TIMEOUT")
return False
except Exception as e:
print(f"ERROR: {e}")
return False
# Handle PreToolUse hooks (exit code based)
blocked = exit_code == 2
expect_blocked = expectation == "blocked"
passed = blocked == expect_blocked
expected = "BLOCKED" if expect_blocked else "ALLOWED"
actual = "BLOCKED" if blocked else "ALLOWED"
if passed:
print(f"PASS: {expected} - {value}")
else:
print(f"FAIL: Expected {expected}, got {actual} - {value}")
if stderr:
print(f" stderr: {stderr[:200]}")
return passed
def main():
# Check for interactive mode
if len(sys.argv) >= 2 and sys.argv[1].lower() in ('-i', '--interactive'):
run_interactive_mode()
sys.exit(0)
# CLI mode requires at least 4 args
if len(sys.argv) < 4:
print(__doc__)
sys.exit(1)
hook_type = sys.argv[1].lower()
tool_name = sys.argv[2]
value = sys.argv[3]
# Default expectation
expectation = "blocked"
if len(sys.argv) > 4:
flag = sys.argv[4].lower()
if flag == "--expect-allowed":
expectation = "allowed"
elif flag == "--expect-blocked":
expectation = "blocked"
passed = run_test(hook_type, tool_name, value, expectation)
sys.exit(0 if passed else 1)
if __name__ == "__main__":
main()
# /// script
# requires-python = ">=3.8"
# dependencies = ["pyyaml"]
# ///
"""
Claude Code Write Tool Damage Control
======================================
Blocks writes to protected files via PreToolUse hook on Write tool.
Loads zeroAccessPaths and readOnlyPaths from patterns.yaml.
Exit codes:
0 = Allow write
2 = Block write (stderr fed back to Claude)
"""
import json
import sys
import os
import fnmatch
from pathlib import Path
from typing import Dict, Any, List, Tuple
import yaml
def is_glob_pattern(pattern: str) -> bool:
"""Check if pattern contains glob wildcards."""
return '*' in pattern or '?' in pattern or '[' in pattern
def match_path(file_path: str, pattern: str) -> bool:
"""Match file path against pattern, supporting both prefix and glob matching."""
expanded_pattern = os.path.expanduser(pattern)
normalized = os.path.normpath(file_path)
expanded_normalized = os.path.expanduser(normalized)
if is_glob_pattern(pattern):
# Glob pattern matching (case-insensitive for security)
basename = os.path.basename(expanded_normalized)
basename_lower = basename.lower()
pattern_lower = pattern.lower()
expanded_pattern_lower = expanded_pattern.lower()
# Match against basename for patterns like *.pem, .env*
if fnmatch.fnmatch(basename_lower, expanded_pattern_lower):
return True
if fnmatch.fnmatch(basename_lower, pattern_lower):
return True
# Also try full path match for patterns like /path/*.pem
if fnmatch.fnmatch(expanded_normalized.lower(), expanded_pattern_lower):
return True
return False
else:
# Prefix matching (original behavior for directories)
if expanded_normalized.startswith(expanded_pattern) or expanded_normalized == expanded_pattern.rstrip('/'):
return True
return False
def get_config_path() -> Path:
"""Get path to patterns.yaml, checking multiple locations."""
# 1. Check project hooks directory (installed location)
project_dir = os.environ.get("CLAUDE_PROJECT_DIR")
if project_dir:
project_config = Path(project_dir) / ".claude" / "hooks" / "damage-control" / "patterns.yaml"
if project_config.exists():
return project_config
# 2. Check script's own directory (installed location)
script_dir = Path(__file__).parent
local_config = script_dir / "patterns.yaml"
if local_config.exists():
return local_config
# 3. Check skill root directory (development location)
skill_root = script_dir.parent.parent / "patterns.yaml"
if skill_root.exists():
return skill_root
return local_config # Default, even if it doesn't exist
def load_config() -> Dict[str, Any]:
"""Load config from YAML."""
config_path = get_config_path()
if not config_path.exists():
return {"zeroAccessPaths": [], "readOnlyPaths": []}
with open(config_path, "r") as f:
config = yaml.safe_load(f) or {}
return config
def check_path(file_path: str, config: Dict[str, Any]) -> Tuple[bool, str]:
"""Check if file_path is blocked. Returns (blocked, reason)."""
# Check zero-access paths first (no access at all)
for zero_path in config.get("zeroAccessPaths", []):
if match_path(file_path, zero_path):
return True, f"zero-access path {zero_path} (no operations allowed)"
# Check read-only paths (writes not allowed)
for readonly in config.get("readOnlyPaths", []):
if match_path(file_path, readonly):
return True, f"read-only path {readonly}"
return False, ""
def main() -> None:
config = load_config()
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
sys.exit(1)
tool_name = input_data.get("tool_name", "")
tool_input = input_data.get("tool_input", {})
# Only check Write tool
if tool_name != "Write":
sys.exit(0)
file_path = tool_input.get("file_path", "")
if not file_path:
sys.exit(0)
blocked, reason = check_path(file_path, config)
if blocked:
print(f"SECURITY: Blocked write to {reason}: {file_path}", file=sys.stderr)
sys.exit(2)
sys.exit(0)
if __name__ == "__main__":
main()
<purpose> Guide the user through installing the Damage Control security hooks system at their chosen settings level (global, project, or project personal). Uses interactive prompts to determine runtime preference and handle conflicts. </purpose>
<variables> SKILL_DIR: skills/damage-control SCRIPTS_DIR: skills/damage-control/scripts GLOBAL_SETTINGS: ~/.claude/settings.json PROJECT_SETTINGS: .claude/settings.json LOCAL_SETTINGS: .claude/settings.local.json </variables>
<instructions>
- Use the AskUserQuestion tool at each decision point to guide the user
- Check for existing settings before installation
- Handle merge/overwrite conflicts gracefully
- Copy the Python hook implementation (recommended)
- Ensure the patterns.yaml file is included with the hooks
- Verify installation by checking file existence after copy
</instructions>
<workflow>
Step 1: Determine Installation Level
1. Use AskUserQuestion:
Question: "Where would you like to install Damage Control?"
Options:
- Global (affects all projects) - ~/.claude/settings.json
- Project (shared with team) - .claude/settings.json
- Project Personal (just for you) - .claude/settings.local.json2. Store the chosen path as TARGET_SETTINGS
Step 2: Check for Existing Settings
3. Use the Read tool to check if TARGET_SETTINGS exists
4. If settings file does NOT exist: Proceed to Step 3 (Fresh Install)
5. If settings file EXISTS: Use AskUserQuestion:
Question: "Existing settings found at [TARGET_SETTINGS]. How would you like to proceed?"
Options:
- Merge (combine existing hooks with damage-control)
- Overwrite (replace with damage-control settings)
- Stop (cancel installation)6. Handle the response:
- Merge: Read existing file, merge hooks arrays, write combined result
- Overwrite: Proceed to Step 3 (Fresh Install)
- Stop: Report "Installation cancelled" and exit workflow
Step 3: Install Hook Files
7. Determine target hooks directory based on TARGET_SETTINGS:
- Global:
~/.claude/hooks/damage-control/ - Project/Local:
.claude/hooks/damage-control/
8. Create target hooks directory:
mkdir -p [TARGET_HOOKS_DIR]9. Copy Python hook scripts from skills directory:
cp [SCRIPTS_DIR]/*.py [TARGET_HOOKS_DIR]/
cp [SCRIPTS_DIR]/patterns.yaml [TARGET_HOOKS_DIR]/Step 4: Install Settings Configuration
10. Read the settings template from [SCRIPTS_DIR]/settings-template.json
11. For Fresh Install or Overwrite:
- Write the settings template to TARGET_SETTINGS
- Update paths in settings to match TARGET_HOOKS_DIR
12. For Merge:
- Parse existing settings JSON
- Parse template settings JSON
- Use jq to deep merge:
jq -s '
def merge_hooks: (.[0].hooks.PreToolUse // []) + (.[1].hooks.PreToolUse // []) | unique_by(.matcher);
def merge_permissions: {
deny: ((.[0].permissions.deny // []) + (.[1].permissions.deny // []) | unique),
ask: ((.[0].permissions.ask // []) + (.[1].permissions.ask // []) | unique)
};
.[0] * .[1] | .hooks.PreToolUse = (.[0] | merge_hooks) | .permissions = ([.[0], .[1]] | merge_permissions)
' [EXISTING_SETTINGS] [TEMPLATE] > merged.json && mv merged.json [TARGET_SETTINGS]- This merges both hooks.PreToolUse arrays AND permissions (deny/ask rules)
Step 5: Verify Installation
13. Verify all files exist:
ls -la [TARGET_HOOKS_DIR]/14. Verify settings file was created/updated:
cat [TARGET_SETTINGS] | head -20Step 6: Check UV Runtime
15. Check if UV is installed:
which uv || echo "UV not installed"16. If UV is not installed, display install command:
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh
# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"Step 7: Restart Reminder
17. CRITICAL: Tell the user:
Restart your agent for these changes to take effect.
Hooks are only loaded at agent startup.
</workflow>
<report> Present the installation summary:
Damage Control Installation Complete
Installation Level: [Global/Project/Project Personal] Settings File: [TARGET_SETTINGS] Hooks Directory: [TARGET_HOOKS_DIR]
Files Installed
bash-tool-damage-control.py- Command pattern blockingedit-tool-damage-control.py- Edit path protectionwrite-tool-damage-control.py- Write path protectiontest-damage-control.py- Hook test runnerpatterns.yaml- Security patterns and protected paths
Runtime
Ensure UV is installed: curl -LsSf https://astral.sh/uv/install.sh | sh
IMPORTANT
Restart your agent for these changes to take effect.
Next Steps
1. Restart your Claude Code session 2. Run /hooks to verify hooks are registered 3. Test with: rm -rf /tmp/test (should be blocked) 4. Customize patterns.yaml to add your own protected paths </report>
<purpose> Display a summary of all Damage Control security configurations across all settings levels (global, project, project personal). </purpose>
<variables> GLOBAL_PATTERNS: ~/.claude/hooks/damage-control/patterns.yaml PROJECT_PATTERNS: .claude/hooks/damage-control/patterns.yaml </variables>
<instructions>
- Check each settings level for existence
- Read patterns.yaml at each level if it exists
- Present a consolidated view of all protections
- Clearly indicate which levels are active vs not configured
</instructions>
<workflow>
Step 1: Check Installation Status
1. Check which levels have Damage Control installed:
# Global
ls ~/.claude/hooks/damage-control/patterns.yaml 2>/dev/null && echo "Global: INSTALLED" || echo "Global: NOT INSTALLED"
# Project
ls .claude/hooks/damage-control/patterns.yaml 2>/dev/null && echo "Project: INSTALLED" || echo "Project: NOT INSTALLED"Step 2: Read Configurations
2. For each installed level, read the patterns.yaml and extract:
bashToolPatternscount and first few patternszeroAccessPathslistreadOnlyPathslistnoDeletePathslist
Step 3: Present Report
3. Display the consolidated configuration summary
</workflow>
<report>
Damage Control Configuration Summary
Global Level (~/.claude/)
Status: [Installed / Not Configured]
[If installed:]
Zero Access Paths (no operations allowed):
- [list paths or "None configured"]
Read Only Paths (read allowed, no modifications):
- [list paths or "None configured"]
No Delete Paths (all operations except delete):
- [list paths or "None configured"]
Blocked Command Patterns: [count] patterns
- [list first 5 patterns with reasons]
- [if more than 5: "... and [N] more"]
---
Project Level (.claude/)
Status: [Installed / Not Configured]
[Same format as Global]
---
Protection Summary
| Level | Zero Access | Read Only | No Delete | Command Patterns |
|---|---|---|---|---|
| Global | [count] | [count] | [count] | [count] |
| Project | [count] | [count] | [count] | [count] |
---
Note: Hooks at all levels run in parallel. If any level blocks an operation, it is blocked. </report>
<purpose> Guide the user through modifying their Damage Control security configuration. Allows adding/removing protected paths, blocking new commands, and adjusting protection levels. </purpose>
<variables> GLOBAL_PATTERNS: ~/.claude/hooks/damage-control/patterns.yaml PROJECT_PATTERNS: .claude/hooks/damage-control/patterns.yaml </variables>
<instructions>
- Use the AskUserQuestion tool at each decision point
- Always verify settings exist before attempting modifications
- If no settings found, redirect to install workflow
- Validate YAML syntax after modifications
- Show before/after comparison for user confirmation
</instructions>
<workflow>
Step 1: Determine Settings Level
1. Use AskUserQuestion:
Question: "Which settings level do you want to modify?"
Options:
- Global (all projects) - ~/.claude/
- Project (this project) - .claude/2. Store choice and set PATTERNS path accordingly
Step 2: Verify Installation Exists
3. Use Read tool to check if PATTERNS file exists
4. If file doesn't exist:
- Report: "Damage Control is not installed at this level."
- Use AskUserQuestion:
Question: "Would you like to install Damage Control now?"
Options:
- Yes, install it
- No, cancel- If Yes: Read and execute install.md
- If No: Exit workflow
Step 3: Determine Modification Type
5. Use AskUserQuestion:
Question: "What would you like to modify?"
Options:
- Add/Remove Protected Paths (restrict file/directory access)
- Add/Remove Blocked Commands (block specific bash commands)
- View Current ConfigurationBranch A: Modify Protected Paths
6. If "Add/Remove Protected Paths": Use AskUserQuestion:
Question: "What action would you like to take?"
Options:
- Add a new protected path
- Remove an existing protected path
- List all protected paths7. Add new protected path: a. Use AskUserQuestion:
Question: "What protection level should this path have?"
Options:
- Zero Access (no operations allowed - for secrets/credentials)
- Read Only (can read, cannot modify - for configs)
- No Delete (can read/write/edit, cannot delete - for important files)b. Use AskUserQuestion (text input expected via "Other"):
Question: "Enter the path to protect:"
Options:
- ~/.ssh/ (SSH keys)
- ~/.aws/ (AWS credentials)
- .env (environment file)
- Other (enter custom path)c. Read current patterns.yaml d. Add path to appropriate section:
- Zero Access →
zeroAccessPaths - Read Only →
readOnlyPaths - No Delete →
noDeletePaths
e. Write updated patterns.yaml f. Show confirmation
8. Remove protected path: a. Read patterns.yaml and list all protected paths b. Use AskUserQuestion to select path to remove c. Remove path from appropriate section d. Write updated patterns.yaml
Branch B: Modify Blocked Commands
9. If "Add/Remove Blocked Commands": Use AskUserQuestion:
Question: "What action would you like to take?"
Options:
- Add a new blocked command pattern
- Remove an existing pattern
- List all blocked patterns10. Add new blocked pattern: a. Use AskUserQuestion:
Question: "Enter the command to block (I'll create the regex):"
Options:
- npm publish (prevent accidental publishes)
- docker push (prevent accidental pushes)
- Other (enter custom command)b. Escape special regex characters c. Create pattern: \b[escaped_command]\b d. Ask for reason/description e. Read patterns.yaml f. Add to bashToolPatterns:
- pattern: '[generated_pattern]'
reason: '[user_reason]'g. Write updated patterns.yaml
Branch C: View Configuration
11. If "View Current Configuration": a. Read patterns.yaml b. Display formatted configuration summary
Step 4: Restart Reminder
12. CRITICAL: After any modifications, tell the user:
Restart your agent for these changes to take effect.
</workflow>
<report>
Damage Control Configuration Updated
Settings Level: [Global/Project] Modification Type: [Path Protection/Command Blocking]
Changes Made
Action: Added/Removed Item: [path or pattern] Category: [Zero Access/Read Only/No Delete/Blocked Command]
IMPORTANT
Restart your agent for these changes to take effect.
Run /hooks after restart to verify the changes are active. </report>
<purpose> Validate that all Damage Control hooks are working correctly by reading patterns.yaml and running test cases against each configured pattern and protected path. </purpose>
<variables> PROJECT_HOOKS: .claude/hooks/damage-control GLOBAL_HOOKS: ~/.claude/hooks/damage-control </variables>
<instructions>
- Determine which hooks directory to test (project or global)
- Read patterns.yaml to get all configured patterns and paths
- For each pattern/path, call the test script with appropriate arguments
- The test script echoes JSON into the hooks - it does NOT run actual commands
- Track pass/fail counts and report summary
IMPORTANT: You are testing hooks by piping mock data. NO actual dangerous commands are executed. </instructions>
<workflow>
Step 1: Locate Hooks
1. Check if project hooks exist:
ls .claude/hooks/damage-control/patterns.yaml 2>/dev/null2. If not found, check global hooks:
ls ~/.claude/hooks/damage-control/patterns.yaml 2>/dev/null3. Set HOOKS_DIR to the found location
Step 2: Read Configuration
4. Read [HOOKS_DIR]/patterns.yaml
5. Extract:
bashToolPatterns- command patterns to blockzeroAccessPaths- paths with no accessreadOnlyPaths- read-only pathsnoDeletePaths- no-delete paths
Step 3: Test bashToolPatterns
6. For each pattern, generate a matching test command and run:
uv run [HOOKS_DIR]/test-damage-control.py bash Bash "[test_command]" --expect-blockedTest cases:
| Pattern | Test Command |
|---|---|
rm -rf | rm -rf /tmp/test |
git reset --hard | git reset --hard HEAD |
git push --force | git push --force origin main |
chmod 777 | chmod 777 /tmp/test |
7. Test safe commands are allowed:
uv run [HOOKS_DIR]/test-damage-control.py bash Bash "ls -la" --expect-allowed
uv run [HOOKS_DIR]/test-damage-control.py bash Bash "git status" --expect-allowedStep 4: Test zeroAccessPaths
8. For each zero-access path, test ALL access is blocked:
# Bash access
uv run [HOOKS_DIR]/test-damage-control.py bash Bash "cat [path]/test" --expect-blocked
# Edit access
uv run [HOOKS_DIR]/test-damage-control.py edit Edit "[path]/test.txt" --expect-blocked
# Write access
uv run [HOOKS_DIR]/test-damage-control.py write Write "[path]/test.txt" --expect-blockedStep 5: Test readOnlyPaths
9. For each read-only path:
# Read - should be ALLOWED
uv run [HOOKS_DIR]/test-damage-control.py bash Bash "cat [path]" --expect-allowed
# Write - should be BLOCKED
uv run [HOOKS_DIR]/test-damage-control.py bash Bash "echo test > [path]/test" --expect-blocked
# Edit - should be BLOCKED
uv run [HOOKS_DIR]/test-damage-control.py edit Edit "[path]/test.txt" --expect-blockedStep 6: Test noDeletePaths
10. For each no-delete path:
# Delete - should be BLOCKED
uv run [HOOKS_DIR]/test-damage-control.py bash Bash "rm [path]/test.txt" --expect-blocked
# Write - should be ALLOWED
uv run [HOOKS_DIR]/test-damage-control.py bash Bash "echo test > [path]/test.txt" --expect-allowedStep 7: Compile Results
11. Count total passed and failed tests 12. Present the summary report
</workflow>
<report>
Damage Control Test Results
bashToolPatterns
| Test | Command | Expected | Result |
|---|---|---|---|
| 1 | rm -rf /tmp | BLOCKED | PASS/FAIL |
| 2 | git reset --hard | BLOCKED | PASS/FAIL |
| 3 | ls -la | ALLOWED | PASS/FAIL |
zeroAccessPaths
| Path | Tool | Expected | Result |
|---|---|---|---|
| ~/.ssh/ | Bash | BLOCKED | PASS/FAIL |
| ~/.ssh/ | Edit | BLOCKED | PASS/FAIL |
| ~/.ssh/ | Write | BLOCKED | PASS/FAIL |
readOnlyPaths
| Path | Operation | Expected | Result |
|---|---|---|---|
| /etc/ | Read | ALLOWED | PASS/FAIL |
| /etc/ | Write | BLOCKED | PASS/FAIL |
noDeletePaths
| Path | Operation | Expected | Result |
|---|---|---|---|
| .git/ | Delete | BLOCKED | PASS/FAIL |
| .git/ | Write | ALLOWED | PASS/FAIL |
---
Summary
Total Tests: [count] Passed: [count] Failed: [count]
[If all passed] All Damage Control hooks are working correctly.
[If any failed] Some tests failed. Review the failed tests and check hook implementations. </report>