170 lines
6.1 KiB
C#
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);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|