From b30c5f124e922bc6eda33c45d6f2aae92dca0e38 Mon Sep 17 00:00:00 2001 From: lacvet Date: Fri, 3 Apr 2026 21:02:55 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EB=8F=99=EB=93=B1=ED=99=94:=20Plan=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20claw-code=20=EA=B6=8C=ED=95=9C=20=EB=B3=84=EC=B9=AD?= =?UTF-8?q?=20=EC=A0=95=EA=B7=9C=ED=99=94=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전역 권한 모드를 Ask/Plan/Auto/Deny 4단계로 확장 - claw-code 계열 권한 값(default/acceptEdits/dontAsk/bypassPermissions) 입력 시 내부 모드로 정규화 - AgentContext 권한 판정 경로(전역/도구 오버라이드/패턴 오버라이드) 정규화 적용 - Chat/Settings UI에서 Plan 모드 노출 및 인라인 순환(Ask->Plan->Auto->Deny) 반영 - AppState/SettingsViewModel/SettingsService에 권한값 정규화 및 저장 시 일관성 적용 - Permission lifecycle 이벤트 메시지에 유효 모드 표기 보강 - 빌드/테스트 검증: dotnet build 경고0 오류0, dotnet test 372/372 통과 --- src/AxCopilot/Models/AppSettings.cs | 2 +- src/AxCopilot/Models/ChatModels.cs | 2 +- .../Services/Agent/AgentLoopService.cs | 13 ++-- src/AxCopilot/Services/Agent/IAgentTool.cs | 20 +++--- .../Services/Agent/PermissionModeCatalog.cs | 72 +++++++++++++++++++ src/AxCopilot/Services/AppStateService.cs | 20 +++--- src/AxCopilot/Services/SettingsService.cs | 11 +++ src/AxCopilot/ViewModels/SettingsViewModel.cs | 7 +- src/AxCopilot/Views/ChatWindow.xaml.cs | 31 ++++---- src/AxCopilot/Views/SettingsWindow.xaml | 1 + 10 files changed, 140 insertions(+), 39 deletions(-) create mode 100644 src/AxCopilot/Services/Agent/PermissionModeCatalog.cs diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 9a6867f..f17c408 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -591,7 +591,7 @@ public class LlmSettings /// /// 파일 접근 권한 수준. - /// Ask = 매번 확인 | Auto = 자동 허용 | Deny = 차단 + /// Ask = 매번 확인 | Plan = 계획/승인 중심 | Auto = 자동 허용 | Deny = 차단 /// [JsonPropertyName("filePermission")] public string FilePermission { get; set; } = "Ask"; diff --git a/src/AxCopilot/Models/ChatModels.cs b/src/AxCopilot/Models/ChatModels.cs index 190d652..9ae4ba6 100644 --- a/src/AxCopilot/Models/ChatModels.cs +++ b/src/AxCopilot/Models/ChatModels.cs @@ -97,7 +97,7 @@ public class ChatConversation // ─── 대화별 설정 (하단 바에서 변경, 대화마다 독립 저장) ─── - /// 파일 접근 권한. null이면 전역 설정 사용. "Auto" | "Ask" | "Deny" + /// 파일 접근 권한. null이면 전역 설정 사용. "Ask" | "Plan" | "Auto" | "Deny" [JsonPropertyName("permission")] public string? Permission { get; set; } diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 5f9afa3..8c50b3a 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -4068,6 +4068,9 @@ public partial class AgentLoopService case "ask": normalized = "ask"; return true; + case "plan": + normalized = "ask"; + return true; case "auto": normalized = "auto"; return true; @@ -4153,16 +4156,16 @@ public partial class AgentLoopService messages, success: true); - var effectivePerm = context.GetEffectiveToolPermission(toolName, target); + var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(context.GetEffectiveToolPermission(toolName, target)); - if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase)) - EmitEvent(AgentEventType.PermissionRequest, toolName, $"권한 확인 필요 · 대상: {target}"); + if (PermissionModeCatalog.RequiresUserApproval(effectivePerm)) + EmitEvent(AgentEventType.PermissionRequest, toolName, $"권한 확인 필요({effectivePerm}) · 대상: {target}"); var allowed = await context.CheckToolPermissionAsync(toolName, target); if (allowed) { - if (string.Equals(effectivePerm, "Ask", StringComparison.OrdinalIgnoreCase)) - EmitEvent(AgentEventType.PermissionGranted, toolName, $"권한 승인됨 · 대상: {target}"); + if (PermissionModeCatalog.RequiresUserApproval(effectivePerm)) + EmitEvent(AgentEventType.PermissionGranted, toolName, $"권한 승인됨({effectivePerm}) · 대상: {target}"); await RunPermissionLifecycleHooksAsync( "__permission_granted__", "post", diff --git a/src/AxCopilot/Services/Agent/IAgentTool.cs b/src/AxCopilot/Services/Agent/IAgentTool.cs index 4a43b41..49bece7 100644 --- a/src/AxCopilot/Services/Agent/IAgentTool.cs +++ b/src/AxCopilot/Services/Agent/IAgentTool.cs @@ -98,7 +98,7 @@ public class AgentContext /// 작업 폴더 경로. public string WorkFolder { get; set; } = ""; - /// 파일 접근 권한. Ask | Auto | Deny + /// 파일 접근 권한. Ask | Plan | Auto | Deny public string Permission { get; init; } = "Ask"; /// 도구별 권한 오버라이드. 키: 도구명, 값: "ask" | "auto" | "deny". @@ -192,19 +192,21 @@ public class AgentContext public string GetEffectiveToolPermission(string toolName, string? target) { if (TryResolvePatternPermission(toolName, target, out var patternPermission)) - return patternPermission; + return PermissionModeCatalog.NormalizeToolOverride(patternPermission); if (ToolPermissions.TryGetValue(toolName, out var toolPerm) && !string.IsNullOrWhiteSpace(toolPerm)) - return toolPerm; + return PermissionModeCatalog.NormalizeToolOverride(toolPerm); if (ToolPermissions.TryGetValue("*", out var wildcardPerm) && !string.IsNullOrWhiteSpace(wildcardPerm)) - return wildcardPerm; + return PermissionModeCatalog.NormalizeToolOverride(wildcardPerm); if (ToolPermissions.TryGetValue("default", out var defaultPerm) && !string.IsNullOrWhiteSpace(defaultPerm)) - return defaultPerm; + return PermissionModeCatalog.NormalizeToolOverride(defaultPerm); - return SensitiveTools.Contains(toolName) ? Permission : "Auto"; + return SensitiveTools.Contains(toolName) + ? PermissionModeCatalog.NormalizeGlobalMode(Permission) + : PermissionModeCatalog.Auto; } public async Task CheckToolPermissionAsync(string toolName, string target) @@ -213,9 +215,9 @@ public class AgentContext && AxCopilot.Services.OperationModePolicy.IsBlockedAgentToolInInternalMode(toolName, target)) return false; - var effectivePerm = GetEffectiveToolPermission(toolName, target); - if (string.Equals(effectivePerm, "Deny", StringComparison.OrdinalIgnoreCase)) return false; - if (string.Equals(effectivePerm, "Auto", StringComparison.OrdinalIgnoreCase)) return true; + var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(GetEffectiveToolPermission(toolName, target)); + if (PermissionModeCatalog.IsDeny(effectivePerm)) return false; + if (PermissionModeCatalog.IsAuto(effectivePerm)) return true; if (AskPermission == null) return false; var normalizedTarget = string.IsNullOrWhiteSpace(target) ? toolName : target.Trim(); diff --git a/src/AxCopilot/Services/Agent/PermissionModeCatalog.cs b/src/AxCopilot/Services/Agent/PermissionModeCatalog.cs new file mode 100644 index 0000000..dab2935 --- /dev/null +++ b/src/AxCopilot/Services/Agent/PermissionModeCatalog.cs @@ -0,0 +1,72 @@ +namespace AxCopilot.Services.Agent; + +/// +/// AX Agent permission mode constants and normalization helpers. +/// Accepts legacy Ask/Auto/Deny values plus claw-code style aliases. +/// +public static class PermissionModeCatalog +{ + public const string Ask = "Ask"; + public const string Plan = "Plan"; + public const string Auto = "Auto"; + public const string Deny = "Deny"; + + public static readonly IReadOnlyList UserSelectableModes = new[] + { + Ask, + Plan, + Auto, + Deny, + }; + + /// + /// Normalize global permission mode. + /// Supported aliases: ask/auto/deny/plan plus default, acceptEdits, dontAsk, bypassPermissions. + /// + public static string NormalizeGlobalMode(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return Ask; + + return value.Trim().ToLowerInvariant() switch + { + "ask" => Ask, + "default" => Ask, + "plan" => Plan, + "auto" => Auto, + "acceptedits" => Auto, + "dontask" => Auto, + "deny" => Deny, + "bypasspermissions" => Deny, + _ => Ask, + }; + } + + /// + /// Normalize tool override permission. + /// Tool override is constrained to ask/auto/deny. + /// + public static string NormalizeToolOverride(string? value) + { + var mode = NormalizeGlobalMode(value); + return mode switch + { + Auto => "auto", + Deny => "deny", + _ => "ask", + }; + } + + public static bool IsAuto(string? mode) => + string.Equals(NormalizeGlobalMode(mode), Auto, StringComparison.OrdinalIgnoreCase); + + public static bool IsDeny(string? mode) => + string.Equals(NormalizeGlobalMode(mode), Deny, StringComparison.OrdinalIgnoreCase); + + public static bool RequiresUserApproval(string? mode) + { + var normalized = NormalizeGlobalMode(mode); + return !string.Equals(normalized, Auto, StringComparison.OrdinalIgnoreCase) + && !string.Equals(normalized, Deny, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/AxCopilot/Services/AppStateService.cs b/src/AxCopilot/Services/AppStateService.cs index dc52f3f..0796de4 100644 --- a/src/AxCopilot/Services/AppStateService.cs +++ b/src/AxCopilot/Services/AppStateService.cs @@ -194,7 +194,7 @@ public sealed class AppStateService Skills.Enabled = llm.EnableSkillSystem; Skills.FolderPath = llm.SkillsFolderPath ?? ""; - Permissions.FilePermission = llm.FilePermission ?? "Ask"; + Permissions.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission); Permissions.AgentDecisionLevel = llm.AgentDecisionLevel ?? "detailed"; Permissions.PlanMode = llm.PlanMode ?? "off"; Permissions.ToolOverrideCount = llm.ToolPermissions?.Count ?? 0; @@ -501,27 +501,31 @@ public sealed class AppStateService public PermissionSummaryState GetPermissionSummary(ChatConversation? conversation = null) { - var effective = conversation?.Permission; - if (string.IsNullOrWhiteSpace(effective)) - effective = Permissions.FilePermission; + var effective = PermissionModeCatalog.NormalizeGlobalMode(conversation?.Permission); + var defaultMode = PermissionModeCatalog.NormalizeGlobalMode(Permissions.FilePermission); + if (string.IsNullOrWhiteSpace(conversation?.Permission)) + effective = defaultMode; - var risk = string.Equals(effective, "Auto", StringComparison.OrdinalIgnoreCase) + var risk = string.Equals(effective, PermissionModeCatalog.Auto, StringComparison.OrdinalIgnoreCase) ? "high" - : string.Equals(effective, "Deny", StringComparison.OrdinalIgnoreCase) + : string.Equals(effective, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase) ? "locked" + : string.Equals(effective, PermissionModeCatalog.Plan, StringComparison.OrdinalIgnoreCase) + ? "guarded" : "normal"; var description = effective switch { "Auto" => "파일 작업을 자동 허용합니다.", "Deny" => "파일 작업을 차단합니다.", + "Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.", _ => "파일 작업 전마다 사용자 확인을 요청합니다.", }; return new PermissionSummaryState { - EffectiveMode = effective ?? "Ask", - DefaultMode = Permissions.FilePermission, + EffectiveMode = effective, + DefaultMode = defaultMode, OverrideCount = Permissions.ToolOverrideCount, RiskLevel = risk, Description = description, diff --git a/src/AxCopilot/Services/SettingsService.cs b/src/AxCopilot/Services/SettingsService.cs index 1170087..e295e9e 100644 --- a/src/AxCopilot/Services/SettingsService.cs +++ b/src/AxCopilot/Services/SettingsService.cs @@ -1,6 +1,7 @@ using System.IO; using System.Text.Json; using AxCopilot.Models; +using AxCopilot.Services.Agent; namespace AxCopilot.Services; @@ -173,6 +174,15 @@ public class SettingsService private void NormalizeRuntimeSettings() { + _settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Llm.FilePermission); + _settings.Llm.DefaultAgentPermission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Llm.DefaultAgentPermission); + if (_settings.Llm.ToolPermissions != null && _settings.Llm.ToolPermissions.Count > 0) + { + var keys = _settings.Llm.ToolPermissions.Keys.ToList(); + foreach (var key in keys) + _settings.Llm.ToolPermissions[key] = PermissionModeCatalog.NormalizeToolOverride(_settings.Llm.ToolPermissions[key]); + } + NormalizeLlmThresholds(_settings.Llm); } @@ -262,3 +272,4 @@ public class SettingsService }; } } + diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs index 0939cb5..ee7bed8 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs @@ -7,6 +7,7 @@ using System.Windows.Forms; using System.Windows.Media; using AxCopilot.Models; using AxCopilot.Services; +using AxCopilot.Services.Agent; using AxCopilot.Themes; using AxCopilot.Views; @@ -252,7 +253,7 @@ public class SettingsViewModel : INotifyPropertyChanged public string DefaultAgentPermission { get => _defaultAgentPermission; - set { _defaultAgentPermission = value; OnPropertyChanged(); } + set { _defaultAgentPermission = PermissionModeCatalog.NormalizeGlobalMode(value); OnPropertyChanged(); } } // ── 코워크/에이전트 고급 설정 ── @@ -981,7 +982,7 @@ public class SettingsViewModel : INotifyPropertyChanged _llmMaxContextTokens = llm.MaxContextTokens; _llmRetentionDays = llm.RetentionDays; _llmTemperature = llm.Temperature; - _defaultAgentPermission = llm.DefaultAgentPermission; + _defaultAgentPermission = PermissionModeCatalog.NormalizeGlobalMode(llm.DefaultAgentPermission); _defaultOutputFormat = llm.DefaultOutputFormat; _defaultMood = string.IsNullOrEmpty(llm.DefaultMood) ? "modern" : llm.DefaultMood; _autoPreview = llm.AutoPreview; @@ -1405,7 +1406,7 @@ public class SettingsViewModel : INotifyPropertyChanged s.Llm.MaxContextTokens = _llmMaxContextTokens; s.Llm.RetentionDays = _llmRetentionDays; s.Llm.Temperature = _llmTemperature; - s.Llm.DefaultAgentPermission = _defaultAgentPermission; + s.Llm.DefaultAgentPermission = PermissionModeCatalog.NormalizeGlobalMode(_defaultAgentPermission); s.Llm.DefaultOutputFormat = _defaultOutputFormat; s.Llm.DefaultMood = _defaultMood; s.Llm.AutoPreview = _autoPreview; diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 31f058e..326d0c1 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -1282,7 +1282,7 @@ public partial class ChatWindow : Window var llm = _settings.Settings.Llm; if (conv != null && conv.Permission != null) - _settings.Settings.Llm.FilePermission = conv.Permission; + _settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(conv.Permission); _folderDataUsage = conv?.DataUsage ?? llm.FolderDataUsage ?? "active"; _selectedMood = conv?.Mood ?? llm.DefaultMood ?? "modern"; @@ -1304,7 +1304,7 @@ public partial class ChatWindow : Window } else { - conv.Permission = _settings.Settings.Llm.FilePermission; + conv.Permission = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); conv.DataUsage = _folderDataUsage; conv.Mood = _selectedMood; _storage.Save(conv); @@ -1485,10 +1485,11 @@ public partial class ChatWindow : Window var levels = new (string Level, string Sym, string Desc, string Color)[] { ("Ask", "\uE8D7", "매번 확인 — 파일 접근 시 사용자에게 묻습니다", "#4B5EFC"), + ("Plan", "\uE7C3", "계획/승인 중심 — 실행 전 계획과 사용자 확인 흐름을 우선합니다", "#4338CA"), ("Auto", "\uE73E", "자동 허용 — 파일을 자동으로 읽고 씁니다", "#DD6B20"), ("Deny", "\uE711", "접근 차단 — 파일 접근을 허용하지 않습니다", "#C50F1F"), }; - var current = _settings.Settings.Llm.FilePermission; + var current = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission); foreach (var (level, sym, desc, color) in levels) { var isActive = level.Equals(current, StringComparison.OrdinalIgnoreCase); @@ -1548,7 +1549,7 @@ public partial class ChatWindow : Window var capturedLevel = level; btn.Click += (_, _) => { - _settings.Settings.Llm.FilePermission = capturedLevel; + _settings.Settings.Llm.FilePermission = PermissionModeCatalog.NormalizeGlobalMode(capturedLevel); UpdatePermissionUI(); SaveConversationSettings(); PermissionPopup.IsOpen = false; @@ -1571,7 +1572,7 @@ public partial class ChatWindow : Window } else { - toolPermissions[existingKey ?? toolName] = mode; + toolPermissions[existingKey ?? toolName] = PermissionModeCatalog.NormalizeToolOverride(mode); } try { _settings.Save(); } catch { } @@ -1601,10 +1602,11 @@ public partial class ChatWindow : Window ChatConversation? currentConversation; lock (_convLock) currentConversation = _currentConversation; var summary = _appState.GetPermissionSummary(currentConversation); - var perm = summary.EffectiveMode; + var perm = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode); PermissionLabel.Text = perm; PermissionIcon.Text = perm switch { + "Plan" => "\uE7C3", "Auto" => "\uE73E", "Deny" => "\uE711", _ => "\uE8D7", @@ -1629,8 +1631,12 @@ public partial class ChatWindow : Window { _autoWarningDismissed = false; // Auto가 아닌 모드로 전환하면 리셋 var defaultFg = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray; - var iconFg = perm == "Deny" ? new SolidColorBrush(Color.FromRgb(0xC5, 0x0F, 0x1F)) - : new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)); // Ask = 파란색 + var iconFg = perm switch + { + "Deny" => new SolidColorBrush(Color.FromRgb(0xC5, 0x0F, 0x1F)), + "Plan" => new SolidColorBrush(Color.FromRgb(0x43, 0x38, 0xCA)), + _ => new SolidColorBrush(Color.FromRgb(0x4B, 0x5E, 0xFC)), // Ask = 파란색 + }; PermissionLabel.Foreground = defaultFg; PermissionIcon.Foreground = iconFg; if (AutoPermissionWarning != null) @@ -1739,11 +1745,11 @@ public partial class ChatWindow : Window if (_activeTab == "Chat") { // Chat 탭: 경고 배너 숨기고 기본 Ask 모드로 복원 - _settings.Settings.Llm.FilePermission = "Ask"; + _settings.Settings.Llm.FilePermission = PermissionModeCatalog.Ask; UpdatePermissionUI(); return; } - var defaultPerm = _settings.Settings.Llm.DefaultAgentPermission; + var defaultPerm = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.DefaultAgentPermission); if (!string.IsNullOrEmpty(defaultPerm)) { _settings.Settings.Llm.FilePermission = defaultPerm; @@ -10318,9 +10324,10 @@ public partial class ChatWindow : Window "detailed" => "높음", _ => "중간", }; - private static string NextPermission(string current) => (current ?? "Ask").ToLowerInvariant() switch + private static string NextPermission(string current) => PermissionModeCatalog.NormalizeGlobalMode(current).ToLowerInvariant() switch { - "ask" => "Auto", + "ask" => "Plan", + "plan" => "Auto", "auto" => "Deny", _ => "Ask", }; diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml index 92d8487..f6c0986 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml +++ b/src/AxCopilot/Views/SettingsWindow.xaml @@ -4346,6 +4346,7 @@ Width="160" SelectedValue="{Binding DefaultAgentPermission, Mode=TwoWay}" SelectedValuePath="Tag"> +