AX Agent 도구·스킬 정합성 재구성 및 실행 품질 보강

변경 목적:
- AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다.
- claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다.

핵심 수정사항:
- 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다.
- ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다.
- Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다.
- 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다.
- 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
This commit is contained in:
2026-04-14 17:52:46 +09:00
parent fa33b98f7e
commit 8cb08576d5
200 changed files with 13522 additions and 5764 deletions

View File

@@ -188,12 +188,20 @@ public partial class AgentLoopService
// 사용자 원본 요청 캡처 (문서 생성 폴백 판단용)
var userQuery = messages.LastOrDefault(m => m.Role == "user")?.Content ?? "";
// IntentGate: 통합 의도 분류
var intentGate = new IntentGateService(_llm);
var intentResult = await intentGate.ClassifyAsync(userQuery, ActiveTab, ct).ConfigureAwait(false);
var explorationState = new ExplorationTrackingState
{
Scope = ClassifyExplorationScope(userQuery, ActiveTab),
Scope = intentResult.SuggestedScope,
SelectiveHit = true,
};
var pathAccessState = new PathAccessTrackingState();
// P3: 누적 학습 — 도구 결과에서 자동 학습 포인트 수집
var sessionLearnings = (_settings.Settings.Llm.EnableSessionLearnings)
? new SessionLearningCollector(_settings.Settings.Llm.MaxSessionLearnings)
: null;
DateTime? lastToolResultAtUtc = null;
string? lastToolResultToolName = null;
@@ -236,9 +244,10 @@ public partial class AgentLoopService
string? documentPlanScaffold = null; // document_plan이 생성한 body 골격 HTML
string? lastArtifactFilePath = null; // 생성/수정 산출물 파일 추적
var statsModifiedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase); // 수정/생성된 파일 추적
var taskType = ClassifyTaskType(userQuery, ActiveTab);
var taskType = intentResult.TaskType;
var taskPolicy = TaskTypePolicy.FromTaskType(taskType);
var executionPolicy = _llm.GetActiveExecutionPolicy();
var executionPolicy = ExecutionPolicyMerger.Apply(
_llm.GetActiveExecutionPolicy(), intentResult.PolicyOverlay);
var consecutiveNoToolResponses = 0;
var noToolResponseThreshold = AgentLoopRuntimeThresholds.GetNoToolCallResponseThreshold(executionPolicy.NoToolResponseThreshold);
var noToolRecoveryMaxRetries = AgentLoopRuntimeThresholds.GetNoToolCallRecoveryMaxRetries(executionPolicy.NoToolRecoveryMaxRetries);
@@ -256,6 +265,19 @@ public partial class AgentLoopService
var context = BuildContext();
InjectTaskTypeGuidance(messages, taskPolicy);
InjectExplorationScopeGuidance(messages, explorationState.Scope);
// P5: 복합 요청 감지 시 DecompositionHint 주입
if (intentResult.IsComplexTask && !string.IsNullOrWhiteSpace(intentResult.DecompositionHint))
{
messages.Add(new ChatMessage
{
Role = "user",
Content = $"[System:DecompositionHint]\n{intentResult.DecompositionHint}\n" +
"Consider using spawn_agents to run independent sub-tasks in parallel.",
MetaKind = "decomposition_hint",
});
}
var preferredInitialToolSequence = BuildPreferredInitialToolSequence(
explorationState,
taskPolicy,
@@ -370,6 +392,11 @@ public partial class AgentLoopService
// PauseAsync가 아직 세마포어를 보유 중이 아닌 경우 — 무시
}
// ── 실행 중 설정 변경 반영 ──
// 사용자가 UI에서 권한 모드나 사내/사외 모드를 바꾼 경우 다음 반복부터 즉시 적용.
// (현재 진행 중인 LLM 호출이나 도구 실행에는 영향 없음 — 다음 사이클부터)
SyncContextFromSettings(context);
// Context Condenser: 토큰 초과 시 이전 대화 자동 압축
// 첫 반복에서도 실행 (이전 대화 복원으로 이미 긴 경우 대비)
{
@@ -475,6 +502,22 @@ public partial class AgentLoopService
}
}
// P3: 누적 학습 메시지 주입 (매 반복 갱신)
if (sessionLearnings is { Count: > 0 })
{
var learningMsg = sessionLearnings.BuildInjectionMessage();
if (learningMsg != null)
{
messages.RemoveAll(m => m.MetaKind == "session_learnings");
messages.Insert(0, new ChatMessage
{
Role = "user",
Content = learningMsg,
MetaKind = "session_learnings",
});
}
}
// P1: 반복당 1회 캐시 — GetRuntimeActiveTools는 동일 파라미터로 반복 내 여러 번 호출됨
var cachedActiveTools = FilterExplorationToolsForCurrentIteration(
GetRuntimeActiveTools(llm.DisabledTools, runtimeOverrides),
@@ -782,9 +825,20 @@ public partial class AgentLoopService
? taskPolicy.TaskType is "bugfix" or "feature" or "refactor"
: IsDocumentCreationRequest(userQuery);
// probe 전용 도구(dev_env_detect, memory 등)만 사용된 경우도
// 실질적 작업이 이뤄지지 않은 것으로 간주하고 NoToolCallLoop 복구를 유도한다.
// (Code/Cowork 양쪽 모두 — 문서 작성 요청도 probe-only로 끝나서는 안 됨)
var onlyProbeToolsUsed =
requiresConcreteArtifactOrEdit
&& totalToolCalls > 0
&& !HasSubstantiveCodeToolUsage(statsUsedTools);
// probe-only 상태는 "1회 no-tool 응답"만으로도 즉시 복구 — 기본 임계(2회)는 느림
var effectiveNoToolThreshold = onlyProbeToolsUsed ? 1 : noToolResponseThreshold;
if (requiresConcreteArtifactOrEdit
&& totalToolCalls == 0
&& consecutiveNoToolResponses >= noToolResponseThreshold
&& (totalToolCalls == 0 || onlyProbeToolsUsed)
&& consecutiveNoToolResponses >= effectiveNoToolThreshold
&& runState.NoToolCallLoopRetry < noToolRecoveryMaxRetries)
{
runState.NoToolCallLoopRetry++;
@@ -1034,6 +1088,18 @@ public partial class AgentLoopService
terminalEvidenceGateRetryMax))
continue;
// 방어: 파이프-래핑 tool_call 토큰 등 원본 마크업이 최종 assistant 응답에 남아있으면 제거
// (패턴 4 폴백 파싱도 실패하여 사용자 화면에 "<|tool_call>call;foo{...}<tool_call|>" 가
// 그대로 노출되는 증상을 방지)
if (!string.IsNullOrEmpty(textResponse))
{
var cleaned = LlmService.StripToolCallTokens(textResponse);
if (!string.Equals(cleaned, textResponse, StringComparison.Ordinal))
{
LogService.Debug("[AgentLoop] 최종 응답에 미파싱 tool_call 토큰 발견 — 정화");
textResponse = cleaned;
}
}
if (!string.IsNullOrEmpty(textResponse))
messages.Add(new ChatMessage { Role = "assistant", Content = textResponse });
EmitEvent(AgentEventType.Complete, "",
@@ -1513,6 +1579,9 @@ public partial class AgentLoopService
lastToolResultAtUtc = DateTime.UtcNow;
lastToolResultToolName = effectiveCall.ToolName;
// P3: 누적 학습 — 도구 결과에서 학습 포인트 추출
sessionLearnings?.TryExtract(effectiveCall.ToolName, result.Output ?? "", result.Success);
if (!result.Success)
{
failedToolHistogram.TryGetValue(effectiveCall.ToolName, out var failedCount);
@@ -1698,7 +1767,7 @@ public partial class AgentLoopService
{
if (consumedExtraIteration)
iteration++;
return result.Output;
return result.Output ?? "";
}
var consumedVerificationIteration = await TryApplyPostToolVerificationTransitionAsync(
@@ -1957,8 +2026,9 @@ public partial class AgentLoopService
{
foreach (var name in disabledToolNames)
{
if (!string.IsNullOrWhiteSpace(name))
disabled.Add(name);
var canonical = AgentToolCatalog.Canonicalize(name);
if (!string.IsNullOrWhiteSpace(canonical))
disabled.Add(canonical);
}
}
@@ -1986,9 +2056,7 @@ public partial class AgentLoopService
if (string.IsNullOrWhiteSpace(normalized))
continue;
var alias = NormalizeAliasToken(normalized);
if (ToolAliasMap.TryGetValue(alias, out var mapped))
normalized = mapped;
normalized = AgentToolCatalog.Canonicalize(normalized);
result.Add(normalized);
}
@@ -2006,7 +2074,7 @@ public partial class AgentLoopService
{
var normalized = token.Trim().Trim('`', '"', '\'');
if (!string.IsNullOrWhiteSpace(normalized))
result.Add(normalized);
result.Add(AgentToolCatalog.Canonicalize(normalized));
}
return result;
@@ -2132,7 +2200,7 @@ public partial class AgentLoopService
}
private static bool IsForkCompliantTool(string toolName)
=> toolName is "spawn_agent" or "wait_agents";
=> toolName is "spawn_agent" or "spawn_agents" or "wait_agents";
private static bool ShouldEnforceForkExecution(
bool enforceForkExecution,
@@ -3164,6 +3232,33 @@ public partial class AgentLoopService
return true;
}
/// <summary>
/// Code 작업에서 "실질적" 도구 사용이 있었는지 판별한다.
/// dev_env_detect/memory/notify 같은 probe·메타 도구만 호출된 경우엔 false를 반환하여
/// 메인 루프가 NoToolCallLoop 복구를 통해 실제 작업을 유도하도록 한다.
/// </summary>
private static bool HasSubstantiveCodeToolUsage(IEnumerable<string> usedTools)
{
// 실질적 진행으로 간주하는 도구(읽기/수정/검색/빌드/테스트/LSP/git 등)
var substantive = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"file_read", "file_write", "file_edit", "file_manage",
"multi_read", "file_info",
"grep", "glob", "code_search", "lsp_code_intel",
"git_tool", "build_run", "test_loop",
"script_create", "process",
"html_create", "markdown_create", "docx_create",
"excel_create", "csv_create", "pptx_create", "chart_create",
"document_plan", "sub_agent", "wait_agents"
};
foreach (var t in usedTools)
{
if (!string.IsNullOrWhiteSpace(t) && substantive.Contains(t))
return true;
}
return false;
}
private static bool HasAnyBuildOrTestEvidence(List<ChatMessage> messages)
{
foreach (var message in messages.AsEnumerable().Reverse())
@@ -3338,8 +3433,8 @@ public partial class AgentLoopService
return false;
if (highImpact && !hasSuccessfulBuildAndTestEvidence)
return false;
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase) && !hasDocumentVerificationEvidence)
return false;
// docs 작업: 검증 도구(document_review 등) 호출이 있으면 그 언급도 필요하지만,
// 호출이 없어도 기본 요약(변경+검증 키워드)만으로 FinalReport를 트리거할 수 있게 허용.
if (string.Equals(taskPolicy.TaskType, "docs", StringComparison.OrdinalIgnoreCase)
&& hasDocumentVerificationEvidence
&& !hasDocumentVerificationToolMention)
@@ -4062,8 +4157,8 @@ public partial class AgentLoopService
string unknownToolName,
IReadOnlyCollection<string> activeToolNames)
{
var normalizedUnknown = NormalizeAliasToken(unknownToolName);
var aliasHint = ToolAliasMap.TryGetValue(normalizedUnknown, out var mappedCandidate)
var mappedCandidate = AgentToolCatalog.Canonicalize(unknownToolName);
var aliasHint = !string.Equals(mappedCandidate, unknownToolName, StringComparison.OrdinalIgnoreCase)
&& activeToolNames.Any(name => string.Equals(name, mappedCandidate, StringComparison.OrdinalIgnoreCase))
? $"- 자동 매핑 후보: {unknownToolName} → {mappedCandidate}\n"
: "";
@@ -4133,67 +4228,6 @@ public partial class AgentLoopService
"- 다음 실행에서는 허용 도구 예시에서 직접 고를 수 있으면 바로 바꾸고, 그래도 애매할 때만 tool_search를 사용하세요.";
}
private static readonly Dictionary<string, string> ToolAliasMap = new(StringComparer.OrdinalIgnoreCase)
{
["read"] = "file_read",
["readfile"] = "file_read",
["read_file"] = "file_read",
["write"] = "file_write",
["writefile"] = "file_write",
["write_file"] = "file_write",
["edit"] = "file_edit",
["editfile"] = "file_edit",
["edit_file"] = "file_edit",
["bash"] = "process",
["shell"] = "process",
["terminal"] = "process",
["run"] = "process",
["ls"] = "glob",
["listfiles"] = "glob",
["list_files"] = "glob",
["grep"] = "grep",
["greptool"] = "grep",
["grep_tool"] = "grep",
["rg"] = "grep",
["ripgrep"] = "grep",
["search"] = "grep",
["globfiles"] = "glob",
["glob_files"] = "glob",
// claw-code 계열 도구명 호환
["webfetch"] = "http_tool",
["websearch"] = "http_tool",
["askuserquestion"] = "user_ask",
["lsp"] = "lsp_code_intel",
["listmcpresourcestool"] = "mcp_list_resources",
["readmcpresourcetool"] = "mcp_read_resource",
["agent"] = "spawn_agent",
["spawnagent"] = "spawn_agent",
["task"] = "spawn_agent",
["sendmessage"] = "notify_tool",
["shellcommand"] = "process",
["execute"] = "process",
["codesearch"] = "search_codebase",
["code_search"] = "search_codebase",
["powershell"] = "process",
["toolsearch"] = "tool_search",
["todowrite"] = "todo_write",
["taskcreate"] = "task_create",
["taskget"] = "task_get",
["tasklist"] = "task_list",
["taskupdate"] = "task_update",
["taskstop"] = "task_stop",
["taskoutput"] = "task_output",
["enterworktree"] = "enter_worktree",
["exitworktree"] = "exit_worktree",
["teamcreate"] = "team_create",
["teamdelete"] = "team_delete",
["croncreate"] = "cron_create",
["crondelete"] = "cron_delete",
["cronlist"] = "cron_list",
["config"] = "project_rules",
["skill"] = "skill_manager",
};
private static string ResolveRequestedToolName(string requestedToolName, IReadOnlyCollection<string> activeToolNames)
{
var requested = requestedToolName.Trim();
@@ -4205,15 +4239,13 @@ public partial class AgentLoopService
if (!string.IsNullOrWhiteSpace(direct))
return direct;
var normalizedRequested = NormalizeAliasToken(requested);
if (ToolAliasMap.TryGetValue(normalizedRequested, out var mapped))
{
var mappedDirect = activeToolNames.FirstOrDefault(name =>
string.Equals(name, mapped, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(mappedDirect))
return mappedDirect;
}
var canonicalRequested = AgentToolCatalog.Canonicalize(requested);
var mappedDirect = activeToolNames.FirstOrDefault(name =>
string.Equals(name, canonicalRequested, StringComparison.OrdinalIgnoreCase));
if (!string.IsNullOrWhiteSpace(mappedDirect))
return mappedDirect;
var normalizedRequested = NormalizeAliasToken(requested);
var normalizedMatch = activeToolNames.FirstOrDefault(name =>
string.Equals(NormalizeAliasToken(name), normalizedRequested, StringComparison.Ordinal));
if (!string.IsNullOrWhiteSpace(normalizedMatch))
@@ -4223,20 +4255,7 @@ public partial class AgentLoopService
}
private static string NormalizeAliasToken(string value)
{
if (string.IsNullOrWhiteSpace(value))
return "";
Span<char> buffer = stackalloc char[value.Length];
var idx = 0;
foreach (var ch in value)
{
if (ch is '_' or '-' or ' ')
continue;
buffer[idx++] = char.ToLowerInvariant(ch);
}
return new string(buffer[..idx]);
}
=> AgentToolCatalog.NormalizeToken(value);
private static string BuildNoProgressAbortResponse(
TaskTypePolicy taskPolicy,
@@ -4555,7 +4574,7 @@ public partial class AgentLoopService
AskPermission = AskPermissionCallback,
UserDecision = UserDecisionCallback,
UserAskCallback = UserAskCallback,
ToolPermissions = new Dictionary<string, string>(llm.ToolPermissions ?? new(), StringComparer.OrdinalIgnoreCase),
ToolPermissions = AgentToolCatalog.CanonicalizePermissionMap(llm.ToolPermissions ?? new Dictionary<string, string>()),
ActiveTab = ActiveTab,
OperationMode = _settings.Settings.OperationMode,
DevMode = llm.DevMode,
@@ -4563,6 +4582,57 @@ public partial class AgentLoopService
};
}
/// <summary>
/// 실행 중인 컨텍스트의 권한/운영모드/차단목록을 현재 설정값으로 갱신합니다.
/// 에이전트 루프가 실행되는 동안 사용자가 UI에서 권한 모드를 바꾸거나
/// 사내/사외 모드를 전환했을 때 다음 도구 호출부터 즉시 반영되도록 합니다.
///
/// 주의: WorkFolder와 ActiveTab은 실행 중 변경되지 않아야 하므로 건드리지 않음.
/// (중간에 바뀌면 이미 진행 중인 도구 호출이 다른 워크스페이스를 바라보게 됨)
/// </summary>
private void SyncContextFromSettings(AgentContext context, bool emitChangeEvents = true)
{
if (context == null) return;
var llm = _settings.Settings.Llm;
var newPermission = PermissionModeCatalog.NormalizeGlobalMode(llm.FilePermission);
var oldPermission = PermissionModeCatalog.NormalizeGlobalMode(context.Permission);
if (!string.Equals(newPermission, oldPermission, StringComparison.OrdinalIgnoreCase))
{
context.Permission = newPermission;
if (emitChangeEvents)
EmitEvent(AgentEventType.Thinking, "",
$"[설정 변경 감지] 권한 모드: {oldPermission} → {newPermission}");
// 권한 변경 시 이전 세션 승인 캐시는 유지 (사용자가 이미 허용한 건은 그대로)
}
var newOpMode = AxCopilot.Services.OperationModePolicy.Normalize(_settings.Settings.OperationMode);
var oldOpMode = AxCopilot.Services.OperationModePolicy.Normalize(context.OperationMode);
if (!string.Equals(newOpMode, oldOpMode, StringComparison.OrdinalIgnoreCase))
{
context.OperationMode = newOpMode;
if (emitChangeEvents)
EmitEvent(AgentEventType.Thinking, "",
$"[설정 변경 감지] 운영 모드: {oldOpMode} → {newOpMode}");
}
// 차단 경로/확장자는 참조 자체가 설정 객체와 동일할 수 있어 Clear+AddRange 대신 새 리스트로 교체
if (!ReferenceEquals(context.BlockedPaths, llm.BlockedPaths))
context.BlockedPaths = llm.BlockedPaths ?? new();
if (!ReferenceEquals(context.BlockedExtensions, llm.BlockedExtensions))
context.BlockedExtensions = llm.BlockedExtensions ?? new();
// 도구별 권한 오버라이드는 훅이 런타임에 쓰는 경로도 있으므로, 설정값과 훅 값을 병합.
// 설정에서 제거된 키는 삭제하되, 훅이 추가한 키(설정에 없는)는 유지.
var desired = AgentToolCatalog.CanonicalizePermissionMap(llm.ToolPermissions ?? new Dictionary<string, string>());
foreach (var kv in desired)
context.ToolPermissions[kv.Key] = kv.Value;
// 설정에서 사라진 키 중, 훅이 추가하지 않은 것만 정리하기는 훅 추적 비용이 커서 생략.
context.DevMode = llm.DevMode;
context.DevModeStepApproval = llm.DevModeStepApproval;
}
/// <summary>
/// 탭(Cowork/Code)별 작업 폴더를 결정합니다.
/// 탭 전용 경로가 설정되어 있으면 우선, 아니면 레거시 WorkFolder 폴백.
@@ -4661,7 +4731,23 @@ public partial class AgentLoopService
var primary = TryReadString(input, "path", "filePath", "destination", "url", "command", "project_path", "cwd");
if (string.IsNullOrWhiteSpace(primary))
{
// ── 권한 대상 보정 ──
// html_create/markdown_create 등은 path 생략 시 WorkFolder 하위에 자동 생성된다.
// 이 경우 권한 검사 대상을 WorkFolder 자체로 간주해야 한다.
// 이전에는 toolName("html_create")을 target으로 리턴했는데, IsOutsideWorkspace가
// Path.GetFullPath("html_create") = <앱 CWD>/html_create 로 해석하여
// "워크스페이스 외부"로 오인 → 사내 모드에서 BypassPermissions여도 강제 승인창이 떴다.
if (toolName is "file_write" or "file_edit" or "file_manage"
or "html_create" or "markdown_create" or "docx_create"
or "excel_create" or "csv_create" or "pptx_create"
or "chart_create" or "script_create"
&& !string.IsNullOrWhiteSpace(context.WorkFolder))
{
return context.WorkFolder;
}
return toolName;
}
if ((toolName is "file_write" or "file_edit" or "file_manage" or "open_external" or "html_create" or "markdown_create" or "docx_create" or "excel_create" or "csv_create" or "pptx_create" or "chart_create" or "script_create")
&& !Path.IsPathRooted(primary)
@@ -4858,11 +4944,7 @@ public partial class AgentLoopService
if (trimmed is "*" or "default")
return trimmed;
var normalizedRequested = NormalizeAliasToken(trimmed);
if (ToolAliasMap.TryGetValue(normalizedRequested, out var mapped))
return mapped;
return trimmed;
return AgentToolCatalog.Canonicalize(trimmed);
}
private async Task RunPermissionLifecycleHooksAsync(
@@ -4907,6 +4989,11 @@ public partial class AgentLoopService
AgentContext context,
List<ChatMessage>? messages = null)
{
// 권한 검사 직전 한 번 더 동기화 — 한 iteration 안에서 여러 툴 콜이 있을 때
// 사용자가 중간에 권한/운영모드를 바꿨으면 즉시 반영되도록.
// 반복 호출이므로 Thinking 이벤트는 조용히 스킵(이미 iteration 시작 시 알림 발생).
SyncContextFromSettings(context, emitChangeEvents: false);
var target = DescribeToolTarget(toolName, input, context);
var requestPayload = JsonSerializer.Serialize(new
{
@@ -5073,8 +5160,10 @@ public partial class AgentLoopService
var toolName = call.ToolName ?? "";
var input = call.ToolInput;
// 사외모드 + 권한 건너뛰기: 모든 도구 승인 생략
if (!AxCopilot.Services.OperationModePolicy.IsInternal(context.OperationMode))
// 권한 건너뛰기: 도구 승인 생략
// 사내모드에서도 동일하게 적용 — 외부 URL 접근, 워크스페이스 외부 경로 접근 등
// 실질적인 위험은 OperationModePolicy(IsBlockedAgentToolInInternalMode) 와
// IAgentTool.CheckToolPermissionAsync 의 IsOutsideWorkspace 체크에서 이미 방어됨
{
var effectivePerm = PermissionModeCatalog.NormalizeGlobalMode(context.Permission);
if (PermissionModeCatalog.IsBypassPermissions(effectivePerm))