Editor/Lifecycle/RuntimeMonitor.cs
using System;
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;

// Subscribes to AppDomain.CurrentDomain.AssemblyLoad. Late-detection layer:
// by the time this fires the assembly's static ctors have already run, so we
// can warn but cannot prevent the initial payload. The install hook is the
// only layer that gates pre-load.
//
// Defers scanning to Secbox.Core via the bridge. EnsureReadyAsync is only
// called the first time we actually need to scan something.
public static class RuntimeMonitor
{
	static bool subscribed;

	public static void Subscribe()
	{
		if (subscribed) return;
		subscribed = true;

		try
		{
			AppDomain.CurrentDomain.AssemblyLoad += OnAssemblyLoad;
			global::Sandbox.Internal.GlobalGameNamespace.Log.Info(
				"[secbox] runtime monitor armed");
		}
		catch (Exception ex)
		{
			global::Sandbox.Internal.GlobalGameNamespace.Log.Warning(
				$"[secbox] runtime monitor could not subscribe: {ex.Message}");
		}
	}

	static void OnAssemblyLoad(object sender, AssemblyLoadEventArgs args)
	{
		try { Handle(args.LoadedAssembly); }
		catch (Exception ex) { DiagnosticsLog.Error("runtime monitor threw on assembly load", ex); }
	}

	static void Handle(System.Reflection.Assembly asm)
	{
		if (asm == null) return;

		var name = asm.GetName().Name ?? "";
		var location = asm.Location;

		// Gate by LOCATION not name. Only scan assemblies loaded from the
		// current project's Libraries/ tree - that's where third-party editor
		// libraries live. Anything from the engine bin (HarfBuzzSharp, ExCSS,
		// Sandbox.*, etc.) or from secbox's own ALC cache is skipped.
		if (!IsUserLibraryAssembly(name, location))
		{
			DiagnosticsLog.Trace($"runtime monitor: skipping {name} (not under Libraries/)");
			return;
		}

		DiagnosticsLog.Info($"runtime monitor: scanning newly-loaded assembly {name} @ {location}");

		var projectRoot = PackageLocator.CurrentProjectRoot();
		var store = !string.IsNullOrEmpty(projectRoot)
			? TrustStore.Load(projectRoot)
			: new TrustStore { Policy = new Policy() };

		if (!store.Policy.RuntimeMonitor) return;

		try
		{
			Task.Run(() => SecboxCoreClient.EnsureReadyAsync()).GetAwaiter().GetResult();
		}
		catch (Exception ex)
		{
			DiagnosticsLog.Warn($"runtime monitor: core load failed, skipping scan of {name}: {ex.Message}");
			return;
		}

		ScanReport report;
		try { report = Task.Run(() => SecboxCoreClient.ScanAssembly(location)).GetAwaiter().GetResult(); }
		catch (Exception ex)
		{
			DiagnosticsLog.Warn($"runtime monitor: scan threw for {name}: {ex.Message}");
			return;
		}

		var critical = report.Findings.Count(f => f.Severity == Severity.Critical);
		var high = report.Findings.Count(f => f.Severity == Severity.High);

		if (critical > 0)
		{
			global::Sandbox.Internal.GlobalGameNamespace.Log.Error(
				$"[secbox] runtime monitor: {name} has {critical} CRITICAL findings "
				+ $"AFTER load - static ctor damage may already be done. "
				+ $"First finding: {report.Findings.First(f => f.Severity == Severity.Critical)}");
		}
		else if (high > 0)
		{
			global::Sandbox.Internal.GlobalGameNamespace.Log.Warning(
				$"[secbox] runtime monitor: {name} has {high} high-severity findings (post-load)");
		}
	}

	// True iff the assembly was loaded from a path strictly under
	// <projectRoot>/Libraries/<sub>/. That's the only place user-installed
	// editor extensions live; everything else is engine, NuGet, or our own
	// downloaded core ALC.
	static bool IsUserLibraryAssembly(string assemblyName, string location)
	{
		if (string.IsNullOrEmpty(location)) return false;

		// secbox itself - never scan our own.
		if (assemblyName.StartsWith("secbox", StringComparison.OrdinalIgnoreCase)) return false;
		if (assemblyName.StartsWith("Secbox.", StringComparison.OrdinalIgnoreCase)) return false;

		var projectRoot = PackageLocator.CurrentProjectRoot();
		if (string.IsNullOrEmpty(projectRoot)) return false;

		string libRoot;
		try
		{
			libRoot = System.IO.Path.GetFullPath(System.IO.Path.Combine(projectRoot, "Libraries"))
				.TrimEnd(System.IO.Path.DirectorySeparatorChar)
				+ System.IO.Path.DirectorySeparatorChar;
		}
		catch { return false; }

		try
		{
			var full = System.IO.Path.GetFullPath(location);
			return full.StartsWith(libRoot, StringComparison.OrdinalIgnoreCase);
		}
		catch { return false; }
	}
}