using System.Security.Cryptography; namespace Agent.Windows.Security; public enum EntryExecutableClassification { ApprovedEntryExecutable, ApprovedShellExecutable, RuntimeComponent, WorkspaceArtifact } public sealed record ApprovedEntryExecutable(string Alias, string ResolvedPath, string Sha256, bool IsShell, string? RuntimeRoot); public sealed record VerifiedEntryExecutable(string ResolvedPath, string Sha256, EntryExecutableClassification Classification, string? Alias = null); public sealed class EntryExecutablePolicy( IEnumerable approvals, IEnumerable runtimeRoots, IEnumerable artifactRoots) { private readonly IReadOnlyDictionary _aliases = approvals.ToDictionary( approval => approval.Alias, Normalize, StringComparer.OrdinalIgnoreCase); private readonly string[] _runtimeRoots = runtimeRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); private readonly string[] _artifactRoots = artifactRoots.Select(path => ResolvedPath.Resolve(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); public VerifiedEntryExecutable VerifyEntry(string executable, string? baseDirectory = null) { if (_aliases.TryGetValue(executable, out ApprovedEntryExecutable? byAlias)) return VerifyApproved(byAlias); string candidate = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable); string resolved; try { resolved = ResolvedPath.Resolve(candidate); } catch (FileNotFoundException) { throw new SandboxPolicyException("process.alias_or_path_unknown", $"Executable Alias or Logical Path does not resolve to an existing file: '{executable}'.", "process", Path.GetFullPath(candidate), "execute", "Use a configured Executable Alias or an existing Runtime Component/development Workspace Artifact path."); } if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root))) return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.RuntimeComponent); if (_artifactRoots.Any(root => IsAtOrBelow(resolved, root))) return new(resolved, ComputeSha256(resolved), EntryExecutableClassification.WorkspaceArtifact); throw new SandboxPolicyException("process.entry_unknown", $"External Unknown Executable cannot be started as an Entry Process: '{resolved}'.", "process", resolved, "execute", "Add an execution.entries item to YAML and run agent-mcp apply, or place the Workspace Artifact in a development Workspace."); } public string? ManagedEntryPoint(string executable, IReadOnlyList arguments, string? baseDirectory = null) { string executablePath = Path.IsPathFullyQualified(executable) ? executable : Path.Combine(baseDirectory ?? Environment.CurrentDirectory, executable); string resolved = _aliases.TryGetValue(executable, out ApprovedEntryExecutable? approval) ? approval.ResolvedPath : ResolvedPath.Resolve(executablePath); if (!Path.GetFileName(resolved).Equals("dotnet.exe", StringComparison.OrdinalIgnoreCase) || arguments.Count == 0) return null; if (arguments[0].Equals("exec", StringComparison.OrdinalIgnoreCase)) { if (arguments.Count < 2 || arguments[1].StartsWith("-", StringComparison.Ordinal)) throw new SandboxPolicyException("process.managed_entry_invalid", "dotnet exec requires an explicit managed entry assembly as its next argument."); return ResolveArgument(arguments[1], baseDirectory); } return arguments[0].EndsWith(".dll", StringComparison.OrdinalIgnoreCase) ? ResolveArgument(arguments[0], baseDirectory) : null; } public void VerifyManagedEntryPoint(string path) { string resolved = ResolvedPath.Resolve(path); if (_runtimeRoots.Any(root => IsAtOrBelow(resolved, root)) || _artifactRoots.Any(root => IsAtOrBelow(resolved, root))) return; throw new SandboxPolicyException("process.managed_entry_unknown", $"Managed Entry Artifact is outside every confirmed Runtime Root and development Workspace: '{resolved}'.", "process", resolved, "execute", "Place the artifact in a development Workspace or confirm its Runtime Root."); } public static string ComputeSha256(string executable) { using FileStream stream = new(ResolvedPath.Resolve(executable), FileMode.Open, FileAccess.Read, FileShare.Read); return Convert.ToHexString(SHA256.HashData(stream)); } private static ApprovedEntryExecutable Normalize(ApprovedEntryExecutable approval) => approval with { ResolvedPath = ResolvedPath.Resolve(approval.ResolvedPath), Sha256 = NormalizeHash(approval.Sha256), RuntimeRoot = approval.RuntimeRoot is null ? null : ResolvedPath.Resolve(approval.RuntimeRoot) }; private static VerifiedEntryExecutable VerifyApproved(ApprovedEntryExecutable approval) { string resolved = ResolvedPath.Resolve(approval.ResolvedPath); string actualHash = ComputeSha256(resolved); if (!CryptographicOperations.FixedTimeEquals(Convert.FromHexString(approval.Sha256), Convert.FromHexString(actualHash))) throw new SandboxPolicyException("process.hash_denied", $"Approved Entry Executable SHA-256 changed: '{resolved}'.", "process", resolved, "execute", $"Review Alias '{approval.Alias}' in YAML and run agent-mcp apply."); return new(resolved, actualHash, approval.IsShell ? EntryExecutableClassification.ApprovedShellExecutable : EntryExecutableClassification.ApprovedEntryExecutable, approval.Alias); } private static bool IsAtOrBelow(string candidate, string root) => candidate.Equals(root, StringComparison.OrdinalIgnoreCase) || candidate.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); private static string ResolveArgument(string path, string? baseDirectory) => Path.IsPathFullyQualified(path) ? path : Path.GetFullPath(path, baseDirectory ?? Environment.CurrentDirectory); private static string NormalizeHash(string hash) { string normalized = hash.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToUpperInvariant(); if (normalized.Length != 64 || !normalized.All(Uri.IsHexDigit)) throw new ArgumentException("SHA-256 approval must contain 64 hexadecimal characters.", nameof(hash)); return normalized; } }