v2 single-rail 5.7: migrate Analyzer trio + AgentAnalyzerManager to Signal-Post (B24-B26)

Full migration — the 3 analyzer classes and their manager all move to
InformationalEventPost, no Action<EnrollmentEvent>-bypass remains in the
analyzer surface (no dual-use concerns; no standalone CLI mode touches
these types).

Analyzers (1 Emit-site each):
  * LocalAdminAnalyzer (B24): local_admin_analysis.
  * SoftwareInventoryAnalyzer (B25): software_inventory_analysis
    (startup baseline + shutdown delta; WG-part discriminator unchanged).
  * IntegrityBypassAnalyzer (B26): integrity_bypass_analysis.

Manager:
  * AgentAnalyzerManager stores InformationalEventPost _post and passes
    it to all 3 analyzer constructors in Initialize().

Program.cs wiring:
  * AgentAnalyzerManager construction deferred into orchestrator.Start's
    onIngressReady hook so lifecyclePost is non-null at construction time.
    The outer-scope `AgentAnalyzerManager analyzerManager = null;`
    declaration is captured by both the later RunStartup() call (after
    Start returns) and the terminationHandler constructor (inside the
    same hook).

Tests:
  * AgentAnalyzerManagerTests helper NewPost() returns a throwaway
    InformationalEventPost(new FakeSignalIngressSink(), VirtualClock)
    for the 7 ctor sites including the null-arg test.
  * 538 V2.Core + 108 DecisionCore = 646 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-23 14:57:44 +02:00
parent 8e3e62f4d3
commit 818fa53728
6 changed files with 58 additions and 35 deletions
@@ -5,8 +5,10 @@ using System.Threading;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Runtime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.Shared.Models;
using Xunit;
@@ -24,12 +26,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
private static AgentLogger NewLogger(string dir) => new AgentLogger(dir, AgentLogLevel.Info);
// Throwaway InformationalEventPost for tests that exercise manager lifecycle, not
// emission surface. Tracker events flow into a discarded FakeSignalIngressSink so
// tests that inject a FakeAnalyzer see no cross-talk from real analyzer emissions.
private static InformationalEventPost NewPost() =>
new InformationalEventPost(new FakeSignalIngressSink(), new VirtualClock(new DateTime(2026, 4, 23, 10, 0, 0, DateTimeKind.Utc)));
[Fact]
public void Initialize_respects_toggle_all_off_registers_no_analyzers()
{
using var tmp = new TempDirectory();
var sut = new AgentAnalyzerManager(
Config(), NewLogger(tmp.Path), _ => { },
Config(), NewLogger(tmp.Path), NewPost(),
new AnalyzerConfiguration
{
EnableLocalAdminAnalyzer = false,
@@ -46,7 +54,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
{
using var tmp = new TempDirectory();
var sut = new AgentAnalyzerManager(
Config(), NewLogger(tmp.Path), _ => { },
Config(), NewLogger(tmp.Path), NewPost(),
new AnalyzerConfiguration
{
EnableLocalAdminAnalyzer = true,
@@ -66,7 +74,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
public void Initialize_tolerates_null_analyzer_config()
{
using var tmp = new TempDirectory();
var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), _ => { }, analyzerConfig: null);
var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), NewPost(), analyzerConfig: null);
sut.Initialize();
// Default AnalyzerConfiguration enables LocalAdmin + IntegrityBypass, disables SoftwareInventory.
@@ -81,7 +89,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
{
using var tmp = new TempDirectory();
var sut = new AgentAnalyzerManager(
Config(), NewLogger(tmp.Path), _ => { },
Config(), NewLogger(tmp.Path), NewPost(),
new AnalyzerConfiguration { EnableLocalAdminAnalyzer = true });
sut.Initialize();
@@ -96,9 +104,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
using var tmp = new TempDirectory();
var logger = NewLogger(tmp.Path);
Assert.Throws<ArgumentNullException>(() =>
new AgentAnalyzerManager(null!, logger, _ => { }, null));
new AgentAnalyzerManager(null!, logger, NewPost(), null));
Assert.Throws<ArgumentNullException>(() =>
new AgentAnalyzerManager(Config(), null!, _ => { }, null));
new AgentAnalyzerManager(Config(), null!, NewPost(), null));
Assert.Throws<ArgumentNullException>(() =>
new AgentAnalyzerManager(Config(), logger, null!, null));
}
@@ -149,7 +157,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
public void RunShutdown_invokes_AnalyzeAtShutdown_on_every_analyzer()
{
using var tmp = new TempDirectory();
var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), _ => { }, new AnalyzerConfiguration());
var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), NewPost(), new AnalyzerConfiguration());
var fake = new FakeAnalyzer();
InjectAnalyzer(sut, fake);
@@ -161,7 +169,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
public void RunShutdown_swallows_analyzer_exceptions_and_continues()
{
using var tmp = new TempDirectory();
var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), _ => { }, new AnalyzerConfiguration());
var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), NewPost(), new AnalyzerConfiguration());
var fake = new FakeAnalyzer { ThrowOnShutdown = new InvalidOperationException("boom") };
InjectAnalyzer(sut, fake);
@@ -175,7 +183,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Runtime
public void RunShutdown_is_safe_on_empty_analyzer_list()
{
using var tmp = new TempDirectory();
var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), _ => { },
var sut = new AgentAnalyzerManager(Config(), NewLogger(tmp.Path), NewPost(),
new AnalyzerConfiguration
{
EnableLocalAdminAnalyzer = false,
@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Management;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
@@ -34,7 +35,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
public string Name => "IntegrityBypassAnalyzer";
@@ -63,12 +64,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
public IntegrityBypassAnalyzer(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
InformationalEventPost post,
AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -220,7 +221,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
}
};
_emitEvent(new EnrollmentEvent
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Management;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
@@ -30,7 +31,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
private readonly List<string> _allowedAccounts;
@@ -57,13 +58,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
public LocalAdminAnalyzer(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
InformationalEventPost post,
AgentLogger logger,
List<string> allowedAccounts = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
// Tenant-supplied accounts are additive (union with built-ins, not replacement)
@@ -203,7 +204,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
? $"{Name}: No unexpected local admins detected"
: $"{Name}: Unexpected admin activity detected (confidence={confidenceScore})";
_emitEvent(new EnrollmentEvent
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
@@ -22,7 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
{
private readonly string _sessionId;
private readonly string _tenantId;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
// Captured at startup for delta detection at shutdown
@@ -349,12 +350,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
public SoftwareInventoryAnalyzer(
string sessionId,
string tenantId,
Action<EnrollmentEvent> emitEvent,
InformationalEventPost post,
AgentLogger logger)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -874,7 +875,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers
: $"{Name}: Shutdown inventory ({inventory.Count} items, {newInstalls?.Count ?? 0} new during enrollment)")
: $"{Name}: Inventory chunk {i + 1}/{totalChunks}";
_emitEvent(new EnrollmentEvent
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Analyzers;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Runtime
@@ -24,7 +25,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
{
private readonly AgentConfiguration _configuration;
private readonly AgentLogger _logger;
private readonly Action<EnrollmentEvent> _emitEvent;
private readonly InformationalEventPost _post;
private readonly AnalyzerConfiguration _analyzerConfig;
private readonly List<IAgentAnalyzer> _analyzers = new List<IAgentAnalyzer>();
@@ -33,12 +34,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
public AgentAnalyzerManager(
AgentConfiguration configuration,
AgentLogger logger,
Action<EnrollmentEvent> emitEvent,
InformationalEventPost post,
AnalyzerConfiguration? analyzerConfig)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_emitEvent = emitEvent ?? throw new ArgumentNullException(nameof(emitEvent));
_post = post ?? throw new ArgumentNullException(nameof(post));
_analyzerConfig = analyzerConfig ?? new AnalyzerConfiguration();
}
@@ -56,7 +57,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
_analyzers.Add(new LocalAdminAnalyzer(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_post,
_logger,
_analyzerConfig.LocalAdminAllowedAccounts));
_logger.Info("LocalAdminAnalyzer registered");
@@ -71,7 +72,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
_analyzers.Add(new SoftwareInventoryAnalyzer(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_post,
_logger));
_logger.Info("SoftwareInventoryAnalyzer registered");
}
@@ -85,7 +86,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Runtime
_analyzers.Add(new IntegrityBypassAnalyzer(
_configuration.SessionId,
_configuration.TenantId,
_emitEvent,
_post,
_logger));
_logger.Info("IntegrityBypassAnalyzer registered");
}
+19 -8
View File
@@ -503,14 +503,14 @@ namespace AutopilotMonitor.Agent.V2
using (var shutdown = new ManualResetEventSlim(false))
using (var shutdownComplete = new ManualResetEventSlim(false))
{
// M4.6.δ — Analyzer manager. Emits through the live orchestrator's event
// emitter. RunStartup fires after orchestrator.Start; RunShutdown is wired
// into the termination handler so it runs before diagnostics upload.
var analyzerManager = new AgentAnalyzerManager(
configuration: agentConfig,
logger: logger,
emitEvent: evt => { orchestrator.EventEmitter.Emit(evt); },
analyzerConfig: remoteConfig.Analyzers);
// M4.6.δ — Analyzer manager. Single-rail refactor (plan §5.7): the manager
// now takes an InformationalEventPost so LocalAdmin / SoftwareInventory /
// IntegrityBypass analyzer events flow through the same ingress pipe as
// every other telemetry source. Construction is deferred into
// orchestrator.Start's onIngressReady hook so lifecyclePost is non-null at
// construction time. RunStartup fires after orchestrator.Start; RunShutdown
// is wired into the termination handler so it runs before diagnostics upload.
AgentAnalyzerManager analyzerManager = null;
// M4.6.β — the peripheral termination sequence (FinalStatus + SummaryDialog
// + diagnostics upload + enrollment-complete.marker + CleanupService) lives
@@ -675,6 +675,17 @@ namespace AutopilotMonitor.Agent.V2
EmitVersionCheckEventIfAny(lifecyclePost, agentConfig, logger);
EmitUnrestrictedModeAuditIfChanged(lifecyclePost, agentConfig, configMergeResult, logger);
// Single-rail refactor (plan §5.7) — AgentAnalyzerManager emits through
// the same InformationalEventPost. Constructed inside this hook so
// lifecyclePost is guaranteed non-null by the time RunStartup (below,
// after Start returns) and RunShutdown (via terminationHandler) call
// into the three analyzers.
analyzerManager = new AgentAnalyzerManager(
configuration: agentConfig,
logger: logger,
post: lifecyclePost,
analyzerConfig: remoteConfig.Analyzers);
// Single-rail refactor (plan §5.3) — EnrollmentTerminationHandler emits
// through the same InformationalEventPost. Constructed inside this hook
// so lifecyclePost is guaranteed non-null; the terminated-dispatch wrapper