From 8baeabbb7029cab110f7989a6c62c4c7f4c8bbe9 Mon Sep 17 00:00:00 2001 From: lacvet Date: Wed, 15 Apr 2026 16:09:30 +0900 Subject: [PATCH] =?UTF-8?q?=EF=BB=BF=EA=B6=8C=ED=95=9C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=ED=95=B4=EC=84=9D=EA=B3=BC=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EC=9E=AC=EC=82=AC=EC=9A=A9=EC=9D=84=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상대 경로 파일 작업에서 권한 팝업과 사내 모드 외부 경로 판정이 워크스페이스가 아니라 프로세스 현재 폴더(dist) 기준으로 해석되면서, 팝업 표시 경로가 틀어지고 '이번 실행 동안 허용'도 raw/absolute 경로 불일치로 재사용되지 않던 문제를 수정했다. - IAgentTool의 경로 판정에 workspace-aware 해석을 추가해 IsPathAllowed와 IsOutsideWorkspace가 상대 경로를 현재 WorkFolder 기준 절대경로로 비교하도록 변경 - ChatWindow 권한 콜백에서 RuntimeWorkFolderOverride 또는 현재 대화 WorkFolder를 기준으로 대상 경로를 먼저 정규화하고, 자동 승인 재사용, 외부 경로 안내, 권한 팝업 표시, 세션 규칙 저장에 동일 경로를 사용하도록 정리 - OperationModePolicyTests에 사내 모드 BypassPermissions에서도 워크스페이스 하위 상대 경로는 승인 없이 허용되는 회귀 테스트를 추가 - README와 docs/DEVELOPMENT.md에 2026-04-15 16:12 (KST) 기준 작업 이력과 검증 결과를 반영 검증: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_permission_workspace_path\\ -p:IntermediateOutputPath=obj\\verify_permission_workspace_path\\ (경고 0 / 오류 0) - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "OperationModePolicyTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_permission_workspace_path_tests\\ -p:IntermediateOutputPath=obj\\verify_permission_workspace_path_tests\\ (통과 34) --- README.md | 8 ++++ docs/DEVELOPMENT.md | 7 +++ .../Services/OperationModePolicyTests.cs | 33 +++++++++++++ src/AxCopilot/Services/Agent/IAgentTool.cs | 22 ++++++++- src/AxCopilot/Views/ChatWindow.xaml.cs | 46 +++++++++++++++---- 5 files changed, 106 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 81c6913..3a7fb70 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,14 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-15 16:12 (KST) +- Code/Cowork 권한 팝업과 승인 재사용이 상대 경로에서 잘못 동작하던 문제를 수정했습니다. 상대 경로 `index.html` 같은 대상이 워크스페이스가 아닌 프로세스 현재 폴더(`dist`) 기준으로 해석되면서, 권한 팝업 미리보기와 사내 모드 외부 경로 판정이 잘못되고 `이번 실행 동안 허용`도 재사용되지 않던 상태였습니다. +- [IAgentTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/IAgentTool.cs)는 새 workspace-aware 경로 해석을 사용해 `IsPathAllowed(...)`, `IsOutsideWorkspace(...)`가 상대 경로를 항상 현재 워크스페이스 기준으로 판정하도록 바꿨습니다. 그 결과 사내 모드에서도 워크스페이스 하위 상대 경로는 외부 접근으로 오판하지 않습니다. +- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)는 권한 콜백에서 상대 경로를 `RuntimeWorkFolderOverride` 또는 현재 대화의 `WorkFolder` 기준 절대경로로 먼저 정규화한 뒤, 자동 승인 재사용/외부 경로 안내/권한 팝업 표시/세션 규칙 저장에 모두 같은 값을 사용하도록 정리했습니다. 이제 `이번 실행 동안 허용`은 동일 워크스페이스 하위 파일들에 일관되게 재사용됩니다. +- 테스트: [OperationModePolicyTests.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs)에 `AgentContext_CheckToolPermissionAsync_InternalMode_BypassPermissions_AllowsRelativeWorkspacePathWithoutPrompt` 추가 +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_permission_workspace_path\\ -p:IntermediateOutputPath=obj\\verify_permission_workspace_path\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "OperationModePolicyTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_permission_workspace_path_tests\\ -p:IntermediateOutputPath=obj\\verify_permission_workspace_path_tests\\` 통과 34 + - 업데이트: 2026-04-15 15:56 (KST) - 메인 루프2 이후 채팅창 바로 위 라이브 진행 카드가 다시 사라질 수 있던 경로를 보강했습니다. [ChatWindow.LiveProgressPresentation.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.LiveProgressPresentation.cs)에 `EnsureAgentLiveCardVisible(...)`를 추가해, Cowork/Code 실행 중 라이브 카드 컨테이너가 아직 없거나 transcript 재구성으로 빠졌을 때 즉시 재생성/재부착되도록 했습니다. - [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs)는 agent event 수신과 live hint 시작 시 eligible 탭이면 위 helper를 먼저 호출한 뒤 상태를 갱신하도록 정리했습니다. 이로써 이벤트는 오는데 채팅창 바로 위 라이브 카드만 사라진 채 남는 회귀를 막습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 91b791e..c025f90 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,5 +1,12 @@ 업데이트: 2026-04-14 19:50 (KST) 업데이트: 2026-04-15 12:51 (KST) +- 업데이트: 2026-04-15 16:12 (KST) +- 권한 경로 해석과 세션 승인 재사용을 workspace-aware 기준으로 정리했습니다. 상대 경로 `index.html` 같은 대상이 권한 팝업/사내 모드 외부 경로 판정에서 프로세스 현재 폴더(`dist`) 기준으로 잘못 절대경로화되면서, 팝업 표시가 틀어지고 `이번 실행 동안 허용`도 raw/absolute 경로 불일치로 재사용되지 않던 문제를 수정했습니다. +- `src/AxCopilot/Services/Agent/IAgentTool.cs`는 `ResolvePathForWorkspaceCheck(...)`를 추가해 `IsPathAllowed(...)`, `IsOutsideWorkspace(...)`가 상대 경로를 현재 `WorkFolder` 기준으로 절대경로화한 뒤 판정하도록 변경했습니다. 사내 모드에서도 워크스페이스 하위 상대 경로는 외부 경로로 오판하지 않습니다. +- `src/AxCopilot/Views/ChatWindow.xaml.cs`는 권한 콜백에서 `RuntimeWorkFolderOverride` 또는 현재 대화의 `WorkFolder`를 사용해 대상 경로를 먼저 정규화하고, 자동 승인 재사용, 외부 경로 notice, 권한 팝업 표시, 세션 규칙 저장에 모두 같은 절대경로를 사용하도록 정리했습니다. +- 테스트: `src/AxCopilot.Tests/Services/OperationModePolicyTests.cs`에 `AgentContext_CheckToolPermissionAsync_InternalMode_BypassPermissions_AllowsRelativeWorkspacePathWithoutPrompt` 추가 +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_permission_workspace_path\\ -p:IntermediateOutputPath=obj\\verify_permission_workspace_path\\` 경고 0 / 오류 0 +- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "OperationModePolicyTests|AgentLoopE2ETests" -p:OutputPath=bin\\verify_permission_workspace_path_tests\\ -p:IntermediateOutputPath=obj\\verify_permission_workspace_path_tests\\` 통과 34 - 업데이트: 2026-04-15 15:56 (KST) - AX Agent 상단 라이브 진행 카드 복원 가드를 추가했습니다. `src/AxCopilot/Views/ChatWindow.LiveProgressPresentation.cs`에 `EnsureAgentLiveCardVisible(...)`를 만들고, Cowork/Code 실행 중 라이브 카드가 아직 생성되지 않았거나 transcript 재구성으로 빠졌을 때 즉시 재생성/재부착되도록 했습니다. - `src/AxCopilot/Views/ChatWindow.xaml.cs`는 agent event 수신과 live hint 시작 시 eligible 탭이면 위 helper를 먼저 호출한 뒤 live card 상태를 갱신하도록 바꿨습니다. 메인 루프2 이후 이벤트는 오는데 채팅창 바로 위 라이브 카드만 사라지는 회귀를 막는 목적입니다. diff --git a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs index ff4d350..c01f2b0 100644 --- a/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs +++ b/src/AxCopilot.Tests/Services/OperationModePolicyTests.cs @@ -2,6 +2,7 @@ using AxCopilot.Models; using AxCopilot.Services; using AxCopilot.Services.Agent; using FluentAssertions; +using System.IO; using Xunit; namespace AxCopilot.Tests.Services; @@ -219,6 +220,38 @@ public class OperationModePolicyTests askCalled.Should().BeFalse(); } + [Fact] + public async Task AgentContext_CheckToolPermissionAsync_InternalMode_BypassPermissions_AllowsRelativeWorkspacePathWithoutPrompt() + { + var workspaceDir = Path.Combine(Path.GetTempPath(), "axcopilot-relative-workspace-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workspaceDir); + try + { + var askCalled = false; + var context = new AgentContext + { + OperationMode = OperationModePolicy.InternalMode, + Permission = "BypassPermissions", + WorkFolder = workspaceDir, + AskPermission = (_, _) => + { + askCalled = true; + return Task.FromResult(false); + } + }; + + var allowed = await context.CheckToolPermissionAsync("file_read", "index.html"); + + allowed.Should().BeTrue(); + askCalled.Should().BeFalse(); + context.IsOutsideWorkspace("index.html").Should().BeFalse(); + } + finally + { + try { if (Directory.Exists(workspaceDir)) Directory.Delete(workspaceDir, true); } catch { } + } + } + [Fact] public async Task AgentContext_CheckToolPermissionAsync_DenyModeBlocksWriteButAllowsRead() { diff --git a/src/AxCopilot/Services/Agent/IAgentTool.cs b/src/AxCopilot/Services/Agent/IAgentTool.cs index c4d742b..2480c80 100644 --- a/src/AxCopilot/Services/Agent/IAgentTool.cs +++ b/src/AxCopilot/Services/Agent/IAgentTool.cs @@ -165,7 +165,7 @@ public class AgentContext /// 경로가 허용되는지 확인합니다. public bool IsPathAllowed(string path) { - var fullPath = Path.GetFullPath(path); + var fullPath = ResolvePathForWorkspaceCheck(path); // 차단 확장자 검사 var ext = Path.GetExtension(fullPath).ToLowerInvariant(); @@ -215,13 +215,31 @@ public class AgentContext try { - var fullPath = Path.GetFullPath(path); + var fullPath = ResolvePathForWorkspaceCheck(path); var workFull = Path.GetFullPath(WorkFolder); return !fullPath.StartsWith(workFull, StringComparison.OrdinalIgnoreCase); } catch { return true; } } + private string ResolvePathForWorkspaceCheck(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return Path.GetFullPath(path ?? string.Empty); + + if (Path.IsPathRooted(path)) + return Path.GetFullPath(path); + + var looksLikePath = path.Contains('/') + || path.Contains('\\') + || Path.HasExtension(path); + + if (looksLikePath && !string.IsNullOrWhiteSpace(WorkFolder)) + return Path.GetFullPath(Path.Combine(WorkFolder, path)); + + return Path.GetFullPath(path); + } + /// /// 파일 경로에 타임스탬프를 추가합니다. /// 예: report.html → report_20260328_1430.html diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 219d695..53e9952 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -650,6 +650,31 @@ public partial class ChatWindow : Window return false; } + private static string NormalizePermissionTarget(string toolName, string target, string? workspaceFolder) + { + if (string.IsNullOrWhiteSpace(target)) + return target; + + var normalizedTarget = target.Trim(); + if (!IsPathLikePermissionTool(toolName)) + return normalizedTarget; + + try + { + if (System.IO.Path.IsPathRooted(normalizedTarget)) + return System.IO.Path.GetFullPath(normalizedTarget); + + if (!string.IsNullOrWhiteSpace(workspaceFolder)) + return System.IO.Path.GetFullPath(System.IO.Path.Combine(workspaceFolder, normalizedTarget)); + + return System.IO.Path.GetFullPath(normalizedTarget); + } + catch + { + return normalizedTarget; + } + } + private void RememberPermissionRuleForSession(string toolName, string target) { if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target)) @@ -692,7 +717,9 @@ public partial class ChatWindow : Window { try { - var fullPath = System.IO.Path.GetFullPath(path).TrimEnd('\\', '/'); + var fullPath = System.IO.Path.IsPathRooted(path) + ? System.IO.Path.GetFullPath(path).TrimEnd('\\', '/') + : System.IO.Path.GetFullPath(System.IO.Path.Combine(folder, path)).TrimEnd('\\', '/'); var fullFolder = System.IO.Path.GetFullPath(folder).TrimEnd('\\', '/'); return !fullPath.StartsWith(fullFolder, StringComparison.OrdinalIgnoreCase); } @@ -5811,7 +5838,14 @@ public partial class ChatWindow : Window }, AskPermissionCallback = async (toolName, filePath) => { - if (IsPermissionAutoApprovedForSession(toolName, filePath)) + _agentLoops.TryGetValue(tab, out var tabLoop); + var wsFolder = tabLoop?.RuntimeWorkFolderOverride; + if (string.IsNullOrWhiteSpace(wsFolder)) + wsFolder = _currentConversation?.WorkFolder ?? ""; + + var resolvedTarget = NormalizePermissionTarget(toolName, filePath, wsFolder); + + if (IsPermissionAutoApprovedForSession(toolName, resolvedTarget)) return true; PermissionRequestWindow.PermissionPromptResult decision = PermissionRequestWindow.PermissionPromptResult.Reject; @@ -5819,7 +5853,6 @@ public partial class ChatWindow : Window await appDispatcher.InvokeAsync(() => { AgentLoopService.PermissionPromptPreview? preview = null; - _agentLoops.TryGetValue(tab, out var tabLoop); if (tabLoop != null && tabLoop.TryGetPendingPermissionPreview(toolName, filePath, out var pendingPreview)) preview = pendingPreview; @@ -5829,18 +5862,15 @@ public partial class ChatWindow : Window if (Services.OperationModePolicy.IsInternal(_settings.Settings) && !string.IsNullOrWhiteSpace(filePath)) { - var wsFolder = tabLoop?.RuntimeWorkFolderOverride; - if (string.IsNullOrWhiteSpace(wsFolder)) - wsFolder = _currentConversation?.WorkFolder ?? ""; if (!string.IsNullOrEmpty(wsFolder) && IsPathOutsideFolder(filePath, wsFolder)) notice = "사내 모드에서는 지정된 경로 외의 접근 시 무조건 승인이 필요합니다."; } - decision = PermissionRequestWindow.Show(this, toolName, filePath, preview, notice); + decision = PermissionRequestWindow.Show(this, toolName, resolvedTarget, preview, notice); }); if (decision == PermissionRequestWindow.PermissionPromptResult.AllowForSession) - RememberPermissionRuleForSession(toolName, filePath); + RememberPermissionRuleForSession(toolName, resolvedTarget); return decision != PermissionRequestWindow.PermissionPromptResult.Reject; },