Compare commits

..

2 Commits

Author SHA1 Message Date
18551a0aea AX Agent 메모리 구조 4차 강화: 외부 include 보안 정책과 설정 추가
Some checks failed
Release Gate / gate (push) Has been cancelled
- 메모리 내용 관리는 /memory 도구로 유지하고, 외부 include 허용 여부만 설정에서 제어하도록 구조를 분리함

- AllowExternalMemoryIncludes 설정과 UI 토글을 추가해 홈 경로/절대 경로/프로젝트 밖 상대 include를 기본 차단하고 필요 시에만 허용하도록 정리함

- AgentMemoryService가 include 해석 시 프로젝트 경계와 설정값을 함께 검사해 claw-code와 유사한 안전 정책을 따르도록 보강함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-07 00:02:20 +09:00
ae765fb543 AX Agent 메모리 구조 3차 강화: @include 지원과 프로젝트 루트 판단 개선
- 계층형 메모리 문서에 @include 확장을 추가해 상대 경로, 홈 경로, 절대 경로 텍스트 파일을 최대 5단계까지 재귀적으로 펼치도록 구현함

- 코드 블록 내부 include 무시, 순환 참조 차단, 비텍스트 파일 제외 규칙을 적용해 안전한 최소 규칙으로 정리함

- 프로젝트 루트 판단을 .git, .sln, *.csproj, package.json, pyproject.toml, go.mod, Cargo.toml 마커 기반으로 강화해 project/local 메모리 탐색과 저장 경로를 더 정확히 맞춤

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
2026-04-06 23:56:19 +09:00
6 changed files with 246 additions and 16 deletions

View File

@@ -1393,3 +1393,9 @@ MIT License
- 업데이트: 2026-04-06 23:57 (KST)
- [MemoryTool.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/Agent/MemoryTool.cs)를 확장해 계층형 메모리 관리 액션을 추가했습니다. 이제 `/memory`는 기존 학습 메모리 `save/search/list/delete` 외에 `save_scope`, `delete_scope`를 통해 `managed / user / project / local` 메모리 파일을 직접 다룰 수 있습니다.
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs) 에는 계층형 메모리 파일의 실제 저장/삭제 경로를 결정하고 내용을 append/remove 하는 로직을 추가했습니다. AX 메모리 구조가 이제 `읽기 전용 계층`이 아니라 `학습 메모리 + 계층형 메모리 파일`을 함께 관리하는 형태로 한 단계 더 올라왔습니다.
- 업데이트: 2026-04-07 00:06 (KST)
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)에 `@include` 확장을 추가했습니다. 이제 `AXMEMORY.md` 안에서 `@./docs/architecture.md`, `@~/shared/rules.md`, 절대 경로 include를 사용할 수 있고, 텍스트 파일만 최대 5단계까지 재귀적으로 펼칩니다.
- 같은 파일에서 프로젝트 루트 판단도 강화했습니다. 이제 단순 현재 작업 폴더가 아니라 `.git`, `.sln`, `*.csproj`, `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml` 같은 마커를 보고 프로젝트 루트를 먼저 잡은 뒤, 그 루트부터 현재 작업 디렉토리까지의 메모리 계층을 조립합니다.
- 업데이트: 2026-04-07 00:13 (KST)
- `claw-code`처럼 외부 메모리 include를 무조건 열어두지 않도록 안전 장치를 추가했습니다. [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs), [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)에 `외부 메모리 include 허용` 설정을 추가했고 기본값은 `꺼짐`입니다.
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)는 이 설정이 꺼져 있으면 프로젝트 바깥으로 빠지는 상대 경로, 홈 경로(`@~/...`), 절대 경로 include를 모두 차단합니다. 즉 메모리 내용 관리는 계속 `/memory` 같은 명령으로 하되, include의 보안 정책만 설정으로 다루는 구조로 정리했습니다.

View File

@@ -5144,3 +5144,24 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
- `GetWritableInstructionPath(...)`, `SaveInstruction(...)`, `DeleteInstruction(...)`를 추가해 `managed / user / project / local` scope별 메모리 파일에 실제 내용을 append/remove 할 수 있게 했다.
- 1차 목표는 `claw-code`처럼 계층형 메모리를 “로드만 하는 구조”에서 “도구로 관리할 수 있는 구조”로 올리는 것이며, 현재는 bullet line append/remove 기반의 안전한 최소 구현을 적용했다.
## 2026-04-07 00:06 (KST)
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
- 계층형 메모리 파일에 `@include` 확장을 추가했다.
- 지원 형식은 `@filename`, `@./relative/path`, `@~/path/in/home`, 절대 경로이며, `#fragment`는 제거 후 해석한다.
- 비텍스트 확장자는 무시하고, 코드 블록 내부 include는 처리하지 않으며, 순환 참조는 차단하고 최대 5단계까지만 재귀 확장한다.
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
- 프로젝트 루트 판단을 단순 `workFolder` 기준에서 `.git`, `.sln`, `*.csproj`, `package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml` 마커 기반으로 강화했다.
- 이제 계층형 메모리 탐색은 프로젝트 루트부터 현재 작업 디렉토리까지 진행되고, `project/local` 메모리 파일 쓰기 경로도 같은 루트 판단을 사용한다.
## 2026-04-07 00:13 (KST)
- [AppSettings.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Models/AppSettings.cs)
- `AllowExternalMemoryIncludes` 설정을 추가했다. 기본값은 `false`이며, 메모리 include가 프로젝트 바깥 경로를 읽는 것을 명시적으로 제어한다.
- [SettingsViewModel.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/ViewModels/SettingsViewModel.cs), [SettingsWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/SettingsWindow.xaml)
- 에이전트 메모리 섹션에 `외부 메모리 include 허용` 토글을 추가했다.
- 메모리 내용 관리 자체는 `/memory` 도구로 하고, include 보안 정책만 설정에서 다루는 구조로 정리했다.
- [AgentMemoryService.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Services/AgentMemoryService.cs)
- `@include` 해석 시 설정을 읽어, 외부 include가 꺼져 있으면 홈 경로(`@~/...`), 절대 경로, 프로젝트 바깥으로 벗어나는 상대 경로를 차단하도록 바꿨다.
- `claw-code`처럼 메모리 편집은 도구/명령 중심으로 하고, 외부 include는 별도 안전 정책으로 관리하는 구조를 목표로 한다.

View File

@@ -999,6 +999,10 @@ public class LlmSettings
[JsonPropertyName("maxMemoryEntries")]
public int MaxMemoryEntries { get; set; } = 100;
/// <summary>프로젝트 바깥 메모리 include 허용. 기본 false.</summary>
[JsonPropertyName("allowExternalMemoryIncludes")]
public bool AllowExternalMemoryIncludes { get; set; } = false;
// ─── 이미지 입력 (멀티모달) ──────────────────────────────────────────
/// <summary>이미지 입력(Ctrl+V 붙여넣기, 파일 첨부) 활성화. 기본 true.</summary>

View File

@@ -21,6 +21,7 @@ public class AgentMemoryService
"AxCopilot", "memory");
private const string MemoryFileName = "AXMEMORY.md";
private const string LocalMemoryFileName = "AXMEMORY.local.md";
private const int MaxIncludeDepth = 5;
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -48,12 +49,13 @@ public class AgentMemoryService
public string? GetWritableInstructionPath(string scope, string? workFolder)
{
scope = (scope ?? "").Trim().ToLowerInvariant();
var projectRoot = ResolveProjectRoot(workFolder);
return scope switch
{
"managed" => Path.Combine(ManagedMemoryDir, MemoryFileName),
"user" => Path.Combine(MemoryDir, MemoryFileName),
"project" => string.IsNullOrWhiteSpace(workFolder) ? null : Path.Combine(Path.GetFullPath(workFolder), MemoryFileName),
"local" => string.IsNullOrWhiteSpace(workFolder) ? null : Path.Combine(Path.GetFullPath(workFolder), LocalMemoryFileName),
"project" => string.IsNullOrWhiteSpace(projectRoot) ? null : Path.Combine(projectRoot, MemoryFileName),
"local" => string.IsNullOrWhiteSpace(projectRoot) ? null : Path.Combine(projectRoot, LocalMemoryFileName),
_ => null
};
}
@@ -351,26 +353,28 @@ public class AgentMemoryService
private void LoadInstructionDocuments(string? workFolder)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var projectRoot = ResolveProjectRoot(workFolder);
AddInstructionFileIfExists(Path.Combine(ManagedMemoryDir, MemoryFileName), "managed", "관리형 메모리", seen);
AddInstructionRuleFilesIfExists(Path.Combine(ManagedMemoryDir, "rules"), "managed", "관리형 메모리", seen);
AddInstructionFileIfExists(Path.Combine(ManagedMemoryDir, MemoryFileName), "managed", "관리형 메모리", seen, projectRoot);
AddInstructionRuleFilesIfExists(Path.Combine(ManagedMemoryDir, "rules"), "managed", "관리형 메모리", seen, projectRoot);
AddInstructionFileIfExists(Path.Combine(MemoryDir, MemoryFileName), "user", "사용자 메모리", seen);
AddInstructionRuleFilesIfExists(Path.Combine(MemoryDir, "rules"), "user", "사용자 메모리", seen);
AddInstructionFileIfExists(Path.Combine(MemoryDir, MemoryFileName), "user", "사용자 메모리", seen, projectRoot);
AddInstructionRuleFilesIfExists(Path.Combine(MemoryDir, "rules"), "user", "사용자 메모리", seen, projectRoot);
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
return;
foreach (var directory in EnumerateDirectoryChain(workFolder))
var normalizedProjectRoot = projectRoot ?? workFolder;
foreach (var directory in EnumerateDirectoryChain(normalizedProjectRoot, workFolder))
{
AddInstructionFileIfExists(Path.Combine(directory, MemoryFileName), "project", "프로젝트 메모리", seen);
AddInstructionFileIfExists(Path.Combine(directory, ".ax", MemoryFileName), "project", "프로젝트 메모리", seen);
AddInstructionRuleFilesIfExists(Path.Combine(directory, ".ax", "rules"), "project", "프로젝트 메모리", seen);
AddInstructionFileIfExists(Path.Combine(directory, LocalMemoryFileName), "local", "로컬 메모리", seen);
AddInstructionFileIfExists(Path.Combine(directory, MemoryFileName), "project", "프로젝트 메모리", seen, normalizedProjectRoot);
AddInstructionFileIfExists(Path.Combine(directory, ".ax", MemoryFileName), "project", "프로젝트 메모리", seen, normalizedProjectRoot);
AddInstructionRuleFilesIfExists(Path.Combine(directory, ".ax", "rules"), "project", "프로젝트 메모리", seen, normalizedProjectRoot);
AddInstructionFileIfExists(Path.Combine(directory, LocalMemoryFileName), "local", "로컬 메모리", seen, normalizedProjectRoot);
}
}
private void AddInstructionRuleFilesIfExists(string rulesDirectory, string layer, string label, HashSet<string> seen)
private void AddInstructionRuleFilesIfExists(string rulesDirectory, string layer, string label, HashSet<string> seen, string? projectRoot)
{
try
{
@@ -378,7 +382,7 @@ public class AgentMemoryService
return;
foreach (var file in Directory.GetFiles(rulesDirectory, "*.md").OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
AddInstructionFileIfExists(file, layer, label, seen);
AddInstructionFileIfExists(file, layer, label, seen, projectRoot);
}
catch (Exception ex)
{
@@ -386,7 +390,7 @@ public class AgentMemoryService
}
}
private void AddInstructionFileIfExists(string path, string layer, string label, HashSet<string> seen)
private void AddInstructionFileIfExists(string path, string layer, string label, HashSet<string> seen, string? projectRoot)
{
try
{
@@ -397,7 +401,7 @@ public class AgentMemoryService
if (!seen.Add(fullPath))
return;
var content = File.ReadAllText(fullPath);
var content = ExpandInstructionIncludes(fullPath, projectRoot, new HashSet<string>(StringComparer.OrdinalIgnoreCase), 0);
if (string.IsNullOrWhiteSpace(content))
return;
@@ -415,14 +419,17 @@ public class AgentMemoryService
}
}
private static IEnumerable<string> EnumerateDirectoryChain(string workFolder)
private static IEnumerable<string> EnumerateDirectoryChain(string projectRoot, string workFolder)
{
var current = Path.GetFullPath(workFolder);
var normalizedRoot = Path.GetFullPath(projectRoot);
var stack = new Stack<string>();
while (!string.IsNullOrWhiteSpace(current))
{
stack.Push(current);
if (string.Equals(current, normalizedRoot, StringComparison.OrdinalIgnoreCase))
break;
var parent = Directory.GetParent(current)?.FullName;
if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, current, StringComparison.OrdinalIgnoreCase))
break;
@@ -433,6 +440,179 @@ public class AgentMemoryService
yield return stack.Pop();
}
private static string? ResolveProjectRoot(string? workFolder)
{
if (string.IsNullOrWhiteSpace(workFolder) || !Directory.Exists(workFolder))
return null;
var current = Path.GetFullPath(workFolder);
while (!string.IsNullOrWhiteSpace(current))
{
if (Directory.Exists(Path.Combine(current, ".git")))
return current;
if (File.Exists(Path.Combine(current, ".git")) ||
File.Exists(Path.Combine(current, ".sln")) ||
Directory.EnumerateFiles(current, "*.sln", SearchOption.TopDirectoryOnly).Any() ||
Directory.EnumerateFiles(current, "*.csproj", SearchOption.TopDirectoryOnly).Any() ||
File.Exists(Path.Combine(current, "package.json")) ||
File.Exists(Path.Combine(current, "pyproject.toml")) ||
File.Exists(Path.Combine(current, "go.mod")) ||
File.Exists(Path.Combine(current, "Cargo.toml")))
{
return current;
}
var parent = Directory.GetParent(current)?.FullName;
if (string.IsNullOrWhiteSpace(parent) || string.Equals(parent, current, StringComparison.OrdinalIgnoreCase))
break;
current = parent;
}
return Path.GetFullPath(workFolder);
}
private string ExpandInstructionIncludes(string path, string? projectRoot, HashSet<string> visited, int depth)
{
try
{
if (depth > MaxIncludeDepth)
return "";
var fullPath = Path.GetFullPath(path);
if (!visited.Add(fullPath))
return "";
if (!File.Exists(fullPath))
return "";
var lines = File.ReadAllLines(fullPath);
var sb = new StringBuilder();
var inCodeBlock = false;
foreach (var originalLine in lines)
{
var line = originalLine;
var trimmed = line.Trim();
if (trimmed.StartsWith("```", StringComparison.Ordinal))
{
inCodeBlock = !inCodeBlock;
sb.AppendLine(line);
continue;
}
if (!inCodeBlock && trimmed.StartsWith("@", StringComparison.Ordinal) && trimmed.Length > 1)
{
var includePath = ResolveIncludePath(fullPath, trimmed, projectRoot, IsExternalMemoryIncludeAllowed());
if (!string.IsNullOrWhiteSpace(includePath))
{
var included = ExpandInstructionIncludes(includePath, projectRoot, visited, depth + 1);
if (!string.IsNullOrWhiteSpace(included))
{
sb.AppendLine(included.TrimEnd());
continue;
}
}
}
sb.AppendLine(line);
}
visited.Remove(fullPath);
return sb.ToString().Trim();
}
catch (Exception ex)
{
LogService.Warn($"메모리 include 확장 실패 ({path}): {ex.Message}");
return "";
}
}
private static string? ResolveIncludePath(string currentFile, string includeDirective, string? projectRoot, bool allowExternal)
{
var target = includeDirective[1..].Trim();
if (string.IsNullOrWhiteSpace(target))
return null;
var hashIndex = target.IndexOf('#');
if (hashIndex >= 0)
target = target[..hashIndex];
if (string.IsNullOrWhiteSpace(target))
return null;
string resolved;
var externalCandidate = false;
if (target.StartsWith("~/", StringComparison.Ordinal))
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
resolved = Path.Combine(home, target[2..]);
externalCandidate = true;
}
else if (Path.IsPathRooted(target))
{
resolved = target;
externalCandidate = true;
}
else
{
var baseDir = Path.GetDirectoryName(currentFile) ?? "";
var relative = target.StartsWith("./", StringComparison.Ordinal) ? target[2..] : target;
resolved = Path.Combine(baseDir, relative);
}
resolved = Path.GetFullPath(resolved);
var ext = Path.GetExtension(resolved);
if (!string.IsNullOrWhiteSpace(ext) &&
!new[] { ".md", ".txt", ".cs", ".json", ".yml", ".yaml", ".xml", ".props", ".targets" }
.Contains(ext, StringComparer.OrdinalIgnoreCase))
return null;
if (!allowExternal)
{
if (externalCandidate)
return null;
if (!string.IsNullOrWhiteSpace(projectRoot) && !IsSubPathOf(projectRoot, resolved))
return null;
}
return File.Exists(resolved) ? resolved : null;
}
private static bool IsSubPathOf(string baseDirectory, string candidatePath)
{
try
{
var basePath = Path.GetFullPath(baseDirectory)
.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
var fullCandidate = Path.GetFullPath(candidatePath);
return fullCandidate.StartsWith(basePath, StringComparison.OrdinalIgnoreCase)
|| string.Equals(fullCandidate.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
basePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar),
StringComparison.OrdinalIgnoreCase);
}
catch
{
return false;
}
}
private static bool IsExternalMemoryIncludeAllowed()
{
try
{
var app = System.Windows.Application.Current as App;
return app?.SettingsService?.Settings.Llm.AllowExternalMemoryIncludes ?? false;
}
catch
{
return false;
}
}
/// <summary>텍스트를 토큰으로 분리합니다.</summary>
private static HashSet<string> Tokenize(string text)
{

View File

@@ -428,6 +428,13 @@ public class SettingsViewModel : INotifyPropertyChanged
set { _maxMemoryEntries = value; OnPropertyChanged(); }
}
private bool _allowExternalMemoryIncludes;
public bool AllowExternalMemoryIncludes
{
get => _allowExternalMemoryIncludes;
set { _allowExternalMemoryIncludes = value; OnPropertyChanged(); }
}
// ── 이미지 입력 (멀티모달) ──
private bool _enableImageInput = true;
public bool EnableImageInput
@@ -1152,6 +1159,7 @@ public class SettingsViewModel : INotifyPropertyChanged
_enableAgentMemory = llm.EnableAgentMemory;
_enableProjectRules = llm.EnableProjectRules;
_maxMemoryEntries = llm.MaxMemoryEntries;
_allowExternalMemoryIncludes = llm.AllowExternalMemoryIncludes;
_enableImageInput = llm.EnableImageInput;
_maxImageSizeKb = llm.MaxImageSizeKb > 0 ? llm.MaxImageSizeKb : 5120;
_enableToolHooks = llm.EnableToolHooks;
@@ -1594,6 +1602,7 @@ public class SettingsViewModel : INotifyPropertyChanged
s.Llm.EnableAgentMemory = _enableAgentMemory;
s.Llm.EnableProjectRules = _enableProjectRules;
s.Llm.MaxMemoryEntries = _maxMemoryEntries;
s.Llm.AllowExternalMemoryIncludes = _allowExternalMemoryIncludes;
s.Llm.EnableImageInput = _enableImageInput;
s.Llm.MaxImageSizeKb = _maxImageSizeKb;
s.Llm.EnableToolHooks = _enableToolHooks;

View File

@@ -4882,6 +4882,16 @@
</StackPanel>
</Grid>
</Border>
<Border Style="{StaticResource AgentSettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<TextBlock Style="{StaticResource RowLabel}" Text="외부 메모리 include 허용"/>
<TextBlock Style="{StaticResource RowHint}" Text="AXMEMORY.md의 @include가 프로젝트 바깥 파일을 읽는 것을 허용합니다. 기본은 안전하게 꺼져 있습니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding AllowExternalMemoryIncludes, Mode=TwoWay}"/>
</Grid>
</Border>
</StackPanel>
</ScrollViewer>