summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Security/ExecutablePolicy.cs
blob: f42569a3e4b564f423324452d478ba074c6e978d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
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<ApprovedEntryExecutable> approvals,
    IEnumerable<string> runtimeRoots,
    IEnumerable<string> artifactRoots)
{
    private readonly IReadOnlyDictionary<string, ApprovedEntryExecutable> _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<string> 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;
    }
}