From 995a5d6c12a871780c9453da22c82b4bd20586df Mon Sep 17 00:00:00 2001 From: Luong NGUYEN Date: Mon, 30 Mar 2026 22:56:36 +0200 Subject: [PATCH] refactor(hooks): Replace auto-adapt hook with one-time permissions setup script Remove the auto-adapt-mode PostToolUse hook (no more dynamic permission learning). Replace with a standalone setup script that seeds ~67 safe auto-mode-equivalent permission rules into settings.json in one shot. Move the script to 09-advanced-features/ alongside the Auto Mode docs. --- 06-hooks/README.md | 182 +------- 06-hooks/auto-adapt-mode.py | 407 ------------------ 09-advanced-features/README.md | 28 ++ .../setup-auto-mode-permissions.py | 157 +++++++ 4 files changed, 207 insertions(+), 567 deletions(-) delete mode 100755 06-hooks/auto-adapt-mode.py create mode 100644 09-advanced-features/setup-auto-mode-permissions.py diff --git a/06-hooks/README.md b/06-hooks/README.md index 337900a..81b057a 100644 --- a/06-hooks/README.md +++ b/06-hooks/README.md @@ -891,174 +891,36 @@ if __name__ == "__main__": > **Note:** Anthropic hasn't released an official offline tokenizer. Both methods are approximations. The transcript includes user prompts, Claude's responses, and tool outputs, but NOT system prompts or internal context. -### Example 7: Auto-Adapt Mode (PostToolUse) +### Example 7: Seed Auto-Mode Permissions (One-Time Setup Script) -Automatically learns from your tool approvals and updates `~/.claude/settings.json` permissions. Every time you accept a tool execution, the hook generalizes the command into a reusable permission rule — so you never have to approve the same type of command twice. Dangerous/destructive commands are **never** remembered. +A one-time setup script that seeds `~/.claude/settings.json` with ~67 safe permission rules equivalent to Claude Code's auto-mode baseline — without any hook, without remembering future choices. Run it once; safe to re-run (skips rules already present). -On first run, it seeds your config with auto-mode-equivalent baseline permissions (read/write files, git operations, package managers, common CLI tools). +**File:** `09-advanced-features/setup-auto-mode-permissions.py` -**File:** `.claude/hooks/auto-adapt-mode.py` +```bash +# Preview what would be added +python3 09-advanced-features/setup-auto-mode-permissions.py --dry-run -```python -#!/usr/bin/env python3 -""" -auto-adapt-mode: Learn from user's tool approvals and update Claude config. - -Hook Type: PostToolUse -Event: Fires after a tool is successfully executed (meaning user approved it) -""" - -import json -import os -import sys -import re -from pathlib import Path - -SETTINGS_PATH = Path.home() / ".claude" / "settings.json" -LOG_PATH = Path.home() / ".claude" / "auto-adapt-mode.log" - -# Auto-mode baseline: safe, local, reversible operations -AUTO_MODE_BASELINE = [ - "Read(*)", "Edit(*)", "Write(*)", "Glob(*)", "Grep(*)", - "Bash(git status:*)", "Bash(git log:*)", "Bash(git diff:*)", - "Bash(git add:*)", "Bash(git commit:*)", "Bash(git checkout:*)", - "Bash(npm install:*)", "Bash(npm test:*)", "Bash(npm run:*)", - "Bash(pip install:*)", "Bash(pytest:*)", - "Bash(ls:*)", "Bash(cat:*)", "Bash(find:*)", "Bash(mkdir:*)", - "Bash(cp:*)", "Bash(mv:*)", "Bash(chmod:*)", - "Bash(gh pr view:*)", "Bash(gh issue list:*)", - "Agent(*)", "Skill(*)", "WebSearch(*)", "WebFetch(*)", - # ... (full list includes 70+ safe patterns) -] - -# Commands that are NEVER auto-remembered -DANGEROUS_PATTERNS = [ - r"rm\s+(-[a-zA-Z]*r[a-zA-Z]*|--recursive)", # rm -rf - r"git\s+push\s+(-[a-zA-Z]*f|--force)", # force push - r"git\s+reset\s+--hard", # hard reset - r"DROP\s+(TABLE|DATABASE)", # SQL destructive - r"curl\s+.*\|\s*(bash|sh)", # pipe to shell - r"sudo\b", # privilege escalation - r"docker\s+(rm|rmi|system\s+prune)", # container destructive - r"kubectl\s+delete", # k8s destructive - r"terraform\s+destroy", # infra destructive - r"npm\s+publish", # irreversible publish - r"deploy\s+.*prod", # production deploy - # ... (full list includes 25+ patterns) -] - - -def is_dangerous_command(command: str) -> bool: - """Check if a bash command matches any dangerous pattern.""" - return any(re.search(p, command, re.IGNORECASE) for p in DANGEROUS_PATTERNS) - - -def generalize_tool_permission(tool_name: str, tool_input: dict) -> str | None: - """Convert a specific tool invocation into a generalized permission rule.""" - if tool_name == "Bash": - command = tool_input.get("command", "") - if not command or is_dangerous_command(command): - return None - parts = command.strip().split() - base = parts[0] - # Compound commands: "git push" -> "Bash(git push:*)" - compound = ["git", "npm", "npx", "pip", "cargo", "go", "gh", "python3"] - if base in compound and len(parts) > 1: - sub = parts[1] - if sub.lower() in {"rm", "delete", "destroy", "publish"}: - return None - return f"Bash({base} {sub}:*)" - return f"Bash({base}:*)" - elif tool_name == "Bash": # Never allow generic Bash(*) - return None - else: - return f"{tool_name}(*)" - - -def main(): - try: - hook_input = json.load(sys.stdin) - except (json.JSONDecodeError, EOFError): - sys.exit(0) - - tool_name = hook_input.get("tool_name", "") - tool_input = hook_input.get("tool_input", {}) - if not tool_name: - sys.exit(0) - - # Load settings, ensure baseline, add new rule if safe - settings = json.load(open(SETTINGS_PATH)) if SETTINGS_PATH.exists() else {} - allow = settings.setdefault("permissions", {}).setdefault("allow", []) - - # Seed baseline on first run - marker = Path.home() / ".claude" / ".auto-adapt-mode-initialized" - if not marker.exists(): - existing = set(allow) - for rule in AUTO_MODE_BASELINE: - if rule not in existing: - allow.append(rule) - marker.touch() - - # Generalize and add the new rule - rule = generalize_tool_permission(tool_name, tool_input) - if rule and rule not in allow: - allow.append(rule) - with open(SETTINGS_PATH, "w") as f: - json.dump(settings, f, indent=2) - f.write("\n") - - sys.exit(0) - -if __name__ == "__main__": - main() +# Apply +python3 09-advanced-features/setup-auto-mode-permissions.py ``` -**Configuration:** -```json -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/auto-adapt-mode.py\"", - "timeout": 10 - } - ] - } - ] - } -} -``` +**What gets added:** -**How it works:** -1. `PostToolUse` fires after **every** successful tool execution (meaning you already approved it) -2. The hook extracts the tool name and input, then generalizes it into a permission rule -3. Compound commands like `git push origin main` become `Bash(git push:*)` — matching any `git push` variant -4. The rule is added to `~/.claude/settings.json` → `permissions.allow` if not already present -5. On first run, seeds ~70 auto-mode-equivalent baseline permissions +| Category | Examples | +|----------|---------| +| Built-in tools | `Read(*)`, `Edit(*)`, `Write(*)`, `Glob(*)`, `Grep(*)`, `Agent(*)`, `WebSearch(*)` | +| Git read | `Bash(git status:*)`, `Bash(git log:*)`, `Bash(git diff:*)` | +| Git write (local) | `Bash(git add:*)`, `Bash(git commit:*)`, `Bash(git checkout:*)` | +| Package managers | `Bash(npm install:*)`, `Bash(pip install:*)`, `Bash(cargo build:*)` | +| Build & test | `Bash(make:*)`, `Bash(pytest:*)`, `Bash(go test:*)` | +| Common shell | `Bash(ls:*)`, `Bash(cat:*)`, `Bash(find:*)`, `Bash(cp:*)`, `Bash(mv:*)` | +| GitHub CLI | `Bash(gh pr view:*)`, `Bash(gh pr create:*)`, `Bash(gh issue list:*)` | -**Safety guarantees:** -- Dangerous commands (force push, rm -rf, sudo, DROP TABLE, etc.) are **never** remembered -- Irreversible operations (npm publish, terraform destroy, prod deploys) are **always** blocked -- Commands in the `deny` list are never overridden -- The hook never blocks tool execution (always exits 0) -- A log file at `~/.claude/auto-adapt-mode.log` tracks all decisions for auditing - -**Generalization examples:** - -| You approve | Rule added | Covers | -|-------------|-----------|--------| -| `git push origin main` | `Bash(git push:*)` | All git push variants | -| `npm run build` | `Bash(npm run:*)` | All npm scripts | -| `ls -la src/` | `Bash(ls:*)` | All ls invocations | -| `rm -rf /tmp/test` | *(blocked)* | Never remembered | -| `git push --force` | *(blocked)* | Never remembered | -| `Write` tool | `Write(*)` | All file writes | - -> **Tip:** Delete `~/.claude/.auto-adapt-mode-initialized` to re-seed baseline permissions. Check `~/.claude/auto-adapt-mode.log` to audit what rules were added and which were blocked. +**What is intentionally excluded** (never added by this script): +- `rm -rf`, `sudo`, force push, `git reset --hard` +- `DROP TABLE`, `kubectl delete`, `terraform destroy` +- `npm publish`, `curl | bash`, production deploys ## Plugin Hooks diff --git a/06-hooks/auto-adapt-mode.py b/06-hooks/auto-adapt-mode.py deleted file mode 100755 index c39515a..0000000 --- a/06-hooks/auto-adapt-mode.py +++ /dev/null @@ -1,407 +0,0 @@ -#!/usr/bin/env python3 -""" -auto-adapt-mode: Learn from user's tool approvals and update Claude config. - -Hook Type: PostToolUse -Event: Fires after a tool is successfully executed (meaning user approved it) - -Behavior: -- When user approves a tool/command, generalize it into a permission rule -- Add the generalized rule to ~/.claude/settings.json permissions.allow -- NEVER remember dangerous/destructive commands (rm -rf, force-push, DROP, etc.) -- On first run, seeds the config with auto-mode-equivalent baseline permissions - -Usage in settings.json: -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "*", - "hooks": [ - { - "type": "command", - "command": "python3 \"$CLAUDE_PROJECT_DIR/06-hooks/auto-adapt-mode.py\"", - "timeout": 10 - } - ] - } - ] - } -} -""" - -import json -import os -import sys -import re -from pathlib import Path - -# ────────────────────────────────────────────────────────────────────────────── -# Constants -# ────────────────────────────────────────────────────────────────────────────── - -SETTINGS_PATH = Path.home() / ".claude" / "settings.json" -LOG_PATH = Path.home() / ".claude" / "auto-adapt-mode.log" - -# Auto-mode baseline permissions (equivalent to Claude Code's auto-mode defaults) -# These are safe, local, reversible operations that auto-mode allows without prompting -AUTO_MODE_BASELINE = [ - # File operations (read/write in working directory) - "Read(*)", - "Edit(*)", - "Write(*)", - "Glob(*)", - "Grep(*)", - # Git read operations - "Bash(git status:*)", - "Bash(git log:*)", - "Bash(git diff:*)", - "Bash(git branch:*)", - "Bash(git show:*)", - "Bash(git rev-parse:*)", - "Bash(git remote -v:*)", - "Bash(git fetch:*)", - "Bash(git stash list:*)", - # Git write operations (local, reversible) - "Bash(git add:*)", - "Bash(git commit:*)", - "Bash(git checkout:*)", - "Bash(git switch:*)", - "Bash(git merge:*)", - "Bash(git rebase:*)", - "Bash(git stash:*)", - "Bash(git tag:*)", - "Bash(git worktree:*)", - # Package managers (install from manifests) - "Bash(npm install:*)", - "Bash(npm ci:*)", - "Bash(npm test:*)", - "Bash(npm run:*)", - "Bash(npx:*)", - "Bash(pip install:*)", - "Bash(pip3 install:*)", - "Bash(cargo build:*)", - "Bash(cargo test:*)", - "Bash(go build:*)", - "Bash(go test:*)", - "Bash(go mod:*)", - # Build and test - "Bash(make:*)", - "Bash(cmake:*)", - "Bash(pytest:*)", - "Bash(python3 -m pytest:*)", - # Common safe commands - "Bash(ls:*)", - "Bash(pwd:*)", - "Bash(which:*)", - "Bash(echo:*)", - "Bash(cat:*)", - "Bash(head:*)", - "Bash(tail:*)", - "Bash(wc:*)", - "Bash(sort:*)", - "Bash(uniq:*)", - "Bash(find:*)", - "Bash(dirname:*)", - "Bash(basename:*)", - "Bash(realpath:*)", - "Bash(mkdir:*)", - "Bash(touch:*)", - "Bash(cp:*)", - "Bash(mv:*)", - "Bash(chmod:*)", - "Bash(date:*)", - "Bash(env:*)", - "Bash(printenv:*)", - # File inspection - "Bash(file:*)", - "Bash(stat:*)", - "Bash(diff:*)", - "Bash(md5sum:*)", - "Bash(sha256sum:*)", - # GitHub CLI (read operations) - "Bash(gh pr view:*)", - "Bash(gh pr list:*)", - "Bash(gh issue view:*)", - "Bash(gh issue list:*)", - "Bash(gh repo view:*)", - # Agents and tools - "Agent(*)", - "Skill(*)", - "WebSearch(*)", - "WebFetch(*)", - "NotebookEdit(*)", - "TaskCreate(*)", - "TaskUpdate(*)", -] - -# ────────────────────────────────────────────────────────────────────────────── -# Dangerous patterns: commands/tools that should NEVER be auto-remembered -# These are irreversible, destructive, or affect shared/production systems -# ────────────────────────────────────────────────────────────────────────────── - -DANGEROUS_PATTERNS = [ - # Destructive file operations - r"rm\s+(-[a-zA-Z]*r[a-zA-Z]*|--recursive)", # rm -rf, rm -r - r"rm\s+(-[a-zA-Z]*f[a-zA-Z]*)", # rm -f (force delete) - r"rmdir", - r"shred\b", - r"dd\s+if=", # disk overwrite - r"mkfs\b", # format filesystem - r"format\b", - - # Git destructive operations - r"git\s+push\s+(-[a-zA-Z]*f|--force)", # force push - r"git\s+push\s+--force-with-lease", - r"git\s+reset\s+--hard", # hard reset - r"git\s+clean\s+(-[a-zA-Z]*f|--force)", # clean force - r"git\s+checkout\s+\.", # discard all changes - r"git\s+restore\s+\.", - r"git\s+branch\s+(-[a-zA-Z]*D|-d\s+main|-d\s+master)", # delete branches - r"git\s+push\s+.*:.*main", # delete remote main - r"git\s+push\s+.*:.*master", - - # Database destructive operations - r"DROP\s+(TABLE|DATABASE|SCHEMA|INDEX)", - r"TRUNCATE\b", - r"DELETE\s+FROM\s+(?!.*WHERE)", # DELETE without WHERE - r"ALTER\s+TABLE\s+.*DROP", - - # System-level dangerous operations - r"sudo\b", - r"chmod\s+777", - r"chown\s+-R\s+root", - - # Network exfiltration / remote execution - r"curl\s+.*\|\s*(bash|sh|zsh)", # pipe to shell - r"wget\s+.*\|\s*(bash|sh|zsh)", - r"curl\s+.*--upload-file", - r"curl\s+.*-T\s+", # upload - r"scp\b(?!.*localhost)", # remote copy (not local) - r"rsync\b.*[^/]:", # remote rsync - - # Container/infra destructive - r"docker\s+(rm|rmi|system\s+prune)", - r"kubectl\s+delete", - r"terraform\s+destroy", - - # Package publishing (irreversible) - r"npm\s+publish", - r"pip\s+upload", - r"cargo\s+publish", - - # Environment/secret exposure - r"printenv\s+.*SECRET", - r"printenv\s+.*PASSWORD", - r"printenv\s+.*TOKEN", - r"echo\s+\$.*SECRET", - r"echo\s+\$.*PASSWORD", - - # Process killing - r"kill\s+-9", - r"killall\b", - r"pkill\b", - - # Production deployment - r"deploy\s+.*prod", - r"migrate\s+.*prod", -] - -# Tools that should never be auto-allowed (non-Bash) -DANGEROUS_TOOLS = { - "Bash", # Generic Bash(*) wildcard is dangerous — we allow specific commands only -} - - -# ────────────────────────────────────────────────────────────────────────────── -# Helper functions -# ────────────────────────────────────────────────────────────────────────────── - -def log(message: str): - """Append a log entry for debugging.""" - try: - with open(LOG_PATH, "a") as f: - from datetime import datetime - f.write(f"[{datetime.now().isoformat()}] {message}\n") - except Exception: - pass - - -def is_dangerous_command(command: str) -> bool: - """Check if a bash command matches any dangerous pattern.""" - for pattern in DANGEROUS_PATTERNS: - if re.search(pattern, command, re.IGNORECASE): - return True - return False - - -def generalize_tool_permission(tool_name: str, tool_input: dict) -> str | None: - """ - Convert a specific tool invocation into a generalized permission rule. - - Returns None if the tool/command should not be remembered. - """ - if tool_name == "Bash": - command = tool_input.get("command", "") - if not command: - return None - - # Never remember dangerous commands - if is_dangerous_command(command): - log(f"BLOCKED dangerous: {command}") - return None - - # Extract the base command (first word or first two words for git/npm/etc.) - parts = command.strip().split() - if not parts: - return None - - base = parts[0] - - # For compound commands (git push, npm run, gh pr create, etc.) - # generalize to "base subcommand:*" - compound_prefixes = [ - "git", "npm", "npx", "pip", "pip3", "cargo", "go", - "docker", "kubectl", "gh", "python3", "python", "node", - "make", "cmake", "pytest", "ruby", "java", "javac", - ] - - if base in compound_prefixes and len(parts) > 1: - sub = parts[1] - # Don't generalize dangerous subcommands even if pattern didn't catch them - danger_subs = {"rm", "delete", "destroy", "prune", "publish"} - if sub.lower() in danger_subs: - log(f"BLOCKED dangerous sub: {base} {sub}") - return None - return f"Bash({base} {sub}:*)" - - # For simple commands, generalize with wildcard args - return f"Bash({base}:*)" - - elif tool_name in DANGEROUS_TOOLS: - return None - - else: - # Non-Bash tools: allow with wildcard - # These are Claude Code built-in tools (Read, Write, Edit, etc.) - # Most are already in the baseline, but learn new ones - return f"{tool_name}(*)" - - -def load_settings() -> dict: - """Load the current settings.json.""" - if SETTINGS_PATH.exists(): - with open(SETTINGS_PATH, "r") as f: - return json.load(f) - return {} - - -def save_settings(settings: dict): - """Save settings.json with formatting.""" - SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(SETTINGS_PATH, "w") as f: - json.dump(settings, f, indent=2) - f.write("\n") - - -def ensure_baseline(settings: dict) -> bool: - """ - Ensure auto-mode baseline permissions are seeded into settings. - Returns True if any changes were made. - """ - permissions = settings.setdefault("permissions", {}) - allow = permissions.setdefault("allow", []) - - # Track what's already there (normalized for comparison) - existing = set(allow) - added = [] - - for rule in AUTO_MODE_BASELINE: - if rule not in existing: - allow.append(rule) - existing.add(rule) - added.append(rule) - - if added: - log(f"Seeded {len(added)} baseline rules") - return True - return False - - -def add_permission(settings: dict, rule: str) -> bool: - """ - Add a permission rule if it doesn't already exist or isn't covered. - Returns True if the rule was added. - """ - permissions = settings.setdefault("permissions", {}) - allow = permissions.setdefault("allow", []) - deny = permissions.get("deny", []) - - # Don't add if already in deny list - if rule in deny: - log(f"SKIPPED (in deny list): {rule}") - return False - - # Don't add if already exists - if rule in allow: - return False - - # Check if a more general rule already covers this - # e.g., "Bash(git:*)" covers "Bash(git status:*)" - for existing in allow: - if existing.endswith("(*)"): - tool_prefix = existing[:-3] - if rule.startswith(tool_prefix + "("): - return False # Already covered by wildcard - - allow.append(rule) - log(f"ADDED: {rule}") - return True - - -# ────────────────────────────────────────────────────────────────────────────── -# Main hook logic -# ────────────────────────────────────────────────────────────────────────────── - -def main(): - # Read hook input from stdin - try: - hook_input = json.load(sys.stdin) - except (json.JSONDecodeError, EOFError): - sys.exit(0) # Non-blocking: don't interfere if input is malformed - - tool_name = hook_input.get("tool_name", "") - tool_input = hook_input.get("tool_input", {}) - - if not tool_name: - sys.exit(0) - - # Load current settings - settings = load_settings() - - changed = False - - # Ensure baseline permissions on first meaningful invocation - marker_file = Path.home() / ".claude" / ".auto-adapt-mode-initialized" - if not marker_file.exists(): - changed = ensure_baseline(settings) - marker_file.touch() - log("Baseline initialized") - - # Generalize the tool invocation into a permission rule - rule = generalize_tool_permission(tool_name, tool_input) - - if rule: - if add_permission(settings, rule): - changed = True - - # Save if anything changed - if changed: - save_settings(settings) - - # Always succeed — never block tool execution - sys.exit(0) - - -if __name__ == "__main__": - main() diff --git a/09-advanced-features/README.md b/09-advanced-features/README.md index 44b512f..4bec39b 100644 --- a/09-advanced-features/README.md +++ b/09-advanced-features/README.md @@ -423,6 +423,34 @@ When the classifier is uncertain, auto mode falls back to prompting the user: This ensures the user always retains control when the classifier cannot confidently approve an action. +### Seeding Auto-Mode-Equivalent Permissions (No Team Plan Required) + +If you don't have a Team plan or want a simpler approach without the background classifier, you can seed your `~/.claude/settings.json` with ~67 safe permission rules that cover the same ground as auto mode's default allow-list. + +**File:** `09-advanced-features/setup-auto-mode-permissions.py` + +```bash +# Preview what would be added (no changes written) +python3 09-advanced-features/setup-auto-mode-permissions.py --dry-run + +# Apply once — safe to re-run (skips rules already present) +python3 09-advanced-features/setup-auto-mode-permissions.py +``` + +The script adds rules across these categories: + +| Category | Examples | +|----------|---------| +| Built-in tools | `Read(*)`, `Edit(*)`, `Write(*)`, `Glob(*)`, `Grep(*)`, `Agent(*)`, `WebSearch(*)` | +| Git (read) | `Bash(git status:*)`, `Bash(git log:*)`, `Bash(git diff:*)` | +| Git (local write) | `Bash(git add:*)`, `Bash(git commit:*)`, `Bash(git checkout:*)` | +| Package managers | `Bash(npm install:*)`, `Bash(pip install:*)`, `Bash(cargo build:*)` | +| Build & test | `Bash(make:*)`, `Bash(pytest:*)`, `Bash(go test:*)` | +| Common shell | `Bash(ls:*)`, `Bash(cat:*)`, `Bash(find:*)`, `Bash(cp:*)`, `Bash(mv:*)` | +| GitHub CLI | `Bash(gh pr view:*)`, `Bash(gh pr create:*)`, `Bash(gh issue list:*)` | + +Dangerous operations (`rm -rf`, `sudo`, force push, `DROP TABLE`, `terraform destroy`, etc.) are intentionally excluded. The script is idempotent — running it twice won't duplicate rules. + --- ## Background Tasks diff --git a/09-advanced-features/setup-auto-mode-permissions.py b/09-advanced-features/setup-auto-mode-permissions.py new file mode 100644 index 0000000..0ff835f --- /dev/null +++ b/09-advanced-features/setup-auto-mode-permissions.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +""" +setup-auto-mode-permissions.py + +One-time script to seed ~/.claude/settings.json with ~67 safe permission rules +equivalent to Claude Code's auto-mode baseline. Run once; idempotent (safe to +re-run — skips rules already present). + +Usage: + python3 setup-auto-mode-permissions.py + python3 setup-auto-mode-permissions.py --dry-run # preview without writing +""" + +import json +import sys +from pathlib import Path + +SETTINGS_PATH = Path.home() / ".claude" / "settings.json" + +# ~67 safe, local, reversible permission rules (auto-mode equivalent) +SAFE_PERMISSIONS = [ + # ── Built-in Claude Code tools ──────────────────────────────────────────── + "Read(*)", + "Edit(*)", + "Write(*)", + "Glob(*)", + "Grep(*)", + "Agent(*)", + "Skill(*)", + "WebSearch(*)", + "WebFetch(*)", + "NotebookEdit(*)", + "TaskCreate(*)", + "TaskUpdate(*)", + + # ── Git read-only ───────────────────────────────────────────────────────── + "Bash(git status:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git branch:*)", + "Bash(git show:*)", + "Bash(git rev-parse:*)", + "Bash(git remote -v:*)", + "Bash(git remote get-url:*)", + "Bash(git stash list:*)", + "Bash(git fetch:*)", + + # ── Git write (local, reversible) ───────────────────────────────────────── + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git checkout:*)", + "Bash(git switch:*)", + "Bash(git merge:*)", + "Bash(git rebase:*)", + "Bash(git stash:*)", + "Bash(git tag:*)", + "Bash(git worktree:*)", + + # ── Package managers ────────────────────────────────────────────────────── + "Bash(npm install:*)", + "Bash(npm ci:*)", + "Bash(npm test:*)", + "Bash(npm run:*)", + "Bash(npm audit:*)", + "Bash(npx:*)", + "Bash(pip install:*)", + "Bash(pip3 install:*)", + "Bash(cargo build:*)", + "Bash(cargo test:*)", + "Bash(go build:*)", + "Bash(go test:*)", + "Bash(go mod:*)", + + # ── Build & test ────────────────────────────────────────────────────────── + "Bash(make:*)", + "Bash(cmake:*)", + "Bash(pytest:*)", + "Bash(python3 -m pytest:*)", + + # ── Common safe shell commands ──────────────────────────────────────────── + "Bash(ls:*)", + "Bash(pwd:*)", + "Bash(which:*)", + "Bash(echo:*)", + "Bash(cat:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(wc:*)", + "Bash(sort:*)", + "Bash(uniq:*)", + "Bash(find:*)", + "Bash(dirname:*)", + "Bash(basename:*)", + "Bash(realpath:*)", + "Bash(mkdir:*)", + "Bash(touch:*)", + "Bash(cp:*)", + "Bash(mv:*)", + "Bash(chmod:*)", + "Bash(date:*)", + "Bash(env:*)", + "Bash(printenv:*)", + "Bash(file:*)", + "Bash(stat:*)", + "Bash(diff:*)", + "Bash(md5sum:*)", + "Bash(sha256sum:*)", + + # ── GitHub CLI (read & common write) ────────────────────────────────────── + "Bash(gh pr view:*)", + "Bash(gh pr list:*)", + "Bash(gh pr create:*)", + "Bash(gh issue view:*)", + "Bash(gh issue list:*)", + "Bash(gh repo view:*)", +] + + +def main(): + dry_run = "--dry-run" in sys.argv + + # Load existing settings (or start fresh) + if SETTINGS_PATH.exists(): + with open(SETTINGS_PATH) as f: + settings = json.load(f) + else: + settings = {} + + permissions = settings.setdefault("permissions", {}) + allow = permissions.setdefault("allow", []) + existing = set(allow) + + added = [r for r in SAFE_PERMISSIONS if r not in existing] + + if not added: + print("Nothing to add — all rules already present.") + return + + print(f"{'Would add' if dry_run else 'Adding'} {len(added)} rule(s):") + for rule in added: + print(f" + {rule}") + + if dry_run: + print("\nDry run — no changes written.") + return + + allow.extend(added) + SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + with open(SETTINGS_PATH, "w") as f: + json.dump(settings, f, indent=2) + f.write("\n") + + print(f"\nDone. {len(added)} rule(s) added to {SETTINGS_PATH}") + + +if __name__ == "__main__": + main()