isolation/TerminalSession.cs

#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Security.Cryptography;
using Microsoft.Win32.SafeHandles;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace DeskPilot.Isolation
{
    public sealed class TerminalSession : IDisposable
    {
        private readonly string _docker;
        private readonly string _project;
        private readonly string _configurationDirectory;
        private readonly string _image;
        private readonly string _version;
        private readonly JsonDocument _policy;
        private readonly Dictionary<string, string> _environment = new Dictionary<string, string>();
        private readonly List<string> _secrets = new List<string>();
        private readonly CancellationTokenSource _cancellation = new CancellationTokenSource();
        private readonly HashSet<string> _changedFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        private int _active;
        private bool _disposed;

        public bool Active => Volatile.Read(ref _active) != 0;
        public bool CleanupFailed { get; private set; }
        public string LastContainer { get; private set; } = string.Empty;
        public string[] FilesWritten
        {
            get
            {
                lock (_changedFiles)
                {
                    var paths = new string[_changedFiles.Count];
                    _changedFiles.CopyTo(paths);
                    return paths;
                }
            }
        }

        public TerminalSession(string docker, string project, string dataDirectory,
            string image, string version, string policyJson)
        {
            _docker = docker;
            _project = NormalizeProject(project);
            _image = image;
            _version = version;
            _policy = JsonDocument.Parse(policyJson);
            foreach (JsonElement entry in _policy.RootElement.GetProperty("environment").EnumerateArray())
            {
                string name = entry.GetProperty("name").GetString()!;
                string? value = Environment.GetEnvironmentVariable(name);
                if (value == null || value.Length > 4096)
                {
                    throw new InvalidOperationException("A selected environment variable is absent or exceeds its size limit: " + name);
                }
                _environment.Add(name, value);
                if (entry.GetProperty("secret").GetBoolean() && value.Length > 0) { _secrets.Add(value); }
            }
            string data = Path.GetFullPath(dataDirectory);
            RejectLinks(data);
            if (IsWithin(data, _project)) { throw new InvalidOperationException("The Project must not contain DeskPilot's control data."); }
            _configurationDirectory = Path.Combine(data, "terminal-client-" + Guid.NewGuid().ToString("N"));
            Directory.CreateDirectory(_configurationDirectory);
            File.WriteAllText(Path.Combine(_configurationDirectory, "config.json"), "{\"auths\":{}}", new UTF8Encoding(false));
        }

        public void Cancel()
        {
            if (!_disposed) { _cancellation.Cancel(); }
        }

        public string MapWorkingDirectory(string? requested)
        {
            string candidate = string.IsNullOrWhiteSpace(requested) ? _project : requested!;
            if (candidate == "/project") { candidate = _project; }
            else if (candidate.StartsWith("/project/", StringComparison.Ordinal))
            {
                candidate = Path.Combine(_project, candidate.Substring(9).Replace('/', Path.DirectorySeparatorChar));
            }
            else if (!Path.IsPathRooted(candidate)) { candidate = Path.Combine(_project, candidate); }
            candidate = Path.GetFullPath(candidate);
            if (!IsWithin(candidate, _project) || !Directory.Exists(candidate))
            {
                throw new InvalidOperationException("The working directory must be inside the selected Project.");
            }
            RejectLinks(candidate);
            string relative = Path.GetRelativePath(_project, candidate);
            return relative == "." ? "/project" : "/project/" + relative.Replace('\\', '/');
        }

        public string Run(string command, string? workingDirectory, int timeoutSeconds)
        {
            if (_disposed || _cancellation.IsCancellationRequested || CleanupFailed)
            {
                throw new InvalidOperationException("Isolated execution is stopped or requires cleanup.");
            }
            if (Interlocked.CompareExchange(ref _active, 1, 0) != 0)
            {
                throw new InvalidOperationException("An isolated command is already running.");
            }
            string name = "deskpilot-terminal-" + Guid.NewGuid().ToString("N");
            string proxy = name + "-proxy";
            LastContainer = name;
            NativeResult result = new NativeResult();
            bool creationUncertain = false;
            bool outOfMemory = false;
            string failure = string.Empty;
            bool proxyCreated = false;
            Dictionary<string, string>? before = null;
            var written = new List<string>();
            try
            {
                if (string.IsNullOrWhiteSpace(command) || command.Length > 2000)
                {
                    throw new InvalidOperationException("An isolated command must contain between 1 and 2000 characters.");
                }
                string directory = MapWorkingDirectory(workingDirectory);
                RejectLinks(_project);
                JsonElement policy = _policy.RootElement;
                string access = policy.GetProperty("projectAccess").GetString()!;
                if (access == "read-write") { before = CaptureProject(false); }
                string mount = "type=bind,source=" + _project + ",target=/project,bind-recursive=disabled";
                if (access == "read-only") { mount += ",readonly"; }
                string prologue = "$ErrorActionPreference = 'Stop'\n" +
                    "if (-not $IsLinux -or $PSVersionTable.PSVersion.ToString() -ne '" + _version + "') { throw 'Unexpected isolated runtime.' }\n" +
                    "foreach ($boundary in @('/mnt/host','/mnt/c','/mnt/d','/host_mnt','/run/desktop/mnt/host','/var/run/docker.sock','/run/docker.sock')) { if ([IO.Directory]::Exists($boundary) -or [IO.File]::Exists($boundary)) { throw 'Unexpected host access in isolated runtime.' } }\n";
                string network = "none";
                string certificate = string.Empty;
                if (policy.GetProperty("network").GetString() == "allow-list")
                {
                    proxyCreated = true;
                    certificate = StartProxy(proxy);
                    network = "container:" + proxy;
                }
                var arguments = new List<string>
                {
                    "create", "--pull", "never", "--name", name,
                    "--label", "io.deskpilot.terminal=1", "--label", "io.deskpilot.owner=" + Environment.ProcessId.ToString(CultureInfo.InvariantCulture),
                    "--network", network, "--read-only", "--cap-drop", "ALL", "--security-opt", "no-new-privileges",
                    "--user", "10001:10001", "--init", "--ipc", "none", "--log-driver", "none",
                    "--memory", policy.GetProperty("memoryMB").GetInt32().ToString(CultureInfo.InvariantCulture) + "m",
                    "--memory-swap", policy.GetProperty("memoryMB").GetInt32().ToString(CultureInfo.InvariantCulture) + "m",
                    "--cpus", policy.GetProperty("cpuCount").GetDouble().ToString(CultureInfo.InvariantCulture),
                    "--pids-limit", policy.GetProperty("processLimit").GetInt32().ToString(CultureInfo.InvariantCulture),
                    "--ulimit", "core=0:0", "--tmpfs", "/tmp:rw,nosuid,nodev,noexec,size=" + policy.GetProperty("tempMB").GetInt32().ToString(CultureInfo.InvariantCulture) + "m",
                    "--mount", mount, "--workdir", directory, "--env", "HOME=/tmp", "--env", "TMPDIR=/tmp",
                    "--env", "POWERSHELL_TELEMETRY_OPTOUT=1", "--env", "DOTNET_CLI_TELEMETRY_OPTOUT=1", "--env", "DOTNET_EnableDiagnostics=0"
                };
                if (access == "read-write" && (Directory.Exists(Path.Combine(_project, ".git")) || File.Exists(Path.Combine(_project, ".git"))))
                {
                    arguments.AddRange(new[] { "--mount", "type=bind,source=" + Path.Combine(_project, ".git") + ",target=/project/.git,readonly" });
                }
                foreach (string key in _environment.Keys) { arguments.AddRange(new[] { "--env", key }); }
                string command64 = Convert.ToBase64String(Encoding.Unicode.GetBytes(prologue + command));
                if (proxyCreated)
                {
                    foreach (string variable in new[] { "SSL_CERT_FILE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "REQUESTS_CA_BUNDLE" })
                    {
                        arguments.AddRange(new[] { "--env", variable + "=/tmp/deskpilot-ca.pem" });
                    }
                    foreach (string variable in new[] { "HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy" })
                    {
                        arguments.AddRange(new[] { "--env", variable + "=http://127.0.0.1:3128" });
                    }
                    arguments.AddRange(new[] { "--env", "NO_PROXY=", "--entrypoint", "/bin/sh", _image, "-c",
                        "/bin/cat /etc/ssl/certs/ca-certificates.crt > /tmp/deskpilot-ca.pem && /usr/bin/printf '%s' \"$1\" | /usr/bin/base64 --decode >> /tmp/deskpilot-ca.pem && exec /opt/microsoft/powershell/7/pwsh -NoLogo -NoProfile -NonInteractive -OutputFormat Text -EncodedCommand \"$2\"",
                        "deskpilot-command", certificate, command64 });
                }
                else
                {
                    arguments.AddRange(new[] { "--entrypoint", "/opt/microsoft/powershell/7/pwsh", _image,
                        "-NoLogo", "-NoProfile", "-NonInteractive", "-OutputFormat", "Text", "-EncodedCommand", command64 });
                }
                NativeResult created = Call(arguments, 30000, 65536, CancellationToken.None);
                creationUncertain = created.TimedOut || created.OutputLimit;
                if (created.ExitCode != 0 || creationUncertain) { throw new InvalidOperationException("Container creation failed: " + created.Error); }
                _cancellation.Token.ThrowIfCancellationRequested();
                int deadline = Math.Min(timeoutSeconds, policy.GetProperty("timeoutSeconds").GetInt32()) * 1000;
                result = Call(new[] { "start", "--attach", name }, deadline, policy.GetProperty("outputBytes").GetInt32(), _cancellation.Token, proxyCreated ? proxy : null);
                if (result.DependencyFailed) { failure = "The HTTPS boundary stopped; the command was terminated."; }
                NativeResult state = Call(new[] { "inspect", "--format", "{{json .State}}", name }, 5000, 65536, CancellationToken.None);
                if (state.ExitCode != 0) { throw new InvalidOperationException("The isolated command state could not be verified."); }
                using (JsonDocument status = JsonDocument.Parse(state.Output))
                {
                    outOfMemory = status.RootElement.GetProperty("OOMKilled").GetBoolean();
                    if (!result.TimedOut && !result.Cancelled && !result.OutputLimit && !result.DependencyFailed)
                    {
                        result.ExitCode = status.RootElement.GetProperty("ExitCode").GetInt32();
                    }
                }
            }
            catch (OperationCanceledException) { result.Cancelled = true; result.ExitCode = 125; }
            catch (Exception error) { failure = Redact(error.Message); result.ExitCode = -1; }
            finally
            {
                try
                {
                    Call(new[] { "rm", "--force", name }, 15000, 65536, CancellationToken.None);
                    if (proxyCreated) { Call(new[] { "rm", "--force", proxy }, 15000, 65536, CancellationToken.None); }
                    NativeResult remaining = Call(new[] { "ps", "--all", "--filter", "name=^/" + name + "$", "--format", "{{.ID}}" }, 5000, 65536, CancellationToken.None);
                    CleanupFailed = creationUncertain || remaining.ExitCode != 0 || !string.IsNullOrWhiteSpace(remaining.Output);
                    if (proxyCreated)
                    {
                        NativeResult proxyRemaining = Call(new[] { "ps", "--all", "--filter", "name=^/" + proxy + "$", "--format", "{{.ID}}" }, 5000, 65536, CancellationToken.None);
                        CleanupFailed |= proxyRemaining.ExitCode != 0 || !string.IsNullOrWhiteSpace(proxyRemaining.Output);
                    }
                }
                catch (Exception error) { CleanupFailed = true; failure = "Container cleanup could not be verified: " + Redact(error.Message); }
                if (before != null)
                {
                    try
                    {
                        Dictionary<string, string> after = CaptureProject(true);
                        foreach (KeyValuePair<string, string> entry in after)
                        {
                            if (!before.TryGetValue(entry.Key, out string? prior) || prior != entry.Value) { written.Add(entry.Key); }
                        }
                        foreach (string path in before.Keys) { if (!after.ContainsKey(path)) { written.Add(path); } }
                        lock (_changedFiles) { foreach (string path in written) { _changedFiles.Add(path); } }
                    }
                    catch (Exception error) { result.ExitCode = -1; failure = "Project change accounting failed: " + Redact(error.Message); }
                }
                Volatile.Write(ref _active, 0);
            }
            if (result.OutputLimit) { result.Output = string.Empty; result.Error = "The combined command output limit was exceeded."; }
            if (CleanupFailed) { result.ExitCode = -1; failure = "Isolated cleanup failed; further Isolated commands are blocked."; }
            return JsonSerializer.Serialize(new
            {
                exitCode = result.ExitCode, stdout = Redact(result.Output), stderr = Redact(result.Error),
                error = failure, timedOut = result.TimedOut, cancelled = result.Cancelled,
                outputLimitExceeded = result.OutputLimit, outOfMemory, cleanupSucceeded = !CleanupFailed,
                filesWritten = written,
                execution = new { mode = "isolated", network = _policy.RootElement.GetProperty("network").GetString(), projectAccess = _policy.RootElement.GetProperty("projectAccess").GetString() }
            });
        }

        private Dictionary<string, string> CaptureProject(bool allowNewLinks)
        {
            var snapshot = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
            var directories = new Stack<string>();
            directories.Push(_project);
            var clock = Stopwatch.StartNew();
            long bytes = 0;
            int entries = 0;
            while (directories.Count > 0)
            {
                string directory = directories.Pop();
                foreach (string path in Directory.EnumerateFileSystemEntries(directory))
                {
                    if (++entries > 50000 || clock.ElapsedMilliseconds > 15000)
                    {
                        throw new IOException("Read-write Project accounting is limited to 50000 entries and 15 seconds.");
                    }
                    string relative = Path.GetRelativePath(_project, path).Replace('\\', '/');
                    FileAttributes attributes = File.GetAttributes(path);
                    if ((attributes & FileAttributes.ReparsePoint) != 0)
                    {
                        if (!allowNewLinks) { throw new IOException("Read-write Projects must not contain symbolic links or junctions."); }
                        snapshot[relative] = "link:" + new FileInfo(path).LinkTarget;
                        continue;
                    }
                    if (string.Equals(relative, ".git", StringComparison.OrdinalIgnoreCase)) { continue; }
                    if ((attributes & FileAttributes.Directory) != 0) { directories.Push(path); continue; }
                    using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
                    if (!GetFileInformationByHandle(stream.SafeFileHandle, out FileInformation information))
                    {
                        throw new IOException("A Project file identity could not be verified.");
                    }
                    if (information.NumberOfLinks != 1) { throw new IOException("Read-write Projects must not contain hard-linked files."); }
                    bytes += stream.Length;
                    if (stream.Length > 100L * 1024 * 1024 || bytes > 512L * 1024 * 1024)
                    {
                        throw new IOException("Read-write Project accounting is limited to 100 MiB per file and 512 MiB total.");
                    }
                    snapshot[relative] = Convert.ToHexString(SHA256.HashData(stream));
                }
            }
            return snapshot;
        }

        [DllImport("kernel32.dll", SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        private static extern bool GetFileInformationByHandle(SafeFileHandle handle, out FileInformation information);

        [StructLayout(LayoutKind.Sequential)]
        private struct FileInformation
        {
            public uint Attributes;
            public System.Runtime.InteropServices.ComTypes.FILETIME Created;
            public System.Runtime.InteropServices.ComTypes.FILETIME Accessed;
            public System.Runtime.InteropServices.ComTypes.FILETIME Written;
            public uint Volume;
            public uint SizeHigh;
            public uint SizeLow;
            public uint NumberOfLinks;
            public uint IndexHigh;
            public uint IndexLow;
        }

        private string StartProxy(string name)
        {
            var hosts = new List<object>();
            foreach (JsonElement entry in _policy.RootElement.GetProperty("allowedHosts").EnumerateArray())
            {
                string hostname = entry.GetString()!;
                Task<IPAddress[]> resolve = Dns.GetHostAddressesAsync(hostname);
                if (!resolve.Wait(5000)) { throw new InvalidOperationException("An allowed HTTPS origin could not be resolved in time."); }
                var addresses = new List<string>();
                foreach (IPAddress address in resolve.Result)
                {
                    if (address.AddressFamily != AddressFamily.InterNetwork) { continue; }
                    if (!IsPublicAddress(address)) { throw new InvalidOperationException("An allowed HTTPS origin resolves to a non-public address."); }
                    addresses.Add(address.ToString());
                }
                if (addresses.Count == 0 || addresses.Count > 32) { throw new InvalidOperationException("The HTTPS origin has no supported bounded public address set."); }
                hosts.Add(new { name = hostname, addresses });
            }
            string encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new { hosts })));
            string startup = "/opt/microsoft/powershell/7/pwsh -NoLogo -NoProfile -NonInteractive -File /opt/deskpilot/Start-DpProxy.ps1 -PolicyBase64 \"$1\" && exec /usr/bin/setpriv --reuid=10002 --regid=10002 --clear-groups --bounding-set=-all --inh-caps=-all --ambient-caps=-all --no-new-privs /usr/sbin/squid -N -f /run/deskpilot/squid.conf";
            NativeResult created = Call(new[]
            {
                "run", "--detach", "--pull", "never", "--name", name,
                "--label", "io.deskpilot.terminal=1", "--label", "io.deskpilot.owner=" + Environment.ProcessId.ToString(CultureInfo.InvariantCulture),
                "--network", "bridge", "--read-only", "--cap-drop", "ALL", "--cap-add", "NET_ADMIN", "--cap-add", "CHOWN", "--cap-add", "DAC_OVERRIDE",
                "--cap-add", "SETUID", "--cap-add", "SETGID", "--cap-add", "SETPCAP", "--security-opt", "no-new-privileges", "--user", "0:0",
                "--memory", "256m", "--memory-swap", "256m", "--cpus", "0.5", "--pids-limit", "64", "--log-driver", "none",
                "--tmpfs", "/run/deskpilot:rw,nosuid,nodev,noexec,size=32m", "--tmpfs", "/tmp:rw,nosuid,nodev,noexec,size=16m",
                "--health-cmd", "test -f /run/deskpilot/prepared && grep -q '0100007F:0C38 .* 0A ' /proc/net/tcp",
                "--health-interval", "1s", "--health-timeout", "1s", "--health-retries", "2",
                "--entrypoint", "/bin/sh", _image, "-c", startup, "deskpilot-proxy", encoded
            }, 30000, 65536, CancellationToken.None);
            if (created.ExitCode != 0) { throw new InvalidOperationException("HTTPS boundary startup failed: " + created.Error); }
            var clock = Stopwatch.StartNew();
            while (clock.ElapsedMilliseconds < 20000)
            {
                _cancellation.Token.ThrowIfCancellationRequested();
                NativeResult state = Call(new[] { "inspect", "--format", "{{json .State}}", name }, 5000, 65536, CancellationToken.None);
                if (state.ExitCode != 0) { throw new InvalidOperationException("The HTTPS boundary state could not be inspected."); }
                using JsonDocument status = JsonDocument.Parse(state.Output);
                if (!status.RootElement.GetProperty("Running").GetBoolean()) { throw new InvalidOperationException("The HTTPS boundary failed during startup."); }
                if (status.RootElement.GetProperty("Health").GetProperty("Status").GetString() == "healthy")
                {
                    NativeResult certificate = Call(new[] { "exec", "--user", "0", name, "/bin/cat", "/run/deskpilot/ca.crt" }, 5000, 65536, CancellationToken.None);
                    if (certificate.ExitCode != 0 || !certificate.Output.Contains("BEGIN CERTIFICATE", StringComparison.Ordinal)) { throw new InvalidOperationException("The disposable HTTPS certificate is unavailable."); }
                    return Convert.ToBase64String(Encoding.UTF8.GetBytes(certificate.Output));
                }
                Task.Delay(100, _cancellation.Token).GetAwaiter().GetResult();
            }
            throw new InvalidOperationException("The HTTPS boundary did not become ready in time.");
        }

        private static bool IsPublicAddress(IPAddress address)
        {
            byte[] bytes = address.GetAddressBytes();
            return bytes.Length == 4 && bytes[0] != 0 && bytes[0] != 10 && bytes[0] != 127 && bytes[0] < 224 &&
                !(bytes[0] == 100 && bytes[1] >= 64 && bytes[1] <= 127) &&
                !(bytes[0] == 169 && bytes[1] == 254) && !(bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) &&
                !(bytes[0] == 192 && (bytes[1] == 168 || bytes[1] == 0 || (bytes[1] == 88 && bytes[2] == 99))) &&
                !(bytes[0] == 198 && (bytes[1] == 18 || bytes[1] == 19 || (bytes[1] == 51 && bytes[2] == 100))) &&
                !(bytes[0] == 203 && bytes[1] == 0 && bytes[2] == 113);
        }

        private NativeResult Call(IEnumerable<string> arguments, int timeout, int outputLimit, CancellationToken cancellation, string? requiredContainer = null)
        {
            var start = new ProcessStartInfo(_docker)
            {
                UseShellExecute = false, CreateNoWindow = true,
                RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = true,
                WorkingDirectory = Path.GetDirectoryName(_docker)!
            };
            start.Environment.Clear();
            start.Environment["SystemRoot"] = Environment.GetEnvironmentVariable("SystemRoot") ?? @"C:\Windows";
            start.Environment["PATH"] = Path.GetDirectoryName(_docker)!;
            start.Environment["TEMP"] = _configurationDirectory;
            start.Environment["TMP"] = _configurationDirectory;
            foreach (KeyValuePair<string, string> entry in _environment) { start.Environment[entry.Key] = entry.Value; }
            start.ArgumentList.Add("--config"); start.ArgumentList.Add(_configurationDirectory);
            start.ArgumentList.Add("--host"); start.ArgumentList.Add("npipe:////./pipe/dockerDesktopLinuxEngine");
            foreach (string argument in arguments) { start.ArgumentList.Add(argument); }
            using var process = new Process { StartInfo = start };
            using var output = new MemoryStream();
            using var error = new MemoryStream();
            var exceeded = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
            var sync = new object();
            long count = 0;
            async Task DrainAsync(Stream source, MemoryStream target)
            {
                var buffer = new byte[4096];
                int read;
                while ((read = await source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0)
                {
                    lock (sync)
                    {
                        count += read;
                        if (count <= outputLimit) { target.Write(buffer, 0, read); }
                        else { exceeded.TrySetResult(true); }
                    }
                }
            }
            process.Start();
            process.StandardInput.Close();
            Task stdout = DrainAsync(process.StandardOutput.BaseStream, output);
            Task stderr = DrainAsync(process.StandardError.BaseStream, error);
            Task complete = Task.WhenAll(stdout, stderr, process.WaitForExitAsync());
            Task expiry = Task.Delay(timeout, cancellation);
            Task finished;
            bool dependencyFailed = false;
            while (true)
            {
                Task inspection = requiredContainer == null ? Task.Delay(Timeout.Infinite) : Task.Delay(500);
                finished = Task.WhenAny(complete, expiry, exceeded.Task, inspection).GetAwaiter().GetResult();
                if (finished != inspection) { break; }
                try
                {
                    NativeResult health = Call(new[] { "inspect", "--format", "{{.State.Running}}", requiredContainer! }, 2000, 16384, CancellationToken.None);
                    dependencyFailed = health.ExitCode != 0 || health.Output.Trim() != "true";
                }
                catch (Exception) { dependencyFailed = true; }
                if (dependencyFailed) { break; }
            }
            var result = new NativeResult
            {
                DependencyFailed = dependencyFailed,
                OutputLimit = exceeded.Task.IsCompleted,
                Cancelled = cancellation.IsCancellationRequested,
                TimedOut = finished == expiry && !cancellation.IsCancellationRequested
            };
            if (finished != complete && !process.HasExited) { process.Kill(true); }
            if (!complete.Wait(5000)) { throw new IOException("The Docker command did not close its output streams."); }
            complete.GetAwaiter().GetResult();
            result.Output = Encoding.UTF8.GetString(output.ToArray());
            result.Error = Encoding.UTF8.GetString(error.ToArray());
            result.ExitCode = dependencyFailed ? -1 : result.TimedOut ? 124 : result.Cancelled ? 125 : result.OutputLimit ? 126 : process.ExitCode;
            return result;
        }

        private string Redact(string text)
        {
            foreach (string secret in _secrets) { text = text.Replace(secret, "[secret]", StringComparison.Ordinal); }
            return text;
        }

        private static string NormalizeProject(string project)
        {
            if (string.IsNullOrWhiteSpace(project) || !Path.IsPathFullyQualified(project) || project.StartsWith(@"\\", StringComparison.Ordinal) ||
                project.IndexOfAny(new[] { ',', '"', '\r', '\n', '\0', '~' }) >= 0)
            {
                throw new InvalidOperationException("Isolated execution requires an unambiguous local Project path.");
            }
            string root = Path.GetFullPath(project).TrimEnd(Path.DirectorySeparatorChar);
            string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
            if (!Directory.Exists(root) || root == Path.GetPathRoot(root)!.TrimEnd(Path.DirectorySeparatorChar) || IsWithin(home, root))
            {
                throw new InvalidOperationException("Isolated execution cannot mount a drive root or the home directory.");
            }
            RejectLinks(root);
            return root;
        }

        private static bool IsWithin(string path, string root)
        {
            return string.Equals(path.TrimEnd(Path.DirectorySeparatorChar), root, StringComparison.OrdinalIgnoreCase) ||
                path.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
        }

        private static void RejectLinks(string path)
        {
            string? candidate = path;
            while (!string.IsNullOrEmpty(candidate))
            {
                if ((Directory.Exists(candidate) || File.Exists(candidate)) && (File.GetAttributes(candidate) & FileAttributes.ReparsePoint) != 0)
                {
                    throw new InvalidOperationException("An isolated path must not contain a symbolic link or junction.");
                }
                string segment = Path.GetFileName(candidate);
                if (segment.EndsWith(".", StringComparison.Ordinal) || segment.EndsWith(" ", StringComparison.Ordinal))
                {
                    throw new InvalidOperationException("An isolated path must not use a trailing-dot or trailing-space alias.");
                }
                candidate = Path.GetDirectoryName(candidate);
            }
        }

        public void Dispose()
        {
            if (_disposed) { return; }
            if (Active) { throw new InvalidOperationException("An active isolated command must stop before disposal."); }
            _disposed = true;
            _cancellation.Dispose();
            _policy.Dispose();
            _environment.Clear();
            _secrets.Clear();
            File.Delete(Path.Combine(_configurationDirectory, "config.json"));
            Directory.Delete(_configurationDirectory);
        }

        private sealed class NativeResult
        {
            public int ExitCode { get; set; } = -1;
            public string Output { get; set; } = string.Empty;
            public string Error { get; set; } = string.Empty;
            public bool TimedOut { get; set; }
            public bool Cancelled { get; set; }
            public bool OutputLimit { get; set; }
            public bool DependencyFailed { get; set; }
        }
    }
}