권한 체계를 사내 모드 기준으로 정리하고 실행 단위 승인 범위를 바로잡음
사내 모드에서 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건 통과를 확인했습니다.
This commit is contained in:
@@ -839,7 +839,7 @@ public class LlmSettings
|
||||
/// <summary>
|
||||
/// 파일 접근 권한 수준.
|
||||
/// Default = 매번 확인 | AcceptEdits = 파일 편집 자동 허용 | Plan = 계획/승인 중심
|
||||
/// BypassPermissions = 모든 확인 생략 | Deny = 읽기 전용
|
||||
/// BypassPermissions = 같은 실행 안의 확인 최대 생략(사내 모드의 워크스페이스 외부 접근은 제외) | Deny = 읽기 전용
|
||||
/// </summary>
|
||||
[JsonPropertyName("filePermission")]
|
||||
public string FilePermission { get; set; } = "Deny";
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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}"));
|
||||
}
|
||||
|
||||
// 파일/폴더 경로
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -544,7 +544,7 @@ public sealed class AppStateService : IAppStateService
|
||||
"AcceptEdits" => "파일 편집 도구는 자동 허용하고 명령 실행은 계속 확인합니다.",
|
||||
"Deny" => "기존 파일은 읽기만 가능하며 수정/삭제가 차단되고, 새 파일 생성은 가능합니다.",
|
||||
"Plan" => "계획/승인 흐름을 우선 적용한 뒤 파일 작업을 진행합니다.",
|
||||
"BypassPermissions" => "모든 권한 확인을 생략합니다. 주의해서 사용해야 합니다.",
|
||||
"BypassPermissions" => "같은 실행 안의 권한 확인을 최대한 생략하지만, 사내 모드에서 지정 경로 밖 접근은 계속 승인받습니다.",
|
||||
_ => "파일 작업 전마다 사용자 확인을 요청합니다.",
|
||||
};
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -417,7 +417,7 @@ public partial class ChatWindow
|
||||
PermissionTopBannerIcon.Foreground = autoColor;
|
||||
PermissionTopBannerTitle.Text = "현재 권한 모드 · 권한 건너뛰기";
|
||||
PermissionTopBannerTitle.Foreground = autoColor;
|
||||
PermissionTopBannerText.Text = "파일 편집과 명령 실행까지 모두 자동 허용합니다. 민감한 작업 전에는 설정을 다시 확인하세요.";
|
||||
PermissionTopBannerText.Text = "같은 실행 안의 권한 확인을 최대한 생략합니다. 사내 모드에서는 지정 경로 밖 접근이 계속 승인 대상입니다.";
|
||||
PermissionTopBanner.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +99,8 @@ public partial class ChatWindow : Window
|
||||
private int _lastRenderedMessageCount;
|
||||
private int _lastRenderedEventCount;
|
||||
private bool _lastRenderedShowHistory;
|
||||
private readonly HashSet<string> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, HashSet<string>> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object _sessionPermissionRulesLock = new();
|
||||
private readonly Dictionary<string, bool> _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _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<string> 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<string>(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;
|
||||
|
||||
Reference in New Issue
Block a user