권한 경로 해석과 세션 승인 재사용을 워크스페이스 기준으로 정렬
상대 경로 파일 작업에서 권한 팝업과 사내 모드 외부 경로 판정이 워크스페이스가 아니라 프로세스 현재 폴더(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)
This commit is contained in:
@@ -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를 먼저 호출한 뒤 상태를 갱신하도록 정리했습니다. 이로써 이벤트는 오는데 채팅창 바로 위 라이브 카드만 사라진 채 남는 회귀를 막습니다.
|
||||
|
||||
@@ -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 이후 이벤트는 오는데 채팅창 바로 위 라이브 카드만 사라지는 회귀를 막는 목적입니다.
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -165,7 +165,7 @@ public class AgentContext
|
||||
/// <summary>경로가 허용되는지 확인합니다.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 파일 경로에 타임스탬프를 추가합니다.
|
||||
/// 예: report.html → report_20260328_1430.html
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user