Files
claude-local-overview-display/Services/PermissionRules.cs
2026-06-25 09:59:54 +02:00

170 lines
6.1 KiB
C#

using System.IO;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ClaudeOverview.Services;
/// <summary>
/// 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".
/// </summary>
public sealed class PermissionRules
{
// Tools allowed for any input, e.g. an "Edit" rule with no parens.
private readonly HashSet<string> _allowAll = new(StringComparer.OrdinalIgnoreCase);
// Tool -> specifiers it's allowed for, e.g. Bash -> ["npm run build:*", "ls:*"].
private readonly Dictionary<string, List<string>> _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" };
/// <summary>True when <paramref name="tool"/> with <paramref name="arg"/> would run without prompting.</summary>
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;
}
/// <summary>True only when every pending tool/arg pair is auto-approved.</summary>
public bool AllowsEvery(IReadOnlyList<string> tools, IReadOnlyList<string> 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 -----
/// <summary>
/// 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.
/// </summary>
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<string>();
list.Add(spec);
}
}
}