Skip to content

Instantly share code, notes, and snippets.

@stephenfeather
Created April 6, 2026 02:03
Show Gist options
  • Select an option

  • Save stephenfeather/76bfd25c935ed571f8c21d60abb4b761 to your computer and use it in GitHub Desktop.

Select an option

Save stephenfeather/76bfd25c935ed571f8c21d60abb4b761 to your computer and use it in GitHub Desktop.
Claude Code Permission Tuner — analyze permission logs and optimize settings.local.json
#!/usr/bin/env python3
"""Analyze permission-requests.jsonl to find high-frequency permission prompts.
Reads the log, aggregates prompted entries by suggested_permission,
filters out already-allowed rules, and outputs ranked recommendations.
Usage:
python analyze_permissions.py [--log-file PATH] [--settings-file PATH] [--min-count N] [--format json|table]
"""
import argparse
import json
import sys
from collections import Counter
from pathlib import Path
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="Analyze permission request logs")
parser.add_argument(
"--log-file",
type=Path,
help="Path to permission-requests.jsonl (default: .claude/permission-requests.jsonl)",
)
parser.add_argument(
"--settings-file",
type=Path,
help="Path to settings.local.json (default: .claude/settings.local.json)",
)
parser.add_argument(
"--min-count",
type=int,
default=2,
help="Minimum prompt count to recommend (default: 2)",
)
parser.add_argument(
"--format",
choices=["json", "table"],
default="table",
help="Output format (default: table)",
)
parser.add_argument(
"--all",
action="store_true",
help="Include all entries, not just prompted ones",
)
parser.add_argument(
"--show-existing",
action="store_true",
help="Also show permissions that are already allowed",
)
return parser.parse_args(argv)
def find_project_root():
"""Walk up from cwd to find a directory with .claude/."""
current = Path.cwd()
while current != current.parent:
if (current / ".claude").is_dir():
return current
current = current.parent
return Path.cwd()
def load_log_entries(log_path):
"""Parse JSONL file, yielding valid entries."""
if not log_path.exists():
print(f"Log file not found: {log_path}", file=sys.stderr)
sys.exit(1)
entries = []
for i, line in enumerate(log_path.read_text().splitlines(), 1):
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
print(f"Skipping malformed line {i}", file=sys.stderr)
return entries
def load_existing_permissions(settings_path):
"""Load already-allowed permissions from settings.local.json."""
if not settings_path.exists():
return set()
try:
data = json.loads(settings_path.read_text())
return set(data.get("permissions", {}).get("allow", []))
except (json.JSONDecodeError, KeyError):
return set()
def derive_permission(tool, tool_input):
"""Generate a permission string from tool name and input.
Mirrors the logic in permission-request-handler.sh for entries
that lack a pre-computed suggested_permission field.
"""
if not tool:
return None
if tool == "Bash":
cmd = tool_input.get("command", "")
first_word = cmd.split()[0] if cmd.split() else "unknown"
return f"Bash({first_word}:*)"
elif tool in ("Read", "Write", "Edit"):
path = tool_input.get("file_path", "")
parent = "/".join(path.rsplit("/", 1)[:-1]) + "/**" if "/" in path else "**"
# Paths already start with / so // + /path would be ///; strip leading /
parent = parent.lstrip("/")
return f"{tool}(//{parent})"
elif tool == "Agent":
subtype = tool_input.get("subagent_type", "general-purpose")
return f"Agent({subtype})"
elif tool == "Skill":
skill = tool_input.get("skill", "*")
return f"Skill({skill})"
elif tool == "WebFetch":
url = tool_input.get("url", "")
# Extract domain
if "://" in url:
domain = url.split("://", 1)[1].split("/", 1)[0]
return f"WebFetch(domain:{domain})"
return "WebFetch(*)"
elif tool.startswith("mcp__"):
return tool
elif tool in ("Glob", "Grep"):
return tool
else:
return f"{tool}(*)"
def get_permission(entry):
"""Get or derive the permission string for a log entry."""
perm = entry.get("suggested_permission")
if perm:
return perm
return derive_permission(entry.get("tool"), entry.get("input", {}))
def aggregate_permissions(entries, only_prompted):
"""Count occurrences of each permission string."""
counts = Counter()
for entry in entries:
if only_prompted and not entry.get("prompted"):
continue
perm = get_permission(entry)
if perm:
counts[perm] += 1
return counts
def classify_permission(perm):
"""Classify a permission for safety review.
Returns: 'safe', 'review', or 'dangerous'
"""
dangerous_patterns = ["Bash(rm:", "Bash(sudo:", "Bash(chmod:", "Bash(chown:"]
review_patterns = ["Bash(git:", "Write(", "Edit("]
for pattern in dangerous_patterns:
if perm.startswith(pattern):
return "dangerous"
for pattern in review_patterns:
if perm.startswith(pattern):
return "review"
return "safe"
def format_table(recommendations):
"""Format recommendations as a readable table."""
if not recommendations:
return "No new permissions to recommend."
lines = []
lines.append(f"{'#':<4} {'Count':<7} {'Safety':<10} {'Permission'}")
lines.append("-" * 70)
for i, rec in enumerate(recommendations, 1):
safety = rec["safety"]
marker = {"safe": " ", "review": "?", "dangerous": "!"}[safety]
lines.append(f"{i:<4} {rec['count']:<7} {marker} {safety:<8} {rec['permission']}")
lines.append("")
lines.append("Legend: (space)=safe ?=review !=dangerous")
return "\n".join(lines)
def format_json(recommendations):
"""Format recommendations as JSON."""
return json.dumps(recommendations, indent=2)
def main(argv=None):
args = parse_args(argv)
project_root = find_project_root()
log_path = args.log_file or (project_root / ".claude" / "permission-requests.jsonl")
settings_path = args.settings_file or (project_root / ".claude" / "settings.local.json")
entries = load_log_entries(log_path)
existing = load_existing_permissions(settings_path)
counts = aggregate_permissions(entries, only_prompted=not args.all)
recommendations = []
for perm, count in counts.most_common():
if count < args.min_count:
continue
already_allowed = perm in existing
if already_allowed and not args.show_existing:
continue
recommendations.append({
"permission": perm,
"count": count,
"safety": classify_permission(perm),
"already_allowed": already_allowed,
})
# Summary stats
total_entries = len(entries)
prompted_entries = sum(1 for e in entries if e.get("prompted"))
unique_permissions = len(counts)
if args.format == "json":
output = {
"summary": {
"total_entries": total_entries,
"prompted_entries": prompted_entries,
"unique_permissions": unique_permissions,
"existing_allow_rules": len(existing),
"new_recommendations": len([r for r in recommendations if not r["already_allowed"]]),
},
"recommendations": recommendations,
"existing_permissions": sorted(existing),
}
print(format_json(output))
else:
print(f"Permission Log Analysis")
print(f"=======================")
print(f"Log file: {log_path}")
print(f"Total entries: {total_entries}")
print(f"Prompted entries: {prompted_entries}")
print(f"Unique permissions: {unique_permissions}")
print(f"Existing rules: {len(existing)}")
print()
print(format_table(recommendations))
return recommendations
if __name__ == "__main__":
main()
#!/usr/bin/env python3
"""Apply selected permissions to settings.local.json.
Reads a JSON array of permission strings from stdin or --permissions arg,
and idempotently adds them to the project's settings.local.json.
Usage:
echo '["Read(//path/**)", "Bash(npm:*)"]' | python apply_permissions.py
python apply_permissions.py --permissions 'Read(//path/**)' 'Bash(npm:*)'
python apply_permissions.py --settings-file /path/to/settings.local.json --permissions '...'
"""
import argparse
import json
import sys
from pathlib import Path
def parse_args(argv=None):
parser = argparse.ArgumentParser(description="Apply permissions to settings.local.json")
parser.add_argument(
"--settings-file",
type=Path,
help="Path to settings.local.json",
)
parser.add_argument(
"--permissions",
nargs="*",
help="Permission strings to add (alternative to stdin JSON)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be added without writing",
)
return parser.parse_args(argv)
def find_project_root():
"""Walk up from cwd to find a directory with .claude/."""
current = Path.cwd()
while current != current.parent:
if (current / ".claude").is_dir():
return current
current = current.parent
return Path.cwd()
def load_settings(settings_path):
"""Load existing settings or return empty structure."""
if settings_path.exists():
try:
return json.loads(settings_path.read_text())
except json.JSONDecodeError:
print(f"Warning: malformed {settings_path}, starting fresh", file=sys.stderr)
return {"permissions": {"allow": []}}
def main(argv=None):
args = parse_args(argv)
project_root = find_project_root()
settings_path = args.settings_file or (project_root / ".claude" / "settings.local.json")
# Get permissions from args or stdin
if args.permissions:
new_perms = args.permissions
elif not sys.stdin.isatty():
try:
new_perms = json.loads(sys.stdin.read())
except json.JSONDecodeError:
print("Error: invalid JSON on stdin", file=sys.stderr)
sys.exit(1)
else:
print("No permissions provided. Use --permissions or pipe JSON to stdin.", file=sys.stderr)
sys.exit(1)
settings = load_settings(settings_path)
existing = set(settings.get("permissions", {}).get("allow", []))
added = []
skipped = []
for perm in new_perms:
if perm in existing:
skipped.append(perm)
else:
added.append(perm)
existing.add(perm)
if args.dry_run:
print("Dry run — would add:")
for p in added:
print(f" + {p}")
if skipped:
print("Already exists:")
for p in skipped:
print(f" = {p}")
return
if not added:
print("All permissions already exist. Nothing to add.")
return
# Write updated settings
settings.setdefault("permissions", {}).setdefault("allow", [])
settings["permissions"]["allow"] = sorted(existing)
settings_path.parent.mkdir(parents=True, exist_ok=True)
settings_path.write_text(json.dumps(settings, indent=2) + "\n")
print(f"Added {len(added)} permission(s) to {settings_path}:")
for p in added:
print(f" + {p}")
if skipped:
print(f"Skipped {len(skipped)} (already exist)")
if __name__ == "__main__":
main()
name permission-tuner
description Analyze permission logs and optimize settings.local.json to reduce permission prompts
allowed-tools
Bash
Read
Write
Edit

Permission Tuner

Analyze the permission-requests.jsonl log to find frequently prompted permissions, then batch-update settings.local.json to reduce interruptions.

When to Use

  • "tune permissions", "optimize permissions", "reduce permission prompts"
  • "analyze permission log", "what permissions am I being asked about"
  • "update settings.local.json from logs"
  • After a long session with many permission prompts
  • Periodically to keep permissions current

Prerequisites

The permission logging hooks must be active (PreToolUse logger + PermissionRequest handler). These log to {project}/.claude/permission-requests.jsonl.

Instructions

Step 1: Analyze the Log

Run the analyzer to see what's being prompted most often:

uv run python ~/.claude/skills/permission-tuner/scripts/analyze_permissions.py

Options:

  • --min-count 1 — show even single-occurrence prompts (default: 2)
  • --all — include non-prompted tool uses (shows full usage, not just prompts)
  • --show-existing — include permissions already in allow list
  • --format json — machine-readable output for further processing

Step 2: Present Findings to User

Show the table output. Highlight:

  • Safe permissions (Read, Grep, Glob, Skill, Agent, mcp__*) — can approve freely
  • Review permissions (Write, Edit, Bash git:*) — explain what they allow
  • Dangerous permissions (Bash rm:, sudo:, chmod:*) — recommend keeping prompted

Step 3: Get User Approval

Ask which permissions to add. Options:

  • "Add all safe ones" — apply all safety=safe recommendations
  • "Add these: 1, 3, 5" — pick by number from the table
  • "Add all" — apply everything (warn if dangerous ones included)
  • "Skip" — don't change anything

Step 4: Apply Approved Permissions

For selected permissions, run:

uv run python ~/.claude/skills/permission-tuner/scripts/apply_permissions.py --permissions "Perm1" "Perm2" "Perm3"

Use --dry-run first if the user wants to preview.

Step 5: Verify

Read the updated settings.local.json to confirm changes look correct.

Step 6: Offer Log Rotation

If the log file is large (500+ entries), suggest:

"The permission log has N entries. Want me to archive it and start fresh? This gives cleaner data for the next tuning session."

If yes, rename the log to permission-requests.jsonl.bak (ask first per destructive-commands rule).

Important Notes

  • Never auto-approve dangerous permissions — always flag rm, sudo, chmod, chown
  • Write/Edit permissions need path review — ensure they're scoped to project directories, not system paths
  • Bash permissions are command-specificBash(git:*) allows all git commands; Bash(npm:*) allows all npm commands. Review the command prefix.
  • The log is per-project — each project's .claude/ has its own log. The skill operates on the current project.
  • settings.local.json is per-project — changes only affect the current project's permissions

Safety Classification

Classification Examples Default Action
safe Read, Grep, Glob, Skill, Agent, mcp__* Auto-recommend
review Write, Edit, Bash(git:*) Explain scope, ask
dangerous Bash(rm:), Bash(sudo:) Warn, don't recommend
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment