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
This commit is contained in:
Luong NGUYEN
2026-03-30 23:42:41 +02:00
parent 995a5d6c12
commit 2790fb2b17
2 changed files with 207 additions and 92 deletions
+11 -4
View File
@@ -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) ### 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` **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) # Preview what would be added (no changes written)
python3 09-advanced-features/setup-auto-mode-permissions.py --dry-run 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 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: The script adds rules across these categories:
| Category | Examples | | Category | Examples |
|----------|---------| |----------|---------|
| Built-in tools | `Read(*)`, `Edit(*)`, `Write(*)`, `Glob(*)`, `Grep(*)`, `Agent(*)`, `WebSearch(*)` | | Core read-only tools | `Read(*)`, `Glob(*)`, `Grep(*)`, `Agent(*)`, `WebSearch(*)`, `WebFetch(*)` |
| Git (read) | `Bash(git status:*)`, `Bash(git log:*)`, `Bash(git diff:*)` | | 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:*)` | | Git (local write) | `Bash(git add:*)`, `Bash(git commit:*)`, `Bash(git checkout:*)` |
| Package managers | `Bash(npm install:*)`, `Bash(pip install:*)`, `Bash(cargo build:*)` | | Package managers | `Bash(npm install:*)`, `Bash(pip install:*)`, `Bash(cargo build:*)` |
| Build & test | `Bash(make:*)`, `Bash(pytest:*)`, `Bash(go test:*)` | | Build & test | `Bash(make:*)`, `Bash(pytest:*)`, `Bash(go test:*)` |
@@ -2,82 +2,36 @@
""" """
setup-auto-mode-permissions.py setup-auto-mode-permissions.py
One-time script to seed ~/.claude/settings.json with ~67 safe permission rules Seed ~/.claude/settings.json with a conservative baseline of safe permissions
equivalent to Claude Code's auto-mode baseline. Run once; idempotent (safe to for Claude Code. The default set is read-only and local-inspection oriented;
re-run — skips rules already present). optional flags let you widen the allowlist for editing, test execution, git
write operations, package installs, and GitHub CLI writes.
Usage: Usage:
python3 setup-auto-mode-permissions.py 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 json
import sys import tempfile
from pathlib import Path from pathlib import Path
from typing import Iterable
SETTINGS_PATH = Path.home() / ".claude" / "settings.json" SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
# ~67 safe, local, reversible permission rules (auto-mode equivalent) # Core baseline: read-only inspection and low-risk local shell commands.
SAFE_PERMISSIONS = [ CORE_PERMISSIONS = [
# ── Built-in Claude Code tools ────────────────────────────────────────────
"Read(*)", "Read(*)",
"Edit(*)",
"Write(*)",
"Glob(*)", "Glob(*)",
"Grep(*)", "Grep(*)",
"Agent(*)", "Agent(*)",
"Skill(*)", "Skill(*)",
"WebSearch(*)", "WebSearch(*)",
"WebFetch(*)", "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(ls:*)",
"Bash(pwd:*)", "Bash(pwd:*)",
"Bash(which:*)", "Bash(which:*)",
@@ -92,64 +46,218 @@ SAFE_PERMISSIONS = [
"Bash(dirname:*)", "Bash(dirname:*)",
"Bash(basename:*)", "Bash(basename:*)",
"Bash(realpath:*)", "Bash(realpath:*)",
"Bash(mkdir:*)",
"Bash(touch:*)",
"Bash(cp:*)",
"Bash(mv:*)",
"Bash(chmod:*)",
"Bash(date:*)",
"Bash(env:*)",
"Bash(printenv:*)",
"Bash(file:*)", "Bash(file:*)",
"Bash(stat:*)", "Bash(stat:*)",
"Bash(diff:*)", "Bash(diff:*)",
"Bash(md5sum:*)", "Bash(md5sum:*)",
"Bash(sha256sum:*)", "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 view:*)",
"Bash(gh pr list:*)", "Bash(gh pr list:*)",
"Bash(gh pr create:*)",
"Bash(gh issue view:*)", "Bash(gh issue view:*)",
"Bash(gh issue list:*)", "Bash(gh issue list:*)",
"Bash(gh repo view:*)", "Bash(gh repo view:*)",
] ]
def main(): def parse_args() -> argparse.Namespace:
dry_run = "--dry-run" in sys.argv 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(): def load_settings(path: Path) -> dict:
with open(SETTINGS_PATH) as f: if not path.exists():
return {}
try:
with path.open() as f:
settings = json.load(f) settings = json.load(f)
else: except json.JSONDecodeError as exc:
settings = {} 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", {}) 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: if not added:
print("Nothing to add — all rules already present.") print("Nothing to add — all selected rules already present.")
return 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: for rule in added:
print(f" + {rule}") print(f" + {rule}")
if dry_run: if args.dry_run:
print("\nDry run — no changes written.") print("\nDry run — no changes written.")
return return
allow.extend(added) atomic_write_json(SETTINGS_PATH, settings)
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}") print(f"\nDone. {len(added)} rule(s) added to {SETTINGS_PATH}")