권한 코어를 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()
{
var settings = new SettingsService();
settings.Settings.Llm.FilePermission = "Auto";
settings.Settings.Llm.FilePermission = "AcceptEdits";
settings.Settings.Llm.AgentDecisionLevel = "normal";
settings.Settings.Llm.PlanMode = "always";
settings.Settings.Llm.ToolPermissions["process"] = "Deny";
@@ -29,7 +29,7 @@ public class AppStateServiceTests
state.LoadFromSettings(settings);
state.Permissions.FilePermission.Should().Be("Auto");
state.Permissions.FilePermission.Should().Be("AcceptEdits");
state.Permissions.AgentDecisionLevel.Should().Be("normal");
state.Permissions.PlanMode.Should().Be("always");
state.Permissions.ToolOverrideCount.Should().Be(1);
@@ -346,14 +346,14 @@ public class AppStateServiceTests
{
var state = new AppStateService();
var settings = new SettingsService();
settings.Settings.Llm.FilePermission = "Ask";
settings.Settings.Llm.FilePermission = "Default";
settings.Settings.Llm.ToolPermissions["process"] = "Deny";
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.DefaultMode.Should().Be("Ask");
summary.EffectiveMode.Should().Be("AcceptEdits");
summary.DefaultMode.Should().Be("Default");
summary.OverrideCount.Should().Be(1);
summary.RiskLevel.Should().Be("high");
summary.TopOverrides.Should().ContainSingle();

View File

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

View File

@@ -241,6 +241,10 @@ public class LauncherSettings
/// <summary>독 바 마지막 위치 Y. -1이면 하단.</summary>
[JsonPropertyName("dockBarTop")]
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>
@@ -591,14 +595,15 @@ public class LlmSettings
/// <summary>
/// 파일 접근 권한 수준.
/// Ask = 매번 확인 | Plan = 계획/승인 중심 | Auto = 자동 허용 | Deny = 차단
/// Default = 매번 확인 | AcceptEdits = 파일 편집 자동 허용 | Plan = 계획/승인 중심
/// BypassPermissions = 모든 확인 생략 | DontAsk = 권한 질문 없이 진행 | Deny = 읽기 전용
/// </summary>
[JsonPropertyName("filePermission")]
public string FilePermission { get; set; } = "Ask";
public string FilePermission { get; set; } = "Default";
/// <summary>Cowork/Code 탭의 기본 파일 접근 권한. 탭 전환 시 자동 적용.</summary>
[JsonPropertyName("defaultAgentPermission")]
public string DefaultAgentPermission { get; set; } = "Ask";
public string DefaultAgentPermission { get; set; } = "Default";
// ── 서비스별 독립 설정 ──────────────────────────────────────
[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("vllmApiKey")] public string VllmApiKey { 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("geminiModel")] public string GeminiModel { get; set; } = "gemini-2.5-flash";
@@ -636,6 +642,14 @@ public class LlmSettings
[JsonPropertyName("maxAgentIterations")]
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>
[JsonPropertyName("maxRetryOnError")]
public int MaxRetryOnError { get; set; } = 3;
@@ -648,6 +662,10 @@ public class LlmSettings
[JsonPropertyName("agentLogLevel")]
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>
[JsonPropertyName("planDiffSeverityMediumCount")]
public int PlanDiffSeverityMediumCount { get; set; } = 2;
@@ -699,9 +717,9 @@ public class LlmSettings
[JsonPropertyName("lastActiveTab")]
public string LastActiveTab { get; set; } = "Chat";
/// <summary>폴더 데이터 활용 모드. active(적극) | passive(소극) | none(미활용)</summary>
/// <summary>폴더 데이터 활용 모드. none(미활용) | passive(소극) | active(적극)</summary>
[JsonPropertyName("folderDataUsage")]
public string FolderDataUsage { get; set; } = "active";
public string FolderDataUsage { get; set; } = "none";
/// <summary>
/// 에이전트 의사결정 수준. AI가 중요한 작업 전에 사용자 확인을 요청하는 빈도.
@@ -795,6 +813,10 @@ public class LlmSettings
[JsonPropertyName("enableChatRainbowGlow")]
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>
@@ -866,7 +888,7 @@ public class LlmSettings
public List<string> DisabledTools { get; set; } = new();
/// <summary>
/// 도구별 실행 권한 오버라이드. 키: 도구 이름, 값: "ask" | "auto" | "deny".
/// 도구별 실행 권한 오버라이드. 키: 도구 이름 또는 tool@pattern, 값: 권한 모드.
/// 여기에 없는 도구는 전역 FilePermission 설정을 따릅니다.
/// </summary>
[JsonPropertyName("toolPermissions")]
@@ -1125,6 +1147,10 @@ public class RegisteredModel
[JsonPropertyName("apiKey")]
public string ApiKey { get; set; } = "";
/// <summary>이 모델에 한해 TLS 인증서 검증을 생략합니다(vLLM 사내 인증서 예외 대응).</summary>
[JsonPropertyName("allowInsecureTls")]
public bool AllowInsecureTls { get; set; } = false;
// ── CP4D (IBM Cloud Pak for Data) 인증 ──────────────────────────────
/// <summary>인증 방식. bearer (기본) | cp4d</summary>

View File

@@ -95,8 +95,22 @@ public class AgentContext
private static readonly HashSet<string> DangerousAutoTools = new(StringComparer.OrdinalIgnoreCase)
{
"process",
"build_run",
"spawn_agent",
"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();
@@ -104,10 +118,10 @@ public class AgentContext
/// <summary>작업 폴더 경로.</summary>
public string WorkFolder { get; set; } = "";
/// <summary>파일 접근 권한. Ask | Plan | Auto | Deny</summary>
public string Permission { get; init; } = "Ask";
/// <summary>파일 접근 권한. Default | AcceptEdits | Plan | BypassPermissions | DontAsk | Deny</summary>
public string Permission { get; init; } = "Default";
/// <summary>도구별 권한 오버라이드. 키: 도구명, 값: "ask" | "auto" | "deny".</summary>
/// <summary>도구별 권한 오버라이드. 키: 도구명 또는 tool@pattern, 값: 권한 모드.</summary>
public Dictionary<string, string> ToolPermissions { get; init; } = new();
/// <summary>차단 경로 패턴 목록.</summary>
@@ -201,22 +215,22 @@ public class AgentContext
var normalizedToolName = toolName.Trim();
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) &&
!string.IsNullOrWhiteSpace(toolPerm))
return ApplyDangerousAutoGuard(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm));
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(toolPerm));
if (ToolPermissions.TryGetValue("*", out var wildcardPerm) &&
!string.IsNullOrWhiteSpace(wildcardPerm))
return ApplyDangerousAutoGuard(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(wildcardPerm));
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(wildcardPerm));
if (ToolPermissions.TryGetValue("default", out var defaultPerm) &&
!string.IsNullOrWhiteSpace(defaultPerm))
return ApplyDangerousAutoGuard(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(defaultPerm));
return ResolveModeForTool(normalizedToolName, PermissionModeCatalog.NormalizeToolOverride(defaultPerm));
var fallback = SensitiveTools.Contains(toolName)
? PermissionModeCatalog.NormalizeGlobalMode(Permission)
: PermissionModeCatalog.Auto;
return ApplyDangerousAutoGuard(normalizedToolName, fallback);
: PermissionModeCatalog.AcceptEdits;
return ResolveModeForTool(normalizedToolName, fallback);
}
public async Task<bool> CheckToolPermissionAsync(string toolName, string target)
@@ -256,6 +270,30 @@ public class AgentContext
var normalizedTool = toolName.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)
{
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);
}
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)
{
if (string.IsNullOrWhiteSpace(toolName))
return permission;
if (PermissionModeCatalog.IsAuto(permission)
&& PermissionModeCatalog.IsAuto(Permission)
&& !PermissionModeCatalog.IsBypassPermissions(permission)
&& !PermissionModeCatalog.IsDontAsk(permission)
&& DangerousAutoTools.Contains(toolName))
return "ask";
return PermissionModeCatalog.Default;
return permission;
}

View File

@@ -6,16 +6,22 @@
/// </summary>
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 Auto = "Auto";
public const string BypassPermissions = "BypassPermissions";
public const string DontAsk = "DontAsk";
public const string Auto = AcceptEdits;
public const string Deny = "Deny";
public static readonly IReadOnlyList<string> UserSelectableModes = new[]
{
Ask,
Default,
AcceptEdits,
Plan,
Auto,
BypassPermissions,
DontAsk,
Deny,
};
@@ -26,15 +32,23 @@ public static class PermissionModeCatalog
public static string NormalizeGlobalMode(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return Ask;
return Default;
return value.Trim().ToLowerInvariant() switch
{
"default" => Default,
"ask" => Ask,
"plan" => Plan,
"acceptedits" => AcceptEdits,
"accept" => AcceptEdits,
"auto" => Auto,
"plan" => Plan,
"bypasspermissions" => BypassPermissions,
"bypass" => BypassPermissions,
"dontask" => DontAsk,
"don't ask" => DontAsk,
"allow" => AcceptEdits,
"deny" => Deny,
_ => Ask,
_ => Default,
};
}
@@ -47,14 +61,32 @@ public static class PermissionModeCatalog
var mode = NormalizeGlobalMode(value);
return mode switch
{
Auto => "auto",
Deny => "deny",
_ => "ask",
AcceptEdits => AcceptEdits,
Plan => Plan,
BypassPermissions => BypassPermissions,
DontAsk => DontAsk,
Deny => Deny,
_ => Default,
};
}
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) =>
string.Equals(NormalizeGlobalMode(mode), Deny, StringComparison.OrdinalIgnoreCase);
@@ -62,7 +94,24 @@ public static class PermissionModeCatalog
public static bool RequiresUserApproval(string? 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);
}
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 string FilePermission { get; set; } = "Ask";
public string FilePermission { get; set; } = "Default";
public string AgentDecisionLevel { get; set; } = "detailed";
public string PlanMode { get; set; } = "off";
public int ToolOverrideCount { get; set; }
@@ -506,7 +506,9 @@ public sealed class AppStateService
if (string.IsNullOrWhiteSpace(conversation?.Permission))
effective = defaultMode;
var risk = string.Equals(effective, PermissionModeCatalog.Auto, StringComparison.OrdinalIgnoreCase)
var risk = PermissionModeCatalog.IsBypassPermissions(effective) || PermissionModeCatalog.IsDontAsk(effective)
? "critical"
: PermissionModeCatalog.IsAcceptEdits(effective)
? "high"
: string.Equals(effective, PermissionModeCatalog.Deny, StringComparison.OrdinalIgnoreCase)
? "locked"
@@ -516,9 +518,11 @@ public sealed class AppStateService
var description = effective switch
{
"Auto" => "파일 작업을 자동 허용합니다.",
"Deny" => "파일 작업을 차단합니다.",
"AcceptEdits" => "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다.",
"Deny" => "파일 읽기만 허용하고 생성/수정/삭제는 차단합니다.",
"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;
}
}