diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Agent.App/Configuration/SkillLoader.cs | 105 | ||||
| -rw-r--r-- | src/Agent.App/Program.cs | 4 | ||||
| -rw-r--r-- | src/Agent.Core/Prompts/PromptComposer.cs | 22 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntime.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Core/Runtime/AgentRuntimeSettings.cs | 4 | ||||
| -rw-r--r-- | src/Agent.Core/Skills/AgentSkill.cs | 15 | ||||
| -rw-r--r-- | src/Agent.Core/Tools/InternalToolCatalog.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Core/Tools/InternalToolValidator.cs | 2 | ||||
| -rw-r--r-- | src/Agent.Windows/Recovery/RecoveryManager.cs | 13 | ||||
| -rw-r--r-- | src/Agent.Windows/Recovery/TransactionalFileEditor.cs | 41 | ||||
| -rw-r--r-- | src/Agent.Windows/Tools/TextFileDetector.cs | 141 | ||||
| -rw-r--r-- | src/Agent.Windows/Tools/WindowsToolBackend.cs | 16 |
12 files changed, 349 insertions, 18 deletions
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) |
