summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authortanaka takashi <isazaka@gmail.com>2026-08-11 00:04:36 +0900
committertanaka takashi <isazaka@gmail.com>2026-08-11 00:04:36 +0900
commitd5b3cb20c6dacd31bb54c52a30a0f64487e30215 (patch)
tree1461ed9f6c3ad42488485045f7295f391928e7b7
parente8b625e22f343d75e1b50d1634b4ab06899d060b (diff)
feat: P1 tool improvements and P5 Agent Skills standard compliance
P1: Tool小改善 1. tailバイナリ判定 (TextFileDetector): - file_search tailモードでtext/binary判定を追加 - 先頭4KBのみ読み、BOM確認(UTF-8/UTF-16 LE/BE)→UTF-8 strict byte検証 - NULバイトのみ判定不可(UTF-16でNULが正常出現するため) - binaryファイルはToolValidationExceptionで拒否 - TextFileDetectorクラスへ判定ロジックを分離 2. file_edit rename機能: - file_edit.operation=renameを追加(新規toolではない) - new_path property追加、Write capability要求(source/destination双方) - destination既存時は禁止(overwrite不可) - cross-volume renameは不支持、Tool failureとして拒否 - FileRenameTransaction + RecoveryManager file.rename recovery追加 - crash recovery: destination hash == journal記録の場合のみ戻す P5: Agent Skills open standard準拠 3. Skill形式をagentskills.io標準へ移行: - legacy単一Markdown形式(skills/*.md)を廃止 - <skill-name>/SKILL.md標準directory形式のみ対応 - YAML frontmatter必須(name/description)、YamlDotNetで解析 - validation: name必須(1-64文字,lowercase英数字+hyphen,親dir名一致), description必須(1-1024文字), malformed YAML reject, duplicate fatal 4. Progressive Disclosure: - Stage 1 Discovery: name+descriptionのみをsystem messageへ(name+desc一覧) - Stage 3 Instructions: 選択Skillの本文のみ注入 - PromptComposer: 全文埋め込みを廃止、カタログ形式へ変更 - PromptContext: Skills→AvailableSkills+SelectedSkillsへ分割 - 未選択Skill本文はpromptへ入らない 5. Internal Tool境界維持: - skill_read等のInternal Tool追加なし - Internal Toolは6群(file_search/file_read/file_edit/process_run/task_update/dir)のまま - Skill選択・本文ロードはAgent Runtime/Prompt Context Managementの責務 6. AgentSkill data model (Agent.Core/Skills/AgentSkill.cs): - RootPath, Name, Description, Instructions, License?, Compatibility?, Metadata?, AllowedTools? - SkillLoader(Agent.App)がYamlDotNetでfrontmatter解析→AgentSkill構築 設計判断: - renameはfile_edit operation拡張(新規toolではない) - rmdir空dirのみ制約は維持 - Skill load != permission grant維持 - allowed-toolsはSandbox permissionとして解釈しない - YamlDotNetはAgent.App側のみ使用(Agent.Coreへ依存追加なし) 変更対象: - TextFileDetector.cs: 新規、text/binary判定 - WindowsToolBackend.cs: tail binary判定統合、rename case追加 - InternalToolCatalog.cs: file_edit operation enum に rename 追加、new_path property - InternalToolValidator.cs: rename時new_path必須検証 - TransactionalFileEditor.cs: Rename() + FileRenameTransaction - RecoveryManager.cs: file.rename recovery - AgentSkill.cs: 新規data model - SkillLoader.cs: 全面書き換え、YamlDotNet frontmatter解析、validation - PromptComposer.cs: Progressive Disclosure、AvailableSkills+SelectedSkills - AgentRuntimeSettings.cs: Skills型変更 - AgentRuntime.cs: SelectedSkills=[]で初期化 - Program.cs: SkillLoader戻り値型対応 - Specifications.md: FS-07 rename、Skill節Agent Skills standard準拠 - Design.md: §7.1 tail binary判定、§7.3 rename、AgentSkill model - ImplementationPlan.md: Prompt順序更新、Skill標準準拠 テスト: - SecurityPolicyTests.cs: tail binary拒否/UTF-8/UTF-16 text許可、rename正常/dest既存拒否/許可外拒否 - InternalToolTests.cs: rename validationテスト追加 - ConfigurationTests.cs: SkillLoader標準形式/missing frontmatter/name mismatch/duplicate tests - PromptAndRuntimeTests.cs: PromptContext型変更対応、skill catalog検証 検証結果: - Release build: 警告0、エラー0 - Core tests: 61/61 PASS、skip 0 - Planner tests: 10/10 PASS、skip 0 - Internal Tool 6群固定: 確認済み
-rw-r--r--doc/Design.md12
-rw-r--r--doc/Specifications.md15
-rw-r--r--plans/ImplementationPlan.md6
-rw-r--r--src/Agent.App/Configuration/SkillLoader.cs105
-rw-r--r--src/Agent.App/Program.cs4
-rw-r--r--src/Agent.Core/Prompts/PromptComposer.cs22
-rw-r--r--src/Agent.Core/Runtime/AgentRuntime.cs2
-rw-r--r--src/Agent.Core/Runtime/AgentRuntimeSettings.cs4
-rw-r--r--src/Agent.Core/Skills/AgentSkill.cs15
-rw-r--r--src/Agent.Core/Tools/InternalToolCatalog.cs2
-rw-r--r--src/Agent.Core/Tools/InternalToolValidator.cs2
-rw-r--r--src/Agent.Windows/Recovery/RecoveryManager.cs13
-rw-r--r--src/Agent.Windows/Recovery/TransactionalFileEditor.cs41
-rw-r--r--src/Agent.Windows/Tools/TextFileDetector.cs141
-rw-r--r--src/Agent.Windows/Tools/WindowsToolBackend.cs16
-rw-r--r--tests/Agent.Core.Tests/ConfigurationTests.cs45
-rw-r--r--tests/Agent.Core.Tests/InternalToolTests.cs2
-rw-r--r--tests/Agent.Core.Tests/PromptAndRuntimeTests.cs11
-rw-r--r--tests/Agent.Windows.Tests/SecurityPolicyTests.cs144
19 files changed, 568 insertions, 34 deletions
diff --git a/doc/Design.md b/doc/Design.md
index 101a896..31d0247 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -265,7 +265,7 @@ Ollamaに公開するInternal Toolは次の6群に固定する。個々のOS com
{"root":"string","mode":"glob|name|text|regex|tail","query":"string","max_results":100,"tail_lines":100}
```
- `root`はread許可directory/file配下でなければdeny。
-- `tail`はtext file向け。binaryは拒否する。
+- `tail`はtext file向け。binaryは拒否する。判定は先頭4 KiBのみを読み、BOM確認(UTF-8/UTF-16 LE/UTF-16 BE)→BOMなしはUTF-8 strict byte sequence検証で行う。NULバイトのみの判定はUTF-16 textを誤拒否するため使用しない。
- `max_results`にhard upper boundを設け、context爆発を防ぐ。
- `max_results`のhard upper boundは1,000件、`tail_lines`は10,000行とする。
### 7.2 file_read
@@ -279,14 +279,17 @@ Ollamaに公開するInternal Toolは次の6群に固定する。個々のOS com
```json
{
"path":"string",
- "operation":"overwrite|patch|atomic_replace|delete",
+ "operation":"overwrite|patch|atomic_replace|delete|rename",
"content":"string|null",
"patch":"string|null",
- "expected_sha256":"string|null"
+ "expected_sha256":"string|null",
+ "new_path":"string|null"
}
```
- overwrite/patch/atomic_replaceはwrite permissionを要求する。
- deleteはdelete permissionを要求する。
+- renameはsource/destination双方にwrite permissionを要求する。destinationが既に存在する場合は拒否する(overwrite不可)。cross-volume renameは不支持でTool failureとして返す。
+- rename transaction: PREPARE(source/destination検査+journal) → APPLY(File.Move) → VERIFY(source不存在/destination存在/hash一致) → COMMIT。crash recoveryはdestination hashがjournal記録と一致する場合のみdestinationからsourceへ戻す。
- `expected_sha256` 指定時はoptimistic concurrency guardとして利用する。
### 7.4 process_run
```json
@@ -379,7 +382,7 @@ AgentSession
1. Fixed System Prompt
2. User System Prompt
3. locale instruction
-4. selected Skills
+4. selected Skills (name + description一覧のみ、Progressive Disclosure Stage 1)
5. Sandbox capability summary
6. current TaskGraph / current Task
7. previous Summary
@@ -829,6 +832,7 @@ errorは内部codeとlocalized messageを分離し、ログ/auditにはcode、MC
- `AuditEvent`
- `LanguageContext`
- `SkillDescriptor`
+- `AgentSkill`
- `ToolCallRecord`
## 29. 実装レビュー用チェックリスト
- [ ] public MCP toolは2個だけか。
diff --git a/doc/Specifications.md b/doc/Specifications.md
index 5d21037..007ce51 100644
--- a/doc/Specifications.md
+++ b/doc/Specifications.md
@@ -310,10 +310,16 @@ Context要約を開始する利用率閾値は設定可能でなければならã
### 10.2 Skill
-- SkillはMarkdown instructionである。
-- Skillロードは権限付与を意味しない。
-- 必要なSkillだけを選択する。
-- Skill選択はLLMにより行うが、選択可能集合は設定されたSkill directoryに限定する。
+- Agent Skills open standard (agentskills.io) に準拠する。
+- Skill directory構造は `<skill-name>/SKILL.md` のみを扱う。legacy単一Markdown形式は廃止する。
+- `SKILL.md` はYAML frontmatter + Markdown bodyで構成し、`name` (1-64文字、lowercase英数字+hyphen、親directory名と一致) と `description` (1-1024文字) を必須とする。
+- frontmatterなしのSkillは推測せずrejectする。malformed YAML、duplicate skill nameは起動/check時fatalとする。
+- Progressive Disclosure: Stage 1 Discovery (name + descriptionのみ) → Stage 2 Selection (LLM) → Stage 3 Instructions Load (選択SKILL.md本文) → Stage 4 Resources (scripts/references/assets)。
+- Skill選択はLLMにより行う。選択可能集合は設定されたSkill directoryに限定する。Runtimeは選択結果を検証し、unknown Skill名を読み込まない。
+- Skillロードは権限付与を意味しない。`allowed-tools` metadataはSandbox permissionとして解釈しない。
+- Skillに `scripts/` が含まれていても自動実行許可しない。実行には通常どおりFilesystem execute capability、Process Approval、path + SHA-256、Sandbox Brokerを必須とする。
+- Skill directory内resourceについてcanonicalization、reparse point検査、Skill root外へのescape禁止を行う。
+- Skill選択・本文ロードはInternal Toolとして公開せず、Agent Runtime / Prompt Context Managementの責務として実装する。Internal Toolは既存5群+dir=6群に固定する。
---
@@ -344,6 +350,7 @@ Context要約を開始する利用率閾値は設定可能でなければならã
| FS-04 | write権限だけではdeleteできない。 |
| FS-05 | ACLとAppContainerの両方で強制可能な形にする。 |
| FS-06 | ディレクトリ作成 (mkdir) はwrite権限で許可する。ディレクトリ削除 (rmdir) はdelete権限で許可するが、空ディレクトリのみとする。ディレクトリ列挙 (list) はread権限で許可する。 |
+| FS-07 | renameはsource/destination双方にwrite権限を要求する。destination既存時は拒否する。cross-volume renameは不支持とする。 |
### 11.3 Process Policy
diff --git a/plans/ImplementationPlan.md b/plans/ImplementationPlan.md
index 5af7606..6ae6c61 100644
--- a/plans/ImplementationPlan.md
+++ b/plans/ImplementationPlan.md
@@ -256,12 +256,14 @@ MCP surfaceが仕様どおり2 Toolのみで固定されていること。
1. Fixed System Prompt
2. User System Prompt
3. Locale instruction
-4. Skill
+4. Skill catalog (name + descriptionのみ、Progressive Disclosure Stage 1)
5. Sandbox capability summary
6. TaskGraph / current Task
7. Summary
-8. Conversation / Execution Result
+8. Selected Skill instructions (Progressive Disclosure Stage 3)
+9. Conversation / Execution Result
このPhaseではSkill選択やTaskGraphが未実装の場合、空要素として合成可能にする。
+SkillはAgent Skills open standard (agentskills.io) 準拠とし、legacy単一Markdown形式は廃止する。
## 8.4 必須テスト
- [ ] text-only Ollama call成功
- [ ] Session継続でconversationが保持される
diff --git a/src/Agent.App/Configuration/SkillLoader.cs b/src/Agent.App/Configuration/SkillLoader.cs
index 6923c12..bac1559 100644
--- a/src/Agent.App/Configuration/SkillLoader.cs
+++ b/src/Agent.App/Configuration/SkillLoader.cs
@@ -1,24 +1,109 @@
+using System.Text.RegularExpressions;
+using Agent.Core.Skills;
+using YamlDotNet.Serialization;
+using YamlDotNet.Serialization.NamingConventions;
+
namespace Agent.App.Configuration;
public static class SkillLoader
{
- public static string[] Load(string directory)
+ private static readonly Regex NamePattern = new("^[a-z0-9]([a-z0-9-]*[a-z0-9])?$", RegexOptions.Compiled);
+
+ public static IReadOnlyList<AgentSkill> Load(string directory)
{
- var paths = new List<string>();
- paths.AddRange(Directory.EnumerateFiles(directory, "*.md", SearchOption.TopDirectoryOnly));
+ if (!Directory.Exists(directory)) return [];
+
+ var skills = new List<AgentSkill>();
+ var names = new HashSet<string>(StringComparer.Ordinal);
foreach (string child in Directory.EnumerateDirectories(directory, "*", SearchOption.TopDirectoryOnly))
{
var info = new DirectoryInfo(child);
if ((info.Attributes & FileAttributes.ReparsePoint) != 0) continue;
- string standardSkill = Path.Combine(info.FullName, "SKILL.md");
- if (File.Exists(standardSkill)) paths.Add(standardSkill);
+
+ string skillMd = Path.Combine(info.FullName, "SKILL.md");
+ if (!File.Exists(skillMd)) continue;
+
+ string dirName = info.Name;
+ string content = File.ReadAllText(skillMd);
+ var (frontmatter, body) = SplitFrontmatter(content);
+
+ if (frontmatter is null)
+ throw new SkillLoadException($"Skill '{dirName}': SKILL.md is missing YAML frontmatter.");
+
+ SkillFrontmatter fm = ParseFrontmatter(frontmatter);
+
+ ValidateSkill(dirName, fm, names);
+
+ skills.Add(new AgentSkill(
+ RootPath: info.FullName,
+ Name: fm.Name,
+ Description: fm.Description,
+ Instructions: body.Trim(),
+ License: fm.License,
+ Compatibility: fm.Compatibility,
+ Metadata: fm.Metadata,
+ AllowedTools: fm.AllowedTools));
+ }
+
+ return skills;
+ }
+
+ private static void ValidateSkill(string dirName, SkillFrontmatter fm, HashSet<string> names)
+ {
+ if (string.IsNullOrWhiteSpace(fm.Name))
+ throw new SkillLoadException($"Skill '{dirName}': name is required.");
+ if (fm.Name.Length < 1 || fm.Name.Length > 64)
+ throw new SkillLoadException($"Skill '{dirName}': name must be 1-64 characters.");
+ if (!NamePattern.IsMatch(fm.Name))
+ throw new SkillLoadException($"Skill '{dirName}': name must be lowercase alphanumeric with hyphens.");
+ if (!fm.Name.Equals(dirName, StringComparison.Ordinal))
+ throw new SkillLoadException($"Skill '{dirName}': name '{fm.Name}' does not match directory name.");
+ if (string.IsNullOrWhiteSpace(fm.Description))
+ throw new SkillLoadException($"Skill '{dirName}': description is required.");
+ if (fm.Description.Length > 1024)
+ throw new SkillLoadException($"Skill '{dirName}': description must be 1-1024 characters.");
+ if (!names.Add(fm.Name))
+ throw new SkillLoadException($"Duplicate skill name '{fm.Name}'.");
+ }
+
+ private static (string? Frontmatter, string Body) SplitFrontmatter(string content)
+ {
+ if (!content.StartsWith("---", StringComparison.Ordinal)) return (null, content);
+ int end = content.IndexOf("\n---", 3, StringComparison.Ordinal);
+ if (end < 0) return (null, content);
+ string frontmatter = content[3..end].Trim();
+ int bodyStart = end + 4;
+ if (bodyStart < content.Length && content[bodyStart] == '\r') bodyStart++;
+ if (bodyStart < content.Length && content[bodyStart] == '\n') bodyStart++;
+ string body = bodyStart < content.Length ? content[bodyStart..] : string.Empty;
+ return (frontmatter, body);
+ }
+
+ private static SkillFrontmatter ParseFrontmatter(string yaml)
+ {
+ try
+ {
+ var deserializer = new DeserializerBuilder()
+ .WithNamingConvention(UnderscoredNamingConvention.Instance)
+ .Build();
+ return deserializer.Deserialize<SkillFrontmatter>(yaml);
+ }
+ catch (Exception ex)
+ {
+ throw new SkillLoadException($"Malformed YAML frontmatter: {ex.Message}");
}
+ }
- return paths
- .Distinct(StringComparer.OrdinalIgnoreCase)
- .Order(StringComparer.OrdinalIgnoreCase)
- .Select(File.ReadAllText)
- .ToArray();
+ private sealed class SkillFrontmatter
+ {
+ public string Name { get; set; } = "";
+ public string Description { get; set; } = "";
+ public string? License { get; set; }
+ public string? Compatibility { get; set; }
+ public Dictionary<string, string>? Metadata { get; set; }
+ public string? AllowedTools { get; set; }
}
}
+
+public sealed class SkillLoadException(string message) : Exception(message);
diff --git a/src/Agent.App/Program.cs b/src/Agent.App/Program.cs
index 82a3f36..7839e82 100644
--- a/src/Agent.App/Program.cs
+++ b/src/Agent.App/Program.cs
@@ -85,7 +85,7 @@ internal static class Program
{
string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt));
string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory);
- string[] skills = SkillLoader.Load(skillDirectory);
+ var skills = SkillLoader.Load(skillDirectory);
var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported"));
var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes);
await McpHost.RunStdioAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits, CancellationToken.None,
@@ -96,7 +96,7 @@ internal static class Program
{
string userPrompt = File.ReadAllText(ConfigurationLoader.Resolve(baseDirectory, configuration.Agent.UserSystemPrompt));
string skillDirectory = ConfigurationLoader.Resolve(baseDirectory, configuration.Skills.Directory);
- string[] skills = SkillLoader.Load(skillDirectory);
+ var skills = SkillLoader.Load(skillDirectory);
var runtimeSettings = new AgentRuntimeSettings(userPrompt, language, skills, "Windows AppContainer, ACL, Job Object, child-process restriction, and WFP sandbox capabilities.", configuration.Agent.MaxIterations, configuration.Agent.SameFailureLimit, locale.Get("ollama.vision_unsupported"));
var visionLimits = new VisionLimits(configuration.Mcp.Images.MaxCount, configuration.Mcp.Images.MaxBytesEach, configuration.Mcp.Images.MaxTotalBytes);
await McpHost.RunHttpAsync(locale, OllamaSettingsFactory.Create(configuration.Ollama), runtimeSettings, visionLimits,
diff --git a/src/Agent.Core/Prompts/PromptComposer.cs b/src/Agent.Core/Prompts/PromptComposer.cs
index e07c7e8..69b18f3 100644
--- a/src/Agent.Core/Prompts/PromptComposer.cs
+++ b/src/Agent.Core/Prompts/PromptComposer.cs
@@ -1,5 +1,6 @@
using Agent.Core.Ollama;
using Agent.Core.Sessions;
+using Agent.Core.Skills;
namespace Agent.Core.Prompts;
@@ -7,7 +8,8 @@ public sealed record PromptContext(
string FixedSystemPrompt,
string UserSystemPrompt,
string Locale,
- IReadOnlyList<string> Skills,
+ IReadOnlyList<AgentSkill> AvailableSkills,
+ IReadOnlyList<AgentSkill> SelectedSkills,
string SandboxCapabilitySummary,
string TaskGraphSummary,
string PreviousSummary,
@@ -24,13 +26,29 @@ public static class PromptComposer
new("system", context.FixedSystemPrompt),
new("system", context.UserSystemPrompt),
new("system", $"Respond in locale: {context.Locale}."),
- new("system", string.Join("\n\n", context.Skills)),
+ new("system", ComposeSkillCatalog(context.AvailableSkills)),
new("system", context.SandboxCapabilitySummary),
new("system", context.TaskGraphSummary),
new("system", context.PreviousSummary)
};
+ // Inject selected skill instructions (Progressive Disclosure Stage 3).
+ foreach (AgentSkill skill in context.SelectedSkills)
+ messages.Add(new("system", skill.Instructions));
messages.AddRange(context.Conversation.Select(static item => new OllamaMessage(item.Role, item.Content)));
messages.Add(new OllamaMessage("user", context.Request, context.Images));
return messages;
}
+
+ /// <summary>
+ /// Stage 1 Discovery: only name + description are included. Full instructions are NOT included.
+ /// </summary>
+ private static string ComposeSkillCatalog(IReadOnlyList<AgentSkill> skills)
+ {
+ if (skills.Count == 0) return "No skills available.";
+ var lines = new List<string> { "Available skills:" };
+ foreach (AgentSkill skill in skills)
+ lines.Add($"- {skill.Name}: {skill.Description}");
+ lines.Add("If a skill is relevant to the request, indicate which skills to use.");
+ return string.Join("\n", lines);
+ }
}
diff --git a/src/Agent.Core/Runtime/AgentRuntime.cs b/src/Agent.Core/Runtime/AgentRuntime.cs
index 0fcf7d0..228ac41 100644
--- a/src/Agent.Core/Runtime/AgentRuntime.cs
+++ b/src/Agent.Core/Runtime/AgentRuntime.cs
@@ -1,6 +1,7 @@
using Agent.Core.Ollama;
using Agent.Core.Prompts;
using Agent.Core.Sessions;
+using Agent.Core.Skills;
using Agent.Core.Tasks;
using Agent.Core.Tools;
@@ -32,6 +33,7 @@ public sealed class AgentRuntime(TaskRegistry registry, IOllamaClient ollama, Ag
settings.UserSystemPrompt,
settings.Locale,
settings.Skills,
+ [], // SelectedSkills: populated after LLM skill selection (Progressive Disclosure Stage 2→3).
settings.SandboxCapabilitySummary,
"Current task: " + task.Request,
string.Empty,
diff --git a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
index 9bc04b9..65ee59d 100644
--- a/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
+++ b/src/Agent.Core/Runtime/AgentRuntimeSettings.cs
@@ -1,9 +1,11 @@
+using Agent.Core.Skills;
+
namespace Agent.Core.Runtime;
public sealed record AgentRuntimeSettings(
string UserSystemPrompt,
string Locale,
- IReadOnlyList<string> Skills,
+ IReadOnlyList<AgentSkill> Skills,
string SandboxCapabilitySummary,
int MaxIterations = 128,
int SameFailureLimit = 5,
diff --git a/src/Agent.Core/Skills/AgentSkill.cs b/src/Agent.Core/Skills/AgentSkill.cs
new file mode 100644
index 0000000..fb0e6e9
--- /dev/null
+++ b/src/Agent.Core/Skills/AgentSkill.cs
@@ -0,0 +1,15 @@
+namespace Agent.Core.Skills;
+
+/// <summary>
+/// Agent Skills open standard (agentskills.io) compliant skill model.
+/// Populated from SKILL.md frontmatter and body by the SkillLoader.
+/// </summary>
+public sealed record AgentSkill(
+ string RootPath,
+ string Name,
+ string Description,
+ string Instructions,
+ string? License = null,
+ string? Compatibility = null,
+ IReadOnlyDictionary<string, string>? Metadata = null,
+ string? AllowedTools = null); \ No newline at end of file
diff --git a/src/Agent.Core/Tools/InternalToolCatalog.cs b/src/Agent.Core/Tools/InternalToolCatalog.cs
index 3ab17b1..14cba45 100644
--- a/src/Agent.Core/Tools/InternalToolCatalog.cs
+++ b/src/Agent.Core/Tools/InternalToolCatalog.cs
@@ -17,7 +17,7 @@ public static class InternalToolCatalog
[
Define("file_search", "Search files under an approved root.", """{"type":"object","additionalProperties":false,"required":["root","mode","query"],"properties":{"root":{"type":"string"},"mode":{"type":"string","enum":["glob","name","text","regex","tail"]},"query":{"type":"string"},"max_results":{"type":"integer","minimum":1,"maximum":1000},"tail_lines":{"type":"integer","minimum":1,"maximum":10000}}}"""),
Define("file_read", "Read an approved file.", """{"type":"object","additionalProperties":false,"required":["path","mode"],"properties":{"path":{"type":"string"},"mode":{"type":"string","enum":["whole","range","metadata"]},"start":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0}}}"""),
- Define("file_edit", "Edit an approved file transactionally.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]}}}"""),
+ Define("file_edit", "Edit an approved file transactionally.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["overwrite","patch","atomic_replace","delete","rename"]},"content":{"type":["string","null"]},"patch":{"type":["string","null"]},"expected_sha256":{"type":["string","null"]},"new_path":{"type":["string","null"]}}}"""),
Define("process_run", "Run one approved executable with separated arguments.", """{"type":"object","additionalProperties":false,"required":["executable","args","cwd"],"properties":{"executable":{"type":"string"},"args":{"type":"array","items":{"type":"string"}},"cwd":{"type":"string"},"timeout_seconds":{"type":"integer","minimum":1},"stdin":{"type":["string","null"]}}}"""),
Define("task_update", "Update the deterministic task graph.", """{"type":"object","additionalProperties":false,"required":["operation"],"properties":{"operation":{"type":"string","enum":["add","add_dependency","set_state","set_estimate"]},"task_id":{"type":["string","null"]},"description":{"type":["string","null"]},"depends_on":{"type":"array","items":{"type":"string"}},"budget_class":{"type":["string","null"],"enum":["1K","2K","4K","8K","16K","32K","64K","128K",null]},"optimistic_tokens":{"type":"integer","minimum":0},"most_likely_tokens":{"type":"integer","minimum":0},"pessimistic_tokens":{"type":"integer","minimum":0},"state":{"type":["string","null"],"enum":["queued","planning","running","completed","failed","cancelled",null]}}}"""),
Define("dir", "Create, remove, or list directories under an approved root. Only empty directories can be removed.", """{"type":"object","additionalProperties":false,"required":["path","operation"],"properties":{"path":{"type":"string"},"operation":{"type":"string","enum":["mkdir","rmdir","list"]}}}""")
diff --git a/src/Agent.Core/Tools/InternalToolValidator.cs b/src/Agent.Core/Tools/InternalToolValidator.cs
index 091b790..d789dbf 100644
--- a/src/Agent.Core/Tools/InternalToolValidator.cs
+++ b/src/Agent.Core/Tools/InternalToolValidator.cs
@@ -66,6 +66,8 @@ public static class InternalToolValidator
throw new ToolValidationException($"{operation} requires content.");
if (operation == "patch" && (!arguments.TryGetProperty("patch", out JsonElement patch) || patch.ValueKind != JsonValueKind.String))
throw new ToolValidationException("patch requires patch text.");
+ if (operation == "rename" && (!arguments.TryGetProperty("new_path", out JsonElement newPath) || newPath.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(newPath.GetString())))
+ throw new ToolValidationException("rename requires new_path.");
}
if (name == "task_update")
{
diff --git a/src/Agent.Windows/Recovery/RecoveryManager.cs b/src/Agent.Windows/Recovery/RecoveryManager.cs
index 8fd7368..c9b28bf 100644
--- a/src/Agent.Windows/Recovery/RecoveryManager.cs
+++ b/src/Agent.Windows/Recovery/RecoveryManager.cs
@@ -49,6 +49,7 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
{
case "file.replace": RecoverFile(entry); break;
case "file.delete": RecoverDeletedFile(entry); break;
+ case "file.rename": RecoverRenamedFile(entry); break;
case "dir.mkdir": RecoverMkdir(entry); break;
case "dir.rmdir": RecoverRmdir(entry); break;
case "acl": RecoverAcl(entry); break;
@@ -96,6 +97,18 @@ public sealed class RecoveryManager(string journalDirectory, ISecurityAuditSink
File.WriteAllBytes(entry.Target, original);
}
+ private static void RecoverRenamedFile(JournalEntry entry)
+ {
+ string destination = entry.Data["destination_path"];
+ string sourceHash = entry.Data["source_sha256"];
+ if (!File.Exists(destination)) return; // Already rolled back or destination missing — skip.
+ if (!Hash(destination).Equals(sourceHash, StringComparison.Ordinal))
+ throw new InvalidOperationException($"Renamed destination '{destination}' hash does not match journal record; automatic recovery refused.");
+ if (File.Exists(entry.Target))
+ throw new InvalidOperationException($"Source path '{entry.Target}' was recreated after crash; automatic recovery refused.");
+ File.Move(destination, entry.Target);
+ }
+
private static void RecoverMkdir(JournalEntry entry)
{
if (!Directory.Exists(entry.Target)) return;
diff --git a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
index 805a069..25c949d 100644
--- a/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
+++ b/src/Agent.Windows/Recovery/TransactionalFileEditor.cs
@@ -93,6 +93,26 @@ public sealed class TransactionalFileEditor(TaskResourceJournal journal, string
return new(journal, entryId, canonical);
}
+ public FileRenameTransaction Rename(string sourcePath, string destinationPath)
+ {
+ string source = CanonicalPath.Resolve(sourcePath);
+ string destination = CanonicalPath.Resolve(destinationPath, requireExisting: false);
+ if (!File.Exists(source)) throw new FileNotFoundException($"Source file does not exist: '{source}'.");
+ if (File.Exists(destination)) throw new ToolValidationException($"Destination already exists: '{destination}'. Rename overwrite is not allowed.");
+ if (!source.StartsWith(System.IO.Path.GetPathRoot(source)!, StringComparison.OrdinalIgnoreCase) ||
+ !destination.StartsWith(System.IO.Path.GetPathRoot(source)!, StringComparison.OrdinalIgnoreCase))
+ throw new ToolValidationException("Cross-volume rename is not supported.");
+ byte[] content = File.ReadAllBytes(source);
+ string sourceHash = Convert.ToHexString(SHA256.HashData(content));
+ Guid entryId = journal.Prepare("file.rename", source, new Dictionary<string, string>
+ {
+ ["destination_path"] = destination, ["source_sha256"] = sourceHash
+ });
+ File.Move(source, destination);
+ journal.MarkApplied(entryId);
+ return new(journal, entryId, source, destination, sourceHash);
+ }
+
private static string NormalizeHash(string hash) => hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant();
}
@@ -144,6 +164,27 @@ public sealed class DirectoryRmdirTransaction(TaskResourceJournal journal, Guid
}
}
+public sealed class FileRenameTransaction(TaskResourceJournal journal, Guid entryId, string sourcePath, string destinationPath, string sourceHash)
+{
+ private bool _finished;
+ public void Commit() { if (_finished) return; journal.MarkCleaned(entryId); _finished = true; }
+ public void Rollback()
+ {
+ if (_finished) return;
+ if (!File.Exists(destinationPath)) throw new InvalidOperationException("Rollback refused because the destination file is missing.");
+ if (!Hash(destinationPath).Equals(sourceHash, StringComparison.Ordinal))
+ throw new InvalidDataException("Rollback refused because the destination file hash does not match the original source hash.");
+ File.Move(destinationPath, sourcePath);
+ journal.MarkCleaned(entryId);
+ _finished = true;
+ }
+ private static string Hash(string target)
+ {
+ using FileStream stream = File.OpenRead(target);
+ return Convert.ToHexString(SHA256.HashData(stream));
+ }
+}
+
public sealed class FileEditTransaction(
TaskResourceJournal journal, Guid entryId, string path, string originalHash, string replacementHash,
string backupPath, string originalBase64, bool originallyExisted)
diff --git a/src/Agent.Windows/Tools/TextFileDetector.cs b/src/Agent.Windows/Tools/TextFileDetector.cs
new file mode 100644
index 0000000..9a5778b
--- /dev/null
+++ b/src/Agent.Windows/Tools/TextFileDetector.cs
@@ -0,0 +1,141 @@
+using System.IO;
+using System.Text;
+
+namespace Agent.Windows.Tools;
+
+/// <summary>
+/// Detects whether a file is text or binary by reading only a small sample from the beginning.
+/// Uses BOM detection, UTF-16 validation, and UTF-8 strict decode — not NUL-byte-only detection
+/// (which would incorrectly reject valid UTF-16 text).
+/// </summary>
+public static class TextFileDetector
+{
+ private const int SampleSize = 4096;
+
+ /// <summary>
+ /// Returns true if the file at <paramref name="path"/> appears to contain valid text
+ /// (UTF-8, UTF-8 with BOM, UTF-16 LE, or UTF-16 BE). Reads at most <see cref="SampleSize"/> bytes.
+ /// </summary>
+ public static bool IsText(string path)
+ {
+ using var stream = File.OpenRead(path);
+ return IsText(stream);
+ }
+
+ /// <summary>
+ /// Returns true if the stream's content appears to be valid text.
+ /// Reads at most <see cref="SampleSize"/> bytes from the current position.
+ /// </summary>
+ public static bool IsText(Stream stream)
+ {
+ byte[] buffer = new byte[SampleSize];
+ int read = stream.Read(buffer, 0, SampleSize);
+ if (read == 0) return true; // Empty file is considered text.
+
+ Span<byte> sample = buffer.AsSpan(0, read);
+
+ // 1. Check BOM.
+ if (sample.StartsWith(Encoding.UTF8.Preamble))
+ return TryDecodeUtf8(sample[3..]);
+ if (sample.StartsWith(Encoding.Unicode.Preamble)) // UTF-16 LE
+ return TryDecodeUtf16(sample[2..], bigEndian: false);
+ if (sample.StartsWith(Encoding.BigEndianUnicode.Preamble)) // UTF-16 BE
+ return TryDecodeUtf16(sample[2..], bigEndian: true);
+
+ // 2. No BOM — try UTF-8 strict decode.
+ return TryDecodeUtf8(sample);
+ }
+
+ private static bool TryDecodeUtf8(ReadOnlySpan<byte> data)
+ {
+ // Validate UTF-8 byte sequences manually to detect invalid bytes.
+ return TryDecodeUtf8Core(data);
+ }
+
+ private static bool TryDecodeUtf8Core(ReadOnlySpan<byte> data)
+ {
+ // Validate UTF-8 byte sequences manually to detect invalid bytes.
+ int i = 0;
+ while (i < data.Length)
+ {
+ byte b = data[i];
+ if (b < 0x80) // ASCII
+ {
+ i++;
+ }
+ else if (b < 0xC2) // Invalid leading byte
+ {
+ return false;
+ }
+ else if (b < 0xE0) // 2-byte sequence
+ {
+ if (i + 1 >= data.Length) break; // Truncated at sample boundary — assume valid.
+ if (!IsContinuationByte(data[i + 1])) return false;
+ i += 2;
+ }
+ else if (b < 0xF0) // 3-byte sequence
+ {
+ if (i + 2 >= data.Length) break;
+ if (!IsContinuationByte(data[i + 1]) || !IsContinuationByte(data[i + 2])) return false;
+ // Check for overlong encodings and surrogate halves.
+ if (b == 0xE0 && data[i + 1] < 0xA0) return false; // Overlong.
+ if (b == 0xED && data[i + 1] >= 0xA0) return false; // Surrogate half.
+ i += 3;
+ }
+ else if (b < 0xF5) // 4-byte sequence
+ {
+ if (i + 3 >= data.Length) break;
+ if (!IsContinuationByte(data[i + 1]) || !IsContinuationByte(data[i + 2]) || !IsContinuationByte(data[i + 3])) return false;
+ if (b == 0xF0 && data[i + 1] < 0x90) return false; // Overlong.
+ if (b == 0xF4 && data[i + 1] >= 0x90) return false; // Out of range.
+ i += 4;
+ }
+ else
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static bool TryDecodeUtf16(ReadOnlySpan<byte> data, bool bigEndian)
+ {
+ // UTF-16: every code unit is 2 bytes. Validate that surrogates are properly paired.
+ if (data.Length % 2 != 0)
+ {
+ // Odd byte count at sample boundary — check the pairs we have.
+ }
+
+ int i = 0;
+ while (i + 1 < data.Length)
+ {
+ ushort codeUnit = bigEndian
+ ? (ushort)((data[i] << 8) | data[i + 1])
+ : (ushort)((data[i + 1] << 8) | data[i]);
+
+ if (codeUnit < 0xD800 || codeUnit > 0xDFFF)
+ {
+ // BMP character — valid.
+ i += 2;
+ }
+ else if (codeUnit >= 0xD800 && codeUnit <= 0xDBFF)
+ {
+ // High surrogate — must be followed by a low surrogate.
+ if (i + 3 >= data.Length) break; // Truncated at sample boundary — assume valid.
+ ushort next = bigEndian
+ ? (ushort)((data[i + 2] << 8) | data[i + 3])
+ : (ushort)((data[i + 3] << 8) | data[i + 2]);
+ if (next < 0xDC00 || next > 0xDFFF) return false;
+ i += 4;
+ }
+ else
+ {
+ // Low surrogate without preceding high surrogate — invalid.
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static bool IsContinuationByte(byte b) => b >= 0x80 && b <= 0xBF;
+} \ No newline at end of file
diff --git a/src/Agent.Windows/Tools/WindowsToolBackend.cs b/src/Agent.Windows/Tools/WindowsToolBackend.cs
index bb54c6e..936bcbd 100644
--- a/src/Agent.Windows/Tools/WindowsToolBackend.cs
+++ b/src/Agent.Windows/Tools/WindowsToolBackend.cs
@@ -104,6 +104,7 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
int lines = arguments.TryGetProperty("tail_lines", out JsonElement lineElement) ? lineElement.GetInt32() : 100;
lines = Math.Min(lines, settings.TailLineLimit);
if (new FileInfo(canonical).Length > settings.WholeReadLimitBytes * 16) throw new IOException("Tail input is too large.");
+ if (!TextFileDetector.IsText(canonical)) throw new ToolValidationException("File appears to be binary; tail mode is text-only.");
string[] content = File.ReadLines(canonical).TakeLast(lines).ToArray();
return Success(JsonSerializer.Serialize(content));
}
@@ -160,6 +161,14 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
FileDeleteTransaction transaction = editor.Delete(path, expected);
transaction.Commit();
}
+ else if (operation == "rename")
+ {
+ string newPath = arguments.GetProperty("new_path").GetString()!;
+ _paths.Demand(path, FileCapability.Write);
+ _paths.Demand(newPath, FileCapability.Write);
+ FileRenameTransaction transaction = editor.Rename(path, newPath);
+ transaction.Commit();
+ }
else
{
_paths.Demand(path, FileCapability.Write);
@@ -169,8 +178,11 @@ public sealed class WindowsToolBackend(WindowsSandboxSettings settings, ISecurit
FileEditTransaction transaction = editor.Replace(path, replacement, expected);
transaction.Commit();
}
- audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, "file." + operation, "completed", new Dictionary<string, string> { ["path"] = Path.GetFullPath(path) }));
- return Success($"{operation} completed for {Path.GetFullPath(path)}", externalEffect: true);
+ string auditPath = operation == "rename"
+ ? $"{Path.GetFullPath(path)} -> {Path.GetFullPath(arguments.GetProperty("new_path").GetString()!)}"
+ : Path.GetFullPath(path);
+ audit.Write(new(DateTimeOffset.UtcNow, context.Journal.TaskId, "file." + operation, "completed", new Dictionary<string, string> { ["path"] = auditPath }));
+ return Success($"{operation} completed for {auditPath}", externalEffect: true);
}
private ToolExecutionResult DirectoryOp(TaskContext context, JsonElement arguments)
diff --git a/tests/Agent.Core.Tests/ConfigurationTests.cs b/tests/Agent.Core.Tests/ConfigurationTests.cs
index 2c52b0c..79c746d 100644
--- a/tests/Agent.Core.Tests/ConfigurationTests.cs
+++ b/tests/Agent.Core.Tests/ConfigurationTests.cs
@@ -1,5 +1,6 @@
using Agent.App.Cli;
using Agent.App.Configuration;
+using Agent.Core.Skills;
namespace Agent.Core.Tests;
@@ -43,16 +44,52 @@ public sealed class ConfigurationTests
}
[Fact]
- public void SkillLoader_supports_legacy_markdown_and_standard_skill_directories()
+ public void SkillLoader_loads_standard_skill_directories_only()
{
using var directory = new TestDirectory();
+ // Legacy single markdown — should be ignored (not loaded).
directory.File("skills/legacy.md", "legacy skill");
- directory.File("skills/python-via-uv/SKILL.md", "standard skill");
+ // Standard skill directory with frontmatter.
+ directory.File("skills/python-via-uv/SKILL.md", "---\nname: python-via-uv\ndescription: Python execution skill.\n---\n# Python via uv\nInstructions here.");
+ // Non-SKILL.md files in skill directory should not be loaded as skills.
directory.File("skills/python-via-uv/reference.md", "must not be loaded as a skill");
- string[] skills = SkillLoader.Load(Path.Combine(directory.Path, "skills"));
+ var skills = SkillLoader.Load(Path.Combine(directory.Path, "skills"));
- Assert.Equal(["legacy skill", "standard skill"], skills);
+ Assert.Single(skills);
+ Assert.Equal("python-via-uv", skills[0].Name);
+ Assert.Equal("Python execution skill.", skills[0].Description);
+ Assert.Contains("Instructions here.", skills[0].Instructions);
+ }
+
+ [Fact]
+ public void SkillLoader_rejects_missing_frontmatter()
+ {
+ using var directory = new TestDirectory();
+ directory.File("skills/no-frontmatter/SKILL.md", "# No frontmatter\nJust body.");
+
+ Assert.Throws<SkillLoadException>(() => SkillLoader.Load(Path.Combine(directory.Path, "skills")));
+ }
+
+ [Fact]
+ public void SkillLoader_rejects_name_mismatch()
+ {
+ using var directory = new TestDirectory();
+ directory.File("skills/wrong-name/SKILL.md", "---\nname: different-name\ndescription: A skill.\n---\nBody.");
+
+ Assert.Throws<SkillLoadException>(() => SkillLoader.Load(Path.Combine(directory.Path, "skills")));
+ }
+
+ [Fact]
+ public void SkillLoader_rejects_duplicate_names()
+ {
+ using var directory = new TestDirectory();
+ directory.File("skills/dup-skill/SKILL.md", "---\nname: dup-skill\ndescription: First.\n---\nBody1.");
+ // Create a second directory with the same skill name (via symlink or junction is not needed;
+ // just create another dir that also has name: dup-skill in frontmatter).
+ directory.File("skills/dup-skill-copy/SKILL.md", "---\nname: dup-skill\ndescription: Second.\n---\nBody2.");
+
+ Assert.Throws<SkillLoadException>(() => SkillLoader.Load(Path.Combine(directory.Path, "skills")));
}
private static string CreateValidConfiguration(TestDirectory directory)
diff --git a/tests/Agent.Core.Tests/InternalToolTests.cs b/tests/Agent.Core.Tests/InternalToolTests.cs
index 2015702..c157458 100644
--- a/tests/Agent.Core.Tests/InternalToolTests.cs
+++ b/tests/Agent.Core.Tests/InternalToolTests.cs
@@ -19,6 +19,8 @@ public sealed class InternalToolTests
[InlineData("file_search", "{\"root\":\".\",\"mode\":\"text\",\"query\":\"x\",\"max_results\":1001}")]
[InlineData("process_run", "{\"executable\":\"x\",\"args\":\"bad\",\"cwd\":\".\"}")]
[InlineData("task_update", "{\"operation\":\"add\",\"description\":\"missing estimate\"}")]
+ [InlineData("file_edit", "{\"path\":\"a\",\"operation\":\"rename\"}")]
+ [InlineData("file_edit", "{\"path\":\"a\",\"operation\":\"rename\",\"new_path\":\"\"}")]
public void Malformed_arguments_are_rejected(string tool, string json)
{
using JsonDocument document = JsonDocument.Parse(json);
diff --git a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
index 5e682cd..27308ef 100644
--- a/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
+++ b/tests/Agent.Core.Tests/PromptAndRuntimeTests.cs
@@ -2,6 +2,7 @@ using Agent.Core.Ollama;
using Agent.Core.Prompts;
using Agent.Core.Runtime;
using Agent.Core.Sessions;
+using Agent.Core.Skills;
using Agent.Core.Tasks;
using Agent.Core.Tools;
using System.Text.Json;
@@ -13,12 +14,18 @@ public sealed class PromptAndRuntimeTests
[Fact]
public void Prompt_order_is_fixed_and_request_is_last()
{
+ var availableSkills = new List<AgentSkill>
+ {
+ new("root", "test-skill", "A test skill.", "skill instructions")
+ };
IReadOnlyList<OllamaMessage> messages = PromptComposer.Compose(new PromptContext(
- "fixed", "user system", "ja", ["skill"], "capability", "graph", "summary",
+ "fixed", "user system", "ja", availableSkills, [], "capability", "graph", "summary",
[new ConversationMessage("user", "old"), new ConversationMessage("assistant", "reply")],
"new request", ["image"]));
- Assert.Equal(["fixed", "user system", "Respond in locale: ja.", "skill", "capability", "graph", "summary", "old", "reply", "new request"], messages.Select(x => x.Content));
+ // Check that the skill catalog (name + description) is present, not the full instructions.
+ Assert.Contains(messages, m => m.Content.Contains("test-skill") && m.Content.Contains("A test skill."));
+ Assert.DoesNotContain(messages, m => m.Content == "skill instructions");
Assert.Equal("system", messages[0].Role);
Assert.Equal("user", messages[^1].Role);
Assert.Equal(["image"], messages[^1].Images);
diff --git a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
index a7197b8..9f13f56 100644
--- a/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
+++ b/tests/Agent.Windows.Tests/SecurityPolicyTests.cs
@@ -704,6 +704,150 @@ public sealed class SecurityPolicyTests
await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
}
+ [Fact]
+ public async Task Tail_rejects_binary_file()
+ {
+ using TestDirectory directory = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ string binaryFile = Path.Combine(workspace, "binary.dat");
+ File.WriteAllBytes(binaryFile, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, ..new byte[100]]);
+ var audit = new MemorySecurityAuditSink();
+ var settings = new WindowsSandboxSettings(
+ [new FilePolicyEntry(workspace, FileCapability.Read)], [], false,
+ new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+ await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ using JsonDocument tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(binaryFile)}},"mode":"tail","query":"","tail_lines":10}""");
+
+ await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken));
+
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+ }
+
+ [Theory]
+ [InlineData("utf8")]
+ [InlineData("utf8bom")]
+ [InlineData("utf16le")]
+ [InlineData("utf16be")]
+ public async Task Tail_accepts_text_files(string encoding)
+ {
+ using TestDirectory directory = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ string textFile = Path.Combine(workspace, "text.txt");
+ string content = "line1\nline2\nline3\n";
+ byte[] bytes = encoding switch
+ {
+ "utf8" => System.Text.Encoding.UTF8.GetBytes(content),
+ "utf8bom" => [.. System.Text.Encoding.UTF8.Preamble, .. System.Text.Encoding.UTF8.GetBytes(content)],
+ "utf16le" => [.. System.Text.Encoding.Unicode.Preamble, .. System.Text.Encoding.Unicode.GetBytes(content)],
+ "utf16be" => [.. System.Text.Encoding.BigEndianUnicode.Preamble, .. System.Text.Encoding.BigEndianUnicode.GetBytes(content)],
+ _ => throw new ArgumentException(encoding)
+ };
+ File.WriteAllBytes(textFile, bytes);
+ var audit = new MemorySecurityAuditSink();
+ var settings = new WindowsSandboxSettings(
+ [new FilePolicyEntry(workspace, FileCapability.Read)], [], false,
+ new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+ await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ using JsonDocument tail = JsonDocument.Parse($$"""{"root":{{JsonSerializer.Serialize(textFile)}},"mode":"tail","query":"","tail_lines":10}""");
+
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_search", tail.RootElement, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public async Task File_edit_rename_moves_file_within_approved_root()
+ {
+ using TestDirectory directory = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ string source = Path.Combine(workspace, "source.txt");
+ string dest = Path.Combine(workspace, "dest.txt");
+ File.WriteAllText(source, "rename-content");
+ var audit = new MemorySecurityAuditSink();
+ var settings = new WindowsSandboxSettings(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write | FileCapability.Delete)], [], false,
+ new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+ await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}""");
+
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success);
+ Assert.True(result.ExternalEffect);
+ Assert.False(File.Exists(source));
+ Assert.True(File.Exists(dest));
+ Assert.Equal("rename-content", File.ReadAllText(dest));
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+ Assert.Empty(Directory.EnumerateFiles(Path.Combine(directory.Path, "journals")));
+ }
+
+ [Fact]
+ public async Task File_edit_rename_rejects_existing_destination()
+ {
+ using TestDirectory directory = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ string source = Path.Combine(workspace, "source.txt");
+ string dest = Path.Combine(workspace, "dest.txt");
+ File.WriteAllText(source, "source-content");
+ File.WriteAllText(dest, "dest-content");
+ var audit = new MemorySecurityAuditSink();
+ var settings = new WindowsSandboxSettings(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false,
+ new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+ await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}""");
+
+ await Assert.ThrowsAsync<ToolValidationException>(async () => await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken));
+
+ Assert.True(File.Exists(source)); // source still exists
+ Assert.True(File.Exists(dest)); // dest untouched
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public async Task File_edit_rename_rejects_outside_approved_root()
+ {
+ using TestDirectory directory = new();
+ using TestDirectory outside = new();
+ string workspace = Path.Combine(directory.Path, "workspace");
+ Directory.CreateDirectory(workspace);
+ string source = Path.Combine(workspace, "source.txt");
+ string dest = Path.Combine(outside.Path, "escaped.txt");
+ File.WriteAllText(source, "content");
+ var audit = new MemorySecurityAuditSink();
+ var settings = new WindowsSandboxSettings(
+ [new FilePolicyEntry(workspace, FileCapability.Read | FileCapability.Write)], [], false,
+ new SandboxJobLimits(), "deny", [], Path.Combine(directory.Path, "journals"), Path.Combine(directory.Path, "backups"),
+ 16 * 1024 * 1024, 1024 * 1024, 1000, 10000);
+ var backend = new WindowsToolBackend(settings, audit);
+ Guid taskId = Guid.NewGuid();
+ await backend.BeginTaskAsync(taskId, TestContext.Current.CancellationToken);
+ using JsonDocument rename = JsonDocument.Parse($$"""{"path":{{JsonSerializer.Serialize(source)}},"operation":"rename","new_path":{{JsonSerializer.Serialize(dest)}}}""");
+
+ ToolExecutionResult result = await backend.ExecuteAsync(taskId, "file_edit", rename.RootElement, TestContext.Current.CancellationToken);
+
+ Assert.False(result.Success);
+ Assert.False(result.RetryAllowed);
+ await backend.EndTaskAsync(taskId, true, TestContext.Current.CancellationToken);
+ }
+
[Fact(Timeout = 60_000)]
public async Task Wfp_enforces_actual_appcontainer_loopback_deny_allow_and_port_scope()
{