diff --git a/rules/analyze/ANALYZE-ESP-002.json b/rules/analyze/ANALYZE-ESP-002.json
new file mode 100644
index 00000000..758df3df
--- /dev/null
+++ b/rules/analyze/ANALYZE-ESP-002.json
@@ -0,0 +1,58 @@
+{
+ "$schema": "../schema/analyze-rule.schema.json",
+ "ruleId": "ANALYZE-ESP-002",
+ "title": "ESP Subcategory Failed (Certificates)",
+ "description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
+ "severity": "high",
+ "category": "esp",
+ "version": "1.0.0",
+ "author": "Autopilot Monitor",
+ "enabled": true,
+ "trigger": "single",
+ "baseConfidence": 80,
+ "conditions": [
+ {
+ "signal": "certificates_subcategory_failed",
+ "source": "event_data",
+ "eventType": "esp_provisioning_status",
+ "dataField": "failedSubcategories",
+ "operator": "contains",
+ "value": "Certificates",
+ "required": true
+ }
+ ],
+ "confidenceFactors": [],
+ "confidenceThreshold": 70,
+ "explanation": "The ESP Certificates subcategory transitioned to **failed** during enrollment. This is frequently masked when the user clicks 'Continue anyway' on the ESP error screen — the overall enrollment then appears successful while a required certificate (e.g. Wi-Fi, VPN, SCEP/PKCS) was never deployed.\n\nThe subcategory name is read from the registry (`CertificatesSubcategory`) and is language-invariant, so this rule fires regardless of OS display language.",
+ "remediation": [
+ {
+ "title": "Validate certificate delivery on the device",
+ "steps": [
+ "Run `certutil -store My` and `certutil -store -user My` on the device to inspect installed certificates",
+ "Check `Event Viewer → Applications and Services Logs → Microsoft → Windows → DeviceManagement-Enterprise-Diagnostics-Provider → Admin` for SCEP/PKCS errors",
+ "Review the Intune portal for the device's certificate profile assignments and deployment status"
+ ]
+ },
+ {
+ "title": "Fix common certificate provisioning issues",
+ "steps": [
+ "Verify the NDES/SCEP connector is online and healthy",
+ "Confirm the certificate template permissions grant 'Enroll' to the Intune service account / NDES computer account",
+ "Check that the device has network line-of-sight to the SCEP endpoint at the time of ESP",
+ "Re-sync the device (Settings → Accounts → Access work or school → Info → Sync) and re-check the Certificates subcategory"
+ ]
+ }
+ ],
+ "relatedDocs": [
+ {
+ "title": "Microsoft Learn: Troubleshoot certificate profiles in Intune",
+ "url": "https://learn.microsoft.com/en-us/mem/intune/protect/troubleshoot-certificate-profiles"
+ },
+ {
+ "title": "Microsoft Learn: Enrollment Status Page tracking",
+ "url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
+ }
+ ],
+ "markSessionAsFailedDefault": false,
+ "tags": ["esp", "certificates", "subcategory", "silent-failure"]
+}
diff --git a/rules/dist/analyze-rules.json b/rules/dist/analyze-rules.json
index 270ca99a..ba7d6d93 100644
--- a/rules/dist/analyze-rules.json
+++ b/rules/dist/analyze-rules.json
@@ -960,6 +960,68 @@
"timeout"
]
},
+ {
+ "ruleId": "ANALYZE-ESP-002",
+ "title": "ESP Subcategory Failed (Certificates)",
+ "description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
+ "severity": "high",
+ "category": "esp",
+ "version": "1.0.0",
+ "author": "Autopilot Monitor",
+ "enabled": true,
+ "trigger": "single",
+ "baseConfidence": 80,
+ "conditions": [
+ {
+ "signal": "certificates_subcategory_failed",
+ "source": "event_data",
+ "eventType": "esp_provisioning_status",
+ "dataField": "failedSubcategories",
+ "operator": "contains",
+ "value": "Certificates",
+ "required": true
+ }
+ ],
+ "confidenceFactors": [],
+ "confidenceThreshold": 70,
+ "explanation": "The ESP Certificates subcategory transitioned to **failed** during enrollment. This is frequently masked when the user clicks 'Continue anyway' on the ESP error screen — the overall enrollment then appears successful while a required certificate (e.g. Wi-Fi, VPN, SCEP/PKCS) was never deployed.\n\nThe subcategory name is read from the registry (`CertificatesSubcategory`) and is language-invariant, so this rule fires regardless of OS display language.",
+ "remediation": [
+ {
+ "title": "Validate certificate delivery on the device",
+ "steps": [
+ "Run `certutil -store My` and `certutil -store -user My` on the device to inspect installed certificates",
+ "Check `Event Viewer → Applications and Services Logs → Microsoft → Windows → DeviceManagement-Enterprise-Diagnostics-Provider → Admin` for SCEP/PKCS errors",
+ "Review the Intune portal for the device's certificate profile assignments and deployment status"
+ ]
+ },
+ {
+ "title": "Fix common certificate provisioning issues",
+ "steps": [
+ "Verify the NDES/SCEP connector is online and healthy",
+ "Confirm the certificate template permissions grant 'Enroll' to the Intune service account / NDES computer account",
+ "Check that the device has network line-of-sight to the SCEP endpoint at the time of ESP",
+ "Re-sync the device (Settings → Accounts → Access work or school → Info → Sync) and re-check the Certificates subcategory"
+ ]
+ }
+ ],
+ "relatedDocs": [
+ {
+ "title": "Microsoft Learn: Troubleshoot certificate profiles in Intune",
+ "url": "https://learn.microsoft.com/en-us/mem/intune/protect/troubleshoot-certificate-profiles"
+ },
+ {
+ "title": "Microsoft Learn: Enrollment Status Page tracking",
+ "url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
+ }
+ ],
+ "markSessionAsFailedDefault": false,
+ "tags": [
+ "esp",
+ "certificates",
+ "subcategory",
+ "silent-failure"
+ ]
+ },
{
"ruleId": "ANALYZE-ESP-003",
"title": "Enrollment Duration Exceeded 60 Minutes",
diff --git a/rules/schema/analyze-rule.schema.json b/rules/schema/analyze-rule.schema.json
index 3b7675c1..d2436725 100644
--- a/rules/schema/analyze-rule.schema.json
+++ b/rules/schema/analyze-rule.schema.json
@@ -123,6 +123,11 @@
"items": { "type": "string" },
"default": []
},
+ "markSessionAsFailedDefault": {
+ "type": "boolean",
+ "description": "When true, a firing of this rule causes the entire enrollment session to be marked as failed (KO criterion). Tenants can override this via the portal.",
+ "default": false
+ },
"templateVariables": {
"type": "array",
"description": "Template variables that must be customized per-tenant. If non-empty, the rule is a template: enabling it creates a tenant custom copy with the user's values substituted.",
@@ -161,7 +166,7 @@
"operator": {
"type": "string",
"description": "Comparison operator.",
- "enum": ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "count_gte", "count_per_group_gte"]
+ "enum": ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "count_gte", "count_per_group_gte", "in", "not_in"]
},
"value": {
"type": "string",
@@ -208,7 +213,7 @@
"eventAFilterOperator": {
"type": "string",
"description": "Operator for the Event A filter.",
- "enum": ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "count_gte", "count_per_group_gte"]
+ "enum": ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "count_gte", "count_per_group_gte", "in", "not_in"]
},
"eventAFilterValue": {
"type": "string",
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ProvisioningStatusTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ProvisioningStatusTracker.cs
index 0d11880a..b4c14700 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ProvisioningStatusTracker.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Collectors/ProvisioningStatusTracker.cs
@@ -739,6 +739,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Collectors
});
}
eventData["transitions"] = transitionData;
+
+ // Flat top-level field for RuleEngine matching — the RuleEngine cannot
+ // traverse nested collections via dataField, so we expose the names of
+ // subcategories that just transitioned to "failed" as a comma-separated
+ // string. Names come from registry keys and are language-invariant.
+ var failedNames = transitions
+ .Where(t => t.IsFailure)
+ .Select(t => t.SubcategoryName)
+ .ToList();
+ if (failedNames.Count > 0)
+ {
+ eventData["failedSubcategories"] = string.Join(",", failedNames);
+ }
}
_onEventCollected(new EnrollmentEvent
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index e12ce0f8..3d78d3c5 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-963
+971
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRuleSessionFailureTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRuleSessionFailureTests.cs
new file mode 100644
index 00000000..fb6fef01
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AnalyzeRuleSessionFailureTests.cs
@@ -0,0 +1,82 @@
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.Models;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Validates the rule-JSON side of the KO-criterion feature (MarkSessionAsFailed).
+/// Deep end-to-end behavior (RuleEngine actually flipping a session to Failed) is exercised
+/// against live Table Storage during manual QA — these tests pin the rule shape so future
+/// refactors can't silently break the wiring.
+///
+public class AnalyzeRuleSessionFailureTests
+{
+ [Fact]
+ public void BuiltInAnalyzeRules_EspCertificatesRule_HasExpectedShape()
+ {
+ var rules = BuiltInAnalyzeRules.GetAll();
+ var rule = rules.FirstOrDefault(r => r.RuleId == "ANALYZE-ESP-002");
+
+ Assert.NotNull(rule);
+ Assert.Equal("high", rule!.Severity);
+ Assert.Equal("esp", rule.Category);
+
+ // The rule must match against the flat top-level field emitted by ProvisioningStatusTracker,
+ // not a nested subcategories path — the RuleEngine can't traverse nested collections.
+ var cond = rule.Conditions.Single();
+ Assert.Equal("esp_provisioning_status", cond.EventType);
+ Assert.Equal("event_data", cond.Source);
+ Assert.Equal("failedSubcategories", cond.DataField);
+ Assert.Equal("contains", cond.Operator);
+ Assert.Equal("Certificates", cond.Value);
+ Assert.True(cond.Required);
+ }
+
+ [Fact]
+ public void BuiltInAnalyzeRules_MarkSessionAsFailedDefault_IsFalseForAllBuiltIns()
+ {
+ // Opt-in only: no built-in rule should force sessions to failed out of the box.
+ // Tenants enable KO behavior per rule via the portal. If you add a rule that genuinely
+ // needs `true` as a default, update this test with a narrow allow-list.
+ foreach (var rule in BuiltInAnalyzeRules.GetAll())
+ {
+ Assert.False(rule.MarkSessionAsFailedDefault,
+ $"Rule {rule.RuleId} ships with MarkSessionAsFailedDefault=true, which would fail sessions for every tenant without opt-in.");
+ }
+ }
+
+ [Fact]
+ public void RuleState_NullOverride_MeansInheritDefault()
+ {
+ // The dict returned by GetRuleStatesAsync maps ruleId → RuleState. A null MarkSessionAsFailed
+ // is the canonical "tenant did not express a preference" value and AnalyzeRuleService
+ // must copy it through to AnalyzeRule.MarkSessionAsFailed unchanged.
+ var state = new RuleState { Enabled = true, MarkSessionAsFailed = null };
+ var rule = new AnalyzeRule
+ {
+ RuleId = "ANALYZE-ESP-002",
+ MarkSessionAsFailedDefault = false
+ };
+
+ rule.Enabled = state.Enabled;
+ rule.MarkSessionAsFailed = state.MarkSessionAsFailed;
+
+ var effective = rule.MarkSessionAsFailed ?? rule.MarkSessionAsFailedDefault;
+ Assert.False(effective);
+ }
+
+ [Fact]
+ public void RuleState_ExplicitOverride_WinsOverDefault()
+ {
+ var state = new RuleState { Enabled = true, MarkSessionAsFailed = true };
+ var rule = new AnalyzeRule
+ {
+ RuleId = "ANALYZE-ESP-002",
+ MarkSessionAsFailedDefault = false
+ };
+
+ rule.MarkSessionAsFailed = state.MarkSessionAsFailed;
+ var effective = rule.MarkSessionAsFailed ?? rule.MarkSessionAsFailedDefault;
+ Assert.True(effective);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleRepository.cs
index 268bcaed..f3b305d6 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleRepository.cs
@@ -39,10 +39,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task DeleteGatherRuleAsync(string tenantId, string ruleId)
=> _storage.DeleteGatherRuleAsync(tenantId, ruleId);
- public Task StoreRuleStateAsync(string tenantId, string ruleId, bool enabled)
- => _storage.StoreRuleStateAsync(tenantId, ruleId, enabled);
+ public Task StoreRuleStateAsync(string tenantId, string ruleId, RuleState state)
+ => _storage.StoreRuleStateAsync(tenantId, ruleId, state);
- public Task> GetRuleStatesAsync(string tenantId)
+ public Task> GetRuleStatesAsync(string tenantId)
=> _storage.GetRuleStatesAsync(tenantId);
public Task DeleteRuleStateAsync(string tenantId, string ruleId)
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs
index 618880d7..c1ec15cd 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs
@@ -48,11 +48,12 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
DateTime? completedAt = null, DateTime? earliestEventTimestamp = null,
DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null,
bool? isUserDriven = null, DateTime? resumedAt = null,
- DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false)
+ DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false,
+ string? failureSource = null)
=> _storage.UpdateSessionStatusAsync(tenantId, sessionId, status,
currentPhase, failureReason, completedAt, earliestEventTimestamp,
latestEventTimestamp, isPreProvisioned, isUserDriven, resumedAt,
- stalledAt, clearStalledAt, clearFailureReason);
+ stalledAt, clearStalledAt, clearFailureReason, failureSource);
public Task IncrementSessionEventCountAsync(
string tenantId, string sessionId, int increment,
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs
index ed29081d..5c3e471d 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs
@@ -55,12 +55,16 @@ namespace AutopilotMonitor.Functions.Services
// Apply tenant state overrides to global rules
foreach (var rule in globalRules)
{
- if (ruleStates.TryGetValue(rule.RuleId, out var enabled))
- rule.Enabled = enabled;
+ if (ruleStates.TryGetValue(rule.RuleId, out var state))
+ {
+ rule.Enabled = state.Enabled;
+ rule.MarkSessionAsFailed = state.MarkSessionAsFailed;
+ }
mergedRules.Add(rule);
}
- // Add tenant-specific custom rules
+ // Tenant custom rules carry their own MarkSessionAsFailedDefault; no override needed
+ // since the tenant already fully owns the rule definition.
mergedRules.AddRange(customRules);
return mergedRules;
@@ -95,7 +99,12 @@ namespace AutopilotMonitor.Functions.Services
{
if (rule.IsBuiltIn || rule.IsCommunity)
{
- return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, rule.Enabled);
+ var state = new RuleState
+ {
+ Enabled = rule.Enabled,
+ MarkSessionAsFailed = rule.MarkSessionAsFailed
+ };
+ return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, state);
}
rule.UpdatedAt = DateTime.UtcNow;
@@ -182,7 +191,7 @@ namespace AutopilotMonitor.Functions.Services
throw new InvalidOperationException("Failed to store custom rule.");
// Ensure the template rule is disabled for this tenant
- await _ruleRepo.StoreRuleStateAsync(tenantId, templateRuleId, false);
+ await _ruleRepo.StoreRuleStateAsync(tenantId, templateRuleId, new RuleState { Enabled = false });
_logger.LogInformation("Created custom rule '{CustomRuleId}' from template '{TemplateRuleId}' for tenant '{TenantId}'",
customRule.RuleId, templateRuleId, tenantId);
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs
index db812f2e..9ecbdeaa 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs
@@ -52,11 +52,12 @@ namespace AutopilotMonitor.Functions.Services
var mergedRules = new List();
- // Apply tenant state overrides to global rules
+ // Apply tenant state overrides to global rules.
+ // MarkSessionAsFailed is analyze-rule-only; gather rules ignore it.
foreach (var rule in globalRules)
{
- if (ruleStates.TryGetValue(rule.RuleId, out var enabled))
- rule.Enabled = enabled;
+ if (ruleStates.TryGetValue(rule.RuleId, out var state))
+ rule.Enabled = state.Enabled;
mergedRules.Add(rule);
}
@@ -104,7 +105,7 @@ namespace AutopilotMonitor.Functions.Services
if (globalRule != null && (globalRule.IsBuiltIn || globalRule.IsCommunity))
{
// Built-in/community rule: only persist enabled state per tenant
- return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, rule.Enabled);
+ return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, new RuleState { Enabled = rule.Enabled });
}
// Ensure custom rules keep correct flags (the incoming payload may
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs
index d844f021..7373fa0d 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.ConditionEvaluators.cs
@@ -384,11 +384,33 @@ namespace AutopilotMonitor.Functions.Services
case "not_exists":
return string.IsNullOrEmpty(fieldValue);
+ case "in":
+ return MatchesInList(fieldValue, compareValue);
+
+ case "not_in":
+ return !MatchesInList(fieldValue, compareValue);
+
default:
return false;
}
}
+ private static bool MatchesInList(string fieldValue, string compareValue)
+ {
+ if (string.IsNullOrEmpty(fieldValue) || string.IsNullOrEmpty(compareValue))
+ return false;
+
+ foreach (var entry in compareValue.Split(','))
+ {
+ var trimmed = entry.Trim();
+ if (trimmed.Length == 0)
+ continue;
+ if (string.Equals(fieldValue, trimmed, StringComparison.OrdinalIgnoreCase))
+ return true;
+ }
+ return false;
+ }
+
private (bool matched, object evidence) EvaluateAppInstallDurationCondition(RuleCondition condition, List events)
{
var sortedEvents = events.OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs
index 0bc8fa59..2ec21643 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs
@@ -79,6 +79,15 @@ namespace AutopilotMonitor.Functions.Services
result.TenantId = tenantId;
outcome.Results.Add(result);
_logger.LogInformation($"Rule {rule.RuleId} ({rule.Trigger}) fired for session {sessionId} with confidence {result.ConfidenceScore}%");
+
+ // KO-criterion: if the (effective) MarkSessionAsFailed flag is on,
+ // escalate the rule finding to a terminal session status. Tenant override
+ // wins; otherwise we honor the rule-definition default.
+ var shouldFailSession = rule.MarkSessionAsFailed ?? rule.MarkSessionAsFailedDefault;
+ if (shouldFailSession)
+ {
+ await TryMarkSessionFailedFromRuleAsync(tenantId, sessionId, rule);
+ }
}
}
catch (Exception ex)
@@ -95,6 +104,45 @@ namespace AutopilotMonitor.Functions.Services
return outcome;
}
+ ///
+ /// Promotes a fired rule to a terminal Failed status on the session, but only when the session
+ /// is still in a non-terminal state (InProgress/Pending/Stalled). Terminal states
+ /// (Succeeded/Failed) are left untouched — the agent's own terminal signal wins, and we never
+ /// overwrite a prior rule-based failure.
+ ///
+ private async Task TryMarkSessionFailedFromRuleAsync(string tenantId, string sessionId, AnalyzeRule rule)
+ {
+ try
+ {
+ var session = await _sessionRepo.GetSessionAsync(tenantId, sessionId);
+ if (session == null)
+ return;
+
+ // Don't stomp on an already-terminal session. This also makes the call idempotent:
+ // on re-analysis the rule may fire again, but we only flip the status once.
+ if (session.Status == SessionStatus.Succeeded || session.Status == SessionStatus.Failed)
+ {
+ _logger.LogDebug($"Rule {rule.RuleId} fired for session {sessionId} but status is already {session.Status} — skipping session failure");
+ return;
+ }
+
+ var failureSource = $"rule:{rule.RuleId}";
+ var failureReason = $"Rule: {rule.Title}";
+
+ await _sessionRepo.UpdateSessionStatusAsync(
+ tenantId, sessionId, SessionStatus.Failed,
+ failureReason: failureReason,
+ failureSource: failureSource,
+ completedAt: DateTime.UtcNow);
+
+ _logger.LogWarning($"Session {sessionId} marked as failed by rule {rule.RuleId} ('{rule.Title}')");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, $"Failed to mark session {sessionId} as failed via rule {rule.RuleId}");
+ }
+ }
+
///
/// Evaluates a single rule against the full session event stream
///
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs
index 15b8ab2e..280b478d 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Rules.cs
@@ -224,7 +224,7 @@ namespace AutopilotMonitor.Functions.Services
/// Stores or updates the enabled/disabled state for a built-in or community rule per tenant
/// PartitionKey: TenantId, RowKey: RuleId
///
- public async Task StoreRuleStateAsync(string tenantId, string ruleId, bool enabled)
+ public async Task StoreRuleStateAsync(string tenantId, string ruleId, RuleState state)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
@@ -234,12 +234,20 @@ namespace AutopilotMonitor.Functions.Services
var entity = new TableEntity(tenantId, ruleId)
{
- ["Enabled"] = enabled,
+ ["Enabled"] = state.Enabled,
["UpdatedAt"] = DateTime.UtcNow
};
+ // Nullable override — store as boolean column when set, write an empty string when
+ // cleared so existing rows reset cleanly. Omitting the column would leave a stale value
+ // on the row after a merge.
+ if (state.MarkSessionAsFailed.HasValue)
+ entity["MarkSessionAsFailed"] = state.MarkSessionAsFailed.Value;
+ else
+ entity["MarkSessionAsFailed"] = null;
+
await tableClient.UpsertEntityAsync(entity);
- _logger.LogDebug($"Stored rule state {ruleId} for {tenantId}: enabled={enabled}");
+ _logger.LogDebug($"Stored rule state {ruleId} for {tenantId}: enabled={state.Enabled}, markAsFailed={state.MarkSessionAsFailed?.ToString() ?? "inherit"}");
return true;
}
catch (Exception ex)
@@ -250,9 +258,9 @@ namespace AutopilotMonitor.Functions.Services
}
///
- /// Gets all rule states for a tenant as a dictionary of ruleId → enabled
+ /// Gets all rule states for a tenant as a dictionary of ruleId → RuleState.
///
- public async Task> GetRuleStatesAsync(string tenantId)
+ public async Task> GetRuleStatesAsync(string tenantId)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
@@ -261,10 +269,14 @@ namespace AutopilotMonitor.Functions.Services
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.RuleStates);
var query = tableClient.QueryAsync(filter: $"PartitionKey eq '{tenantId}'");
- var states = new Dictionary();
+ var states = new Dictionary();
await foreach (var entity in query)
{
- states[entity.RowKey] = entity.GetBoolean("Enabled") ?? true;
+ states[entity.RowKey] = new RuleState
+ {
+ Enabled = entity.GetBoolean("Enabled") ?? true,
+ MarkSessionAsFailed = entity.GetBoolean("MarkSessionAsFailed")
+ };
}
return states;
@@ -272,7 +284,7 @@ namespace AutopilotMonitor.Functions.Services
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to get rule states for {tenantId}");
- return new Dictionary();
+ return new Dictionary();
}
}
@@ -330,6 +342,7 @@ namespace AutopilotMonitor.Functions.Services
["TagsJson"] = JsonConvert.SerializeObject(rule.Tags ?? new string[0]),
["TemplateVariablesJson"] = JsonConvert.SerializeObject(rule.TemplateVariables ?? new List()),
["DerivedFromTemplateRuleId"] = rule.DerivedFromTemplateRuleId ?? string.Empty,
+ ["MarkSessionAsFailedDefault"] = rule.MarkSessionAsFailedDefault,
["CreatedAt"] = rule.CreatedAt,
["UpdatedAt"] = rule.UpdatedAt
};
@@ -455,6 +468,7 @@ namespace AutopilotMonitor.Functions.Services
Tags = DeserializeJsonArray(entity.GetString("TagsJson")),
TemplateVariables = DeserializeJson>(entity.GetString("TemplateVariablesJson")),
DerivedFromTemplateRuleId = string.IsNullOrEmpty(derivedFrom) ? null : derivedFrom,
+ MarkSessionAsFailedDefault = entity.GetBoolean("MarkSessionAsFailedDefault") ?? false,
CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.UtcNow,
UpdatedAt = entity.GetDateTimeOffset("UpdatedAt")?.UtcDateTime ?? DateTime.UtcNow
};
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs
index 563d6200..e7e2ab09 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TableStorageService.Sessions.cs
@@ -211,6 +211,7 @@ namespace AutopilotMonitor.Functions.Services
CurrentPhaseDetail = entity.GetString("CurrentPhaseDetail") ?? string.Empty,
Status = status,
FailureReason = entity.GetString("FailureReason") ?? string.Empty,
+ FailureSource = entity.GetString("FailureSource") ?? string.Empty,
EventCount = SafeGetInt32(entity, "EventCount") ?? 0,
DurationSeconds = ComputeEffectiveDuration(entity, status, startedAt, completedAt),
EnrollmentType = entity.GetString("EnrollmentType") ?? "v1",
@@ -909,7 +910,7 @@ namespace AutopilotMonitor.Functions.Services
/// Event count is maintained atomically by IncrementSessionEventCountAsync and is not
/// recounted here — avoiding an expensive full-partition scan on every status change.
///
- public async Task UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null, DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false)
+ public async Task UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null, DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false, string? failureSource = null)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
@@ -1041,6 +1042,19 @@ namespace AutopilotMonitor.Functions.Services
update["FailureReason"] = failureReason;
}
+ // Record the origin of a Failed status (agent / rule: / manual). When the caller
+ // passes null we leave the existing value untouched — concurrent updates from different
+ // sources shouldn't stomp on each other's attribution.
+ if (status == SessionStatus.Failed && !string.IsNullOrEmpty(failureSource))
+ {
+ update["FailureSource"] = failureSource;
+ }
+ // Heal: clear attribution when the session is un-failed (e.g. back to InProgress).
+ if (clearFailureReason)
+ {
+ update["FailureSource"] = string.Empty;
+ }
+
// Set IsPreProvisioned flag atomically with the status update (WhiteGlove)
if (isPreProvisioned.HasValue)
{
@@ -1691,6 +1705,7 @@ namespace AutopilotMonitor.Functions.Services
CurrentPhaseDetail = entity.GetString("CurrentPhaseDetail") ?? string.Empty,
Status = status,
FailureReason = entity.GetString("FailureReason") ?? string.Empty,
+ FailureSource = entity.GetString("FailureSource") ?? string.Empty,
EventCount = SafeGetInt32(entity, "EventCount") ?? 0,
DurationSeconds = ComputeEffectiveDuration(entity, status, startedAt, completedAt),
EnrollmentType = entity.GetString("EnrollmentType") ?? "v1",
diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleRepository.cs
index fade7ead..902da106 100644
--- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleRepository.cs
+++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleRepository.cs
@@ -20,8 +20,8 @@ namespace AutopilotMonitor.Shared.DataAccess
Task DeleteGatherRuleAsync(string tenantId, string ruleId);
// --- Rule States ---
- Task StoreRuleStateAsync(string tenantId, string ruleId, bool enabled);
- Task> GetRuleStatesAsync(string tenantId);
+ Task StoreRuleStateAsync(string tenantId, string ruleId, RuleState state);
+ Task> GetRuleStatesAsync(string tenantId);
Task DeleteRuleStateAsync(string tenantId, string ruleId);
// --- Analyze Rules ---
diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs
index 50aa37fc..98a95088 100644
--- a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs
+++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs
@@ -26,7 +26,8 @@ namespace AutopilotMonitor.Shared.DataAccess
DateTime? completedAt = null, DateTime? earliestEventTimestamp = null,
DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null,
bool? isUserDriven = null, DateTime? resumedAt = null,
- DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false);
+ DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false,
+ string? failureSource = null);
Task IncrementSessionEventCountAsync(
string tenantId, string sessionId, int increment,
DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null,
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs b/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs
index 39645b19..ff9d20d8 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Rules/AnalyzeRule.cs
@@ -129,6 +129,23 @@ namespace AutopilotMonitor.Shared.Models
// ===== METADATA =====
+ // ===== SESSION FAILURE POLICY =====
+
+ ///
+ /// Rule-definition default for whether firing this rule should mark the entire session as failed.
+ /// Shipped in the rule JSON. A tenant can override this via
+ /// in their RuleState — a firing rule is considered a "KO criterion" for the enrollment when the
+ /// effective value (override ?? default) is true.
+ ///
+ public bool MarkSessionAsFailedDefault { get; set; } = false;
+
+ ///
+ /// Tenant-scoped override for . Not persisted in the rule
+ /// JSON — populated at load time from the RuleStates table. Null means the tenant has not expressed
+ /// a preference (fall back to the default).
+ ///
+ public bool? MarkSessionAsFailed { get; set; }
+
///
/// Tags for filtering and categorization
///
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleState.cs b/src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleState.cs
new file mode 100644
index 00000000..7760c3b2
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Rules/RuleState.cs
@@ -0,0 +1,22 @@
+namespace AutopilotMonitor.Shared.Models
+{
+ ///
+ /// Per-tenant override of a rule's default behavior.
+ /// Stored in the RuleStates table (PartitionKey=TenantId, RowKey=RuleId).
+ /// Absent entries mean "use rule defaults from the definition".
+ ///
+ public class RuleState
+ {
+ ///
+ /// Whether the rule is enabled for this tenant. Overrides the rule's default.
+ ///
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// Tenant-scoped override for .
+ /// Null means the tenant has not opted in or out — the rule's default applies.
+ /// Only meaningful for analyze rules; ignored for gather rules.
+ ///
+ public bool? MarkSessionAsFailed { get; set; }
+ }
+}
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs
index f6a637dc..9be8f776 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/SessionApiModels.cs
@@ -177,6 +177,15 @@ namespace AutopilotMonitor.Shared.Models
public string CurrentPhaseDetail { get; set; } = default!;
public SessionStatus Status { get; set; }
public string FailureReason { get; set; } = default!;
+
+ ///
+ /// Origin of a Failed status. Values:
+ /// - "" / null: agent-reported (default; terminal enrollment_failed event)
+ /// - "rule:<RuleId>": session failed because an analyze rule with MarkSessionAsFailed fired
+ /// - "manual": operator flipped the session via the portal
+ /// Consumers use this to render rule-based failures distinctly (badge + link to rule).
+ ///
+ public string FailureSource { get; set; } = string.Empty;
public int EventCount { get; set; }
public int? DurationSeconds { get; set; }
diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx
index ea6b3187..d11d847c 100644
--- a/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx
+++ b/src/Web/autopilot-monitor-web/app/analyze-rules/components/AnalyzeRuleCard.tsx
@@ -21,6 +21,7 @@ interface AnalyzeRuleCardProps {
jsonError: string | null;
onToggle: () => void;
onToggleEnabled: (rule: AnalyzeRule) => void;
+ onToggleMarkAsFailed?: (rule: AnalyzeRule) => void;
onStartEditing: (rule: AnalyzeRule) => void;
onSaveEdit: (rule: AnalyzeRule, formOverride?: RuleForm) => void;
onCancelEdit: () => void;
@@ -42,7 +43,7 @@ export default function AnalyzeRuleCard({
rule, isExpanded, isEditing, editForm, setEditForm, saving,
togglingRuleId, deletingRuleId,
jsonModeEdit, jsonText, jsonError,
- onToggle, onToggleEnabled, onStartEditing, onSaveEdit, onCancelEdit,
+ onToggle, onToggleEnabled, onToggleMarkAsFailed, onStartEditing, onSaveEdit, onCancelEdit,
onDelete, onExport,
onSetJsonModeEdit, onSetJsonText, onSetJsonError,
readOnly = false,
@@ -55,6 +56,8 @@ export default function AnalyzeRuleCard({
const canEdit = !rule.isBuiltIn && !rule.isCommunity;
const isTemplate = (rule.templateVariables?.length ?? 0) > 0;
const isDerived = !!rule.derivedFromTemplateRuleId;
+ const effectiveMarkAsFailed = (rule.markSessionAsFailed ?? rule.markSessionAsFailedDefault) ?? false;
+ const koOverrideIsExplicit = rule.markSessionAsFailed !== null && rule.markSessionAsFailed !== undefined;
return (
)}
+ {/* KO-criterion toggle: only meaningful when the rule is actually enabled. Click cycles
+ the tenant override; the badge colour reflects the effective value. */}
+ {!readOnly && rule.enabled && onToggleMarkAsFailed && (
+
+ )}
{rule.severity.charAt(0).toUpperCase() + rule.severity.slice(1)}
diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx
index 103ed727..83bdee4c 100644
--- a/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/analyze-rules/page.tsx
@@ -187,6 +187,36 @@ export default function AnalyzeRulesPage() {
setTogglingRuleId(null);
};
+ // Toggle the "mark session as failed" (KO-criterion) override for a rule.
+ // Cycles between: default (unset) → opt-in (true) → opt-out (false) → default.
+ // Built-in rules' definitions ship a `markSessionAsFailedDefault`; the tenant
+ // state only stores an explicit override when it differs from the default.
+ const handleToggleMarkAsFailed = async (rule: AnalyzeRule) => {
+ const currentEffective = rule.markSessionAsFailed ?? rule.markSessionAsFailedDefault ?? false;
+ const nextEffective = !currentEffective;
+ // Write the override only when it diverges from the rule default; otherwise clear it
+ // so future default changes propagate.
+ const nextOverride: boolean | null =
+ nextEffective === (rule.markSessionAsFailedDefault ?? false) ? null : nextEffective;
+
+ setTogglingRuleId(rule.ruleId);
+ const result = await mutate(
+ api.rules.analyzeRule(rule.ruleId),
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ...rule, markSessionAsFailed: nextOverride }),
+ }
+ );
+ if (result !== null) {
+ setRules((prev) =>
+ (prev || []).map((r) => (r.ruleId === rule.ruleId ? { ...r, markSessionAsFailed: nextOverride } : r))
+ );
+ showSuccess(`Rule "${rule.title}" ${nextEffective ? "set as KO criterion (session will be marked failed)" : "will no longer mark session as failed"}`);
+ }
+ setTogglingRuleId(null);
+ };
+
// Delete custom rule
const handleDeleteRule = async (rule: AnalyzeRule) => {
if (!confirm(`Are you sure you want to delete the rule "${rule.title}"? This action cannot be undone.`)) {
@@ -703,6 +733,7 @@ export default function AnalyzeRulesPage() {
if (expandedRuleId === rule.ruleId && editingRuleId === rule.ruleId) setEditingRuleId(null);
}}
onToggleEnabled={handleToggleRule}
+ onToggleMarkAsFailed={handleToggleMarkAsFailed}
onStartEditing={startEditing}
onSaveEdit={handleSaveEdit}
onCancelEdit={() => { setEditingRuleId(null); setJsonModeEdit(false); setJsonError(null); }}
diff --git a/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts b/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts
index 2eadb096..0adb8025 100644
--- a/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts
+++ b/src/Web/autopilot-monitor-web/app/analyze-rules/types.ts
@@ -63,6 +63,10 @@ export interface AnalyzeRule {
tags: string[];
templateVariables?: TemplateVariable[];
derivedFromTemplateRuleId?: string;
+ /** Rule-definition default for "fire → mark the whole session as failed". */
+ markSessionAsFailedDefault?: boolean;
+ /** Tenant override. `undefined`/`null` = inherit the default. */
+ markSessionAsFailed?: boolean | null;
createdAt: string;
updatedAt: string;
}
@@ -86,7 +90,7 @@ export interface RuleForm {
export const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enrollment"] as const;
export const SEVERITIES = ["info", "warning", "high", "critical"] as const;
export const TRIGGERS = ["single", "correlation"] as const;
-export const OPERATORS = ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "count_gte"] as const;
+export const OPERATORS = ["equals", "not_equals", "contains", "not_contains", "regex", "not_regex", "gt", "lt", "gte", "lte", "exists", "not_exists", "count_gte", "count_per_group_gte", "in", "not_in"] as const;
export const SOURCES = ["event_type", "event_data", "phase_duration", "event_count", "event_correlation"] as const;
export const SEVERITY_COLORS: Record = {
diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx
index e228c9e3..fd366def 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx
@@ -50,7 +50,7 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
}
copyText={session.sessionId}
/>
- } />
+ } />
@@ -129,7 +129,7 @@ function InfoItem({ label, value, copyText }: { label: string; value: React.Reac
);
}
-function StatusBadge({ status, failureReason }: { status: string; failureReason?: string }) {
+function StatusBadge({ status, failureReason, failureSource }: { status: string; failureReason?: string; failureSource?: string }) {
const statusConfig = {
InProgress: { color: "bg-blue-100 text-blue-800", text: "In Progress" },
Pending: { color: "bg-amber-100 text-amber-800", text: "Pending" },
@@ -142,17 +142,31 @@ function StatusBadge({ status, failureReason }: { status: string; failureReason?
const config = statusConfig[status as keyof typeof statusConfig] || statusConfig.Unknown;
const isTimeout = status === "Failed" && failureReason && failureReason.toLowerCase().includes("timed out");
+ const ruleId = status === "Failed" && failureSource && failureSource.startsWith("rule:")
+ ? failureSource.substring("rule:".length)
+ : null;
return (
-
- {config.text}
- {isTimeout && (
-
- ⏱️
-
+
+
+ {config.text}
+ {isTimeout && (
+
+ ⏱️
+
+ )}
+
+ {ruleId && (
+
+ via rule {ruleId}
+
)}
);
diff --git a/src/Web/autopilot-monitor-web/lib/guardrails.generated.ts b/src/Web/autopilot-monitor-web/lib/guardrails.generated.ts
new file mode 100644
index 00000000..2d64e64f
--- /dev/null
+++ b/src/Web/autopilot-monitor-web/lib/guardrails.generated.ts
@@ -0,0 +1,232 @@
+/**
+ * AUTO-GENERATED from rules/guardrails.json — DO NOT EDIT.
+ * Run: node rules/scripts/combine.js
+ */
+
+// ---------------------------------------------------------------------------
+// Categorized data (for documentation / UI display)
+// ---------------------------------------------------------------------------
+
+export interface GuardrailCategory {
+ readonly category: string;
+ readonly items: readonly string[];
+}
+
+export const REGISTRY_PREFIX_CATEGORIES: readonly GuardrailCategory[] = [
+ {
+ category: "MDM / Enrollment",
+ items: [
+ "SOFTWARE\\Microsoft\\Enrollments",
+ "SOFTWARE\\Microsoft\\EnterpriseDesktopAppManagement",
+ "SOFTWARE\\Microsoft\\Provisioning",
+ "SOFTWARE\\Microsoft\\PolicyManager",
+ "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\MDM",
+ ],
+ },
+ {
+ category: "AAD / Entra Join",
+ items: [
+ "SOFTWARE\\Microsoft\\IdentityStore",
+ "SYSTEM\\CurrentControlSet\\Control\\CloudDomainJoin",
+ ],
+ },
+ {
+ category: "Windows Update / WUfB",
+ items: [
+ "SOFTWARE\\Microsoft\\WindowsUpdate",
+ "SOFTWARE\\Policies\\Microsoft\\Windows\\WindowsUpdate",
+ ],
+ },
+ {
+ category: "BitLocker",
+ items: [
+ "SOFTWARE\\Microsoft\\BitLocker",
+ "SYSTEM\\CurrentControlSet\\Control\\BitLockerStatus",
+ ],
+ },
+ {
+ category: "Network / Proxy",
+ items: [
+ "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
+ "SYSTEM\\CurrentControlSet\\Services\\Tcpip",
+ ],
+ },
+ {
+ category: "Autopilot / OOBE / Setup",
+ items: [
+ "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup",
+ "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\OOBE",
+ "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon",
+ ],
+ },
+ {
+ category: "TPM",
+ items: [
+ "SYSTEM\\CurrentControlSet\\Services\\TPM",
+ "SOFTWARE\\Microsoft\\Tpm",
+ ],
+ },
+ {
+ category: "Secure Boot",
+ items: [
+ "SYSTEM\\CurrentControlSet\\Control\\SecureBoot",
+ ],
+ },
+ {
+ category: "Intune IME",
+ items: [
+ "SOFTWARE\\Microsoft\\IntuneManagementExtension",
+ ],
+ },
+ {
+ category: "Certificates (SCEP)",
+ items: [
+ "SOFTWARE\\Microsoft\\SystemCertificates",
+ "SOFTWARE\\Policies\\Microsoft\\SystemCertificates",
+ ],
+ },
+ {
+ category: "Servicing",
+ items: [
+ "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing",
+ ],
+ },
+];
+
+export const COMMAND_CATEGORIES: readonly GuardrailCategory[] = [
+ {
+ category: "TPM and Security",
+ items: [
+ "Get-Tpm",
+ "Get-SecureBootPolicy",
+ "Get-SecureBootUEFI -Name SetupMode",
+ ],
+ },
+ {
+ category: "BitLocker",
+ items: [
+ "Get-BitLockerVolume -MountPoint C:",
+ ],
+ },
+ {
+ category: "Network",
+ items: [
+ "Get-NetAdapter | Select-Object Name, Status, InterfaceDescription, MacAddress, LinkSpeed",
+ "Get-DnsClientServerAddress | Select-Object InterfaceAlias, ServerAddresses",
+ "Get-NetIPConfiguration | Select-Object InterfaceAlias, IPv4Address, IPv4DefaultGateway, DNSServer",
+ "netsh winhttp show proxy",
+ "ipconfig /all",
+ ],
+ },
+ {
+ category: "Domain / Identity",
+ items: [
+ "nltest /dsgetdc:",
+ "dsregcmd /status",
+ ],
+ },
+ {
+ category: "Certificate",
+ items: [
+ "certutil -store My",
+ "$c = Get-ChildItem Cert:\\LocalMachine\\My; if ($c) { $c | Select-Object Subject, Thumbprint, Issuer, NotBefore, NotAfter, HasPrivateKey | ConvertTo-Json } else { '{\"CertificateCount\": 0}' }",
+ ],
+ },
+ {
+ category: "Windows Update",
+ items: [
+ "Get-HotFix | Select-Object -First 10 HotFixID, InstalledOn, Description",
+ ],
+ },
+];
+
+// ---------------------------------------------------------------------------
+// Flat arrays (for validation logic)
+// ---------------------------------------------------------------------------
+
+export const ALLOWED_REGISTRY_PREFIXES: readonly string[] = [
+ "SOFTWARE\\Microsoft\\Enrollments",
+ "SOFTWARE\\Microsoft\\EnterpriseDesktopAppManagement",
+ "SOFTWARE\\Microsoft\\Provisioning",
+ "SOFTWARE\\Microsoft\\PolicyManager",
+ "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\MDM",
+ "SOFTWARE\\Microsoft\\IdentityStore",
+ "SYSTEM\\CurrentControlSet\\Control\\CloudDomainJoin",
+ "SOFTWARE\\Microsoft\\WindowsUpdate",
+ "SOFTWARE\\Policies\\Microsoft\\Windows\\WindowsUpdate",
+ "SOFTWARE\\Microsoft\\BitLocker",
+ "SYSTEM\\CurrentControlSet\\Control\\BitLockerStatus",
+ "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Internet Settings",
+ "SYSTEM\\CurrentControlSet\\Services\\Tcpip",
+ "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Setup",
+ "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\OOBE",
+ "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Winlogon",
+ "SYSTEM\\CurrentControlSet\\Services\\TPM",
+ "SOFTWARE\\Microsoft\\Tpm",
+ "SYSTEM\\CurrentControlSet\\Control\\SecureBoot",
+ "SOFTWARE\\Microsoft\\IntuneManagementExtension",
+ "SOFTWARE\\Microsoft\\SystemCertificates",
+ "SOFTWARE\\Policies\\Microsoft\\SystemCertificates",
+ "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing",
+];
+
+export const ALLOWED_FILE_PREFIXES: readonly string[] = [
+ "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs",
+ "C:\\Windows\\CCM\\Logs",
+ "C:\\Windows\\Logs",
+ "C:\\Windows\\Panther",
+ "C:\\Windows\\SetupDiag",
+ "C:\\ProgramData\\Microsoft\\DiagnosticLogCSP",
+ "C:\\Windows\\SoftwareDistribution\\ReportingEvents.log",
+];
+
+export const ALLOWED_WMI_QUERY_PREFIXES: readonly string[] = [
+ "SELECT * FROM Win32_OperatingSystem",
+ "SELECT * FROM Win32_ComputerSystem",
+ "SELECT * FROM Win32_BIOS",
+ "SELECT * FROM Win32_Processor",
+ "SELECT * FROM Win32_BaseBoard",
+ "SELECT * FROM Win32_Battery",
+ "SELECT * FROM Win32_TPM",
+ "SELECT * FROM Win32_NetworkAdapter",
+ "SELECT * FROM Win32_NetworkAdapterConfiguration",
+ "SELECT * FROM Win32_DiskDrive",
+ "SELECT * FROM Win32_LogicalDisk",
+ "SELECT * FROM SoftwareLicensingProduct",
+];
+
+export const ALLOWED_COMMANDS_LIST: readonly string[] = [
+ "Get-Tpm",
+ "Get-SecureBootPolicy",
+ "Get-SecureBootUEFI -Name SetupMode",
+ "Get-BitLockerVolume -MountPoint C:",
+ "Get-NetAdapter | Select-Object Name, Status, InterfaceDescription, MacAddress, LinkSpeed",
+ "Get-DnsClientServerAddress | Select-Object InterfaceAlias, ServerAddresses",
+ "Get-NetIPConfiguration | Select-Object InterfaceAlias, IPv4Address, IPv4DefaultGateway, DNSServer",
+ "netsh winhttp show proxy",
+ "ipconfig /all",
+ "nltest /dsgetdc:",
+ "dsregcmd /status",
+ "certutil -store My",
+ "$c = Get-ChildItem Cert:\\LocalMachine\\My; if ($c) { $c | Select-Object Subject, Thumbprint, Issuer, NotBefore, NotAfter, HasPrivateKey | ConvertTo-Json } else { '{\"CertificateCount\": 0}' }",
+ "Get-HotFix | Select-Object -First 10 HotFixID, InstalledOn, Description",
+];
+
+export const ALLOWED_DIAGNOSTICS_PATH_PREFIXES: readonly string[] = [
+ "C:\\ProgramData\\AutopilotMonitor",
+ "C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs",
+ "C:\\Windows\\Panther",
+ "C:\\Windows\\Logs",
+ "C:\\Windows\\SetupDiag",
+ "C:\\Windows\\SoftwareDistribution\\ReportingEvents.log",
+ "C:\\Windows\\System32\\winevt\\Logs",
+ "C:\\Windows\\CCM\\Logs",
+ "C:\\ProgramData\\Microsoft\\DiagnosticLogCSP",
+ "C:\\ProgramData\\Microsoft\\Windows\\WER",
+ "C:\\Windows\\Logs\\CBS",
+ "C:\\Install\\Log",
+];
+
+export const BLOCKED_FILE_PREFIXES: readonly string[] = [
+ "C:\\Users",
+];
diff --git a/src/Web/autopilot-monitor-web/types/session.ts b/src/Web/autopilot-monitor-web/types/session.ts
index 76a023e0..28455c11 100644
--- a/src/Web/autopilot-monitor-web/types/session.ts
+++ b/src/Web/autopilot-monitor-web/types/session.ts
@@ -12,6 +12,8 @@ export interface Session {
eventCount: number;
durationSeconds: number;
failureReason?: string;
+ /** Origin of a Failed status — "" for agent-reported, "rule:" for rule-based, "manual" for portal. */
+ failureSource?: string;
enrollmentType?: string; // "v1" | "v2" — absent for sessions before this feature
diagnosticsBlobName?: string;
lastEventAt?: string;