권한 경로 해석과 세션 승인 재사용을 워크스페이스 기준으로 정렬
상대 경로 파일 작업에서 권한 팝업과 사내 모드 외부 경로 판정이 워크스페이스가 아니라 프로세스 현재 폴더(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:
@@ -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