diff options
| author | tanaka takashi <isazaka@gmail.com> | 2026-08-11 00:04:36 +0900 |
|---|---|---|
| committer | tanaka takashi <isazaka@gmail.com> | 2026-08-11 00:04:36 +0900 |
| commit | d5b3cb20c6dacd31bb54c52a30a0f64487e30215 (patch) | |
| tree | 1461ed9f6c3ad42488485045f7295f391928e7b7 /src/Agent.Windows/Tools | |
| parent | e8b625e22f343d75e1b50d1634b4ab06899d060b (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群固定: 確認済み
Diffstat (limited to 'src/Agent.Windows/Tools')
| -rw-r--r-- | src/Agent.Windows/Tools/TextFileDetector.cs | 141 | ||||
| -rw-r--r-- | src/Agent.Windows/Tools/WindowsToolBackend.cs | 16 |
2 files changed, 155 insertions, 2 deletions
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) |
