Editor/Lifecycle/InstallHook.cs
using System;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Editor;
using Sandbox.SecBox.Bridge;
using Sandbox.SecBox.Bridge.Dto;
using DiagnosticsLog = Sandbox.SecBox.Bridge.DiagnosticsLog;
namespace Sandbox.SecBox.Lifecycle;
// Subscribes to PackageManager.OnPackageInstalledToContext via reflection
// (PackageManager is internal - no public alternative for code packages).
// Tries to insert at the front of the delegate chain so we run before
// ToolsDll/GameInstanceDll, giving us a window to scan + prompt before the
// new package's assemblies load. Falls back to appending if the field-level
// trick fails on a future engine version.
//
// Scans are delegated to Secbox.Core via SecboxCoreClient - secbox.editor.dll
// itself is just an adapter; all heavy detection logic lives in the
// downloaded core DLL.
public static class InstallHook
{
const string EventName = "OnPackageInstalledToContext";
static bool subscribed;
public static void Subscribe()
{
if (subscribed) return;
var pmType = ReflectionHelpers.PackageManagerType();
if (pmType == null)
{
global::Sandbox.Internal.GlobalGameNamespace.Log.Warning(
"[secbox] PackageManager type not found - install hook disabled");
return;
}
var ev = pmType.GetEvent(EventName, BindingFlags.Public | BindingFlags.Static);
if (ev == null)
{
global::Sandbox.Internal.GlobalGameNamespace.Log.Warning(
$"[secbox] {EventName} event not found on PackageManager - install hook disabled");
return;
}
var ourMethod = typeof(InstallHook).GetMethod(nameof(OnPackageInstalled),
BindingFlags.NonPublic | BindingFlags.Static);
Delegate handler;
try { handler = Delegate.CreateDelegate(ev.EventHandlerType, ourMethod); }
catch (Exception ex)
{
global::Sandbox.Internal.GlobalGameNamespace.Log.Warning(
$"[secbox] could not bind install handler: {ex.Message}");
return;
}
if (ReflectionHelpers.InsertFirstInChain(pmType, EventName, handler))
{
global::Sandbox.Internal.GlobalGameNamespace.Log.Info(
"[secbox] install hook armed (first-in-chain)");
}
else if (ReflectionHelpers.AppendToChain(pmType, EventName, handler))
{
global::Sandbox.Internal.GlobalGameNamespace.Log.Warning(
"[secbox] install hook armed (appended - pre-load gating may not work)");
}
else
{
global::Sandbox.Internal.GlobalGameNamespace.Log.Warning(
"[secbox] install hook could not subscribe");
return;
}
subscribed = true;
}
static void OnPackageInstalled(object activePackage, string tag)
{
DiagnosticsLog.Trace($"InstallHook.OnPackageInstalled: tag={tag}");
try { HandleInstall(activePackage, tag); }
catch (Exception ex)
{
DiagnosticsLog.Error("install handler threw", ex);
}
DiagnosticsLog.Trace($"InstallHook.OnPackageInstalled: end tag={tag}");
}
static void HandleInstall(object activePackage, string tag)
{
var pkg = ReflectionHelpers.GetProp(activePackage, "Package") as Package;
if (pkg == null) return;
var ident = pkg.FullIdent ?? pkg.Ident ?? "<unknown>";
// Skip the obvious: secbox itself, engine first-party packages.
if (ident.StartsWith("local.secbox", StringComparison.OrdinalIgnoreCase)) return;
if (ident.Equals("secbox", StringComparison.OrdinalIgnoreCase)) return;
if (ident.StartsWith("facepunch.", StringComparison.OrdinalIgnoreCase)) return;
var projectRoot = PackageLocator.CurrentProjectRoot();
if (string.IsNullOrEmpty(projectRoot)) return;
// Skip the project's own package - it loads under "gamemenu" / "local"
// tags during editor boot and would otherwise trigger a full
// project-root scan. The current project's ident comes from its sbproj.
try
{
var currentProjIdent = Project.Current?.Package?.FullIdent
?? Project.Current?.Package?.Ident;
if (!string.IsNullOrEmpty(currentProjIdent)
&& ident.Equals(currentProjIdent, StringComparison.OrdinalIgnoreCase))
{
DiagnosticsLog.Trace($"skipping install of current project itself: {ident}");
return;
}
}
catch { }
// Locate the package's library folder. FolderFor only returns paths
// strictly under <projectRoot>/Libraries/ - engine packages and the
// project itself correctly return null here.
var folder = PackageLocator.FolderFor(pkg);
if (string.IsNullOrEmpty(folder))
{
DiagnosticsLog.Trace($"skipping {ident} tag={tag}: no library folder resolved (engine/external package)");
return;
}
DiagnosticsLog.Info($"package install: ident={ident} tag={tag} folder={folder}");
var store = TrustStore.Load(projectRoot);
if (!store.Policy.ScanOnInstall) return;
// Defence-in-depth: refuse to ever scan a project-root-shaped folder
// even if PackageLocator slipped up.
if (string.Equals(folder, projectRoot, StringComparison.OrdinalIgnoreCase))
{
DiagnosticsLog.Warn($"refusing to scan project root for {ident}");
return;
}
var hash = PackageHasher.HashFolder(folder);
var existing = store.Find(hash);
if (existing != null)
{
switch (existing.Decision)
{
case Decision.TrustAlways:
global::Sandbox.Internal.GlobalGameNamespace.Log.Info(
$"[secbox] {ident} matches TrustAlways entry; allowing");
return;
case Decision.Block:
global::Sandbox.Internal.GlobalGameNamespace.Log.Error(
$"[secbox] {ident} matches Block entry; refuse-install path TBD");
return;
}
}
// Bridge call. Runs on a thread-pool thread (Task.Run) to break any
// SynchronizationContext capture the engine thread might have set -
// without this, the async continuations inside EnsureReadyAsync /
// the Core's internal GetAwaiter().GetResult() deadlock against the
// hook thread we'd be blocking here.
try
{
Task.Run(() => SecboxCoreClient.EnsureReadyAsync()).GetAwaiter().GetResult();
}
catch (Exception ex)
{
DiagnosticsLog.Error("core load failed", ex);
return;
}
ScanReport report;
try
{
report = Task.Run(() => SecboxCoreClient.ScanFolder(folder)).GetAwaiter().GetResult();
}
catch (Exception ex)
{
DiagnosticsLog.Error("scan threw", ex);
return;
}
var critical = report.Findings.Count(f => f.Severity == Severity.Critical);
var high = report.Findings.Count(f => f.Severity == Severity.High);
var medium = report.Findings.Count(f => f.Severity == Severity.Medium);
var low = report.Findings.Count(f => f.Severity == Severity.Low);
global::Sandbox.Internal.GlobalGameNamespace.Log.Info(
$"[secbox] scan {ident}: Critical={critical} High={high} Medium={medium} Low={low} overall={report.Overall}");
var maxSeverity = report.Findings.Count == 0
? Severity.Info
: report.Findings.Max(f => f.Severity);
if (maxSeverity >= store.Policy.PromptThreshold)
{
store.Upsert(new TrustEntry
{
PackageIdent = ident,
Version = pkg.Revision?.VersionId.ToString(),
ContentHash = hash,
Decision = Decision.NotReviewed,
ReviewedAt = DateTime.UtcNow,
CriticalCount = critical,
HighCount = high,
MediumCount = medium,
LowCount = low,
Notes = $"Auto-recorded by install hook. First 5 findings:\n"
+ string.Join("\n", report.Findings.Take(5).Select(f => " " + f)),
});
store.Save();
global::Sandbox.Internal.GlobalGameNamespace.Log.Warning(
$"[secbox] {ident}: {critical} critical / {high} high findings - opening review dialog");
Sandbox.SecBox.UI.ReviewDialog.Show(ident, hash, report.Findings, store);
}
}
}