권한 코어를 claude-code 기준으로 재구성하고 slash palette 상태 분리를 시작\n\n- Default/AcceptEdits/Plan/BypassPermissions/DontAsk/Deny 권한 모드를 추가하고 기존 Ask/Auto 호환을 유지\n- deny 우선 패턴 규칙, allow/override, 글로벌 모드 순서의 권한 해석 체계를 정리\n- file_write/file_edit/file_manage와 process/build_run/test_loop/snippet_runner/spawn_agent 계열을 권한 클래스별로 분리\n- AcceptEdits는 파일 편집 도구 자동 허용, process 계열은 계속 확인하도록 조정\n- Plan은 쓰기 도구를 차단하고 읽기 중심 진행이 되도록 보강\n- BypassPermissions와 DontAsk는 권한 확인을 생략하는 경로로 정규화\n- AX Agent 권한 팝업, 상단 배너, slash 명령 결과를 새 권한 체계에 맞게 정리\n- /permissions, /allowed-tools, /sandbox-toggle 사용법과 상태 출력을 갱신\n- ChatWindow의 slash palette 상태를 전용 SlashPaletteState로 분리해 이후 composer 개편 기반을 마련\n- AppState, 설정 모델, 테스트를 새 권한 체계에 맞게 갱신\n- dotnet build 경고 0 / 오류 0, dotnet test 436 통과를 확인
Some checks failed
Release Gate / gate (push) Has been cancelled

This commit is contained in:
2026-04-04 09:51:38 +09:00
parent cc1f1c4e6c
commit 442e8c2415
9 changed files with 962 additions and 326 deletions

View File

@@ -13,7 +13,7 @@ public class AppStateServiceTests
public void LoadFromSettings_ReflectsPermissionAndMcpSummary() public void LoadFromSettings_ReflectsPermissionAndMcpSummary()
{ {
var settings = new SettingsService(); var settings = new SettingsService();
settings.Settings.Llm.FilePermission = "Auto"; settings.Settings.Llm.FilePermission = "AcceptEdits";
settings.Settings.Llm.AgentDecisionLevel = "normal"; settings.Settings.Llm.AgentDecisionLevel = "normal";
settings.Settings.Llm.PlanMode = "always"; settings.Settings.Llm.PlanMode = "always";
settings.Settings.Llm.ToolPermissions["process"] = "Deny"; settings.Settings.Llm.ToolPermissions["process"] = "Deny";
@@ -29,7 +29,7 @@ public class AppStateServiceTests
state.LoadFromSettings(settings); state.LoadFromSettings(settings);
state.Permissions.FilePermission.Should().Be("Auto"); state.Permissions.FilePermission.Should().Be("AcceptEdits");
state.Permissions.AgentDecisionLevel.Should().Be("normal"); state.Permissions.AgentDecisionLevel.Should().Be("normal");
state.Permissions.PlanMode.Should().Be("always"); state.Permissions.PlanMode.Should().Be("always");
state.Permissions.ToolOverrideCount.Should().Be(1); state.Permissions.ToolOverrideCount.Should().Be(1);
@@ -346,14 +346,14 @@ public class AppStateServiceTests
{ {
var state = new AppStateService(); var state = new AppStateService();
var settings = new SettingsService(); var settings = new SettingsService();
settings.Settings.Llm.FilePermission = "Ask"; settings.Settings.Llm.FilePermission = "Default";
settings.Settings.Llm.ToolPermissions["process"] = "Deny"; settings.Settings.Llm.ToolPermissions["process"] = "Deny";
state.LoadFromSettings(settings); state.LoadFromSettings(settings);
var summary = state.GetPermissionSummary(new ChatConversation { Permission = "Auto" }); var summary = state.GetPermissionSummary(new ChatConversation { Permission = "AcceptEdits" });
summary.EffectiveMode.Should().Be("Auto"); summary.EffectiveMode.Should().Be("AcceptEdits");
summary.DefaultMode.Should().Be("Ask"); summary.DefaultMode.Should().Be("Default");
summary.OverrideCount.Should().Be(1); summary.OverrideCount.Should().Be(1);
summary.RiskLevel.Should().Be("high"); summary.RiskLevel.Should().Be("high");
summary.TopOverrides.Should().ContainSingle(); summary.TopOverrides.Should().ContainSingle();

View File

@@ -37,7 +37,7 @@ public class OperationModePolicyTests
var context = new AgentContext var context = new AgentContext
{ {
OperationMode = OperationModePolicy.InternalMode, OperationMode = OperationModePolicy.InternalMode,
Permission = "Auto" Permission = "AcceptEdits"
}; };
var blocked = await context.CheckToolPermissionAsync("http_tool", "https://example.com"); var blocked = await context.CheckToolPermissionAsync("http_tool", "https://example.com");
@@ -53,7 +53,7 @@ public class OperationModePolicyTests
var context = new AgentContext var context = new AgentContext
{ {
OperationMode = OperationModePolicy.ExternalMode, OperationMode = OperationModePolicy.ExternalMode,
Permission = "Auto" Permission = "AcceptEdits"
}; };
var allowed = await context.CheckToolPermissionAsync("http_tool", "https://example.com"); var allowed = await context.CheckToolPermissionAsync("http_tool", "https://example.com");
@@ -65,18 +65,18 @@ public class OperationModePolicyTests
{ {
var context = new AgentContext var context = new AgentContext
{ {
Permission = "Ask", Permission = "Default",
ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
["process"] = "deny", ["process"] = "deny",
["process@git *"] = "auto", ["process@git *"] = "acceptedits",
["*@*.md"] = "ask", ["*@*.md"] = "default",
} }
}; };
context.GetEffectiveToolPermission("process", "git status").Should().Be("auto"); context.GetEffectiveToolPermission("process", "git status").Should().Be("Default");
context.GetEffectiveToolPermission("process", "powershell -NoProfile").Should().Be("deny"); context.GetEffectiveToolPermission("process", "powershell -NoProfile").Should().Be("Deny");
context.GetEffectiveToolPermission("file_read", @"E:\work\README.md").Should().Be("ask"); context.GetEffectiveToolPermission("file_read", @"E:\work\README.md").Should().Be("AcceptEdits");
} }
[Fact] [Fact]
@@ -86,10 +86,10 @@ public class OperationModePolicyTests
var context = new AgentContext var context = new AgentContext
{ {
OperationMode = OperationModePolicy.ExternalMode, OperationMode = OperationModePolicy.ExternalMode,
Permission = "Ask", Permission = "Default",
ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
["process@git *"] = "auto", ["process@git *"] = "bypassPermissions",
}, },
AskPermission = (_, _) => AskPermission = (_, _) =>
{ {
@@ -104,29 +104,48 @@ public class OperationModePolicyTests
} }
[Fact] [Fact]
public void AgentContext_GetEffectiveToolPermission_DowngradesDangerousAutoToolWhenGlobalAuto() public void AgentContext_GetEffectiveToolPermission_AcceptEditsAllowsWriteButKeepsProcessPrompted()
{ {
var context = new AgentContext var context = new AgentContext
{ {
Permission = "Auto", Permission = "AcceptEdits"
ToolPermissions = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["process"] = "auto",
}
}; };
context.GetEffectiveToolPermission("process", "git status").Should().Be("ask"); context.GetEffectiveToolPermission("process", "git status").Should().Be("Default");
context.GetEffectiveToolPermission("file_read", @"E:\work\README.md").Should().Be("Auto"); context.GetEffectiveToolPermission("file_write", @"E:\work\out.txt").Should().Be("AcceptEdits");
} }
[Fact] [Fact]
public async Task AgentContext_CheckToolPermissionAsync_DangerousAutoToolRequiresPromptInGlobalAuto() public async Task AgentContext_CheckToolPermissionAsync_PlanModeBlocksWriteButAllowsRead()
{ {
var askCalled = false; var askCalled = false;
var context = new AgentContext var context = new AgentContext
{ {
OperationMode = OperationModePolicy.ExternalMode, OperationMode = OperationModePolicy.ExternalMode,
Permission = "Auto", Permission = "Plan",
AskPermission = (_, _) =>
{
askCalled = true;
return Task.FromResult(true);
}
};
var writeAllowed = await context.CheckToolPermissionAsync("file_write", @"E:\work\out.txt");
var readAllowed = await context.CheckToolPermissionAsync("file_read", @"E:\work\in.txt");
writeAllowed.Should().BeFalse();
readAllowed.Should().BeTrue();
askCalled.Should().BeFalse();
}
[Fact]
public async Task AgentContext_CheckToolPermissionAsync_BypassPermissionsSkipsPrompt()
{
var askCalled = false;
var context = new AgentContext
{
OperationMode = OperationModePolicy.ExternalMode,
Permission = "BypassPermissions",
AskPermission = (_, _) => AskPermission = (_, _) =>
{ {
askCalled = true; askCalled = true;
@@ -135,8 +154,8 @@ public class OperationModePolicyTests
}; };
var allowed = await context.CheckToolPermissionAsync("process", "git status"); var allowed = await context.CheckToolPermissionAsync("process", "git status");
allowed.Should().BeFalse(); allowed.Should().BeTrue();
askCalled.Should().BeTrue(); askCalled.Should().BeFalse();
} }
[Fact] [Fact]

View File

@@ -241,6 +241,10 @@ public class LauncherSettings
/// <summary>독 바 마지막 위치 Y. -1이면 하단.</summary> /// <summary>독 바 마지막 위치 Y. -1이면 하단.</summary>
[JsonPropertyName("dockBarTop")] [JsonPropertyName("dockBarTop")]
public double DockBarTop { get; set; } = -1; public double DockBarTop { get; set; } = -1;
/// <summary>모니터별 독 바 위치. key=디바이스명, value=[left, top]</summary>
[JsonPropertyName("monitorDockPositions")]
public Dictionary<string, List<double>> MonitorDockPositions { get; set; } = new(StringComparer.OrdinalIgnoreCase);
} }
/// <summary> /// <summary>
@@ -591,14 +595,15 @@ public class LlmSettings
/// <summary> /// <summary>
/// 파일 접근 권한 수준. /// 파일 접근 권한 수준.
/// Ask = 매번 확인 | Plan = 계획/승인 중심 | Auto = 자동 허용 | Deny = 차단 /// Default = 매번 확인 | AcceptEdits = 파일 편집 자동 허용 | Plan = 계획/승인 중심
/// BypassPermissions = 모든 확인 생략 | DontAsk = 권한 질문 없이 진행 | Deny = 읽기 전용
/// </summary> /// </summary>
[JsonPropertyName("filePermission")] [JsonPropertyName("filePermission")]
public string FilePermission { get; set; } = "Ask"; public string FilePermission { get; set; } = "Default";
/// <summary>Cowork/Code 탭의 기본 파일 접근 권한. 탭 전환 시 자동 적용.</summary> /// <summary>Cowork/Code 탭의 기본 파일 접근 권한. 탭 전환 시 자동 적용.</summary>
[JsonPropertyName("defaultAgentPermission")] [JsonPropertyName("defaultAgentPermission")]
public string DefaultAgentPermission { get; set; } = "Ask"; public string DefaultAgentPermission { get; set; } = "Default";
// ── 서비스별 독립 설정 ────────────────────────────────────── // ── 서비스별 독립 설정 ──────────────────────────────────────
[JsonPropertyName("ollamaEndpoint")] public string OllamaEndpoint { get; set; } = "http://localhost:11434"; [JsonPropertyName("ollamaEndpoint")] public string OllamaEndpoint { get; set; } = "http://localhost:11434";
@@ -608,6 +613,7 @@ public class LlmSettings
[JsonPropertyName("vllmEndpoint")] public string VllmEndpoint { get; set; } = ""; [JsonPropertyName("vllmEndpoint")] public string VllmEndpoint { get; set; } = "";
[JsonPropertyName("vllmApiKey")] public string VllmApiKey { get; set; } = ""; [JsonPropertyName("vllmApiKey")] public string VllmApiKey { get; set; } = "";
[JsonPropertyName("vllmModel")] public string VllmModel { get; set; } = ""; [JsonPropertyName("vllmModel")] public string VllmModel { get; set; } = "";
[JsonPropertyName("vllmAllowInsecureTls")] public bool VllmAllowInsecureTls { get; set; } = false;
[JsonPropertyName("geminiApiKey")] public string GeminiApiKey { get; set; } = ""; [JsonPropertyName("geminiApiKey")] public string GeminiApiKey { get; set; } = "";
[JsonPropertyName("geminiModel")] public string GeminiModel { get; set; } = "gemini-2.5-flash"; [JsonPropertyName("geminiModel")] public string GeminiModel { get; set; } = "gemini-2.5-flash";
@@ -636,6 +642,14 @@ public class LlmSettings
[JsonPropertyName("maxAgentIterations")] [JsonPropertyName("maxAgentIterations")]
public int MaxAgentIterations { get; set; } = 25; public int MaxAgentIterations { get; set; } = 25;
/// <summary>컨텍스트 사전 압축 활성화 여부.</summary>
[JsonPropertyName("enableProactiveContextCompact")]
public bool EnableProactiveContextCompact { get; set; } = true;
/// <summary>컨텍스트 압축 시작 임계치(%). 최대 토큰의 몇 %에서 압축 시작할지.</summary>
[JsonPropertyName("contextCompactTriggerPercent")]
public int ContextCompactTriggerPercent { get; set; } = 80;
/// <summary>도구 실패 시 최대 재시도 횟수 (Self-Reflection).</summary> /// <summary>도구 실패 시 최대 재시도 횟수 (Self-Reflection).</summary>
[JsonPropertyName("maxRetryOnError")] [JsonPropertyName("maxRetryOnError")]
public int MaxRetryOnError { get; set; } = 3; public int MaxRetryOnError { get; set; } = 3;
@@ -648,6 +662,10 @@ public class LlmSettings
[JsonPropertyName("agentLogLevel")] [JsonPropertyName("agentLogLevel")]
public string AgentLogLevel { get; set; } = "simple"; public string AgentLogLevel { get; set; } = "simple";
/// <summary>AX Agent UI 표현 수준. rich | balanced | simple</summary>
[JsonPropertyName("agentUiExpressionLevel")]
public string AgentUiExpressionLevel { get; set; } = "balanced";
/// <summary>계획 diff 심각도 중간 기준: 변경 개수 임계값.</summary> /// <summary>계획 diff 심각도 중간 기준: 변경 개수 임계값.</summary>
[JsonPropertyName("planDiffSeverityMediumCount")] [JsonPropertyName("planDiffSeverityMediumCount")]
public int PlanDiffSeverityMediumCount { get; set; } = 2; public int PlanDiffSeverityMediumCount { get; set; } = 2;
@@ -699,9 +717,9 @@ public class LlmSettings
[JsonPropertyName("lastActiveTab")] [JsonPropertyName("lastActiveTab")]
public string LastActiveTab { get; set; } = "Chat"; public string LastActiveTab { get; set; } = "Chat";
/// <summary>폴더 데이터 활용 모드. active(적극) | passive(소극) | none(미활용)</summary> /// <summary>폴더 데이터 활용 모드. none(미활용) | passive(소극) | active(적극)</summary>
[JsonPropertyName("folderDataUsage")] [JsonPropertyName("folderDataUsage")]
public string FolderDataUsage { get; set; } = "active"; public string FolderDataUsage { get; set; } = "none";
/// <summary> /// <summary>
/// 에이전트 의사결정 수준. AI가 중요한 작업 전에 사용자 확인을 요청하는 빈도. /// 에이전트 의사결정 수준. AI가 중요한 작업 전에 사용자 확인을 요청하는 빈도.
@@ -795,6 +813,10 @@ public class LlmSettings
[JsonPropertyName("enableChatRainbowGlow")] [JsonPropertyName("enableChatRainbowGlow")]
public bool EnableChatRainbowGlow { get; set; } = false; public bool EnableChatRainbowGlow { get; set; } = false;
/// <summary>AX Agent 전용 테마. system | light | dark</summary>
[JsonPropertyName("agentTheme")]
public string AgentTheme { get; set; } = "system";
// ─── 알림 ────────────────────────────────────────────────────────── // ─── 알림 ──────────────────────────────────────────────────────────
/// <summary>에이전트 작업 완료 시 시스템 알림 표시 여부. (Cowork/Code 공통)</summary> /// <summary>에이전트 작업 완료 시 시스템 알림 표시 여부. (Cowork/Code 공통)</summary>
@@ -866,7 +888,7 @@ public class LlmSettings
public List<string> DisabledTools { get; set; } = new(); public List<string> DisabledTools { get; set; } = new();
/// <summary> /// <summary>
/// 도구별 실행 권한 오버라이드. 키: 도구 이름, 값: "ask" | "auto" | "deny". /// 도구별 실행 권한 오버라이드. 키: 도구 이름 또는 tool@pattern, 값: 권한 모드.
/// 여기에 없는 도구는 전역 FilePermission 설정을 따릅니다. /// 여기에 없는 도구는 전역 FilePermission 설정을 따릅니다.
/// </summary> /// </summary>
[JsonPropertyName("toolPermissions")] [JsonPropertyName("toolPermissions")]
@@ -1125,6 +1147,10 @@ public class RegisteredModel
[JsonPropertyName("apiKey")] [JsonPropertyName("apiKey")]
public string ApiKey { get; set; } = ""; public string ApiKey { get; set; } = "";
/// <summary>이 모델에 한해 TLS 인증서 검증을 생략합니다(vLLM 사내 인증서 예외 대응).</summary>
[JsonPropertyName("allowInsecureTls")]
public bool AllowInsecureTls { get; set; } = false;
// ── CP4D (IBM Cloud Pak for Data) 인증 ────────────────────────────── // ── CP4D (IBM Cloud Pak for Data) 인증 ──────────────────────────────
/// <summary>인증 방식. bearer (기본) | cp4d</summary> /// <summary>인증 방식. bearer (기본) | cp4d</summary>

View File

@@ -95,8 +95,22 @@ public class AgentContext
private static readonly HashSet<string> DangerousAutoTools = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> DangerousAutoTools = new(StringComparer.OrdinalIgnoreCase)
{ {
"process", "process",
"build_run",
"spawn_agent", "spawn_agent",
"snippet_runner", "snippet_runner",
"test_loop",
};
private static readonly HashSet<string> WriteTools = new(StringComparer.OrdinalIgnoreCase)
{
"file_write", "file_edit", "file_manage",
"html_create", "markdown_create", "docx_create", "excel_create", "csv_create", "pptx_create",
"chart_create", "script_create", "document_assemble", "format_convert", "template_render", "checkpoint",
"todo_write", "skill_manager", "project_rule", "task_create", "task_update", "task_stop",
"team_create", "team_delete", "cron_create", "cron_delete", "zip",
};
private static readonly HashSet<string> ProcessLikeTools = new(StringComparer.OrdinalIgnoreCase)
{
"process", "build_run", "test_loop", "snippet_runner", "spawn_agent", "git_tool",
}; };
private readonly object _permissionLock = new(); private readonly object _permissionLock = new();
@@ -104,10 +118,10 @@ public class AgentContext
/// <summary>작업 폴더 경로.</summary> /// <summary>작업 폴더 경로.</summary>
public string WorkFolder { get; set; } = ""; public string WorkFolder { get; set; } = "";
/// <summary>파일 접근 권한. Ask | Plan | Auto | Deny</summary> /// <summary>파일 접근 권한. Default | AcceptEdits | Plan | BypassPermissions | DontAsk | Deny</summary>
public string Permission { get; init; } = "Ask"; public string Permission { get; init; } = "Default";
/// <summary>도구별 권한 오버라이드. 키: 도구명, 값: "ask" | "auto" | "deny".</summary> /// <summary>도구별 권한 오버라이드. 키: 도구명 또는 tool@pattern, 값: 권한 모드.</summary>
public Dictionary<string, string> ToolPermissions { get; init; } = new(); public Dictionary<string, string> ToolPermissions { get; init; } = new();
/// <summary>차단 경로 패턴 목록.</summary> /// <summary>차단 경로 패턴 목록.</summary>
@@ -201,22 +215,22 @@ public class AgentContext
var normalizedToolName = toolName.Trim(); var normalizedToolName = toolName.Trim();
if (TryResolvePatternPermission(toolName, target, out var patternPermission)) if (TryResolvePatternPermission(toolName, target, out var patternPermission))
return ApplyDangerousAutoGuard(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(patternPermission)); return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(patternPermission));
if (ToolPermissions.TryGetValue(toolName, out var toolPerm) && if (ToolPermissions.TryGetValue(toolName, out var toolPerm) &&
!string.IsNullOrWhiteSpace(toolPerm)) !string.IsNullOrWhiteSpace(toolPerm))
return ApplyDangerousAutoGuard(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm)); return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm));
if (ToolPermissions.TryGetValue("*", out var wildcardPerm) && if (ToolPermissions.TryGetValue("*", out var wildcardPerm) &&
!string.IsNullOrWhiteSpace(wildcardPerm)) !string.IsNullOrWhiteSpace(wildcardPerm))
return ApplyDangerousAutoGuard(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(wildcardPerm)); return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(wildcardPerm));
if (ToolPermissions.TryGetValue("default", out var defaultPerm) && if (ToolPermissions.TryGetValue("default", out var defaultPerm) &&
!string.IsNullOrWhiteSpace(defaultPerm)) !string.IsNullOrWhiteSpace(defaultPerm))
return ApplyDangerousAutoGuard(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(defaultPerm)); return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(defaultPerm));
var fallback = SensitiveTools.Contains(toolName) var fallback = SensitiveTools.Contains(toolName)
? PermissionModeCatalog.NormalizeGlobalMode(Permission) ? PermissionModeCatalog.NormalizeGlobalMode(Permission)
: PermissionModeCatalog.Auto; : PermissionModeCatalog.AcceptEdits;
return ApplyDangerousAutoGuard(normalizedToolName, fallback); return ResolveModeForTool(normalizedToolName, fallback);
} }
public async Task<bool> CheckToolPermissionAsync(string toolName, string target) public async Task<bool> CheckToolPermissionAsync(string toolName, string target)
@@ -256,6 +270,30 @@ public class AgentContext
var normalizedTool = toolName.Trim(); var normalizedTool = toolName.Trim();
var normalizedTarget = target.Trim(); var normalizedTarget = target.Trim();
foreach (var kv in ToolPermissions)
{
if (TryParsePatternRule(kv.Key, out var ruleTool, out var rulePattern)
&& string.Equals(ruleTool, normalizedTool, StringComparison.OrdinalIgnoreCase)
&& WildcardMatch(normalizedTarget, rulePattern)
&& PermissionModeCatalog.IsDeny(kv.Value))
{
permission = kv.Value.Trim();
return true;
}
}
foreach (var kv in ToolPermissions)
{
if (TryParsePatternRule(kv.Key, out var ruleTool, out var rulePattern)
&& string.Equals(ruleTool, "*", StringComparison.Ordinal)
&& WildcardMatch(normalizedTarget, rulePattern)
&& PermissionModeCatalog.IsDeny(kv.Value))
{
permission = kv.Value.Trim();
return true;
}
}
foreach (var kv in ToolPermissions) foreach (var kv in ToolPermissions)
{ {
if (TryParsePatternRule(kv.Key, out var ruleTool, out var rulePattern) if (TryParsePatternRule(kv.Key, out var ruleTool, out var rulePattern)
@@ -308,15 +346,63 @@ public class AgentContext
return Regex.IsMatch(input, regex, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); return Regex.IsMatch(input, regex, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
} }
private string ResolveModeForTool(string toolName, string mode)
{
if (string.IsNullOrWhiteSpace(toolName))
return mode;
var normalizedMode = PermissionModeCatalog.NormalizeGlobalMode(mode);
if (PermissionModeCatalog.IsDeny(normalizedMode))
return PermissionModeCatalog.Deny;
if (PermissionModeCatalog.IsBypassPermissions(normalizedMode) || PermissionModeCatalog.IsDontAsk(normalizedMode))
return normalizedMode;
if (PermissionModeCatalog.IsPlan(normalizedMode))
{
if (IsWriteTool(toolName))
return PermissionModeCatalog.Deny;
return IsProcessLikeTool(toolName)
? PermissionModeCatalog.Default
: PermissionModeCatalog.AcceptEdits;
}
if (PermissionModeCatalog.IsAcceptEdits(normalizedMode))
{
if (IsWriteTool(toolName))
return PermissionModeCatalog.AcceptEdits;
return IsProcessLikeTool(toolName)
? PermissionModeCatalog.Default
: ApplyDangerousAutoGuard(toolName, PermissionModeCatalog.AcceptEdits);
}
if (PermissionModeCatalog.IsDefault(normalizedMode))
{
if (!SensitiveTools.Contains(toolName))
return PermissionModeCatalog.AcceptEdits;
return ApplyDangerousAutoGuard(toolName, PermissionModeCatalog.Default);
}
return ApplyDangerousAutoGuard(toolName, normalizedMode);
}
private static bool IsWriteTool(string toolName) => WriteTools.Contains(toolName);
private static bool IsProcessLikeTool(string toolName) => ProcessLikeTools.Contains(toolName);
private string ApplyDangerousAutoGuard(string toolName, string permission) private string ApplyDangerousAutoGuard(string toolName, string permission)
{ {
if (string.IsNullOrWhiteSpace(toolName)) if (string.IsNullOrWhiteSpace(toolName))
return permission; return permission;
if (PermissionModeCatalog.IsAuto(permission) if (PermissionModeCatalog.IsAuto(permission)
&& PermissionModeCatalog.IsAuto(Permission) && !PermissionModeCatalog.IsBypassPermissions(permission)
&& !PermissionModeCatalog.IsDontAsk(permission)
&& DangerousAutoTools.Contains(toolName)) && DangerousAutoTools.Contains(toolName))
return "ask"; return PermissionModeCatalog.Default;
return permission; return permission;
} }

View File

@@ -6,16 +6,22 @@
/// </summary> /// </summary>
public static class PermissionModeCatalog public static class PermissionModeCatalog
{ {
public const string Ask = "Ask"; public const string Default = "Default";
public const string Ask = Default;
public const string AcceptEdits = "AcceptEdits";
public const string Plan = "Plan"; public const string Plan = "Plan";
public const string Auto = "Auto"; public const string BypassPermissions = "BypassPermissions";
public const string DontAsk = "DontAsk";
public const string Auto = AcceptEdits;
public const string Deny = "Deny"; public const string Deny = "Deny";
public static readonly IReadOnlyList<string> UserSelectableModes = new[] public static readonly IReadOnlyList<string> UserSelectableModes = new[]
{ {
Ask, Default,
AcceptEdits,
Plan, Plan,
Auto, BypassPermissions,
DontAsk,
Deny, Deny,
}; };
@@ -26,15 +32,23 @@ public static class PermissionModeCatalog
public static string NormalizeGlobalMode(string? value) public static string NormalizeGlobalMode(string? value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
return Ask; return Default;
return value.Trim().ToLowerInvariant() switch return value.Trim().ToLowerInvariant() switch
{ {
"default" => Default,
"ask" => Ask, "ask" => Ask,
"plan" => Plan, "acceptedits" => AcceptEdits,
"accept" => AcceptEdits,
"auto" => Auto, "auto" => Auto,
"plan" => Plan,
"bypasspermissions" => BypassPermissions,
"bypass" => BypassPermissions,
"dontask" => DontAsk,
"don't ask" => DontAsk,
"allow" => AcceptEdits,
"deny" => Deny, "deny" => Deny,
_ => Ask, _ => Default,
}; };
} }
@@ -47,14 +61,32 @@ public static class PermissionModeCatalog
var mode = NormalizeGlobalMode(value); var mode = NormalizeGlobalMode(value);
return mode switch return mode switch
{ {
Auto => "auto", AcceptEdits => AcceptEdits,
Deny => "deny", Plan => Plan,
_ => "ask", BypassPermissions => BypassPermissions,
DontAsk => DontAsk,
Deny => Deny,
_ => Default,
}; };
} }
public static bool IsAuto(string? mode) => public static bool IsAuto(string? mode) =>
string.Equals(NormalizeGlobalMode(mode), Auto, StringComparison.OrdinalIgnoreCase); IsAcceptEdits(mode) || IsBypassPermissions(mode) || IsDontAsk(mode);
public static bool IsDefault(string? mode) =>
string.Equals(NormalizeGlobalMode(mode), Default, StringComparison.OrdinalIgnoreCase);
public static bool IsAcceptEdits(string? mode) =>
string.Equals(NormalizeGlobalMode(mode), AcceptEdits, StringComparison.OrdinalIgnoreCase);
public static bool IsPlan(string? mode) =>
string.Equals(NormalizeGlobalMode(mode), Plan, StringComparison.OrdinalIgnoreCase);
public static bool IsBypassPermissions(string? mode) =>
string.Equals(NormalizeGlobalMode(mode), BypassPermissions, StringComparison.OrdinalIgnoreCase);
public static bool IsDontAsk(string? mode) =>
string.Equals(NormalizeGlobalMode(mode), DontAsk, StringComparison.OrdinalIgnoreCase);
public static bool IsDeny(string? mode) => public static bool IsDeny(string? mode) =>
string.Equals(NormalizeGlobalMode(mode), Deny, StringComparison.OrdinalIgnoreCase); string.Equals(NormalizeGlobalMode(mode), Deny, StringComparison.OrdinalIgnoreCase);
@@ -62,7 +94,24 @@ public static class PermissionModeCatalog
public static bool RequiresUserApproval(string? mode) public static bool RequiresUserApproval(string? mode)
{ {
var normalized = NormalizeGlobalMode(mode); var normalized = NormalizeGlobalMode(mode);
return !string.Equals(normalized, Auto, StringComparison.OrdinalIgnoreCase) return !IsAcceptEdits(normalized)
&& !IsBypassPermissions(normalized)
&& !IsDontAsk(normalized)
&& !string.Equals(normalized, Deny, StringComparison.OrdinalIgnoreCase); && !string.Equals(normalized, Deny, StringComparison.OrdinalIgnoreCase);
} }
public static string ToDisplayLabel(string? mode)
{
var normalized = NormalizeGlobalMode(mode);
return normalized switch
{
Default => "소극 활용",
AcceptEdits => "적극 활용",
Plan => "Plan",
BypassPermissions => "Bypass",
DontAsk => "DontAsk",
Deny => "활용하지 않음",
_ => normalized,
};
}
} }

View File

@@ -30,7 +30,7 @@ public sealed class AppStateService
public sealed class PermissionPolicyState public sealed class PermissionPolicyState
{ {
public string FilePermission { get; set; } = "Ask"; public string FilePermission { get; set; } = "Default";
public string AgentDecisionLevel { get; set; } = "detailed"; public string AgentDecisionLevel { get; set; } = "detailed";
public string PlanMode { get; set; } = "off"; public string PlanMode { get; set; } = "off";
public int ToolOverrideCount { get; set; } public int ToolOverrideCount { get; set; }
@@ -506,7 +506,9 @@ public sealed class AppStateService
if (string.IsNullOrWhiteSpace(conversation?.Permission)) if (string.IsNullOrWhiteSpace(conversation?.Permission))
effective = defaultMode; effective = defaultMode;
var risk = string.Equals(effective, PermissionModeCatalog.Auto, StringComparison.OrdinalIgnoreCase) var risk = PermissionModeCatalog.IsBypassPermissions(effective) || PermissionModeCatalog.IsDontAsk(effective)
? "critical"
: PermissionModeCatalog.IsAcceptEdits(effective)
? "high" ? "high"
: string.Equals(effective, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase) : string.Equals(effective, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase)
? "locked" ? "locked"
@@ -516,9 +518,11 @@ public sealed class AppStateService
var description = effective switch var description = effective switch
{ {
"Auto" => "파일 작업을 자동 허용합니다.", "AcceptEdits" => "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다.",
"Deny" => "파일 작업을 차단합니다.", "Deny" => "파일 읽기만 허용하고 생성/수정/삭제는 차단합니다.",
"Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.", "Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.",
"BypassPermissions" => "모든 권한 확인을 생략합니다. 주의해서 사용해야 합니다.",
"DontAsk" => "권한 질문 없이 진행합니다. 자동 실행 범위를 반드시 점검해야 합니다.",
_ => "파일 작업 전마다 사용자 확인을 요청합니다.", _ => "파일 작업 전마다 사용자 확인을 요청합니다.",
}; };

View File

@@ -0,0 +1,383 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
public partial class AgentSettingsWindow : Window
{
private const string UnifiedAdminPassword = "axgo123!";
private readonly SettingsService _settings;
private readonly LlmSettings _llm;
private string _permissionMode = PermissionModeCatalog.Ask;
private string _planMode = "off";
private string _reasoningMode = "detailed";
private string _folderDataUsage = "active";
private string _operationMode = OperationModePolicy.InternalMode;
public AgentSettingsWindow(SettingsService settings)
{
_settings = settings;
_llm = _settings.Settings.Llm;
InitializeComponent();
LoadFromSettings();
}
private void LoadFromSettings()
{
ModelInput.Text = _llm.Model ?? "";
_permissionMode = PermissionModeCatalog.NormalizeGlobalMode(_llm.FilePermission);
_planMode = string.IsNullOrWhiteSpace(_llm.PlanMode) ? "off" : _llm.PlanMode;
_reasoningMode = string.IsNullOrWhiteSpace(_llm.AgentDecisionLevel) ? "detailed" : _llm.AgentDecisionLevel;
_folderDataUsage = string.IsNullOrWhiteSpace(_llm.FolderDataUsage) ? "active" : _llm.FolderDataUsage;
_operationMode = OperationModePolicy.Normalize(_settings.Settings.OperationMode);
ChkVllmAllowInsecureTls.IsChecked = _llm.VllmAllowInsecureTls;
ChkEnableProactiveCompact.IsChecked = _llm.EnableProactiveContextCompact;
TxtContextCompactTriggerPercent.Text = Math.Clamp(_llm.ContextCompactTriggerPercent, 10, 95).ToString();
TxtMaxContextTokens.Text = Math.Max(1024, _llm.MaxContextTokens).ToString();
TxtMaxRetryOnError.Text = Math.Clamp(_llm.MaxRetryOnError, 0, 10).ToString();
ChkEnableSkillSystem.IsChecked = _llm.EnableSkillSystem;
ChkEnableToolHooks.IsChecked = _llm.EnableToolHooks;
ChkEnableHookInputMutation.IsChecked = _llm.EnableHookInputMutation;
ChkEnableHookPermissionUpdate.IsChecked = _llm.EnableHookPermissionUpdate;
ChkEnableCoworkVerification.IsChecked = _llm.EnableCoworkVerification;
ChkEnableCodeVerification.IsChecked = _llm.Code.EnableCodeVerification;
ChkEnableParallelTools.IsChecked = _llm.EnableParallelTools;
RefreshServiceCards();
RefreshThemeCards();
RefreshModeLabels();
BuildModelChips();
}
private void RefreshThemeCards()
{
var selected = (_llm.AgentTheme ?? "system").ToLowerInvariant();
SetCardSelection(ThemeSystemCard, selected == "system");
SetCardSelection(ThemeLightCard, selected == "light");
SetCardSelection(ThemeDarkCard, selected == "dark");
}
private void RefreshServiceCards()
{
var service = (_llm.Service ?? "ollama").ToLowerInvariant();
SetCardSelection(SvcOllamaCard, service == "ollama");
SetCardSelection(SvcVllmCard, service == "vllm");
SetCardSelection(SvcGeminiCard, service == "gemini");
SetCardSelection(SvcClaudeCard, service is "claude" or "sigmoid");
}
private void RefreshModeLabels()
{
BtnOperationMode.Content = BuildOperationModeLabel(_operationMode);
BtnPermissionMode.Content = PermissionModeCatalog.ToDisplayLabel(_permissionMode);
BtnPlanMode.Content = _planMode;
BtnReasoningMode.Content = _reasoningMode;
BtnFolderDataUsage.Content = _folderDataUsage;
AdvancedPanel.Visibility = Visibility.Visible;
}
private static string BuildOperationModeLabel(string mode)
{
return OperationModePolicy.Normalize(mode) == OperationModePolicy.ExternalMode
? "사외 모드"
: "사내 모드";
}
private void SetCardSelection(Border border, bool selected)
{
var accent = TryFindResource("AccentColor") as Brush ?? Brushes.DodgerBlue;
var normal = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
border.BorderBrush = selected ? accent : normal;
border.Background = selected
? (TryFindResource("HintBackground") as Brush ?? Brushes.Transparent)
: Brushes.Transparent;
}
private void BuildModelChips()
{
ModelChipPanel.Children.Clear();
var models = GetModelCandidates(_llm.Service);
foreach (var model in models)
{
var captured = model;
var border = new Border
{
Cursor = Cursors.Hand,
CornerRadius = new CornerRadius(10),
BorderThickness = new Thickness(1),
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
Background = Brushes.Transparent,
Padding = new Thickness(10, 6, 10, 6),
Margin = new Thickness(0, 0, 8, 8),
Child = new TextBlock
{
Text = model,
FontSize = 11,
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
},
};
border.MouseLeftButtonUp += (_, _) => ModelInput.Text = captured;
ModelChipPanel.Children.Add(border);
}
}
private List<string> GetModelCandidates(string? service)
{
var key = (service ?? "ollama").ToLowerInvariant();
var result = new List<string>();
foreach (var m in _llm.RegisteredModels)
{
if (!string.Equals(m.Service, key, StringComparison.OrdinalIgnoreCase))
continue;
if (!string.IsNullOrWhiteSpace(m.Alias) && !result.Contains(m.Alias))
result.Add(m.Alias);
}
void AddIf(string? value)
{
if (!string.IsNullOrWhiteSpace(value) && !result.Contains(value))
result.Add(value);
}
if (key == "ollama") AddIf(_llm.OllamaModel);
else if (key == "vllm") AddIf(_llm.VllmModel);
else if (key == "gemini") AddIf(_llm.GeminiModel);
else AddIf(_llm.ClaudeModel);
return result;
}
private void SetService(string service)
{
_llm.Service = service;
RefreshServiceCards();
BuildModelChips();
}
private void SetTheme(string theme)
{
_llm.AgentTheme = theme;
RefreshThemeCards();
}
private static string CycleOperationMode(string current)
{
return OperationModePolicy.Normalize(current) == OperationModePolicy.ExternalMode
? OperationModePolicy.InternalMode
: OperationModePolicy.ExternalMode;
}
private bool PromptPasswordDialog(string title, string header, string message)
{
var bgBrush = TryFindResource("LauncherBackground") as Brush ?? Brushes.White;
var fgBrush = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var subFgBrush = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
var itemBg = TryFindResource("ItemBackground") as Brush ?? Brushes.WhiteSmoke;
var dlg = new Window
{
Title = title,
Width = 340,
SizeToContent = SizeToContent.Height,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
Owner = this,
ResizeMode = ResizeMode.NoResize,
WindowStyle = WindowStyle.None,
AllowsTransparency = true,
Background = Brushes.Transparent,
ShowInTaskbar = false,
};
var border = new Border
{
Background = bgBrush,
CornerRadius = new CornerRadius(12),
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
Padding = new Thickness(20),
};
var stack = new StackPanel();
stack.Children.Add(new TextBlock
{
Text = header,
FontSize = 15,
FontWeight = FontWeights.SemiBold,
Foreground = fgBrush,
Margin = new Thickness(0, 0, 0, 12),
});
stack.Children.Add(new TextBlock
{
Text = message,
FontSize = 12,
Foreground = subFgBrush,
Margin = new Thickness(0, 0, 0, 6),
});
var pwBox = new PasswordBox
{
FontSize = 14,
Padding = new Thickness(8, 6, 8, 6),
Background = itemBg,
Foreground = fgBrush,
BorderBrush = borderBrush,
PasswordChar = '*',
};
stack.Children.Add(pwBox);
var btnRow = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Margin = new Thickness(0, 16, 0, 0),
};
var cancelBtn = new Button { Content = "취소", Padding = new Thickness(16, 6, 16, 6), Margin = new Thickness(0, 0, 8, 0) };
cancelBtn.Click += (_, _) => dlg.DialogResult = false;
btnRow.Children.Add(cancelBtn);
var okBtn = new Button { Content = "확인", Padding = new Thickness(16, 6, 16, 6), IsDefault = true };
okBtn.Click += (_, _) =>
{
if (pwBox.Password == UnifiedAdminPassword)
dlg.DialogResult = true;
else
{
pwBox.Clear();
pwBox.Focus();
}
};
btnRow.Children.Add(okBtn);
stack.Children.Add(btnRow);
border.Child = stack;
dlg.Content = border;
dlg.Loaded += (_, _) => pwBox.Focus();
return dlg.ShowDialog() == true;
}
private void ThemeSystemCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetTheme("system");
private void ThemeLightCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetTheme("light");
private void ThemeDarkCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetTheme("dark");
private void SvcOllamaCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetService("ollama");
private void SvcVllmCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetService("vllm");
private void SvcGeminiCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetService("gemini");
private void SvcClaudeCard_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => SetService("claude");
private void BtnOperationMode_Click(object sender, RoutedEventArgs e)
{
var next = CycleOperationMode(_operationMode);
if (!PromptPasswordDialog(
"운영 모드 변경",
"사내/사외 모드 변경",
"비밀번호를 입력하세요:"))
{
return;
}
_operationMode = next;
RefreshModeLabels();
}
private void BtnPermissionMode_Click(object sender, RoutedEventArgs e)
{
_permissionMode = PermissionModeCatalog.NormalizeGlobalMode(_permissionMode) switch
{
PermissionModeCatalog.Deny => PermissionModeCatalog.Default,
PermissionModeCatalog.Default => PermissionModeCatalog.AcceptEdits,
PermissionModeCatalog.AcceptEdits => PermissionModeCatalog.Plan,
PermissionModeCatalog.Plan => PermissionModeCatalog.BypassPermissions,
PermissionModeCatalog.BypassPermissions => PermissionModeCatalog.DontAsk,
_ => PermissionModeCatalog.Deny,
};
RefreshModeLabels();
}
private void BtnPlanMode_Click(object sender, RoutedEventArgs e)
{
_planMode = _planMode switch
{
"off" => "auto",
"auto" => "always",
_ => "off",
};
RefreshModeLabels();
}
private void BtnReasoningMode_Click(object sender, RoutedEventArgs e)
{
_reasoningMode = _reasoningMode switch
{
"minimal" => "normal",
"normal" => "detailed",
_ => "minimal",
};
RefreshModeLabels();
}
private void BtnFolderDataUsage_Click(object sender, RoutedEventArgs e)
{
_folderDataUsage = _folderDataUsage switch
{
"none" => "passive",
"passive" => "active",
_ => "none",
};
RefreshModeLabels();
}
private void BtnSave_Click(object sender, RoutedEventArgs e)
{
_llm.Model = ModelInput.Text.Trim();
_llm.FilePermission = _permissionMode;
_llm.PlanMode = _planMode;
_llm.AgentDecisionLevel = _reasoningMode;
_llm.FolderDataUsage = _folderDataUsage;
_llm.AgentUiExpressionLevel = "rich";
_llm.VllmAllowInsecureTls = ChkVllmAllowInsecureTls.IsChecked == true;
_llm.EnableProactiveContextCompact = ChkEnableProactiveCompact.IsChecked == true;
_llm.ContextCompactTriggerPercent = ParseInt(TxtContextCompactTriggerPercent.Text, 80, 10, 95);
_llm.MaxContextTokens = ParseInt(TxtMaxContextTokens.Text, 4096, 1024, 200000);
_llm.MaxRetryOnError = ParseInt(TxtMaxRetryOnError.Text, 3, 0, 10);
_llm.EnableSkillSystem = ChkEnableSkillSystem.IsChecked == true;
_llm.EnableToolHooks = ChkEnableToolHooks.IsChecked == true;
_llm.EnableHookInputMutation = ChkEnableHookInputMutation.IsChecked == true;
_llm.EnableHookPermissionUpdate = ChkEnableHookPermissionUpdate.IsChecked == true;
_llm.EnableCoworkVerification = ChkEnableCoworkVerification.IsChecked == true;
_llm.Code.EnableCodeVerification = ChkEnableCodeVerification.IsChecked == true;
_llm.EnableParallelTools = ChkEnableParallelTools.IsChecked == true;
_settings.Settings.OperationMode = OperationModePolicy.Normalize(_operationMode);
_settings.Save();
DialogResult = true;
Close();
}
private void BtnOpenFullSettings_Click(object sender, RoutedEventArgs e)
{
if (System.Windows.Application.Current is App app)
app.OpenSettingsFromChat();
}
private void BtnClose_MouseLeftButtonUp(object sender, MouseButtonEventArgs e) => Close();
private static int ParseInt(string? text, int fallback, int min, int max)
{
if (!int.TryParse(text, out var value))
value = fallback;
return Math.Clamp(value, min, max);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
namespace AxCopilot.Views;
internal sealed class SlashPaletteState
{
public List<(string Cmd, string Label, bool IsSkill)> Matches { get; set; } = [];
public int SelectedIndex { get; set; } = -1;
public string? ActiveCommand { get; set; }
public void ResetMatches(List<(string Cmd, string Label, bool IsSkill)> matches)
{
Matches = matches;
SelectedIndex = -1;
}
public void Clear()
{
Matches = [];
SelectedIndex = -1;
ActiveCommand = null;
}
}