child/RunAuthority.cs

#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace DeskPilot.Child
{
    /// <summary>Single-child, narrowing-only authority and one-use action approval.</summary>
    public sealed class RunAuthority : IDisposable
    {
        private readonly object _sync = new object();
        private readonly Stopwatch _clock = Stopwatch.StartNew();
        private readonly string _launch;
        private readonly string _conversation;
        private readonly string _parentTurn;
        private readonly string _child;
        private readonly string _policyDigest;
        private readonly bool _file;
        private readonly bool _terminal;
        private readonly bool _writable;
        private readonly int _duration;
        private readonly int _approvalSeconds;
        private readonly HashSet<string> _requests = new HashSet<string>(StringComparer.Ordinal);
        private readonly HashSet<string> _received = new HashSet<string>(StringComparer.Ordinal);
        private readonly CancellationTokenSource _cancel = new CancellationTokenSource();
        private readonly Timer _deadline;
        private int _closed;
        private long _generation = 1;
        private string _reason = string.Empty;
        private string? _request;
        private string? _approvalId;
        private string? _fingerprint;
        private string? _pendingJson;
        private long _approvalExpires;
        private TaskCompletionSource<bool>? _decision;
        private bool _decided;
        private bool _consumed;

        /// <summary>Freezes all identities and scope before any capture or execution.</summary>
        public RunAuthority(string launch, string conversation, string parentTurn, string child,
            string policyDigest, bool file, bool terminal, bool writable, int durationSeconds, int approvalSeconds)
        {
            foreach (string identity in new[] { launch, conversation, parentTurn, child })
            {
                if (string.IsNullOrWhiteSpace(identity) || identity.Length > 128)
                { throw new ArgumentException("Invalid child authority identity."); }
            }
            if (!System.Text.RegularExpressions.Regex.IsMatch(policyDigest, "^[a-f0-9]{64}$") ||
                durationSeconds < 1 || durationSeconds > 600 || approvalSeconds < 1 || approvalSeconds > 120)
            { throw new ArgumentException("Invalid child authority limits."); }
            _launch = launch; _conversation = conversation; _parentTurn = parentTurn; _child = child;
            _policyDigest = policyDigest; _file = file; _terminal = terminal; _writable = writable;
            _duration = durationSeconds; _approvalSeconds = approvalSeconds;
            _deadline = new Timer(_ => Close("duration-limit"), null, durationSeconds * 1000, Timeout.Infinite);
        }

        /// <summary>Cancellation shared by all complete-run waits.</summary>
        public CancellationToken Cancellation => _cancel.Token;
        /// <summary>Whether any new work can be admitted.</summary>
        public bool Open => Volatile.Read(ref _closed) == 0 && _clock.Elapsed.TotalSeconds < _duration;
        /// <summary>First terminal cause, never overwritten by cleanup errors.</summary>
        public string Reason => Volatile.Read(ref _reason);
        /// <summary>Current approval facts as untrusted display data, without any execution authority.</summary>
        public string? PendingApproval { get { if (!Open) { return null; } lock (_sync) { return Open && !_decided && !_consumed ? _pendingJson : null; } } }
        /// <summary>Complete-run monotonic time remaining.</summary>
        public int RemainingMilliseconds => Math.Max(0, _duration * 1000 - (int)_clock.ElapsedMilliseconds);

        /// <summary>Refuses unavailable authority before an effect.</summary>
        public void Ensure(string operation)
        {
            lock (_sync)
            {
                if (!Open || (operation == "read" && !_file) ||
                    (operation == "write" && (!_file || !_writable)) || (operation == "terminal" && !_terminal) ||
                    (operation != "read" && operation != "write" && operation != "terminal" && operation != "provider" && operation != "export"))
                { throw new InvalidOperationException("Child authority is unavailable."); }
            }
        }

        /// <summary>Prepares one exact action without executing it or inheriting any prior grant.</summary>
        public string Prepare(string requestId, string operation, string actionJson)
        {
            lock (_sync)
            {
                Ensure(operation);
                if (operation != "write" && operation != "terminal") { throw new InvalidDataException("Unsupported child approval action."); }
                if (_requests.Count >= 256 || string.IsNullOrEmpty(requestId) || requestId.Length > 128 ||
                    _requests.Contains(requestId) || (_decision != null && !_consumed))
                { throw new InvalidOperationException("Stale or overlapping child approval."); }
                if (Encoding.UTF8.GetByteCount(actionJson) > 8192) { throw new InvalidDataException("Approval facts exceed their byte limit."); }
                using JsonDocument action = JsonDocument.Parse(actionJson, new JsonDocumentOptions { MaxDepth = 8 });
                if (action.RootElement.ValueKind != JsonValueKind.Object) { throw new InvalidDataException("Invalid approval facts."); }
                _requests.Add(requestId);
                _request = requestId;
                _approvalId = Guid.NewGuid().ToString("N");
                _approvalExpires = Math.Min(_duration * 1000L, _clock.ElapsedMilliseconds + _approvalSeconds * 1000L);
                byte[] identity = JsonSerializer.SerializeToUtf8Bytes(new
                {
                    launchId = _launch, conversationId = _conversation, parentTurnId = _parentTurn, childId = _child,
                    attempt = 1, generation = _generation, requestId, approvalId = _approvalId, policyDigest = _policyDigest,
                    operation, action = action.RootElement
                });
                _fingerprint = Convert.ToHexString(SHA256.HashData(identity)).ToLowerInvariant();
                _pendingJson = JsonSerializer.Serialize(new
                {
                    id = _approvalId, fingerprint = _fingerprint, launchId = _launch, conversationId = _conversation,
                    parentTurnId = _parentTurn, childId = _child, requestId, attempt = 1, generation = _generation,
                    profile = "single-child-v3", budgetMode = "provider-estimate", policyDigest = _policyDigest,
                    operation, action = action.RootElement,
                    expiresUtc = DateTime.UtcNow.AddMilliseconds(_approvalExpires - _clock.ElapsedMilliseconds)
                });
                _decision = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
                _decided = false;
                _consumed = false;
                return _pendingJson;
            }
        }

        /// <summary>Accepts one exact window answer; cross-run, expired and replayed answers do nothing.</summary>
        public bool Submit(string conversation, string child, string approvalId, string fingerprint, bool approved)
        {
            lock (_sync)
            {
                if (!Open || _decision == null || _decided || _consumed || _clock.ElapsedMilliseconds >= _approvalExpires ||
                    conversation != _conversation || child != _child || approvalId != _approvalId || fingerprint != _fingerprint)
                { return false; }
                _decided = true;
                return _decision.TrySetResult(approved);
            }
        }

        /// <summary>Waits only within the approval and complete-run deadlines.</summary>
        public async Task<bool> WaitForDecisionAsync()
        {
            Task<bool> pending;
            int milliseconds;
            lock (_sync)
            {
                if (_decision == null || !Open) { return false; }
                pending = _decision.Task;
                milliseconds = (int)Math.Max(1, _approvalExpires - _clock.ElapsedMilliseconds);
            }
            try { return await pending.WaitAsync(TimeSpan.FromMilliseconds(milliseconds), _cancel.Token).ConfigureAwait(false); }
            catch (Exception error) when (error is TimeoutException || error is OperationCanceledException)
            {
                lock (_sync) { _decided = true; _decision?.TrySetResult(false); }
                return false;
            }
        }

        /// <summary>Consumes a positive decision exactly once immediately before committed dispatch.</summary>
        public bool Consume(string requestId, string fingerprint)
        {
            lock (_sync)
            {
                if (!Open || _decision == null || !_decided || _consumed || requestId != _request || fingerprint != _fingerprint)
                { return false; }
                _consumed = true;
                return _clock.ElapsedMilliseconds < _approvalExpires && _decision.Task.IsCompletedSuccessfully && _decision.Task.Result;
            }
        }

        internal void Commit(string operation, Action dispatch)
        {
            lock (_sync) { Ensure(operation); dispatch(); }
        }

        /// <summary>Admits each authenticated child request identity once within a fixed message bound.</summary>
        public void AdmitRequest(string requestId)
        {
            lock (_sync)
            {
                Ensure("provider");
                if (!System.Text.RegularExpressions.Regex.IsMatch(requestId, "^[a-f0-9]{32}$") ||
                    _received.Count >= 256 || !_received.Add(requestId))
                { throw new InvalidDataException("Stale or excessive child requests were refused."); }
            }
        }

        /// <summary>Live Permissions can revoke captured authority but cannot widen it.</summary>
        public void UpdatePermissions(bool file, bool terminal, bool enabled) => UpdatePermissions(file, terminal, enabled, true);

        /// <summary>Also revokes private write access independently of File Permission.</summary>
        public void UpdatePermissions(bool file, bool terminal, bool enabled, bool writable)
        {
            if (!enabled || (_file && !file) || (_terminal && !terminal) || (_writable && !writable)) { Close("permission-revoked"); }
        }

        /// <summary>Closes admission and releases waits before any resource cleanup starts.</summary>
        public void Close(string reason)
        {
            if (Interlocked.CompareExchange(ref _closed, 1, 0) != 0) { return; }
            Volatile.Write(ref _reason, reason);
            Interlocked.Increment(ref _generation);
            _cancel.Cancel();
            Volatile.Read(ref _decision)?.TrySetResult(false);
        }

        /// <summary>Withdraws authority and its independent deadline.</summary>
        public void Dispose() { Close("stopped"); _deadline.Dispose(); }
    }
}