summaryrefslogtreecommitdiff
path: root/src/Agent.Windows
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.Windows')
-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
4 files changed, 209 insertions, 2 deletions
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)