From f4351aa0eb9e58bc5647da1bb4f087907b8c1e67 Mon Sep 17 00:00:00 2001 From: lacvet Date: Wed, 15 Apr 2026 16:34:34 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=B2=B4=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EC=82=AC=EB=82=B4=20=EB=AA=A8=EB=93=9C=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EA=B3=A0=20=EC=8B=A4=ED=96=89=20=EB=8B=A8=EC=9C=84=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20=EB=B2=94=EC=9C=84=EB=A5=BC=20=EB=B0=94=EB=A1=9C?= =?UTF-8?q?=EC=9E=A1=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사내 모드에서 process/build_run/open_external 경로의 외부 접근 차단 범위를 강화했습니다. http_tool과 외부 URI 차단에 더해 curl, Invoke-WebRequest 같은 네트워크성 명령과 build_run custom 실행을 내부 정책으로 막아 실제 동작이 정책 선언과 더 가깝게 맞춰지도록 했습니다. ChatWindow의 '이번 실행 동안 허용' 승인 규칙을 run-scope로 변경했습니다. 탭 실행 시작과 종료 시 승인 캐시를 초기화하고 같은 실행 안에서만 동일 범위 접근을 재질문 없이 재사용하도록 정리해 창 수명 동안 규칙이 남던 문제를 줄였습니다. 권한 건너뛰기 관련 UI/상태 문구를 실제 동작과 맞췄고, OperationModePolicyTests·OperationModeReadinessTests·AgentLoopE2ETests·LlmOperationModeTests를 통해 권한 정책과 사내 모드 차단 회귀를 검증했습니다. dotnet build 경고 0 / 오류 0, 권한 관련 테스트 49건 통과를 확인했습니다. --- README.md | 8 +++ docs/DEVELOPMENT.md | 34 ++++++++++ .../Services/OperationModePolicyTests.cs | 57 +++++++++++++++++ .../Services/OperationModeReadinessTests.cs | 17 +++++ src/AxCopilot/Models/AppSettings.cs | 2 +- src/AxCopilot/Services/Agent/BuildRunTool.cs | 4 ++ .../Services/Agent/OpenExternalTool.cs | 9 ++- .../PermissionModePresentationCatalog.cs | 10 +-- src/AxCopilot/Services/Agent/ProcessTool.cs | 4 ++ src/AxCopilot/Services/AppStateService.cs | 2 +- src/AxCopilot/Services/OperationModePolicy.cs | 64 ++++++++++++++++++- .../ChatWindow.PermissionPresentation.cs | 2 +- src/AxCopilot/Views/ChatWindow.xaml.cs | 42 ++++++++++-- 13 files changed, 234 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 3a7fb70..a5e3fc3 100644 --- a/README.md +++ b/README.md @@ -2130,3 +2130,11 @@ MIT License - 테스트: [ChatWindowSlashPolicyTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Views/ChatWindowSlashPolicyTests.cs)에 라이브 카드 대상 탭 회귀 검증 추가 - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_live_card_restore\\ -p:IntermediateOutputPath=obj\\verify_live_card_restore\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "ChatWindowSlashPolicyTests" -p:OutputPath=bin\\verify_live_card_restore_tests\\ -p:IntermediateOutputPath=obj\\verify_live_card_restore_tests\\` 통과 49 +업데이트: 2026-04-15 16:30 (KST) +- 권한 체계 정리 1차를 반영했습니다. 사내 모드에서는 `http_tool`과 외부 URI뿐 아니라 `process`, `build_run` 경로의 명백한 네트워크성 명령도 차단하도록 [OperationModePolicy.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/OperationModePolicy.cs), [ProcessTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/ProcessTool.cs), [BuildRunTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/BuildRunTool.cs), [OpenExternalTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/OpenExternalTool.cs)를 보강했습니다. +- `이번 실행 동안 허용`은 이제 실제 실행(run) 단위로만 유지됩니다. [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)는 탭별 실행 시작/종료 시 승인 규칙을 초기화하고, 같은 실행 안에서만 경로 승인 재사용이 일어나도록 정리했습니다. +- 권한 설명 문구도 실제 동작과 맞췄습니다. [PermissionModePresentationCatalog.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs), [ChatWindow.PermissionPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs), [AppStateService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AppStateService.cs)는 `권한 건너뛰기`가 사내 모드의 워크스페이스 외부 접근까지 무조건 자동 허용하는 것은 아니라는 점을 명시합니다. +- 검증: + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_permission_policy_cleanup\\ -p:IntermediateOutputPath=obj\\verify_permission_policy_cleanup\\` 경고 0 / 오류 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "OperationModePolicyTests|OperationModeReadinessTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_permission_policy_cleanup_tests\\ -p:IntermediateOutputPath=obj\\verify_permission_policy_cleanup_tests\\` 통과 46 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "LlmOperationModeTests" -p:OutputPath=bin\\verify_permission_policy_llm_tests\\ -p:IntermediateOutputPath=obj\\verify_permission_policy_llm_tests\\` 통과 3 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c025f90..86ad701 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1436,3 +1436,37 @@ UI ?붿옄???€洹쒕え 由ы뙥?좊쭅 ???꾪뿕 ?묒뾽 ??湲곕줉???덉쟾 - `src/AxCopilot.Tests/Services/AgentLoopE2ETests.cs`에 세 가지 회귀를 추가했습니다. 대화 워크스페이스가 stale settings 폴더보다 우선 적용되는지, 사내 모드 + BypassPermissions에서 워크스페이스 내부 쓰기가 승인 없이 허용되는지, 외부 경로 쓰기는 반드시 승인 콜백을 타는지를 각각 검증합니다. - 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_workspace_permission_fix\\ -p:IntermediateOutputPath=obj\\verify_workspace_permission_fix\\` 경고 0 / 오류 0 - 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "FullyQualifiedName~RunAsync_CodeRuntimeWorkspaceOverride_PrefersConversationWorkspaceOverSettingsFolder|FullyQualifiedName~RunAsync_InternalMode_BypassPermissions_AllowsWorkspaceWriteWithoutPrompt|FullyQualifiedName~RunAsync_InternalMode_BypassPermissions_RequestsApprovalForPathOutsideWorkspace|FullyQualifiedName~RunAsync_EmptyWorkspace_BlocksExternalFallbackAndRecoversToFileWrite|FullyQualifiedName~RunAsync_EmptyWorkspace_DisallowsSkillManagerAndRecoversToFileWrite|FullyQualifiedName~RunAsync_TextEmbeddedToolCall_RecoversAndExecutesFileWrite" -p:OutputPath=bin\\verify_workspace_permission_fix_tests\\ -p:IntermediateOutputPath=obj\\verify_workspace_permission_fix_tests\\` 통과 6 +업데이트: 2026-04-15 16:30 (KST) + +### 권한 체계 정리 1차 +- `src/AxCopilot/Services/OperationModePolicy.cs` + - 사내 모드에서 차단할 외부 접근 기준을 보강했습니다. + - `open_external`은 HTTP/HTTPS뿐 아니라 `mailto:` 같은 외부 URI scheme도 차단하고, `process`/`build_run`에서 재사용할 네트워크성 명령 패턴 판정 helper를 추가했습니다. +- `src/AxCopilot/Services/Agent/ProcessTool.cs` + - 사내 모드에서 `curl`, `Invoke-WebRequest` 등 외부 네트워크 접근 가능성이 높은 명령은 실행 전에 즉시 차단합니다. +- `src/AxCopilot/Services/Agent/BuildRunTool.cs` + - 사내 모드에서 `action=custom`은 차단하고, 알려진 네트워크성 명령 패턴도 실행 전에 막습니다. +- `src/AxCopilot/Services/Agent/OpenExternalTool.cs` + - 직접 도구 호출 경로에서도 외부 URI 차단이 일관되게 적용되도록 `OperationModePolicy.IsExternalUri(...)`를 사용하도록 정리했습니다. +- `src/AxCopilot/Views/ChatWindow.xaml.cs` + - `이번 실행 동안 허용` 승인 규칙을 탭 실행 단위로 관리하도록 바꿨습니다. + - 실행 시작과 종료 시 run-scope 승인 캐시를 비우고, 같은 실행 안에서만 동일 범위 접근을 재질문 없이 통과시킵니다. +- `src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs` + - 권한 모드 설명을 실제 동작에 맞게 재작성했습니다. +- `src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs` + - `권한 건너뛰기` 배너 문구를 사내 모드 예외까지 반영하도록 수정했습니다. +- `src/AxCopilot/Services/AppStateService.cs` + - 앱 상태 요약의 권한 설명도 동일한 의미론으로 맞췄습니다. + +### 테스트 +- `src/AxCopilot.Tests/Services/OperationModePolicyTests.cs` + - 외부 URI/mailto 차단 + - 네트워크성 shell 명령 감지 + - `ProcessTool` 사내 모드 차단 + - `BuildRunTool` custom 차단 +- `src/AxCopilot.Tests/Services/OperationModeReadinessTests.cs` + - `OpenExternalTool`의 외부 URI scheme 차단 회귀 추가 +- 검증 + - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_permission_policy_cleanup\\ -p:IntermediateOutputPath=obj\\verify_permission_policy_cleanup\\` 경고 0 / 오류 0 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "OperationModePolicyTests|OperationModeReadinessTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_permission_policy_cleanup_tests\\ -p:IntermediateOutputPath=obj\\verify_permission_policy_cleanup_tests\\` 통과 46 + - `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "LlmOperationModeTests" -p:OutputPath=bin\\verify_permission_policy_llm_tests\\ -p:IntermediateOutputPath=obj\\verify_permission_policy_llm_tests\\` 통과 3 diff --git a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs index c01f2b0..07e25c9 100644 --- a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs +++ b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs @@ -3,6 +3,7 @@ using AxCopilot.Services; using AxCopilot.Services.Agent; using FluentAssertions; using System.IO; +using System.Text.Json; using Xunit; namespace AxCopilot.Tests.Services; @@ -29,9 +30,20 @@ public class OperationModePolicyTests { OperationModePolicy.IsBlockedAgentToolInInternalMode("http_tool", "https://example.com").Should().BeTrue(); OperationModePolicy.IsBlockedAgentToolInInternalMode("open_external", "https://example.com").Should().BeTrue(); + OperationModePolicy.IsBlockedAgentToolInInternalMode("open_external", "mailto:admin@example.com").Should().BeTrue(); OperationModePolicy.IsBlockedAgentToolInInternalMode("open_external", @"E:\work\report.html").Should().BeFalse(); } + [Theory] + [InlineData("curl https://example.com", true)] + [InlineData("powershell Invoke-WebRequest https://example.com", true)] + [InlineData("git status", false)] + [InlineData("dotnet build", false)] + public void IsBlockedShellCommandInInternalMode_DetectsOnlyNetworkLikeCommands(string command, bool expected) + { + OperationModePolicy.IsBlockedShellCommandInInternalMode(command).Should().Be(expected); + } + [Fact] public void IsExternalUrl_DetectsOnlyHttpSchemes() { @@ -267,4 +279,49 @@ public class OperationModePolicyTests writeAllowed.Should().BeFalse(); readAllowed.Should().BeTrue(); } + + [Fact] + public async Task ProcessTool_ExecuteAsync_InternalMode_BlocksNetworkShellCommand() + { + var tool = new ProcessTool(); + using var doc = JsonDocument.Parse("""{"command":"curl https://example.com","shell":"cmd"}"""); + var context = new AgentContext + { + OperationMode = OperationModePolicy.InternalMode, + Permission = "BypassPermissions", + WorkFolder = Path.GetTempPath() + }; + + var result = await tool.ExecuteAsync(doc.RootElement, context, CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Output.Should().Contain("사내 모드"); + } + + [Fact] + public async Task BuildRunTool_ExecuteAsync_InternalMode_BlocksCustomCommand() + { + var workspaceDir = Path.Combine(Path.GetTempPath(), "axcopilot-buildrun-internal-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workspaceDir); + try + { + var tool = new BuildRunTool(); + using var doc = JsonDocument.Parse("""{"action":"custom","command":"curl https://example.com"}"""); + var context = new AgentContext + { + OperationMode = OperationModePolicy.InternalMode, + Permission = "BypassPermissions", + WorkFolder = workspaceDir + }; + + var result = await tool.ExecuteAsync(doc.RootElement, context, CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Output.Should().Contain("사내 모드"); + } + finally + { + try { if (Directory.Exists(workspaceDir)) Directory.Delete(workspaceDir, true); } catch { } + } + } } diff --git a/src/AxCopilot.Tests/Services/OperationModeReadinessTests.cs b/src/AxCopilot.Tests/Services/OperationModeReadinessTests.cs index f6d132c..e610b20 100644 --- a/src/AxCopilot.Tests/Services/OperationModeReadinessTests.cs +++ b/src/AxCopilot.Tests/Services/OperationModeReadinessTests.cs @@ -87,4 +87,21 @@ public class OperationModeReadinessTests result.Success.Should().BeFalse(); result.Output.Should().Contain("사내모드"); } + + [Fact] + public async Task OpenExternal_InternalMode_BlocksExternalUriSchemes() + { + var tool = new OpenExternalTool(); + using var doc = JsonDocument.Parse("""{"path":"mailto:admin@example.com"}"""); + var context = new AgentContext + { + OperationMode = OperationModePolicy.InternalMode, + Permission = "Auto" + }; + + var result = await tool.ExecuteAsync(doc.RootElement, context, CancellationToken.None); + + result.Success.Should().BeFalse(); + result.Output.Should().Contain("사내모드"); + } } diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 7d3e3de..00df85b 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -839,7 +839,7 @@ public class LlmSettings /// /// 파일 접근 권한 수준. /// Default = 매번 확인 | AcceptEdits = 파일 편집 자동 허용 | Plan = 계획/승인 중심 - /// BypassPermissions = 모든 확인 생략 | Deny = 읽기 전용 + /// BypassPermissions = 같은 실행 안의 확인 최대 생략(사내 모드의 워크스페이스 외부 접근은 제외) | Deny = 읽기 전용 /// [JsonPropertyName("filePermission")] public string FilePermission { get; set; } = "Deny"; diff --git a/src/AxCopilot/Services/Agent/BuildRunTool.cs b/src/AxCopilot/Services/Agent/BuildRunTool.cs index cdfce54..f83fcef 100644 --- a/src/AxCopilot/Services/Agent/BuildRunTool.cs +++ b/src/AxCopilot/Services/Agent/BuildRunTool.cs @@ -116,6 +116,10 @@ public class BuildRunTool : IAgentTool return ToolResult.Fail($"이 프로젝트 타입({project.Type})에서 '{action}' 작업은 지원되지 않습니다."); } + if (AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode) + && AxCopilot.Services.OperationModePolicy.IsBlockedBuildRunCommandInInternalMode(action, command)) + return ToolResult.Fail("사내 모드에서는 외부 네트워크 접근 가능성이 있는 빌드/실행 명령이 차단됩니다."); + // 위험 명령 검사 foreach (var pattern in DangerousPatterns) { diff --git a/src/AxCopilot/Services/Agent/OpenExternalTool.cs b/src/AxCopilot/Services/Agent/OpenExternalTool.cs index 4f883f8..77bc310 100644 --- a/src/AxCopilot/Services/Agent/OpenExternalTool.cs +++ b/src/AxCopilot/Services/Agent/OpenExternalTool.cs @@ -34,15 +34,14 @@ public class OpenExternalTool : IAgentTool try { - // URL인 경우 - if (rawPath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || - rawPath.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + // 외부 URI인 경우 + if (AxCopilot.Services.OperationModePolicy.IsExternalUri(rawPath)) { if (AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode)) - return Task.FromResult(ToolResult.Fail("사내모드에서는 외부 URL 열기가 차단됩니다. operationMode=external에서만 사용할 수 있습니다.")); + return Task.FromResult(ToolResult.Fail("사내모드에서는 외부 URI 열기가 차단됩니다. operationMode=external에서만 사용할 수 있습니다.")); Process.Start(new ProcessStartInfo(rawPath) { UseShellExecute = true }); - return Task.FromResult(ToolResult.Ok($"URL 열기: {rawPath}")); + return Task.FromResult(ToolResult.Ok($"외부 URI 열기: {rawPath}")); } // 파일/폴더 경로 diff --git a/src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs b/src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs index 3764654..1b5a6e6 100644 --- a/src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs +++ b/src/AxCopilot/Services/Agent/PermissionModePresentationCatalog.cs @@ -21,25 +21,25 @@ internal static class PermissionModePresentationCatalog PermissionModeCatalog.Default, "\uE8D7", "권한 요청", - "변경하기 전에 항상 확인합니다.", + "변경하거나 실행하기 전에 항상 확인합니다.", "#2563EB"), new PermissionModePresentation( PermissionModeCatalog.AcceptEdits, "\uE73E", - "편집 자동 승인", - "모든 파일 편집을 자동 승인합니다.", + "편집 자동 허용", + "모든 파일 편집은 자동 허용하고, 위험한 실행은 계속 확인합니다.", "#107C10"), new PermissionModePresentation( PermissionModeCatalog.Plan, "\uE769", "계획 모드", - "파일을 읽고 분석한 뒤, 실행 전에 계획을 먼저 보여줍니다.", + "파일을 바꾸거나 실행하기 전에 계획을 먼저 보여줍니다.", "#D97706"), new PermissionModePresentation( PermissionModeCatalog.BypassPermissions, "\uE814", "권한 건너뛰기", - "파일 편집과 명령 실행까지 모두 자동 허용합니다.", + "같은 실행 안의 권한 확인을 최대한 생략하지만, 사내 모드에서 지정 경로 밖 접근은 계속 승인받습니다.", "#B45309"), }; diff --git a/src/AxCopilot/Services/Agent/ProcessTool.cs b/src/AxCopilot/Services/Agent/ProcessTool.cs index a002d84..6cb9f59 100644 --- a/src/AxCopilot/Services/Agent/ProcessTool.cs +++ b/src/AxCopilot/Services/Agent/ProcessTool.cs @@ -46,6 +46,10 @@ public class ProcessTool : IAgentTool if (string.IsNullOrWhiteSpace(command)) return ToolResult.Fail("명령이 비어 있습니다."); + if (AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode) + && AxCopilot.Services.OperationModePolicy.IsBlockedShellCommandInInternalMode(command)) + return ToolResult.Fail("사내 모드에서는 외부 네트워크 접근 가능성이 있는 명령 실행이 차단됩니다."); + // 위험 명령 차단 foreach (var pattern in DangerousPatterns) { diff --git a/src/AxCopilot/Services/AppStateService.cs b/src/AxCopilot/Services/AppStateService.cs index 660d641..dad5ace 100644 --- a/src/AxCopilot/Services/AppStateService.cs +++ b/src/AxCopilot/Services/AppStateService.cs @@ -544,7 +544,7 @@ public sealed class AppStateService : IAppStateService "AcceptEdits" => "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다.", "Deny" => "기존 파일은 읽기만 가능하며 수정/삭제가 차단되고, 새 파일 생성은 가능합니다.", "Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.", - "BypassPermissions" => "모든 권한 확인을 생략합니다. 주의해서 사용해야 합니다.", + "BypassPermissions" => "같은 실행 안의 권한 확인을 최대한 생략하지만, 사내 모드에서 지정 경로 밖 접근은 계속 승인받습니다.", _ => "파일 작업 전마다 사용자 확인을 요청합니다.", }; diff --git a/src/AxCopilot/Services/OperationModePolicy.cs b/src/AxCopilot/Services/OperationModePolicy.cs index 1b8982b..99d82b1 100644 --- a/src/AxCopilot/Services/OperationModePolicy.cs +++ b/src/AxCopilot/Services/OperationModePolicy.cs @@ -7,6 +7,35 @@ public static class OperationModePolicy public const string InternalMode = "internal"; public const string ExternalMode = "external"; + private static readonly string[] s_blockedNetworkShellPatterns = + [ + "curl ", + "wget ", + "invoke-webrequest", + "invoke-restmethod", + "start-bitstransfer", + "bitsadmin ", + "ftp ", + "tftp ", + "scp ", + "sftp ", + "ssh ", + "telnet ", + "nc ", + "ncat ", + "netcat ", + "certutil -urlcache", + "python -m pip install ", + "pip install ", + "npm install ", + "pnpm add ", + "yarn add ", + "dotnet add package ", + "nuget install ", + "mvn dependency:get", + "gradle dependency", + ]; + public static string Normalize(string? mode) { var token = (mode ?? "").Trim().ToLowerInvariant(); @@ -25,11 +54,44 @@ public static class OperationModePolicy return true; if (string.Equals(toolName, "open_external", StringComparison.OrdinalIgnoreCase)) - return IsExternalUrl(target); + return IsExternalUri(target); return false; } + public static bool IsBlockedShellCommandInInternalMode(string? command) + { + if (string.IsNullOrWhiteSpace(command)) + return false; + + foreach (var pattern in s_blockedNetworkShellPatterns) + { + if (command.Contains(pattern, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + + public static bool IsBlockedBuildRunCommandInInternalMode(string? action, string? command) + { + if (string.Equals(action, "custom", StringComparison.OrdinalIgnoreCase)) + return true; + + return IsBlockedShellCommandInInternalMode(command); + } + + public static bool IsExternalUri(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return false; + + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) + return false; + + return !string.Equals(uri.Scheme, Uri.UriSchemeFile, StringComparison.OrdinalIgnoreCase); + } + public static bool IsExternalUrl(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs b/src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs index b4eacea..d5964af 100644 --- a/src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.PermissionPresentation.cs @@ -417,7 +417,7 @@ public partial class ChatWindow PermissionTopBannerIcon.Foreground = autoColor; PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 건너뛰기"; PermissionTopBannerTitle.Foreground = autoColor; - PermissionTopBannerText.Text = "파일 편집과 명령 실행까지 모두 자동 허용합니다. 민감한 작업 전에는 설정을 다시 확인하세요."; + PermissionTopBannerText.Text = "같은 실행 안의 권한 확인을 최대한 생략합니다. 사내 모드에서는 지정 경로 밖 접근이 계속 승인 대상입니다."; PermissionTopBanner.Visibility = Visibility.Collapsed; } } diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 53e9952..713ce3e 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -99,7 +99,8 @@ public partial class ChatWindow : Window private int _lastRenderedMessageCount; private int _lastRenderedEventCount; private bool _lastRenderedShowHistory; - private readonly HashSet _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase); + private readonly object _sessionPermissionRulesLock = new(); private readonly Dictionary _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _sessionMcpAuthTokens = new(StringComparer.OrdinalIgnoreCase); // 경과 시간 표시 @@ -617,14 +618,22 @@ public partial class ChatWindow : Window }), DispatcherPriority.Input); } - private bool IsPermissionAutoApprovedForSession(string toolName, string target) + private bool IsPermissionAutoApprovedForSession(string tab, string toolName, string target) { if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target)) return false; var normalizedTarget = target.Trim(); var pathLikeTool = IsPathLikePermissionTool(toolName); - foreach (var rule in _sessionPermissionRules) + List rulesSnapshot; + lock (_sessionPermissionRulesLock) + { + if (!_sessionPermissionRules.TryGetValue(tab, out var rules) || rules.Count == 0) + return false; + rulesSnapshot = rules.ToList(); + } + + foreach (var rule in rulesSnapshot) { var pivot = rule.IndexOf('|'); if (pivot <= 0 || pivot >= rule.Length - 1) @@ -675,7 +684,7 @@ public partial class ChatWindow : Window } } - private void RememberPermissionRuleForSession(string toolName, string target) + private void RememberPermissionRuleForSession(string tab, string toolName, string target) { if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target)) return; @@ -701,7 +710,24 @@ public partial class ChatWindow : Window } } - _sessionPermissionRules.Add($"{toolName}|{scopedTarget}"); + lock (_sessionPermissionRulesLock) + { + if (!_sessionPermissionRules.TryGetValue(tab, out var rules)) + { + rules = new HashSet(StringComparer.OrdinalIgnoreCase); + _sessionPermissionRules[tab] = rules; + } + + rules.Add($"{toolName}|{scopedTarget}"); + } + } + + private void ResetPermissionRulesForRun(string tab) + { + lock (_sessionPermissionRulesLock) + { + _sessionPermissionRules.Remove(tab); + } } private static bool IsPathLikePermissionTool(string toolName) @@ -5845,7 +5871,7 @@ public partial class ChatWindow : Window var resolvedTarget = NormalizePermissionTarget(toolName, filePath, wsFolder); - if (IsPermissionAutoApprovedForSession(toolName, resolvedTarget)) + if (IsPermissionAutoApprovedForSession(tab, toolName, resolvedTarget)) return true; PermissionRequestWindow.PermissionPromptResult decision = PermissionRequestWindow.PermissionPromptResult.Reject; @@ -5870,7 +5896,7 @@ public partial class ChatWindow : Window }); if (decision == PermissionRequestWindow.PermissionPromptResult.AllowForSession) - RememberPermissionRuleForSession(toolName, resolvedTarget); + RememberPermissionRuleForSession(tab, toolName, resolvedTarget); return decision != PermissionRequestWindow.PermissionPromptResult.Reject; }, @@ -5893,6 +5919,7 @@ public partial class ChatWindow : Window _tabCumulativeInputTokens[runTab] = 0; _tabCumulativeOutputTokens[runTab] = 0; + ResetPermissionRulesForRun(runTab); var loop = GetAgentLoop(runTab); // 클로저로 runTab 캡처 — 동시에 여러 탭이 실행될 때도 이벤트가 올바른 탭에 귀속됨 @@ -5952,6 +5979,7 @@ public partial class ChatWindow : Window } finally { + ResetPermissionRulesForRun(runTab); loop.RuntimeWorkFolderOverride = null; loop.EventOccurred -= agentEventHandler; loop.UserDecisionCallback = null;