|
#!/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() |