diff --git a/ClaudeRunner.cs b/ClaudeRunner.cs index 4706884..2ab3de1 100644 --- a/ClaudeRunner.cs +++ b/ClaudeRunner.cs @@ -220,6 +220,8 @@ namespace QuickClaude Finished?.Invoke(error); } + // File writes also go through a guard hook ("QuickClaude.exe --guard-write") that blocks the + // Desktop and AppData, where OpenRules lets things open without asking. // Shell commands go through QuickClaude's own confirm box: a PreToolUse hook that runs // "QuickClaude.exe --confirm" (exec form, so no shell quoting). The dialog denies after // 2 minutes; the hook timeout is a bit longer so Claude Code never cuts it off first. @@ -237,6 +239,12 @@ namespace QuickClaude { matcher = "Bash|PowerShell|Monitor", // Monitor also runs shell commands hooks = new[] { new { type = "command", command = exe, args = new[] { "--confirm" }, timeout = 150 } } + }, + new + { + // Never lets Claude write to the Desktop or AppData (see WriteGuard) + matcher = "Write|Edit|MultiEdit|NotebookEdit", + hooks = new[] { new { type = "command", command = exe, args = new[] { "--guard-write" }, timeout = 30 } } } } } diff --git a/Program.cs b/Program.cs index 3db8703..bfbf20f 100644 --- a/Program.cs +++ b/Program.cs @@ -12,6 +12,8 @@ namespace QuickClaude { // Run as Claude Code's shell-command confirm hook (separate short-lived process) if (Array.IndexOf(args, "--confirm") >= 0) return ConfirmHook.Run(); + // Run as Claude Code's file-write guard hook (see WriteGuard) + if (Array.IndexOf(args, "--guard-write") >= 0) return WriteGuard.Run(); using var mutex = new Mutex(true, "QuickClaude.SingleInstance", out bool first); if (!first) return 0; diff --git a/QuickClaude.csproj b/QuickClaude.csproj index 36f631c..961f2a1 100644 --- a/QuickClaude.csproj +++ b/QuickClaude.csproj @@ -10,7 +10,7 @@ disable QuickClaude QuickClaude - 1.3.0 + 1.4.0 QuickClaude app.ico win-x64 diff --git a/WriteGuard.cs b/WriteGuard.cs new file mode 100644 index 0000000..e41fbbf --- /dev/null +++ b/WriteGuard.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; + +namespace QuickClaude +{ + // QuickClaude.exe --guard-write: Claude Code's PreToolUse hook for Write, Edit and NotebookEdit. + // Keeps Claude from writing anywhere OpenRules lets things open without a confirm box that a + // normal (non-admin) account can write to: your Desktop and all of AppData (which holds your + // own Start Menu, AppData\Local\Programs and the Store app aliases). Without this, Claude in + // "Edit files" or "Auto" mode could drop a shortcut or program there and then open it unasked. + // Every other folder is left to the "Claude can" setting as before. + public static class WriteGuard + { + public static int Run() + { + string path = null, cwd = null; + try + { + using var stdin = new StreamReader(Console.OpenStandardInput(), new UTF8Encoding(false)); + using var doc = JsonDocument.Parse(stdin.ReadToEnd()); + var root = doc.RootElement; + cwd = Str(root, "cwd"); + if (root.TryGetProperty("tool_input", out var input)) + path = Str(input, "file_path") ?? Str(input, "notebook_path") ?? Str(input, "path"); + } + catch { } + + if (string.IsNullOrEmpty(path)) return 0; // nothing to check: normal rules decide + + if (!Blocked(path, cwd)) return 0; + + var json = JsonSerializer.Serialize(new + { + hookSpecificOutput = new + { + hookEventName = "PreToolUse", + permissionDecision = "deny", + permissionDecisionReason = + "QuickClaude doesn't let Claude write to the Desktop or AppData folders (programs and shortcuts there " + + "can open without a confirm box). Tell the user, and suggest a different folder if one would do." + } + }); + using var stdout = new StreamWriter(Console.OpenStandardOutput(), new UTF8Encoding(false)); + stdout.Write(json); + return 0; + } + + static bool Blocked(string path, string cwd) + { + try + { + var full = Path.IsPathRooted(path) || string.IsNullOrEmpty(cwd) + ? Path.GetFullPath(path) + : Path.GetFullPath(Path.Combine(cwd, path)); + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + var dirs = new[] + { + Path.Combine(home, "AppData"), + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), + Environment.GetFolderPath(Environment.SpecialFolder.CommonDesktopDirectory), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Microsoft", "Windows", "Start Menu"), + }.Where(d => !string.IsNullOrEmpty(d)); + return dirs.Any(d => + { + var root = Path.GetFullPath(d).TrimEnd('\\'); + return full.Equals(root, StringComparison.OrdinalIgnoreCase) || + full.StartsWith(root + "\\", StringComparison.OrdinalIgnoreCase); + }); + } + catch { return true; } // a path we can't even parse doesn't get written + } + + static string Str(JsonElement e, string name) => + e.ValueKind == JsonValueKind.Object && e.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; + } +}