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;
},