using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; namespace ClawdDotNet.Core.Runtimes; /// #712: One change a foreign run proposes. public sealed record ProposedChange( [property: JsonPropertyName("path")] string Path, [property: JsonPropertyName("change")] string Change, [property: JsonPropertyName("bytes")] long Bytes, [property: JsonPropertyName("sha256")] string? Sha256) { public const string Added = "added"; public const string Modified = "modified"; public const string Deleted = "deleted"; } /// #712: What a foreign run left behind — never applied, kept for review. public sealed record Proposal { [JsonPropertyName("runId")] public string RunId { get; init; } = ""; [JsonPropertyName("agentId")] public string AgentId { get; init; } = ""; [JsonPropertyName("taskId")] public string? TaskId { get; init; } [JsonPropertyName("runtime")] public string Runtime { get; init; } = ""; [JsonPropertyName("createdAtUtc")] public DateTime CreatedAtUtc { get; init; } [JsonPropertyName("changes")] public IReadOnlyList Changes { get; init; } = []; /// What the run left out: links, special files — named, never read. [JsonPropertyName("ignored")] public IReadOnlyList Ignored { get; init; } = []; } /// /// #712: The foreign run's place to work — a copy of the agent's workspace, never the /// workspace itself (rule 1 of the concept). What changed there becomes a proposal /// (rule 3): the changed files and a list, next to the agent, for review. Whether any of it /// is used is decided by the delivery chain (#695), not by the run. /// /// Reading what a sandbox left is reading hostile ground. Only regular files /// are read. A symbolic link could point to a key outside the sandbox — inside it the /// target did not exist, outside it does; a named pipe would block the reader for good. /// Both are listed as ignored and never opened. After the run every process of the sandbox /// is gone, so nothing changes while it is read. /// public static class ForeignWorkspace { public const long MaxBytes = 512L * 1024 * 1024; public const int MaxFiles = 20_000; private static readonly JsonSerializerOptions Json = new() { WriteIndented = true }; /// Copies the workspace to — regular files and folders only. public static void Copy(string workspace, string target) { Directory.CreateDirectory(target); if (!Directory.Exists(workspace)) return; long bytes = 0; var files = 0; foreach (var (relative, full) in Walk(workspace, _ => { })) { var info = new FileInfo(full); bytes += info.Length; if (++files > MaxFiles || bytes > MaxBytes) throw new ForeignRunException( $"The workspace is too large for a foreign run (more than {MaxFiles} files or {MaxBytes / (1024 * 1024)} MB)."); var destination = Path.Combine(target, relative); Directory.CreateDirectory(Path.GetDirectoryName(destination)!); File.Copy(full, destination); } } /// What differs between the workspace and the run's copy. public static (IReadOnlyList Changes, IReadOnlyList Ignored) Compare(string workspace, string copy) { var before = Directory.Exists(workspace) ? Walk(workspace, _ => { }).ToDictionary(e => e.Relative, e => e.Full, StringComparer.Ordinal) : new Dictionary(StringComparer.Ordinal); var ignored = new List(); var changes = new List(); long bytes = 0; foreach (var (relative, full) in Walk(copy, ignored.Add)) { var length = new FileInfo(full).Length; if ((bytes += length) > MaxBytes || changes.Count >= MaxFiles) throw new ForeignRunException("The run left more than a proposal can hold."); var hash = Hash(full); if (!before.Remove(relative, out var original)) changes.Add(new ProposedChange(relative, ProposedChange.Added, length, hash)); else if (new FileInfo(original).Length != length || Hash(original) != hash) changes.Add(new ProposedChange(relative, ProposedChange.Modified, length, hash)); } foreach (var relative in before.Keys) changes.Add(new ProposedChange(relative, ProposedChange.Deleted, 0, null)); changes.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); return (changes, ignored); } /// Writes the proposal to : proposal.json and the files under files/. public static void Keep(Proposal proposal, string copy, string directory) { var files = Path.Combine(directory, "files"); Directory.CreateDirectory(files); foreach (var change in proposal.Changes.Where(c => c.Change != ProposedChange.Deleted)) { var destination = Path.Combine(files, change.Path); Directory.CreateDirectory(Path.GetDirectoryName(destination)!); File.Copy(Path.Combine(copy, change.Path), destination, overwrite: true); } File.WriteAllText(Path.Combine(directory, "proposal.json"), JsonSerializer.Serialize(proposal, Json)); } /// Regular files below , relative with '/'; everything else goes to . private static IEnumerable<(string Relative, string Full)> Walk(string root, Action skipped) { var pending = new Stack([Path.GetFullPath(root)]); var rootLength = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar).Length + 1; while (pending.Count > 0) { var dir = pending.Pop(); foreach (var entry in new DirectoryInfo(dir).EnumerateFileSystemInfos()) { var relative = entry.FullName[rootLength..].Replace('\\', '/'); switch (FileKind.Of(entry)) { case FileKind.Kind.Directory: pending.Push(entry.FullName); break; case FileKind.Kind.Regular: yield return (relative, entry.FullName); break; default: skipped(relative); break; } } } } private static string Hash(string path) { using var stream = File.OpenRead(path); return Convert.ToHexStringLower(SHA256.HashData(stream)); } } /// /// #712: What an entry really is, without following it. .NET tells links apart but not a /// named pipe or a device from a file — under Linux that takes statx, whose layout, /// unlike stat's, is the same on every architecture. /// internal static class FileKind { public enum Kind { Regular, Directory, Other } public static Kind Of(FileSystemInfo entry) { if (entry.LinkTarget is not null || entry.Attributes.HasFlag(FileAttributes.ReparsePoint)) return Kind.Other; if (!OperatingSystem.IsLinux()) return entry is DirectoryInfo ? Kind.Directory : Kind.Regular; var buffer = new byte[256]; if (Statx(AtFdCwd, entry.FullName, AtSymlinkNoFollow, StatxType, buffer) != 0) return Kind.Other; var mode = BitConverter.ToUInt16(buffer, 0x1C) & 0xF000; return mode switch { 0x8000 => Kind.Regular, 0x4000 => Kind.Directory, _ => Kind.Other }; } private const int AtFdCwd = -100; private const int AtSymlinkNoFollow = 0x100; private const uint StatxType = 0x1; [DllImport("libc", EntryPoint = "statx", SetLastError = true)] private static extern int Statx(int dirfd, [MarshalAs(UnmanagedType.LPUTF8Str)] string path, int flags, uint mask, byte[] buffer); } /// #712: A foreign run that could not take place or not be read. public sealed class ForeignRunException(string message) : Exception(message);