using System.IO; using System.Text.Json; using System.Text.RegularExpressions; namespace ClaudeOverview.Services; /// /// A merged view of the user's Claude Code permission settings, used to decide whether a given /// tool call would run silently (auto-approved) or pop a permission prompt. This is an /// approximation of Claude Code's own matcher — good enough to keep the overview widget from /// mislabelling an allowlisted long-running tool as "needs permission". /// public sealed class PermissionRules { // Tools allowed for any input, e.g. an "Edit" rule with no parens. private readonly HashSet _allowAll = new(StringComparer.OrdinalIgnoreCase); // Tool -> specifiers it's allowed for, e.g. Bash -> ["npm run build:*", "ls:*"]. private readonly Dictionary> _allowSpec = new(StringComparer.OrdinalIgnoreCase); private bool _bypassAll; // defaultMode = bypassPermissions private bool _acceptEdits; // defaultMode = acceptEdits (file edits don't prompt) private static readonly string[] EditTools = { "Edit", "Write", "MultiEdit", "NotebookEdit" }; /// True when with would run without prompting. public bool Allows(string tool, string arg) { if (_bypassAll) return true; if (_acceptEdits && Array.Exists(EditTools, t => t.Equals(tool, StringComparison.OrdinalIgnoreCase))) return true; if (_allowAll.Contains(tool)) return true; if (_allowSpec.TryGetValue(tool, out var specs)) foreach (var spec in specs) if (Matches(tool, spec, arg)) return true; return false; } /// True only when every pending tool/arg pair is auto-approved. public bool AllowsEvery(IReadOnlyList tools, IReadOnlyList args) { if (tools.Count == 0) return false; for (int i = 0; i < tools.Count; i++) { var arg = i < args.Count ? args[i] : ""; if (!Allows(tools[i], arg)) return false; } return true; } private static bool Matches(string tool, string spec, string arg) { if (spec is "*" or "") return true; // Bash specifiers are command prefixes; "git push:*" allows any command starting "git push". if (tool.Equals("Bash", StringComparison.OrdinalIgnoreCase)) { if (spec.EndsWith(":*")) return arg.StartsWith(spec[..^2], StringComparison.Ordinal); return arg.Equals(spec, StringComparison.Ordinal); } // Everything else (paths, urls): treat the specifier as a glob over the argument. return GlobMatch(spec, arg); } private static bool GlobMatch(string pattern, string text) { // Translate a gitignore-ish glob to a regex: ** = any, * = any except '/'. var rx = "^" + Regex.Escape(pattern) .Replace(@"\*\*", ".*") .Replace(@"\*", "[^/]*") .Replace(@"\?", ".") + "$"; try { return Regex.IsMatch(text, rx); } catch { return false; } } // ----- Loading ----- /// /// Loads and merges permission rules for a chat with the given working directory: user-level /// settings plus the project's settings.json and settings.local.json. Missing files are ignored. /// public static PermissionRules Load(AppConfig config, string cwd) { var rules = new PermissionRules(); // ~/.claude/settings.json rules.MergeFile(config.ToWindowsPath($"{config.LinuxHome}/.claude/settings.json")); if (!string.IsNullOrWhiteSpace(cwd)) { var projectDir = config.ToWindowsPath($"{cwd.TrimEnd('/')}/.claude"); rules.MergeFile(Path.Combine(projectDir, "settings.json")); rules.MergeFile(Path.Combine(projectDir, "settings.local.json")); } return rules; } private void MergeFile(string path) { JsonDocument doc; try { if (!File.Exists(path)) return; doc = JsonDocument.Parse(File.ReadAllText(path)); } catch { return; } using (doc) { var root = doc.RootElement; if (root.ValueKind != JsonValueKind.Object) return; if (root.TryGetProperty("permissions", out var perms) && perms.ValueKind == JsonValueKind.Object) { AddRules(perms, "allow"); if (perms.TryGetProperty("defaultMode", out var modeEl) && modeEl.ValueKind == JsonValueKind.String) ApplyMode(modeEl.GetString()); } // Legacy / top-level forms. AddRules(root, "allowedTools"); if (root.TryGetProperty("defaultMode", out var topMode) && topMode.ValueKind == JsonValueKind.String) ApplyMode(topMode.GetString()); } } private void ApplyMode(string? mode) { if (string.Equals(mode, "bypassPermissions", StringComparison.OrdinalIgnoreCase)) _bypassAll = true; else if (string.Equals(mode, "acceptEdits", StringComparison.OrdinalIgnoreCase)) _acceptEdits = true; } private void AddRules(JsonElement parent, string key) { if (!parent.TryGetProperty(key, out var arr) || arr.ValueKind != JsonValueKind.Array) return; foreach (var item in arr.EnumerateArray()) { if (item.ValueKind != JsonValueKind.String) continue; var rule = item.GetString(); if (string.IsNullOrWhiteSpace(rule)) continue; // "Tool" or "Tool(specifier)". int open = rule.IndexOf('('); if (open < 0) { _allowAll.Add(rule.Trim()); continue; } var tool = rule[..open].Trim(); int close = rule.LastIndexOf(')'); var spec = close > open ? rule[(open + 1)..close].Trim() : ""; if (!_allowSpec.TryGetValue(tool, out var list)) _allowSpec[tool] = list = new List(); list.Add(spec); } } }