summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Tools/TextFileDetector.cs
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 /src/Agent.Windows/Tools/TextFileDetector.cs
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群固定: 確認済み
Diffstat (limited to 'src/Agent.Windows/Tools/TextFileDetector.cs')
-rw-r--r--src/Agent.Windows/Tools/TextFileDetector.cs141
1 files changed, 141 insertions, 0 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