From 2790fb2b17bffc907f20cec8347effaed011e275 Mon Sep 17 00:00:00 2001 From: Luong NGUYEN Date: Mon, 30 Mar 2026 23:42:41 +0200 Subject: [PATCH] refactor(advanced-features): narrow auto-mode permissions baseline - Replace broad wildcard allowlist with conservative defaults - Add opt-in flags for edits, tests, git writes, packages, and gh writes - Update README to match the new permission tiers --- 09-advanced-features/README.md | 15 +- .../setup-auto-mode-permissions.py | 284 ++++++++++++------ 2 files changed, 207 insertions(+), 92 deletions(-) diff --git a/09-advanced-features/README.md b/09-advanced-features/README.md index 4bec39b..fb060c0 100644 --- a/09-advanced-features/README.md +++ b/09-advanced-features/README.md @@ -425,7 +425,7 @@ This ensures the user always retains control when the classifier cannot confiden ### 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. +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 a conservative baseline of safe permission rules. The script starts with read-only and local-inspection rules, then lets you opt into edits, tests, local git writes, package installs, and GitHub write actions only when you want them. **File:** `09-advanced-features/setup-auto-mode-permissions.py` @@ -433,16 +433,23 @@ If you don't have a Team plan or want a simpler approach without the background # 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) +# Apply the conservative baseline python3 09-advanced-features/setup-auto-mode-permissions.py + +# Add more capability only when you need it +python3 09-advanced-features/setup-auto-mode-permissions.py --include-edits --include-tests +python3 09-advanced-features/setup-auto-mode-permissions.py --include-git-write --include-packages ``` 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:*)` | +| Core read-only tools | `Read(*)`, `Glob(*)`, `Grep(*)`, `Agent(*)`, `WebSearch(*)`, `WebFetch(*)` | +| Local inspection | `Bash(git status:*)`, `Bash(git log:*)`, `Bash(git diff:*)`, `Bash(cat:*)` | +| Optional edits | `Edit(*)`, `Write(*)`, `NotebookEdit(*)` | +| Optional test/build | `Bash(pytest:*)`, `Bash(python3 -m pytest:*)`, `Bash(cargo test:*)` | +| Optional git writes | `Bash(git add:*)`, `Bash(git commit:*)`, `Bash(git stash:*)` | | 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:*)` | diff --git a/09-advanced-features/setup-auto-mode-permissions.py b/09-advanced-features/setup-auto-mode-permissions.py index 0ff835f..a677314 100644 --- a/09-advanced-features/setup-auto-mode-permissions.py +++ b/09-advanced-features/setup-auto-mode-permissions.py @@ -2,82 +2,36 @@ """ 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). +Seed ~/.claude/settings.json with a conservative baseline of safe permissions +for Claude Code. The default set is read-only and local-inspection oriented; +optional flags let you widen the allowlist for editing, test execution, git +write operations, package installs, and GitHub CLI writes. Usage: python3 setup-auto-mode-permissions.py - python3 setup-auto-mode-permissions.py --dry-run # preview without writing + python3 setup-auto-mode-permissions.py --dry-run + python3 setup-auto-mode-permissions.py --include-edits --include-tests """ +from __future__ import annotations + +import argparse import json -import sys +import tempfile from pathlib import Path +from typing import Iterable SETTINGS_PATH = Path.home() / ".claude" / "settings.json" -# ~67 safe, local, reversible permission rules (auto-mode equivalent) -SAFE_PERMISSIONS = [ - # ── Built-in Claude Code tools ──────────────────────────────────────────── +# Core baseline: read-only inspection and low-risk local shell commands. +CORE_PERMISSIONS = [ "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:*)", @@ -92,64 +46,218 @@ SAFE_PERMISSIONS = [ "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:*)", + "Bash(date:*)", + "Bash(env:*)", + "Bash(printenv:*)", + "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:*)", +] - # ── GitHub CLI (read & common write) ────────────────────────────────────── +# Optional but still local: file edits and task bookkeeping. +EDITING_PERMISSIONS = [ + "Edit(*)", + "Write(*)", + "NotebookEdit(*)", + "TaskCreate(*)", + "TaskUpdate(*)", +] + +# Optional dev/test commands. These can still execute arbitrary project scripts, +# so keep them opt-in rather than part of the default baseline. +TEST_AND_BUILD_PERMISSIONS = [ + "Bash(npm test:*)", + "Bash(cargo test:*)", + "Bash(go test:*)", + "Bash(pytest:*)", + "Bash(python3 -m pytest:*)", + "Bash(make:*)", + "Bash(cmake:*)", +] + +# Optional local git write operations. History-rewriting commands stay out of +# the default baseline because they are easy to misuse. +GIT_WRITE_PERMISSIONS = [ + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git checkout:*)", + "Bash(git switch:*)", + "Bash(git stash:*)", + "Bash(git tag:*)", +] + +# Optional dependency/package commands. These are intentionally excluded from +# the default baseline because they can execute project hooks or fetch code. +PACKAGE_MANAGER_PERMISSIONS = [ + "Bash(npm ci:*)", + "Bash(npm install:*)", + "Bash(pip install:*)", + "Bash(pip3 install:*)", +] + +# Optional GitHub CLI write access. +GITHUB_WRITE_PERMISSIONS = [ + "Bash(gh pr create:*)", +] + +# Optional extra GitHub CLI read access. +GITHUB_READ_PERMISSIONS = [ "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 +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Seed Claude Code settings with a conservative permission baseline." + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview the rules that would be added without writing settings.json", + ) + parser.add_argument( + "--include-edits", + action="store_true", + help="Add file-editing permissions (Edit/Write/NotebookEdit/TaskCreate/TaskUpdate)", + ) + parser.add_argument( + "--include-tests", + action="store_true", + help="Add local build/test commands such as pytest, cargo test, and make", + ) + parser.add_argument( + "--include-git-write", + action="store_true", + help="Add local git mutation commands such as add, commit, checkout, and stash", + ) + parser.add_argument( + "--include-packages", + action="store_true", + help="Add package install commands such as npm ci, npm install, and pip install", + ) + parser.add_argument( + "--include-gh-write", + action="store_true", + help="Add GitHub CLI write permissions such as gh pr create", + ) + parser.add_argument( + "--include-gh-read", + action="store_true", + help="Add GitHub CLI read permissions such as gh pr view and gh repo view", + ) + return parser.parse_args() - # Load existing settings (or start fresh) - if SETTINGS_PATH.exists(): - with open(SETTINGS_PATH) as f: + +def load_settings(path: Path) -> dict: + if not path.exists(): + return {} + + try: + with path.open() as f: settings = json.load(f) - else: - settings = {} + except json.JSONDecodeError as exc: + raise SystemExit(f"Invalid JSON in {path}: {exc}") from exc + if not isinstance(settings, dict): + raise SystemExit(f"Expected {path} to contain a JSON object.") + + return settings + + +def build_permissions(args: argparse.Namespace) -> list[str]: + permissions = list(CORE_PERMISSIONS) + + if args.include_edits: + permissions.extend(EDITING_PERMISSIONS) + + if args.include_tests: + permissions.extend(TEST_AND_BUILD_PERMISSIONS) + + if args.include_git_write: + permissions.extend(GIT_WRITE_PERMISSIONS) + + if args.include_packages: + permissions.extend(PACKAGE_MANAGER_PERMISSIONS) + + if args.include_gh_write: + permissions.extend(GITHUB_WRITE_PERMISSIONS) + + if args.include_gh_read: + permissions.extend(GITHUB_READ_PERMISSIONS) + + return permissions + + +def append_unique(existing: list, new_items: Iterable[str]) -> list[str]: + seen = set(existing) + added: list[str] = [] + for item in new_items: + if item not in seen: + existing.append(item) + seen.add(item) + added.append(item) + return added + + +def atomic_write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=str(path.parent), + delete=False, + ) as tmp: + json.dump(payload, tmp, indent=2) + tmp.write("\n") + tmp_path = Path(tmp.name) + + tmp_path.replace(path) + + +def main() -> None: + args = parse_args() + permissions_to_add = build_permissions(args) + + settings = load_settings(SETTINGS_PATH) 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 isinstance(permissions, dict): + raise SystemExit("Expected permissions to be a JSON object.") + + allow = permissions.setdefault("allow", []) + if not isinstance(allow, list): + raise SystemExit("Expected permissions.allow to be a JSON array.") + + added = append_unique(allow, permissions_to_add) if not added: - print("Nothing to add — all rules already present.") + print("Nothing to add — all selected rules already present.") return - print(f"{'Would add' if dry_run else 'Adding'} {len(added)} rule(s):") + print(f"{'Would add' if args.dry_run else 'Adding'} {len(added)} rule(s):") for rule in added: print(f" + {rule}") - if dry_run: + if args.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") - + atomic_write_json(SETTINGS_PATH, settings) print(f"\nDone. {len(added)} rule(s) added to {SETTINGS_PATH}")