diff --git a/ClaudeRunner.cs b/ClaudeRunner.cs index 1387c66..4706884 100644 --- a/ClaudeRunner.cs +++ b/ClaudeRunner.cs @@ -39,7 +39,13 @@ namespace QuickClaude "or other personal information from your context into web searches or URLs you fetch, " + "unless the user specifically asks you to search for them. " + "Shell commands (Bash or PowerShell) are allowed for other things, but each one first shows the user a confirm box; " + - "if they decline, carry on without it."; + "if they decline, carry on without it. " + + "When the user asks you to open something, run exactly one PowerShell Start-Process command with one quoted target " + + "and nothing else on the line (no arguments, no second command); those open without a confirm box. " + + "A website: Start-Process \"https://x.com\" (always the full https:// URL). " + + "A folder: Start-Process \"C:\\wallpapers\" (the full path). " + + "A program: Start-Process mspaint (just its name). If QuickClaude answers that the name isn't on the PATH and gives " + + "a Start Menu shortcut, run Start-Process with that shortcut's full path, exactly as given."; // Run without prompts. Bash and PowerShell stay out so they go through the confirm box hook. // Write and Edit only when the user picked "Edit files" or "Auto"; "Only look" removes them outright. diff --git a/ConfirmWindow.cs b/ConfirmWindow.cs index 81775fa..919ab70 100644 --- a/ConfirmWindow.cs +++ b/ConfirmWindow.cs @@ -31,14 +31,31 @@ namespace QuickClaude // Nothing to show: say nothing, so Claude Code's normal permission rules decide if (string.IsNullOrEmpty(command)) return 0; - new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; - var win = new ConfirmWindow(toolName, command, cwd); - win.ShowDialog(); + string decision, reason; + var verdict = OpenRules.Check(command, cwd, out var hint); + if (verdict == OpenRules.Verdict.Allow) + { + // "open x.com", "open C:\wallpapers", "open mspaint": no confirm box (see OpenRules) + decision = "allow"; + reason = "Opening a website, folder or installed program is allowed without asking."; + } + else if (verdict == OpenRules.Verdict.Hint) + { + // The bare name wouldn't resolve, but there's a Start Menu shortcut for it + decision = "deny"; + reason = hint; + } + else + { + new Application { ShutdownMode = ShutdownMode.OnExplicitShutdown }; + var win = new ConfirmWindow(toolName, command, cwd); + win.ShowDialog(); - string decision = win.Allowed ? "allow" : "deny"; - string reason = win.Allowed ? "The user allowed this command." - : win.TimedOut ? "The user didn't answer within 2 minutes, so the command was not run. Carry on without it." - : "The user declined to run this command. Carry on without it."; + decision = win.Allowed ? "allow" : "deny"; + reason = win.Allowed ? "The user allowed this command." + : win.TimedOut ? "The user didn't answer within 2 minutes, so the command was not run. Carry on without it." + : "The user declined to run this command. Carry on without it."; + } var json = JsonSerializer.Serialize(new { diff --git a/OpenRules.cs b/OpenRules.cs new file mode 100644 index 0000000..73d9330 --- /dev/null +++ b/OpenRules.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.Win32; + +namespace QuickClaude +{ + // Decides which "open ..." shell commands skip the confirm box. Only a command that is a + // single open of ONE target, with no arguments and nothing chained on, qualifies: + // Start-Process "https://x.com" a website (http/https only) + // Start-Process "C:\wallpapers" a folder that exists (no network paths) + // Start-Process mspaint a program: an .exe in Program Files, Windows, + // Start-Process "C:\...\mirc.exe" AppData\Local\Programs or the Store app aliases, + // Start-Process "...\mIRC.lnk" or a shortcut in the Start Menu or on the Desktop + // Scripts (.bat, .ps1 and so on) and programs anywhere else still get the confirm box. + public static class OpenRules + { + public enum Verdict { Ask, Allow, Hint } + + // verb, optional "" title (cmd start), optional -FilePath/-Path, then ONE target. + // Quoted targets may contain spaces and ( ) for "Program Files (x86)", but never quotes, + // ` $ % or line breaks (PowerShell and cmd expand those). Unquoted targets are stricter. + static readonly Regex OpenCmd = new Regex( + @"^\s*(?:cmd(?:\.exe)?\s+/c\s+)?(?:Start-Process|saps|start|explorer(?:\.exe)?|Invoke-Item|ii)\s+" + + @"(?:""""\s+)?(?:-(?:FilePath|Path|LiteralPath)\s+)?" + + @"(?:""(?[^""'`$%\r\n;&|<>^]+)""|'(?[^""'`$%\r\n;&|<>^]+)'|(?[^\s""'`$%;&|<>()^]+))\s*$", + RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + + static readonly Regex Url = new Regex(@"^https?://[^\s""'`;&|<>$()^%]+$", RegexOptions.IgnoreCase); + static readonly Regex BareName = new Regex(@"^[A-Za-z0-9_.\-]+$"); + + public static Verdict Check(string command, string cwd, out string hint) + { + hint = null; + if (string.IsNullOrEmpty(command) || command.IndexOfAny(new[] { '\n', '\r' }) >= 0) return Verdict.Ask; + var m = OpenCmd.Match(command); + if (!m.Success) return Verdict.Ask; + var target = m.Groups["t"].Value.Trim(); + if (target.Length == 0) return Verdict.Ask; + + if (Url.IsMatch(target)) return Verdict.Allow; + + try + { + if (Path.IsPathFullyQualified(target)) + { + if (target.StartsWith(@"\\")) return Verdict.Ask; // no network paths + var full = Path.GetFullPath(target); + if (Directory.Exists(full)) return Verdict.Allow; + if (File.Exists(full) && TrustedProgram(full)) return Verdict.Allow; + return Verdict.Ask; + } + + if (BareName.IsMatch(target)) + { + // A bare name could also pick up a file sitting in the work folder; never allow that + if (!string.IsNullOrEmpty(cwd) && Directory.Exists(cwd) && + (File.Exists(Path.Combine(cwd, target)) || Directory.EnumerateFiles(cwd, target + ".*").Any())) return Verdict.Ask; + + if (FindProgram(target) != null) return Verdict.Allow; + + var lnk = FindShortcut(target); + if (lnk != null) + { + hint = "\"" + target + "\" isn't on the PATH, so that command wouldn't find it. " + + "Its Start Menu shortcut is " + lnk + ". Run Start-Process \"" + lnk + "\" instead " + + "(that one opens without a confirm box)."; + return Verdict.Hint; + } + } + } + catch { } + return Verdict.Ask; + } + + static string Env(string name) => Environment.GetEnvironmentVariable(name) ?? ""; + static string Special(Environment.SpecialFolder f) => Environment.GetFolderPath(f); + + static string[] ProgramDirs => new[] + { + Env("ProgramFiles"), Env("ProgramFiles(x86)"), Env("ProgramW6432"), + Env("WINDIR"), + Path.Combine(Special(Environment.SpecialFolder.LocalApplicationData), "Programs"), + Path.Combine(Special(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "WindowsApps"), + }.Where(d => d.Length > 0).ToArray(); + + static string[] ShortcutDirs => new[] + { + Path.Combine(Special(Environment.SpecialFolder.CommonApplicationData), "Microsoft", "Windows", "Start Menu"), + Path.Combine(Special(Environment.SpecialFolder.ApplicationData), "Microsoft", "Windows", "Start Menu"), + Special(Environment.SpecialFolder.DesktopDirectory), + Special(Environment.SpecialFolder.CommonDesktopDirectory), + }.Where(d => d.Length > 0).ToArray(); + + static bool Under(string path, string dir) + { + var d = Path.GetFullPath(dir).TrimEnd('\\') + "\\"; + return path.StartsWith(d, StringComparison.OrdinalIgnoreCase); + } + + static bool TrustedProgram(string full) + { + var ext = Path.GetExtension(full).ToLowerInvariant(); + if (ext == ".exe") return ProgramDirs.Any(d => Under(full, d)); + if (ext == ".lnk") return ShortcutDirs.Any(d => Under(full, d)); + return false; + } + + // Where Windows itself would find a bare program name: the Windows folders, the Store app + // aliases, and the App Paths registry (how "mspaint" and friends resolve). + static string FindProgram(string name) + { + var exe = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? name : name + ".exe"; + var win = Env("WINDIR"); + foreach (var dir in new[] + { + Path.Combine(win, "System32"), win, + Path.Combine(Special(Environment.SpecialFolder.LocalApplicationData), "Microsoft", "WindowsApps"), + }) + { + var p = Path.Combine(dir, exe); + if (File.Exists(p)) return p; + } + foreach (var hive in new[] { Registry.CurrentUser, Registry.LocalMachine }) + { + using var key = hive.OpenSubKey(@"Software\Microsoft\Windows\CurrentVersion\App Paths\" + exe); + var val = (key?.GetValue(null) as string)?.Trim('"'); + if (!string.IsNullOrEmpty(val) && File.Exists(val) && TrustedProgram(Path.GetFullPath(val))) return val; + } + return null; + } + + // A Start Menu or Desktop shortcut named like the program ("mirc" finds "mIRC.lnk"). + // Exact name first, then the shortest name that starts with it. + static string FindShortcut(string name) + { + var all = ShortcutDirs.Where(Directory.Exists) + .SelectMany(d => { try { return Directory.EnumerateFiles(d, "*.lnk", SearchOption.AllDirectories); } catch { return Enumerable.Empty(); } }) + .ToList(); + var exact = all.FirstOrDefault(f => string.Equals(Path.GetFileNameWithoutExtension(f), name, StringComparison.OrdinalIgnoreCase)); + if (exact != null) return exact; + return all.Where(f => Path.GetFileNameWithoutExtension(f).StartsWith(name, StringComparison.OrdinalIgnoreCase)) + .OrderBy(f => Path.GetFileNameWithoutExtension(f).Length).FirstOrDefault(); + } + } +} diff --git a/QuickClaude.csproj b/QuickClaude.csproj index c41564c..36f631c 100644 --- a/QuickClaude.csproj +++ b/QuickClaude.csproj @@ -10,7 +10,7 @@ disable QuickClaude QuickClaude - 1.2.0 + 1.3.0 QuickClaude app.ico win-x64