namespace Agent.Windows.Security; [Flags] public enum FileCapability { None = 0, Read = 1, Write = 2, Execute = 4, Delete = 8 } public sealed record FilePolicyEntry(string Path, FileCapability Capabilities, string Origin = "unspecified"); public sealed class PathPolicy(IEnumerable entries) { private readonly IReadOnlyList _entries = entries.Select(entry => entry with { Path = ResolvedPath.Resolve(entry.Path, requireExisting: false) }).ToArray(); public bool IsAllowed(string path, FileCapability required) { string canonical = ResolvedPath.Resolve(path, requireExisting: required is FileCapability.Read or FileCapability.Execute or FileCapability.Delete); return _entries.Any(entry => IsAtOrBelow(canonical, entry.Path) && (entry.Capabilities & required) == required); } public void Demand(string path, FileCapability required) { if (!IsAllowed(path, required)) throw new SandboxPolicyException("policy.deny", $"Resolved Path '{path}' is outside the Effective Sandbox Policy for {required}.", "filesystem", Path.GetFullPath(path), required.ToString().ToLowerInvariant(), "Confirm a Workspace or Mount that grants the required Filesystem Capability, then restart the server."); } private static bool IsAtOrBelow(string candidate, string root) => candidate.Equals(root, StringComparison.OrdinalIgnoreCase) || candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); } public static class ResolvedPath { public static string Resolve(string path, bool requireExisting = true) { if (string.IsNullOrWhiteSpace(path)) throw new SandboxPolicyException("path.invalid", "Path is required."); string fullPath = Path.GetFullPath(path); string? root = Path.GetPathRoot(fullPath); if (string.IsNullOrEmpty(root)) throw new SandboxPolicyException("path.invalid", "Path has no filesystem root."); if (root.StartsWith("\\\\", StringComparison.Ordinal)) throw new SandboxPolicyException("path.non_local", "Only local filesystem paths are supported.", "filesystem", fullPath, null, "Select a local Workspace, Mount, Runtime Root, or Executable."); string current = root; var visited = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (string segment in fullPath[root.Length..].Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)) { current = Path.Combine(current, segment); if (!File.Exists(current) && !Directory.Exists(current)) continue; while ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) { string reparsePath = Path.GetFullPath(current); if (!visited.Add(reparsePath)) throw new SandboxPolicyException("path.reparse_cycle", $"Reparse resolution cycle detected at '{reparsePath}'.", "filesystem", reparsePath, null, "Remove the reparse cycle."); FileSystemInfo info = Directory.Exists(current) ? new DirectoryInfo(current) : new FileInfo(current); string? target; try { target = info.ResolveLinkTarget(returnFinalTarget: false)?.FullName; } catch (IOException ex) { throw new SandboxPolicyException("path.reparse_unresolved", $"Could not resolve reparse component '{current}': {ex.Message}", "filesystem", current, null, "Repair or remove the reparse point."); } if (string.IsNullOrWhiteSpace(target)) throw new SandboxPolicyException("path.reparse_unresolved", $"Could not resolve reparse component '{current}'.", "filesystem", current, null, "Repair or remove the reparse point."); current = Path.GetFullPath(target); } } if (requireExisting && !File.Exists(current) && !Directory.Exists(current)) throw new FileNotFoundException("Resolved Path does not exist.", current); return Path.TrimEndingDirectorySeparator(current); } } public sealed class SandboxPolicyException(string code, string message, string category = "policy", string? targetPath = null, string? requiredPermission = null, string? resolution = null) : UnauthorizedAccessException(message) { public string Code { get; } = code; public string Category { get; } = category; public string? TargetPath { get; } = targetPath; public string? RequiredPermission { get; } = requiredPermission; public string? Resolution { get; } = resolution; }