Editor/Lifecycle/RuntimeMonitorCoordinator.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Sandbox.SecBox.Bridge;
using Sandbox.SecBox.Bridge.Dto;
using DiagnosticsLog = Sandbox.SecBox.Bridge.DiagnosticsLog;
namespace Sandbox.SecBox.Lifecycle;
// Orchestrates Tier E - the in-editor managed-call enforcement hook. Wires the
// event sink to DiagnosticsLog + a small in-memory ring the status panel reads
// for live preview.
//
// Detection tiers were removed: Tier A (Sentinel ETW service + MSI) and Tier B
// (native CLR profiler) are gone. The only sensor is the Harmony hook, which
// suspends the calling thread and shows its OWN blocking decision dialog
// in-process - so the adapter never spawns an AlertUI itself anymore.
//
// Lifecycle:
// * EnsureAttached - idempotent, called after SecboxCoreClient is ready (boot).
// * ReapplySettings - call when the user toggles enforcement; detach + reattach.
// * Detach - call on shutdown / dev-mode reload.
public static class RuntimeMonitorCoordinator
{
// Locks are split so the event hot path doesn't share a lock with the
// attach lifecycle or with reader queries.
//
// _attachLock - serialises Attach/Detach/Reapply. Cold path.
// _writeLock - protects the _recent queue. Held briefly during enqueue
// + snapshot publish. Held ONLY by event-receiver threads.
// _snapshot - volatile reference, atomic publish on each event.
// Readers never take any lock; just read the field.
static readonly object _attachLock = new();
static readonly object _writeLock = new();
static bool _attached;
static readonly Queue<RuntimeFinding> _recent = new();
static volatile RuntimeFinding[] _snapshot = System.Array.Empty<RuntimeFinding>();
const int RecentMax = 256;
public static bool IsAttached => System.Threading.Volatile.Read(ref _attached);
// Lock-free read: just returns the latest snapshot. Updaters publish a
// new array atomically. Safe against any number of concurrent readers
// and writers without taking a lock on the read path.
public static IReadOnlyList<RuntimeFinding> RecentFindings => _snapshot;
public static int RecentCount => _snapshot.Length;
public static event Action<RuntimeFinding> FindingReceived;
public static void EnsureAttached()
{
var cfg = SecboxConfig.Load();
if (!cfg.RuntimeMonitoringEnabled)
{
DiagnosticsLog.Info("runtime monitoring disabled in config - skipping attach");
return;
}
try
{
Task.Run(() => SecboxCoreClient.EnsureReadyAsync()).GetAwaiter().GetResult();
}
catch (Exception ex)
{
DiagnosticsLog.Warn($"runtime monitor: core not ready, will retry on next boot: {ex.Message}");
return;
}
AttachOnce(cfg);
}
static void AttachOnce(SecboxConfig cfg)
{
lock (_attachLock)
{
if (_attached) return;
try
{
// Tier E only. Enforcement is gated by BlockLibraryProcessStart;
// the hook handles the suspend dialog in-process.
var opts = new RuntimeSensorOptions
{
EnableManagedHook = true,
Enforcement = new EnforcementPolicyDto
{
BlockLibraryProcessStart = cfg.BlockLibraryProcessStart,
},
};
var result = RuntimeMonitorBridge.Attach(opts, OnFindingJson);
_attached = result.Attached;
if (result.Attached)
{
DiagnosticsLog.Info($"runtime sensors attached: "
+ string.Join(", ", result.Sensors.Select(s => $"{s.Id}={s.Status}"
+ (string.IsNullOrEmpty(s.LastError) ? "" : " - " + s.LastError))));
}
else
{
// Surface BOTH the message and every sensor's status so
// state-mismatch bugs (adapter says detached / Core says
// already attached, etc.) are visible without ad-hoc traces.
var sensorDump = result.Sensors == null || result.Sensors.Count == 0
? "(none)"
: string.Join(", ", result.Sensors.Select(s =>
$"{s.Id}={s.Status}"
+ (string.IsNullOrEmpty(s.LastError) ? "" : " - " + s.LastError)));
DiagnosticsLog.Warn($"runtime sensor attach reported failure: {result.Message ?? "(no message)"} | sensors=[{sensorDump}]");
}
}
catch (Exception ex)
{
DiagnosticsLog.Error("runtime sensor attach threw", ex);
}
}
}
public static void Detach()
{
lock (_attachLock)
{
if (!_attached) return;
try { RuntimeMonitorBridge.Detach(); }
catch (Exception ex) { DiagnosticsLog.Warn($"detach threw: {ex.Message}"); }
_attached = false;
}
// Clear the recent ring outside the attach lock to keep that lock cold.
lock (_writeLock)
{
_recent.Clear();
_snapshot = System.Array.Empty<RuntimeFinding>();
}
}
public static void ReapplySettings()
{
// Detach + re-attach so a changed BlockLibraryProcessStart flag takes
// effect. EnsureAttached re-reads config.
Detach();
EnsureAttached();
}
static void OnFindingJson(string json)
{
RuntimeFinding f;
try { f = System.Text.Json.JsonSerializer.Deserialize<RuntimeFinding>(json,
new System.Text.Json.JsonSerializerOptions { PropertyNameCaseInsensitive = true }); }
catch (Exception ex)
{
DiagnosticsLog.Trace($"runtime monitor: unparseable finding json: {ex.Message}");
return;
}
if (f == null) return;
// Hot path. _writeLock is the SHORT critical section - never held for
// any other purpose, so the UI thread cannot block on it via any reader
// (RecentFindings reads the volatile snapshot lock-free).
lock (_writeLock)
{
_recent.Enqueue(f);
while (_recent.Count > RecentMax) _recent.Dequeue();
_snapshot = _recent.ToArray();
}
try { FindingReceived?.Invoke(f); } catch { }
// Record only - the Tier E hook shows its own blocking decision dialog
// in-process, so the adapter does not spawn any UI here.
var line = $"[{f.Severity}] {f.Kind} @ {f.Target ?? "(no target)"} "
+ (string.IsNullOrEmpty(f.CallerAssembly) ? "" : $"by {f.CallerAssembly}::{f.CallerMethod} ")
+ $"[{string.Join(",", f.SensorIds ?? new List<string>())}]";
var isCritical = string.Equals(f.Severity, "Critical", StringComparison.OrdinalIgnoreCase);
if (isCritical)
DiagnosticsLog.Error("runtime: " + line);
else if (string.Equals(f.Severity, "High", StringComparison.OrdinalIgnoreCase))
DiagnosticsLog.Warn("runtime: " + line);
else
DiagnosticsLog.Trace("runtime: " + line);
}
}