mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
Observation coverage in get_session_summary, absence audit with coverage lint, diagnostics packaging counters (D-213)
MCP: coverage block (window, IME tracker, collectors, upload, diagnostics, gaps) built from the health events the summary already fetches (session-coverage.ts, data.<key> slices); validate_rule warns on a required not_exists of an IME-log-derived event without the ime_tracker_degraded precondition; authoring guide 'absence'; instructions and prompts point at coverage.gaps. Rules: guardrails.json imeLogDerivedEventTypes (schema + combine). Backend: catalog invariant test — absence rules carry the coverage gate or a documented exception (ANALYZE-ID-004). Agent: diagnostics_uploaded/_upload_failed carry packaging counters (truncated, includedFiles, skippedFiles, skippedByReason, problemsByKind) — counts only, never paths. Also: shared-manifests.json regenerated (was stale at HEAD; McpAccessPolicy doc string) + codegen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule .claude/commands updated: 171813b2cb...9b48783b26
+1
-1
Submodule internal updated: b6c8e277eb...3b57dea319
@@ -282,5 +282,25 @@
|
||||
"log_entry",
|
||||
"agent_trace",
|
||||
"stall_probe_check"
|
||||
],
|
||||
"imeLogDerivedEventTypes": [
|
||||
"app_install_started",
|
||||
"app_install_completed",
|
||||
"app_install_failed",
|
||||
"app_install_skipped",
|
||||
"app_download_started",
|
||||
"app_tracking_summary",
|
||||
"download_progress",
|
||||
"do_telemetry",
|
||||
"script_started",
|
||||
"script_completed",
|
||||
"script_failed",
|
||||
"script_timeout_suspected",
|
||||
"esp_phase_changed",
|
||||
"ime_agent_version",
|
||||
"ime_token_failure",
|
||||
"ime_user_session_completed",
|
||||
"ime_process_exited",
|
||||
"ime_user_token_acquired"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"blockedFilePrefixes": { "$ref": "#/$defs/stringList" },
|
||||
"blockedCommandPatterns": { "$ref": "#/$defs/stringList" },
|
||||
"blockedInterimTriggerEventTypes": { "$ref": "#/$defs/stringList" },
|
||||
"imeLogDerivedEventTypes": { "$ref": "#/$defs/stringList" },
|
||||
"maxCommandLength": { "type": "integer", "minimum": 1, "maximum": 100000 }
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -142,6 +142,7 @@ if (fs.existsSync(guardrailsPath)) {
|
||||
assertStringList(guardrails[key], key);
|
||||
}
|
||||
assertStringList(guardrails.blockedInterimTriggerEventTypes ?? [], 'blockedInterimTriggerEventTypes');
|
||||
assertStringList(guardrails.imeLogDerivedEventTypes ?? [], 'imeLogDerivedEventTypes');
|
||||
if (!Number.isInteger(guardrails.maxCommandLength) || guardrails.maxCommandLength < 1) {
|
||||
fail(`maxCommandLength: expected a positive integer, got ${JSON.stringify(guardrails.maxCommandLength)}`);
|
||||
}
|
||||
|
||||
+82
@@ -903,5 +903,87 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================= packaging counters
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_records_packaging_counters_by_reason_and_kind_without_paths()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "small.json"), "{}");
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "big-1.json"), new string('x', 64));
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "big-2.json"), new string('y', 64));
|
||||
var blockedPath = @"C:\Windows\System32\config\SYSTEM";
|
||||
|
||||
var service = rig.Build(cfg => cfg.DiagnosticsLogPaths.Add(
|
||||
new AutopilotMonitor.Shared.Models.DiagnosticsLogPath { Path = blockedPath }));
|
||||
service.Budget = new DiagnosticsBudget { MaxSingleFileBytes = 16, MaxTotalUncompressedBytes = 1L << 30, MaxFileCount = 5000 };
|
||||
|
||||
Assert.Null(service.LastPackaging);
|
||||
var bytes = service.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var stats = service.LastPackaging;
|
||||
Assert.NotNull(stats);
|
||||
Assert.True(stats!.Truncated);
|
||||
Assert.Equal(2, stats.SkippedFiles);
|
||||
Assert.Equal(2, stats.SkippedByReason["size"]);
|
||||
Assert.Single(stats.SkippedByReason);
|
||||
Assert.Equal(1, stats.ProblemsByKind["path-guard"]);
|
||||
Assert.Single(stats.ProblemsByKind);
|
||||
Assert.True(stats.IncludedFiles >= 1, "the small file must have been included");
|
||||
Assert.True(stats.IncludedBytes >= 2);
|
||||
|
||||
// The ZIP still carries the per-file list; the counters never do.
|
||||
Assert.NotNull(ReadZipEntryText(bytes, "_TRUNCATED.txt"));
|
||||
foreach (var key in stats.SkippedByReason.Keys.Concat(stats.ProblemsByKind.Keys))
|
||||
{
|
||||
Assert.DoesNotContain("\\", key);
|
||||
Assert.DoesNotContain(":", key);
|
||||
Assert.DoesNotContain("/", key);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_reports_a_clean_build_as_not_truncated_with_empty_maps()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
File.WriteAllText(Path.Combine(rig.StateFolder, "snapshot.json"), "{}");
|
||||
|
||||
var service = rig.Build();
|
||||
service.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
var stats = service.LastPackaging;
|
||||
Assert.NotNull(stats);
|
||||
Assert.False(stats!.Truncated);
|
||||
Assert.Equal(0, stats.SkippedFiles);
|
||||
Assert.Empty(stats.SkippedByReason);
|
||||
Assert.Empty(stats.ProblemsByKind);
|
||||
Assert.True(stats.IncludedFiles >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArchiveBytes_counts_a_rejected_source_folder_as_a_problem()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
using var outside = new TempDirectory();
|
||||
File.WriteAllText(Path.Combine(outside.Path, "snapshot.json"), "{}");
|
||||
var link = Path.Combine(rig.Tmp.Path, "StateLink");
|
||||
NtfsLinks.CreateJunction(link, outside.Path);
|
||||
|
||||
try
|
||||
{
|
||||
var service = rig.Build(sectionFolderOverrides: new Dictionary<string, string> { ["AgentState"] = link });
|
||||
service.BuildArchiveBytes(enrollmentSucceeded: true);
|
||||
|
||||
// The section is walked once per search pattern (11 patterns) but the folder
|
||||
// failed once — the counter says so.
|
||||
Assert.Equal(1, service.LastPackaging!.ProblemsByKind["folder-rejected"]);
|
||||
Assert.False(service.LastPackaging.Truncated);
|
||||
}
|
||||
finally
|
||||
{
|
||||
NtfsLinks.RemoveLink(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+81
@@ -1701,5 +1701,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Termination
|
||||
rig.PromotionCalls[0].failureType);
|
||||
Assert.Null(rig.PromotionCalls[0].errorCode);
|
||||
}
|
||||
|
||||
// ============================================================= packaging counters on the diag events
|
||||
|
||||
[Fact]
|
||||
public void Handle_diagnostics_uploaded_carries_packaging_counters_when_the_packager_reported_them()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult
|
||||
{
|
||||
BlobName = "diag-blob.zip",
|
||||
Packaging = new DiagnosticsPackagingStats
|
||||
{
|
||||
IncludedFiles = 412,
|
||||
IncludedBytes = 123456,
|
||||
SkippedFiles = 3,
|
||||
SkippedByReason = new Dictionary<string, int> { ["size"] = 2, ["total"] = 1 },
|
||||
ProblemsByKind = new Dictionary<string, int> { ["copy"] = 1 },
|
||||
},
|
||||
};
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
|
||||
var uploaded = rig.DataOf("diagnostics_uploaded");
|
||||
Assert.NotNull(uploaded);
|
||||
Assert.Equal(true, uploaded!["truncated"]);
|
||||
Assert.Equal(412, uploaded["includedFiles"]);
|
||||
Assert.Equal(123456L, uploaded["includedBytes"]);
|
||||
Assert.Equal(3, uploaded["skippedFiles"]);
|
||||
var byReason = Assert.IsType<Dictionary<string, int>>(uploaded["skippedByReason"]);
|
||||
Assert.Equal(2, byReason["size"]);
|
||||
Assert.Equal(1, byReason["total"]);
|
||||
var problems = Assert.IsType<Dictionary<string, int>>(uploaded["problemsByKind"]);
|
||||
Assert.Equal(1, problems["copy"]);
|
||||
// Counters only — no key or value carries a file path.
|
||||
foreach (var kv in uploaded)
|
||||
Assert.False(kv.Value is string s && (s.Contains("\\") || s.Contains("/")) && kv.Key != "sasUrlPrefix",
|
||||
$"payload key {kv.Key} looks like a path: {kv.Value}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_diagnostics_upload_failed_after_a_build_carries_the_counters_too()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Failed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult
|
||||
{
|
||||
ErrorCode = "upload_5xx",
|
||||
Packaging = new DiagnosticsPackagingStats { IncludedFiles = 40, IncludedBytes = 1000 },
|
||||
};
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Failed, SessionStage.Failed));
|
||||
|
||||
var failed = rig.DataOf("diagnostics_upload_failed");
|
||||
Assert.NotNull(failed);
|
||||
Assert.Equal("upload_5xx", (string)failed!["errorCode"]);
|
||||
Assert.Equal(false, failed["truncated"]);
|
||||
Assert.Equal(40, failed["includedFiles"]);
|
||||
Assert.Empty(Assert.IsType<Dictionary<string, int>>(failed["skippedByReason"]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Handle_diagnostics_events_omit_the_counters_when_no_package_was_built()
|
||||
{
|
||||
using var rig = new Rig();
|
||||
rig.State = new DecisionStateBuilder(DecisionState.CreateInitial("S1", "T1")) { Stage = SessionStage.Completed }.Build();
|
||||
rig.DiagnosticsResult = new DiagnosticsUploadResult { BlobName = "diag-blob.zip" };
|
||||
var cfg = rig.BuildConfig(diagEnabled: true, diagMode: "Always", selfDestruct: false);
|
||||
|
||||
rig.Build(cfg).Handle(sender: null!,
|
||||
Args(EnrollmentTerminationReason.DecisionTerminalStage, EnrollmentTerminationOutcome.Succeeded, SessionStage.Completed));
|
||||
|
||||
var uploaded = rig.DataOf("diagnostics_uploaded");
|
||||
Assert.NotNull(uploaded);
|
||||
Assert.False(uploaded!.ContainsKey("truncated"));
|
||||
Assert.False(uploaded.ContainsKey("skippedByReason"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+88
-1
@@ -65,6 +65,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
public static DiagnosticsUploadResult SkippedBy(string gate) =>
|
||||
new DiagnosticsUploadResult { Skipped = true, ErrorCode = gate };
|
||||
|
||||
/// <summary>
|
||||
/// Counters of the archive build that produced (or preceded) this result; null when no
|
||||
/// package was built (configuration gate, build exception). Surfaced into the
|
||||
/// <c>diagnostics_uploaded</c> / <c>diagnostics_upload_failed</c> events so a truncated or
|
||||
/// partially failed package is visible without opening the ZIP.
|
||||
/// </summary>
|
||||
public DiagnosticsPackagingStats Packaging { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the packager included, skipped and failed on — as bounded counters only. File paths
|
||||
/// stay inside the ZIP (<c>package-manifest.txt</c>, <c>_TRUNCATED.txt</c>): they can carry
|
||||
/// user names, and a skip list has no upper bound while this payload must stay small no
|
||||
/// matter how many files a cap rejected.
|
||||
/// </summary>
|
||||
public sealed class DiagnosticsPackagingStats
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, int> Empty = new Dictionary<string, int>();
|
||||
|
||||
public int IncludedFiles { get; set; }
|
||||
public long IncludedBytes { get; set; }
|
||||
|
||||
/// <summary>Files rejected by a cap or a reparse-point check (the <c>_TRUNCATED.txt</c> list).</summary>
|
||||
public int SkippedFiles { get; set; }
|
||||
|
||||
/// <summary>True when at least one file was skipped — the same condition that writes <c>_TRUNCATED.txt</c>.</summary>
|
||||
public bool Truncated => SkippedFiles > 0;
|
||||
|
||||
/// <summary>Skip count per reason: <c>size</c>, <c>count</c>, <c>total</c>, <c>reparse</c>, <c>resolved-outside-folder</c>.</summary>
|
||||
public IReadOnlyDictionary<string, int> SkippedByReason { get; set; } = Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Packaging problems that are not cap skips, per kind: <c>path-guard</c> (configured path
|
||||
/// refused), <c>no-user-session</c> (user-profile token without a signed-in user),
|
||||
/// <c>folder-rejected</c>, <c>enumerate</c>, <c>open</c>, <c>copy</c>.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, int> ProblemsByKind { get; set; } = Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -134,6 +172,41 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
Skipped.Add(new SkipRecord(path, reason, size));
|
||||
|
||||
public bool HasSkips => Skipped.Count > 0;
|
||||
|
||||
// Non-cap problems (guard refusals, enumeration/open/copy failures) by kind — the
|
||||
// manifest names the files, the event carries only these counts.
|
||||
public Dictionary<string, int> Problems { get; } = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
private readonly HashSet<string> _problemKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Counts one problem of <paramref name="kind"/>. A section is walked once per search
|
||||
/// pattern, so a folder-level failure would otherwise be counted once per pattern —
|
||||
/// pass the folder as <paramref name="dedupeKey"/> to count it once.
|
||||
/// </summary>
|
||||
public void RecordProblem(string kind, string dedupeKey = null)
|
||||
{
|
||||
if (dedupeKey != null && !_problemKeys.Add(kind + "|" + dedupeKey)) return;
|
||||
Problems.TryGetValue(kind, out var n);
|
||||
Problems[kind] = n + 1;
|
||||
}
|
||||
|
||||
public DiagnosticsPackagingStats ToStats()
|
||||
{
|
||||
var byReason = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
foreach (var s in Skipped)
|
||||
{
|
||||
byReason.TryGetValue(s.Reason, out var n);
|
||||
byReason[s.Reason] = n + 1;
|
||||
}
|
||||
return new DiagnosticsPackagingStats
|
||||
{
|
||||
IncludedFiles = FileCount,
|
||||
IncludedBytes = TotalBytes,
|
||||
SkippedFiles = Skipped.Count,
|
||||
SkippedByReason = byReason,
|
||||
ProblemsByKind = new Dictionary<string, int>(Problems, StringComparer.Ordinal),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct SkipRecord
|
||||
@@ -262,6 +335,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
var zipFileName = $"AgentDiagnostics-{_configuration.SessionId}-{timestamp}{suffix}.zip";
|
||||
|
||||
var zipBytes = BuildArchiveBytes(enrollmentSucceeded);
|
||||
var packaging = LastPackaging;
|
||||
|
||||
_logger.Info($"Diagnostics package created: {zipFileName} ({zipBytes.Length / 1024} KB)");
|
||||
|
||||
@@ -276,7 +350,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
var errorCode = uploadUrlResponse?.Message ?? "Failed to get diagnostics upload URL from backend";
|
||||
_logger.Warning($"Failed to get diagnostics upload URL from backend — skipping upload: {errorCode}");
|
||||
return new DiagnosticsUploadResult { ErrorCode = errorCode };
|
||||
return new DiagnosticsUploadResult { ErrorCode = errorCode, Packaging = packaging };
|
||||
}
|
||||
|
||||
var sasUrlPrefix = BuildSasUrlPrefix(uploadUrlResponse.UploadUrl);
|
||||
@@ -305,6 +379,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
BlobName = persistedBlobName,
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
SasUrlPrefix = sasUrlPrefix,
|
||||
Packaging = packaging,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -313,6 +388,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
Destination = uploadUrlResponse.Destination,
|
||||
SasUrlPrefix = sasUrlPrefix,
|
||||
ErrorCode = uploadErrorCode,
|
||||
Packaging = packaging,
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -351,6 +427,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
// INSIDE the ZIP (field case: sessions a11102f4 / 3ae7528b, missing evtx undiagnosable).
|
||||
private StringBuilder _manifest;
|
||||
|
||||
/// <summary>Counters of the most recent <see cref="BuildArchiveBytes"/>; null before the first build.</summary>
|
||||
internal DiagnosticsPackagingStats LastPackaging { get; private set; }
|
||||
|
||||
private void ManifestLine(string text)
|
||||
{
|
||||
_manifest?.AppendLine($"[{DateTime.UtcNow:HH:mm:ss.fff}Z] {text}");
|
||||
@@ -391,6 +470,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Diagnostics path blocked by guard: {entry.Path}");
|
||||
ManifestLine($"BLOCKED (path guard): {entry.Path}");
|
||||
tracker.RecordProblem("path-guard");
|
||||
continue;
|
||||
}
|
||||
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
|
||||
@@ -398,6 +478,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
|
||||
ManifestLine($"SKIPPED (no user session for token): {entry.Path}");
|
||||
tracker.RecordProblem("no-user-session");
|
||||
continue;
|
||||
}
|
||||
var folder = Path.GetDirectoryName(expandedPath);
|
||||
@@ -427,6 +508,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
WriteTruncatedMarker(archive, tracker);
|
||||
}
|
||||
|
||||
LastPackaging = tracker.ToStats();
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -498,6 +580,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Built-in diagnostics section skipped (no user session for token): {section.Id}");
|
||||
ManifestLine($"BUILT-IN SKIPPED (no user session for token): {section.Id} '{section.SourceFolder}'");
|
||||
tracker.RecordProblem("no-user-session");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -584,6 +667,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Diagnostics source folder rejected ({folderRejection}): {sourceFolder}");
|
||||
ManifestLine($"FOLDER REJECTED ({folderRejection}): {sourceFolder} (pattern '{searchPattern}')");
|
||||
tracker.RecordProblem("folder-rejected", sourceFolder);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -597,6 +681,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
|
||||
ManifestLine($"ENUMERATION FAILED: {sourceFolder} (pattern '{searchPattern}'): {ex.Message}");
|
||||
tracker.RecordProblem("enumerate", sourceFolder);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -681,6 +766,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
|
||||
ManifestLine($"FAILED (copy): {file}: {ex.Message}");
|
||||
tracker.RecordProblem("copy");
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -720,6 +806,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
default:
|
||||
_logger.Warning($"Failed to open {file}: {rejection}");
|
||||
ManifestLine($"FAILED (open): {file}: {rejection}");
|
||||
tracker.RecordProblem("open");
|
||||
break;
|
||||
}
|
||||
return null;
|
||||
|
||||
+42
-17
@@ -1007,6 +1007,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
if (result != null && result.Success)
|
||||
{
|
||||
_logger.Info($"EnrollmentTerminationHandler: diagnostics uploaded (blob={result.BlobName}).");
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "blobName", result.BlobName ?? string.Empty },
|
||||
// Tells the backend which storage the blob landed in so it can
|
||||
// stamp Session.DiagnosticsBlobDestination and route downloads
|
||||
// even after a future tenant destination switch. Empty when the
|
||||
// backend predates the field (agent falls through harmlessly).
|
||||
{ "destination", result.Destination ?? string.Empty },
|
||||
{ "sasUrlPrefix", result.SasUrlPrefix ?? string.Empty },
|
||||
};
|
||||
AppendPackagingStats(data, result.Packaging);
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -1015,17 +1026,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Diagnostics package uploaded ({result.BlobName}).",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "blobName", result.BlobName ?? string.Empty },
|
||||
// Tells the backend which storage the blob landed in so it can
|
||||
// stamp Session.DiagnosticsBlobDestination and route downloads
|
||||
// even after a future tenant destination switch. Empty when the
|
||||
// backend predates the field (agent falls through harmlessly).
|
||||
{ "destination", result.Destination ?? string.Empty },
|
||||
{ "sasUrlPrefix", result.SasUrlPrefix ?? string.Empty },
|
||||
},
|
||||
Message = $"Diagnostics package uploaded ({result.BlobName})."
|
||||
+ (result.Packaging != null && result.Packaging.Truncated
|
||||
? $" Truncated: {result.Packaging.SkippedFiles} file(s) skipped by the packaging caps."
|
||||
: string.Empty),
|
||||
Data = data,
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
}
|
||||
@@ -1033,6 +1038,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
{
|
||||
var errorCode = result?.ErrorCode ?? "null-result";
|
||||
_logger.Warning($"EnrollmentTerminationHandler: diagnostics upload failed: {errorCode}.");
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "errorCode", errorCode },
|
||||
{ "blobName", result?.BlobName ?? string.Empty },
|
||||
{ "destination", result?.Destination ?? string.Empty },
|
||||
};
|
||||
AppendPackagingStats(data, result?.Packaging);
|
||||
EmitEventSafe(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _configuration.SessionId,
|
||||
@@ -1042,17 +1054,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Termination
|
||||
Source = "EnrollmentTerminationHandler",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = $"Diagnostics upload failed: {errorCode}.",
|
||||
Data = new Dictionary<string, object>
|
||||
{
|
||||
{ "errorCode", errorCode },
|
||||
{ "blobName", result?.BlobName ?? string.Empty },
|
||||
{ "destination", result?.Destination ?? string.Empty },
|
||||
},
|
||||
Data = data,
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the packager's counters to a diagnostics event payload — counts only, never a
|
||||
/// file path (paths live in the ZIP's manifest and may carry user names). Absent when no
|
||||
/// package was built, so consumers treat the keys as optional.
|
||||
/// </summary>
|
||||
private static void AppendPackagingStats(Dictionary<string, object> data, DiagnosticsPackagingStats packaging)
|
||||
{
|
||||
if (packaging == null) return;
|
||||
data["truncated"] = packaging.Truncated;
|
||||
data["includedFiles"] = packaging.IncludedFiles;
|
||||
data["includedBytes"] = packaging.IncludedBytes;
|
||||
data["skippedFiles"] = packaging.SkippedFiles;
|
||||
data["skippedByReason"] = new Dictionary<string, int>(
|
||||
(IDictionary<string, int>)packaging.SkippedByReason ?? new Dictionary<string, int>(), StringComparer.Ordinal);
|
||||
data["problemsByKind"] = new Dictionary<string, int>(
|
||||
(IDictionary<string, int>)packaging.ProblemsByKind ?? new Dictionary<string, int>(), StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private void WriteEnrollmentCompleteMarker(EnrollmentTerminatedEventArgs args)
|
||||
{
|
||||
// On WhiteGlove Part 1 exit we keep the session alive for Part 2 — DO NOT write the
|
||||
|
||||
@@ -53,4 +53,94 @@ public class BuiltInRuleCatalogPolicyTests
|
||||
Assert.True(duplicates.Count == 0,
|
||||
$"Duplicate built-in gather rule IDs: {string.Join(", ", duplicates)}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Built-in rules that assert an ABSENCE (required not_exists) of an event the agent derives
|
||||
/// from the IME logs, and deliberately do NOT carry the ime_tracker_degraded coverage gate.
|
||||
/// Every entry is a decision with a reason; adding one here is the way to ship such a rule.
|
||||
/// </summary>
|
||||
private static readonly IReadOnlyDictionary<string, string> AbsenceWithoutCoverageGate =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["ANALYZE-ID-004"] =
|
||||
"A hybrid session whose user never obtained an Entra token must not lose its finding because " +
|
||||
"the tracker skipped the remaining patterns on one line; the tracker state is shown next to " +
|
||||
"the finding in get_session_summary (coverage) instead (plan session-coverage-and-absence-audit).",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Required_not_exists_on_ime_derived_events_is_coverage_gated_or_documented()
|
||||
{
|
||||
// "Absence proves nothing": a required not_exists on an IME-log-derived event evaluates
|
||||
// to true both when the event never happened and when the IME log tracker skipped the
|
||||
// line that carried it (ime_tracker_degraded). A built-in rule that asserts such an
|
||||
// absence either suppresses itself on degraded sessions (precondition
|
||||
// ime_tracker_degraded not_exists) or is listed above with the reason it does not.
|
||||
// The IME-derived list is the guardrails.json mirror the MCP validate_rule lint reads,
|
||||
// so catalog and pre-flight judge by one list.
|
||||
var guardrailsPath = Path.Combine(FindRepoRoot(), "rules", "guardrails.json");
|
||||
var json = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(guardrailsPath));
|
||||
var imeDerived = new HashSet<string>(
|
||||
json["imeLogDerivedEventTypes"]!.ToObject<string[]>()!, StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("ime_user_token_acquired", imeDerived);
|
||||
|
||||
var offenders = new List<string>();
|
||||
foreach (var rule in BuiltInAnalyzeRules.GetAll())
|
||||
{
|
||||
var assertsImeAbsence = rule.Conditions.Any(c =>
|
||||
c.Required
|
||||
&& string.Equals(c.Operator, "not_exists", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrEmpty(c.DataField)
|
||||
&& !string.IsNullOrEmpty(c.EventType)
|
||||
&& imeDerived.Contains(c.EventType));
|
||||
if (!assertsImeAbsence) continue;
|
||||
|
||||
var gated = rule.Preconditions.Any(p =>
|
||||
string.Equals(p.EventType, "ime_tracker_degraded", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(p.Operator, "not_exists", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.IsNullOrEmpty(p.DataField));
|
||||
if (gated || AbsenceWithoutCoverageGate.ContainsKey(rule.RuleId)) continue;
|
||||
offenders.Add(rule.RuleId);
|
||||
}
|
||||
|
||||
Assert.True(offenders.Count == 0,
|
||||
"Built-in rules assert the absence of an IME-log-derived event without the " +
|
||||
"ime_tracker_degraded coverage precondition and without a documented exception: " +
|
||||
string.Join(", ", offenders));
|
||||
|
||||
// The exception list must not outlive the rules it excuses.
|
||||
var catalogIds = new HashSet<string>(BuiltInAnalyzeRules.GetAll().Select(r => r.RuleId), StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var excused in AbsenceWithoutCoverageGate.Keys)
|
||||
Assert.True(catalogIds.Contains(excused), $"Exception list names a rule that is not in the catalog: {excused}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ime_derived_list_names_only_real_event_types_and_the_tracker_event_exists()
|
||||
{
|
||||
var guardrailsPath = Path.Combine(FindRepoRoot(), "rules", "guardrails.json");
|
||||
var json = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(guardrailsPath));
|
||||
var imeDerived = json["imeLogDerivedEventTypes"]!.ToObject<string[]>()!;
|
||||
|
||||
var known = new HashSet<string>(
|
||||
typeof(AutopilotMonitor.Shared.Constants.EventTypes)
|
||||
.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static)
|
||||
.Where(f => f.IsLiteral && f.FieldType == typeof(string))
|
||||
.Select(f => (string)f.GetRawConstantValue()!),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var phantom = imeDerived.Where(t => !known.Contains(t)).ToList();
|
||||
Assert.True(phantom.Count == 0, $"imeLogDerivedEventTypes lists unknown event types: {string.Join(", ", phantom)}");
|
||||
Assert.Contains(AutopilotMonitor.Shared.Constants.EventTypes.ImeTrackerDegraded, known);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { dirname, join } from 'node:path';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { EVENT_TYPES_CATALOG, INTERNAL_EVENT_TYPES, ALL_EVENT_TYPES } from '../resource-catalog.js';
|
||||
import { EXCLUDED_EVENT_TYPES, KEY_EVENT_TYPES, PHASE_EVENT_TYPES } from '../tools/sessions.js';
|
||||
import { COVERAGE_EVENT_TYPES } from '../session-coverage.js';
|
||||
|
||||
/** Walk up from this test file to the repo root (marked by AutopilotMonitor.sln). */
|
||||
function findConstantsCs(): string | null {
|
||||
@@ -95,10 +96,11 @@ describe('get_session_summary event-type sets ⊆ canonical catalog', () => {
|
||||
// key-ranks. They bypass the C#-drift test above (which only checks the
|
||||
// public catalog), so guard them directly against ALL_EVENT_TYPES.
|
||||
const known = new Set(ALL_EVENT_TYPES);
|
||||
const cases: Array<[string, Set<string>]> = [
|
||||
const cases: Array<[string, ReadonlySet<string>]> = [
|
||||
['EXCLUDED_EVENT_TYPES', EXCLUDED_EVENT_TYPES],
|
||||
['KEY_EVENT_TYPES', KEY_EVENT_TYPES],
|
||||
['PHASE_EVENT_TYPES', PHASE_EVENT_TYPES],
|
||||
['COVERAGE_EVENT_TYPES', COVERAGE_EVENT_TYPES],
|
||||
];
|
||||
for (const [name, set] of cases) {
|
||||
it(`${name} contains only real event types`, () => {
|
||||
|
||||
@@ -166,6 +166,31 @@ describe('first-page defaults', () => {
|
||||
expect(SUMMARY_EVENT_FIELDS).toContain('data.rejectedSourceTimestamp');
|
||||
// The error-code keys and their enriched siblings travel as slices too (errorCode/errorText).
|
||||
expect(SUMMARY_EVENT_FIELDS.split(',')).toEqual(expect.arrayContaining(['data.errorCode', 'data.errorCodeInfo', 'data.hresultSymbol', 'data.exitCodeInfo']));
|
||||
// The coverage block's payload keys travel as slices too (session-coverage.ts).
|
||||
expect(SUMMARY_EVENT_FIELDS.split(',')).toEqual(expect.arrayContaining(['data.firstSkippedPattern', 'data.bootToAgentStartSeconds', 'data.collector', 'data.skippedByReason']));
|
||||
});
|
||||
|
||||
it('get_session_summary places coverage right after overview and reports gaps', async () => {
|
||||
const handler = handlerFor('get_session_summary');
|
||||
stubFetchCapture({
|
||||
success: true,
|
||||
session: { sessionId: SESSION, tenantId: 't', status: 'Succeeded', startedAt: '2026-09-01T00:00:00Z' },
|
||||
events: [
|
||||
{ eventType: 'agent_started', severity: 'Info', timestamp: '2026-09-01T00:00:05Z', data: {} },
|
||||
{ eventType: 'ime_tracker_degraded', severity: 'Warning', timestamp: '2026-09-01T00:10:00Z', data: { file: 'AppWorkload.log', lineBudgetBreaks: 1, firstSkippedPattern: 'IME-NO-ACTION' } },
|
||||
],
|
||||
results: [],
|
||||
annotations: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
const result = await runWithCaller(GA, () => handler({ sessionId: SESSION }, extra));
|
||||
const body = resultJson(result);
|
||||
expect(Object.keys(body).slice(0, 3)).toEqual(['overview', 'coverage', 'keyEvents']);
|
||||
const coverage = body.coverage as { imeTracker: { degraded: boolean }; gaps: string[]; window: { observedFrom: string } };
|
||||
expect(coverage.window.observedFrom).toBe('2026-09-01T00:00:05Z');
|
||||
expect(coverage.imeTracker.degraded).toBe(true);
|
||||
expect(coverage.gaps).toEqual([expect.stringContaining('IME-NO-ACTION')]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* schema-valid-but-silently-dead rules.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateRuleDraft, type ValidationFinding } from '../rule-validation.js';
|
||||
import { validateRuleDraft, IME_LOG_DERIVED_EVENT_TYPES, type ValidationFinding } from '../rule-validation.js';
|
||||
|
||||
const errors = (findings: ValidationFinding[]) => findings.filter((f) => f.level === 'error').map((f) => f.message);
|
||||
const warnings = (findings: ValidationFinding[]) => findings.filter((f) => f.level === 'warning').map((f) => f.message);
|
||||
@@ -523,3 +523,52 @@ describe('reserved built-in namespace', () => {
|
||||
expect(warnings(g.findings).some((m) => m.includes('reserved built-in namespace'))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('absence lint (IME-log-derived not_exists)', () => {
|
||||
const absenceRule = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
...validAnalyze(),
|
||||
ruleId: 'ANALYZE-CUSTOM-004',
|
||||
conditions: [
|
||||
{ signal: 'desktop', source: 'event_type', eventType: 'desktop_arrived', operator: 'exists', value: '', required: true },
|
||||
{ signal: 'no_token', source: 'event_type', eventType: 'ime_user_token_acquired', operator: 'not_exists', value: '', required: true },
|
||||
],
|
||||
...extra,
|
||||
});
|
||||
|
||||
it('warns on a required not_exists of an IME-log-derived event without the tracker precondition', () => {
|
||||
const r = validateRuleDraft(absenceRule());
|
||||
expect(r.valid).toBe(true);
|
||||
const w = warnings(r.findings);
|
||||
expect(w.some((m) => m.includes('conditions[1]') && m.includes('ime_user_token_acquired') && m.includes('ime_tracker_degraded'))).toBe(true);
|
||||
});
|
||||
|
||||
it('is silent once the ime_tracker_degraded not_exists precondition is present', () => {
|
||||
const r = validateRuleDraft(absenceRule({
|
||||
preconditions: [{ source: 'event_data', eventType: 'ime_tracker_degraded', operator: 'not_exists' }],
|
||||
}));
|
||||
expect(warnings(r.findings).some((m) => m.includes('ime_tracker_degraded'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not fire for optional conditions, field-level absence, or non-IME event types', () => {
|
||||
const optional = absenceRule();
|
||||
(optional.conditions as Array<Record<string, unknown>>)[1].required = false;
|
||||
expect(warnings(validateRuleDraft(optional).findings).some((m) => m.includes('IME-log-derived'))).toBe(false);
|
||||
|
||||
const fieldLevel = absenceRule();
|
||||
(fieldLevel.conditions as Array<Record<string, unknown>>)[1] = {
|
||||
signal: 'no_field', source: 'event_data', eventType: 'app_install_failed', dataField: 'appId', operator: 'not_exists', value: '', required: true,
|
||||
};
|
||||
expect(warnings(validateRuleDraft(fieldLevel).findings).some((m) => m.includes('IME-log-derived'))).toBe(false);
|
||||
|
||||
const terminal = absenceRule();
|
||||
(terminal.conditions as Array<Record<string, unknown>>)[1].eventType = 'enrollment_complete';
|
||||
expect(warnings(validateRuleDraft(terminal).findings).some((m) => m.includes('IME-log-derived'))).toBe(false);
|
||||
});
|
||||
|
||||
it('reads the IME-derived list from the baked guardrails mirror', () => {
|
||||
expect(IME_LOG_DERIVED_EVENT_TYPES.has('ime_user_token_acquired')).toBe(true);
|
||||
expect(IME_LOG_DERIVED_EVENT_TYPES.has('app_install_failed')).toBe(true);
|
||||
expect(IME_LOG_DERIVED_EVENT_TYPES.has('enrollment_complete')).toBe(false);
|
||||
expect(IME_LOG_DERIVED_EVENT_TYPES.size).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Observation-coverage block of get_session_summary: a pure fold over the health/lifecycle
|
||||
* events the agent already emits. Every branch here mirrors one agent emit site — the payload
|
||||
* keys are the ones the agent writes (see session-coverage.ts), typed loosely because half of
|
||||
* them arrive as strings (Dictionary<string,string> posts) and half as numbers.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildSessionCoverage, COVERAGE_EVENT_FIELDS, COVERAGE_EVENT_TYPES } from '../session-coverage.js';
|
||||
import { SUMMARY_EVENT_FIELDS } from '../tools/shared.js';
|
||||
|
||||
const T0 = Date.UTC(2026, 8, 3, 15, 0, 0);
|
||||
const at = (offsetSec: number): string => new Date(T0 + offsetSec * 1000).toISOString();
|
||||
|
||||
const ev = (eventType: string, offsetSec: number, data: Record<string, unknown> = {}, extra: Record<string, unknown> = {}) => ({
|
||||
eventType,
|
||||
timestamp: at(offsetSec),
|
||||
severity: 'Info',
|
||||
data,
|
||||
...extra,
|
||||
});
|
||||
|
||||
const SESSION = { startedAt: at(0) };
|
||||
|
||||
describe('buildSessionCoverage — clean session', () => {
|
||||
it('reports full coverage with no gaps', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 5, { previousExitType: '' }),
|
||||
ev('esp_phase_changed', 60),
|
||||
ev('diagnostics_uploaded', 900, { blobName: 'x.zip', destination: 'Hosted' }),
|
||||
]);
|
||||
expect(c.gaps).toEqual([]);
|
||||
expect(c.window.observedFrom).toBe(at(5));
|
||||
expect(c.window.startedAtBackdatedSeconds).toBe(5);
|
||||
expect(c.window.agentStarts).toBe(1);
|
||||
expect(c.window.previousExitTypes).toEqual([]);
|
||||
expect(c.window.lateStart).toBeNull();
|
||||
expect(c.window.historicReplaySuppressed).toBeNull();
|
||||
expect(c.imeTracker).toEqual({ degraded: false });
|
||||
expect(c.collectors).toEqual([]);
|
||||
expect(c.upload).toEqual({ spoolPressure: false, poisonedItems: 0, blocked: false, backpressureEpisodes: 0, diskSpaceLow: false });
|
||||
// Older agents report no packaging counters — status only, no truncated flag.
|
||||
expect(c.diagnostics).toEqual({ status: 'uploaded' });
|
||||
});
|
||||
|
||||
it('ignores events outside the coverage set and tolerates missing data', () => {
|
||||
const c = buildSessionCoverage({ startedAt: null }, [
|
||||
{ eventType: 'app_install_failed', timestamp: at(1) },
|
||||
{ eventType: 'agent_started', timestamp: at(2) },
|
||||
]);
|
||||
expect(c.window.sessionStartedAt).toBeNull();
|
||||
expect(c.window.startedAtBackdatedSeconds).toBeNull();
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSessionCoverage — observation window', () => {
|
||||
it('flags a missing agent_started', () => {
|
||||
const c = buildSessionCoverage(SESSION, [ev('esp_phase_changed', 10)]);
|
||||
expect(c.window.observedFrom).toBeNull();
|
||||
expect(c.diagnostics.status).toBe('not_collected');
|
||||
expect(c.gaps).toHaveLength(1);
|
||||
expect(c.gaps[0]).toContain('No agent_started');
|
||||
});
|
||||
|
||||
it('flags a back-dated session start (IME replay reconstruction) beyond a minute', () => {
|
||||
const c = buildSessionCoverage({ startedAt: at(-3600) }, [ev('agent_started', 0)]);
|
||||
expect(c.window.startedAtBackdatedSeconds).toBe(3600);
|
||||
expect(c.gaps.some((g) => g.includes('3600 s before the first agent_started'))).toBe(true);
|
||||
});
|
||||
|
||||
it('never reports a negative back-date when startedAt is after the first start', () => {
|
||||
const c = buildSessionCoverage({ startedAt: at(100) }, [ev('agent_started', 0)]);
|
||||
expect(c.window.startedAtBackdatedSeconds).toBe(0);
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('counts restarts and their previous exit types, earliest start first regardless of input order', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 700, { previousExitType: 'Crash' }),
|
||||
ev('agent_started', 0),
|
||||
ev('agent_started', 300, { previousExitType: 'Reboot' }),
|
||||
ev('agent_started', 900, { previousExitType: 'Crash' }),
|
||||
]);
|
||||
expect(c.window.observedFrom).toBe(at(0));
|
||||
expect(c.window.agentStarts).toBe(4);
|
||||
expect(c.window.previousExitTypes).toEqual(['Reboot', 'Crash']);
|
||||
expect(c.gaps).toEqual([expect.stringContaining('started 4 times (previous exit: Reboot, Crash)')]);
|
||||
});
|
||||
|
||||
it('surfaces agent_late_start even at Info severity (successful session) with the agent note', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('agent_late_start', 200, { bootToAgentStartSeconds: '1800', agentUptimeSeconds: 120, outcome: 'Succeeded', note: 'Agent arrived after the work was done.' }),
|
||||
]);
|
||||
expect(c.window.lateStart).toEqual({ bootToAgentStartSeconds: 1800, agentUptimeSeconds: 120, outcome: 'Succeeded', note: 'Agent arrived after the work was done.' });
|
||||
expect(c.gaps).toEqual([expect.stringMatching(/^Low observation coverage \(agent started 1800 s after boot, ran 120 s\): Agent arrived after the work was done\.$/)]);
|
||||
});
|
||||
|
||||
it('surfaces suppressed historic IME replay with the earliest rejected timestamp', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('historic_ime_replay_detected', 1, { decision: 'historic_ime_replay_suppressed', earliestRejectedSourceTimestamp: '2026-08-01T00:00:00.0000000Z' }),
|
||||
]);
|
||||
expect(c.window.historicReplaySuppressed).toEqual({ earliestRejectedSourceTimestamp: '2026-08-01T00:00:00.0000000Z' });
|
||||
expect(c.gaps).toEqual([expect.stringContaining('earliest 2026-08-01T00:00:00.0000000Z')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSessionCoverage — IME log tracker', () => {
|
||||
it('projects ime_tracker_degraded with a calibrated budget-break line', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('ime_tracker_degraded', 50, { file: 'AgentExecutor.log', oversizedLines: 0, regexTimeouts: 0, lineBudgetBreaks: 1, unanchoredPatterns: 0, firstSkippedPattern: 'PS-SCRIPT-GENERATED' }, { severity: 'Warning' }),
|
||||
]);
|
||||
expect(c.imeTracker).toEqual({
|
||||
degraded: true,
|
||||
file: 'AgentExecutor.log',
|
||||
firstSkippedPattern: 'PS-SCRIPT-GENERATED',
|
||||
lineBudgetBreaks: 1,
|
||||
regexTimeouts: 0,
|
||||
oversizedLines: 0,
|
||||
unanchoredPatterns: 0,
|
||||
});
|
||||
expect(c.gaps).toEqual([
|
||||
'IME log tracker skipped the remaining patterns on 1 line(s) (first skipped: PS-SCRIPT-GENERATED) in AgentExecutor.log: the absence of an IME-derived event is not fully provable for this session.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers whole-session totals from ime_pattern_hits over the first-pass counters', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('ime_tracker_degraded', 50, { file: 'AppWorkload.log', lineBudgetBreaks: 1, regexTimeouts: 0, oversizedLines: 0 }),
|
||||
ev('ime_pattern_hits', 900, { linesRead: 12000, lineBudgetBreaks: 7, regexTimeouts: 2, oversizedLines: 1 }, { severity: 'Debug' }),
|
||||
]);
|
||||
expect(c.imeTracker.sessionTotals).toEqual({ linesRead: 12000, lineBudgetBreaks: 7, regexTimeouts: 2, oversizedLines: 1 });
|
||||
expect(c.gaps[0]).toContain('skipped the remaining patterns on 7 line(s)');
|
||||
expect(c.gaps[0]).toContain('gave up on 2 line(s) after a regex timeout');
|
||||
expect(c.gaps[0]).toContain('dropped 1 oversized line(s)');
|
||||
});
|
||||
|
||||
it('keeps session totals on a healthy tracker without declaring a gap', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('ime_pattern_hits', 900, { linesRead: 800, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0 }),
|
||||
]);
|
||||
expect(c.imeTracker).toEqual({ degraded: false, sessionTotals: { linesRead: 800, lineBudgetBreaks: 0, regexTimeouts: 0, oversizedLines: 0 } });
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSessionCoverage — collectors, upload, disk', () => {
|
||||
it('deduplicates collector_degraded by (collector, reason) and keeps the first occurrence', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('collector_degraded', 20, { collector: 'HelloTracker', reason: 'watcher_arm_failed', errorType: 'UnauthorizedAccessException' }, { source: 'HelloTracker' }),
|
||||
ev('collector_degraded', 25, { collector: 'HelloTracker', reason: 'watcher_arm_failed' }),
|
||||
ev('collector_degraded', 30, { collector: 'WindowsUpdateTracker', reason: 'subscription_failed' }),
|
||||
]);
|
||||
expect(c.collectors).toEqual([
|
||||
{ collector: 'HelloTracker', reason: 'watcher_arm_failed', errorType: 'UnauthorizedAccessException', at: at(20) },
|
||||
{ collector: 'WindowsUpdateTracker', reason: 'subscription_failed', errorType: null, at: at(30) },
|
||||
]);
|
||||
expect(c.gaps).toEqual([
|
||||
expect.stringContaining('Collector HelloTracker degraded (watcher_arm_failed, UnauthorizedAccessException)'),
|
||||
expect.stringContaining('Collector WindowsUpdateTracker degraded (subscription_failed)'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('folds spool pressure, poison (summed itemCount), block, backpressure and low disk', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('spool_pressure_detected', 100, { pendingItemCount: 2400, pendingBytes: 6000000 }),
|
||||
ev('telemetry_upload_poisoned', 110, { kind: 'Oversize', itemCount: 1 }),
|
||||
ev('telemetry_upload_poisoned', 120, { kind: 'BackendDeclared', itemCount: '3' }),
|
||||
ev('telemetry_upload_blocked', 130, { reason: 'permanent 4xx' }),
|
||||
ev('ingress_backpressure', 140, { origin: 'ime' }),
|
||||
ev('ingress_backpressure', 141, { origin: 'ime' }),
|
||||
ev('disk_space_low', 150, { disk_free_gb: 1.4, disk_total_gb: 256, threshold_gb: 2 }),
|
||||
]);
|
||||
expect(c.upload).toEqual({ spoolPressure: true, poisonedItems: 4, blocked: true, backpressureEpisodes: 2, diskSpaceLow: true });
|
||||
expect(c.gaps).toEqual([
|
||||
expect.stringContaining('Telemetry spool pressure (2400 items pending)'),
|
||||
expect.stringContaining('4 telemetry item(s) were quarantined as poison and never uploaded (Oversize, BackendDeclared)'),
|
||||
expect.stringContaining('Telemetry upload was blocked'),
|
||||
expect.stringContaining('System drive ran low on space (1.4 GB free)'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('counts a poison event without itemCount as one item', () => {
|
||||
const c = buildSessionCoverage(SESSION, [ev('agent_started', 0), ev('telemetry_upload_poisoned', 1, {})]);
|
||||
expect(c.upload.poisonedItems).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSessionCoverage — diagnostics package', () => {
|
||||
it('reports packaging counters and a truncation gap (no paths)', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('diagnostics_uploaded', 900, {
|
||||
blobName: 'x.zip',
|
||||
truncated: 'true',
|
||||
includedFiles: 412,
|
||||
includedBytes: 123456789,
|
||||
skippedFiles: 3,
|
||||
skippedByReason: { size: 2, total: 1, reparse: 0 },
|
||||
problemsByKind: { copy: 1 },
|
||||
}),
|
||||
]);
|
||||
expect(c.diagnostics).toEqual({
|
||||
status: 'uploaded',
|
||||
truncated: true,
|
||||
includedFiles: 412,
|
||||
skippedFiles: 3,
|
||||
skippedByReason: { size: 2, total: 1 },
|
||||
problemsByKind: { copy: 1 },
|
||||
});
|
||||
expect(c.gaps).toEqual([
|
||||
'Diagnostics ZIP is truncated: 3 file(s) skipped by the packaging caps (size: 2, total: 1); the full list is in _TRUNCATED.txt inside the ZIP.',
|
||||
'Diagnostics packaging hit problems (copy: 1): those files are missing from the ZIP; package-manifest.txt names them.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports a complete package with counters as no gap', () => {
|
||||
const c = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('diagnostics_uploaded', 900, { truncated: false, includedFiles: 40, includedBytes: 1000, skippedFiles: 0, skippedByReason: {}, problemsByKind: {} }),
|
||||
]);
|
||||
expect(c.diagnostics).toEqual({ status: 'uploaded', truncated: false, includedFiles: 40, skippedFiles: 0 });
|
||||
expect(c.gaps).toEqual([]);
|
||||
});
|
||||
|
||||
it('distinguishes a configuration skip from an upload failure', () => {
|
||||
const skipped = buildSessionCoverage(SESSION, [ev('agent_started', 0), ev('diagnostics_upload_failed', 900, { errorCode: 'diagnostics_skipped_mode_off' })]);
|
||||
expect(skipped.diagnostics).toEqual({ status: 'skipped', errorCode: 'diagnostics_skipped_mode_off' });
|
||||
expect(skipped.gaps).toEqual([expect.stringContaining('skipped by configuration (diagnostics_skipped_mode_off)')]);
|
||||
|
||||
const failed = buildSessionCoverage(SESSION, [ev('agent_started', 0), ev('diagnostics_upload_failed', 900, { errorCode: 'upload_5xx' })]);
|
||||
expect(failed.diagnostics).toEqual({ status: 'failed', errorCode: 'upload_5xx' });
|
||||
expect(failed.gaps).toEqual([expect.stringContaining('Diagnostics upload failed (upload_5xx)')]);
|
||||
});
|
||||
|
||||
it('lets a later upload win over an earlier failure (retry) and flags a collection that never finished', () => {
|
||||
const retried = buildSessionCoverage(SESSION, [
|
||||
ev('agent_started', 0),
|
||||
ev('diagnostics_upload_failed', 800, { errorCode: 'upload_5xx' }),
|
||||
ev('diagnostics_uploaded', 900, { blobName: 'x.zip' }),
|
||||
]);
|
||||
expect(retried.diagnostics.status).toBe('uploaded');
|
||||
expect(retried.gaps).toEqual([]);
|
||||
|
||||
const died = buildSessionCoverage(SESSION, [ev('agent_started', 0), ev('diagnostics_collecting', 800, { mode: 'Always' })]);
|
||||
expect(died.diagnostics.status).toBe('not_collected');
|
||||
expect(died.gaps).toEqual([expect.stringContaining('neither an upload nor a failure was reported')]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coverage wiring', () => {
|
||||
it('SUMMARY_EVENT_FIELDS carries every coverage slice, as data.<key> entries only', () => {
|
||||
const fields = SUMMARY_EVENT_FIELDS.split(',');
|
||||
for (const f of COVERAGE_EVENT_FIELDS.split(',')) {
|
||||
expect(f.startsWith('data.')).toBe(true);
|
||||
expect(fields).toContain(f);
|
||||
}
|
||||
expect(fields).not.toContain('data');
|
||||
});
|
||||
|
||||
it('every coverage event type is read by the builder (no dead member)', () => {
|
||||
// A type in the set that the fold never consults would be a maintenance trap: keep the
|
||||
// set equal to what the builder actually switches on.
|
||||
const consulted = [
|
||||
'agent_started', 'agent_late_start', 'historic_ime_replay_detected', 'ime_tracker_degraded', 'ime_pattern_hits',
|
||||
'collector_degraded', 'spool_pressure_detected', 'telemetry_upload_poisoned', 'telemetry_upload_blocked',
|
||||
'ingress_backpressure', 'disk_space_low', 'diagnostics_collecting', 'diagnostics_uploaded', 'diagnostics_upload_failed',
|
||||
];
|
||||
expect([...COVERAGE_EVENT_TYPES].sort()).toEqual([...consulted].sort());
|
||||
});
|
||||
});
|
||||
@@ -138,7 +138,7 @@ export interface AdminConfiguration {
|
||||
maxDiagnosticsDownloadSizeMB: number;
|
||||
/** Timeout in seconds for the entire diagnostics download+stream operation. 0 = no timeout. Default: 120 seconds. */
|
||||
diagnosticsDownloadTimeoutSeconds: number;
|
||||
/** Controls who can access the remote MCP server. "Disabled" = MCP off, "WhitelistOnly" = GlobalAdmins + McpUsers table (default), "AllMembers" = any authenticated user. */
|
||||
/** Controls who can access the remote MCP server (evaluated by McpUserService.IsAllowedAsync). "Disabled" = MCP off for everyone, platform roles included. "WhitelistOnly" = platform roles, delegated (MSP) admins and enabled McpUsers rows (default). "AllMembers" = the same grants plus every account holding an effective member role (Admin / Operator / Viewer) in the tenant its token was issued for — never "any authenticated token": an employee without a role, or a tenant that never onboarded, is denied. An explicitly disabled McpUsers row denies under every policy. */
|
||||
mcpAccessPolicy: string;
|
||||
/** NVD API key for higher rate limits (50 req/30s vs 5 req/30s without key). Free registration at https://nvd.nist.gov/developers/request-an-api-key null = operate without API key (slower, still functional). */
|
||||
nvdApiKey: string;
|
||||
|
||||
@@ -60,7 +60,7 @@ export function buildInstructions(deps: ServerDeps, ga: boolean, strictGa: boole
|
||||
return [
|
||||
headline,
|
||||
'',
|
||||
'Investigating one session: call get_session_summary FIRST (status, filtered timeline, stats, rule analysis in one call), then drill in.',
|
||||
'Investigating one session: call get_session_summary FIRST (status, observation coverage, filtered timeline, stats, rule analysis in one call), then drill in. Read coverage.gaps before treating a missing event as evidence — the agent watches only from its own start, and a degraded tracker or collector makes an absence unprovable.',
|
||||
...(deps.docs
|
||||
? ['Product questions ("how do I…", "what does X mean", "where is my data stored"): use search_docs — the ' +
|
||||
'published customer documentation. search_knowledge is a DIFFERENT corpus (analysis rules and IME log ' +
|
||||
|
||||
@@ -34,7 +34,9 @@ export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolea
|
||||
`Investigate enrollment session ${sessionId} and explain why it ended the way it did.\n\n` +
|
||||
'Follow this order:\n' +
|
||||
`1. Call get_session_summary(sessionId="${sessionId}") first — it gives status, the ` +
|
||||
'noise-filtered key-event timeline, aggregate stats, and any rule analysis in one shot.\n' +
|
||||
'observation coverage (coverage.gaps: what the agent could not see), the noise-filtered ' +
|
||||
'key-event timeline, aggregate stats, and any rule analysis in one shot. Never treat a ' +
|
||||
'missing event as proof while coverage.gaps names a relevant blind spot.\n' +
|
||||
'2. If the summary shows errors or a failure, escalate: use search_events ' +
|
||||
'(hybrid; depth="deep" if the fast pass is thin) for the failing area, then get_session_events for the full ' +
|
||||
'chronological stream around the first error. Follow nextLink until the relevant window is covered.\n' +
|
||||
@@ -78,7 +80,7 @@ export function registerPrompts(server: McpServer, ga: boolean, strictGa: boolea
|
||||
'.\n\n' +
|
||||
'Do a correlated client↔backend analysis:\n' +
|
||||
`1. Call get_session_summary(sessionId="${sessionId}"${tenantId ? `, tenantId="${tenantId}"` : ''}) ` +
|
||||
'first — status, noise-filtered timeline, stats, rule analysis in one shot.\n' +
|
||||
'first — status, observation coverage (coverage.gaps), noise-filtered timeline, stats, rule analysis in one shot.\n' +
|
||||
`2. Call get_session_diagnostics(sessionId="${sessionId}"${tenantId ? `, tenantId="${tenantId}"` : ''}). ` +
|
||||
'If available=true, DOWNLOAD the ZIP from downloadUrl using your local tools (no auth header — ' +
|
||||
'it is a short-lived signed ticket), unzip it locally, and read files in the priority order of the diag_zip_layout resource (get_resource(name="diag_zip_layout")): ' +
|
||||
|
||||
@@ -205,6 +205,18 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
'Optional gate BEFORE conditions: source must be "event_data"; all preconditions are ' +
|
||||
'AND-combined and a failing one silently skips the rule (no finding, no UI card). Use for ' +
|
||||
'applicability ("skip on virtual machines", "only when marker event absent via not_exists").',
|
||||
absence:
|
||||
'ABSENCE PROVES NOTHING. The agent observes an enrollment only from its own start (IME bootstrap) ' +
|
||||
'and only as well as its collectors work: a missing event can mean "never happened" or "not seen". ' +
|
||||
'Prefer positive agent signals (app_install_starved, entra_user_affinity_pending) over asserting that ' +
|
||||
'something did not occur. Use not_exists as a SUPPRESSION (precondition: "skip when enrollment_complete ' +
|
||||
'exists"), rarely as a required condition. A required not_exists on an IME-log-derived event ' +
|
||||
'(guardrails imeLogDerivedEventTypes: app_install_*, script_*, ime_*, …) is only as good as the log ' +
|
||||
'tracker: add a precondition { eventType: "ime_tracker_degraded", operator: "not_exists" } so the rule ' +
|
||||
'stays silent when the tracker skipped work, or accept the risk knowingly — validate_rule warns. ' +
|
||||
'collector_degraded is the same gate for kernel/event-log watchers (precondition on ' +
|
||||
'collector_degraded not_exists, or dataField collector not_equals <name>). When reading a session, ' +
|
||||
'get_session_summary → coverage.gaps lists the blind spots before the findings.',
|
||||
suppressByEvent:
|
||||
'On a condition, suppressByEvent {eventType, joinField} drops matches that a resolving event ' +
|
||||
'shares the joinField value with — e.g. app_install_failed suppressed by a later ' +
|
||||
|
||||
@@ -1053,5 +1053,25 @@ export const RULE_GUARDRAILS = {
|
||||
"log_entry",
|
||||
"agent_trace",
|
||||
"stall_probe_check"
|
||||
],
|
||||
"imeLogDerivedEventTypes": [
|
||||
"app_install_started",
|
||||
"app_install_completed",
|
||||
"app_install_failed",
|
||||
"app_install_skipped",
|
||||
"app_download_started",
|
||||
"app_tracking_summary",
|
||||
"download_progress",
|
||||
"do_telemetry",
|
||||
"script_started",
|
||||
"script_completed",
|
||||
"script_failed",
|
||||
"script_timeout_suspected",
|
||||
"esp_phase_changed",
|
||||
"ime_agent_version",
|
||||
"ime_token_failure",
|
||||
"ime_user_session_completed",
|
||||
"ime_process_exited",
|
||||
"ime_user_token_acquired"
|
||||
]
|
||||
} as const;
|
||||
|
||||
@@ -339,6 +339,32 @@ function lintAnalyzeRule(rule: Record<string, unknown>): ValidationFinding[] {
|
||||
findings.push(...checkEventTypeKnown(preconditions[i].eventType, `preconditions[${i}]`));
|
||||
}
|
||||
|
||||
// Absence proves nothing: a required not_exists on an event the agent derives from the IME
|
||||
// logs is only as good as the tracker that would have matched it. When the tracker reports
|
||||
// ime_tracker_degraded (a line whose remaining patterns were skipped, a regex timeout, an
|
||||
// oversized line) the engine still evaluates the absence as true. The precondition below
|
||||
// suppresses the rule on those sessions; without it the author has to accept that risk
|
||||
// knowingly — hence a warning, never an error (production does not reject the rule).
|
||||
const hasTrackerCoverageGate = preconditions.some(
|
||||
(p) => p.eventType === IME_TRACKER_DEGRADED_EVENT_TYPE && p.operator === 'not_exists' && !p.dataField,
|
||||
);
|
||||
if (!hasTrackerCoverageGate) {
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
const c = conditions[i];
|
||||
if (c.operator !== 'not_exists' || c.required !== true || !c.eventType || c.dataField) continue;
|
||||
if (!IME_LOG_DERIVED_EVENT_TYPES.has(c.eventType)) continue;
|
||||
findings.push({
|
||||
level: 'warning',
|
||||
message:
|
||||
`conditions[${i}]: required not_exists on "${c.eventType}" asserts an ABSENCE of an IME-log-derived event — ` +
|
||||
'it is only as good as the log tracker that would have matched it. Either add a precondition ' +
|
||||
`{ eventType: "${IME_TRACKER_DEGRADED_EVENT_TYPE}", operator: "not_exists" } so the rule stays silent when the tracker ` +
|
||||
'skipped work, or build the rule on a positive agent signal instead (pattern: app_install_starved). ' +
|
||||
'get_session_summary → coverage.gaps shows the tracker state per session.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Confidence factors: exact DSL shapes; "count >= N" counts EVENTS of type factor.signal.
|
||||
const factors = (Array.isArray(rule.confidenceFactors) ? rule.confidenceFactors : []) as Array<{ signal?: string; condition?: string; weight?: number }>;
|
||||
let positiveWeights = 0;
|
||||
@@ -465,6 +491,20 @@ const BLOCKED_INTERIM_TRIGGER_EVENT_TYPES = new Set<string>(
|
||||
((RULE_GUARDRAILS as Record<string, unknown>).blockedInterimTriggerEventTypes as readonly string[] | undefined) ?? [],
|
||||
);
|
||||
|
||||
/**
|
||||
* Event types the agent derives from the IME log files (ImeLogTracker + its adapter). Their
|
||||
* absence in a session can mean "never happened" OR "the tracker skipped the line" — the
|
||||
* absence lint above tells the two apart via the ime_tracker_degraded precondition. Mirrored
|
||||
* from rules/guardrails.json (imeLogDerivedEventTypes); the backend catalog-policy test reads
|
||||
* the same file, so the built-in catalog and this lint judge by one list.
|
||||
*/
|
||||
export const IME_LOG_DERIVED_EVENT_TYPES: ReadonlySet<string> = new Set<string>(
|
||||
((RULE_GUARDRAILS as Record<string, unknown>).imeLogDerivedEventTypes as readonly string[] | undefined) ?? [],
|
||||
);
|
||||
|
||||
/** The tracker's one-shot self-report of skipped work (Constants.EventTypes.ImeTrackerDegraded). */
|
||||
export const IME_TRACKER_DEGRADED_EVENT_TYPE = 'ime_tracker_degraded';
|
||||
|
||||
// ── Gather-rule semantic lint ───────────────────────────────────────────────
|
||||
|
||||
function lintGatherRule(rule: Record<string, unknown>): ValidationFinding[] {
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Observation coverage of one session — a pure projection of events the agent already emits.
|
||||
*
|
||||
* The agent only sees an enrollment from the moment the IME bootstrapped it, and even inside
|
||||
* that window only as well as its IME log tracker, collectors, spool and upload path worked.
|
||||
* Every one of those degradations is an event today (`agent_late_start`, `ime_tracker_degraded`,
|
||||
* `collector_degraded`, `telemetry_upload_poisoned`, …), but the triage timeline ranks them as
|
||||
* ordinary warnings — first to fall off the 50-entry cap on a noisy session, and the Info-severity
|
||||
* ones (`agent_late_start` on a successful session, `diagnostics_uploaded`) never make it at all.
|
||||
*
|
||||
* `buildSessionCoverage` folds them into one block that `get_session_summary` places BEFORE the
|
||||
* key events, so a reader knows what was and was not observable before interpreting findings —
|
||||
* in particular before trusting the absence of an event. No backend field, no new event type:
|
||||
* the block is computed from the same event list the summary already fetches.
|
||||
*/
|
||||
import type { EnrollmentEvent } from './generated/wire-types.generated.js';
|
||||
|
||||
/** Event types the coverage block reads. Guarded against the catalog by event-types-drift.test.ts. */
|
||||
export const COVERAGE_EVENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'agent_started',
|
||||
'agent_late_start',
|
||||
'historic_ime_replay_detected',
|
||||
'ime_tracker_degraded',
|
||||
'ime_pattern_hits',
|
||||
'collector_degraded',
|
||||
'spool_pressure_detected',
|
||||
'telemetry_upload_poisoned',
|
||||
'telemetry_upload_blocked',
|
||||
'ingress_backpressure',
|
||||
'disk_space_low',
|
||||
'diagnostics_collecting',
|
||||
'diagnostics_uploaded',
|
||||
'diagnostics_upload_failed',
|
||||
]);
|
||||
|
||||
/**
|
||||
* `data.<key>` slices the coverage block needs from the events endpoint, appended to
|
||||
* SUMMARY_EVENT_FIELDS so the backend never ships whole payloads for a summary.
|
||||
*/
|
||||
export const COVERAGE_EVENT_FIELDS =
|
||||
'data.previousExitType,data.oobeStateAtAgentStart,' +
|
||||
'data.bootToAgentStartSeconds,data.agentUptimeSeconds,data.outcome,data.note,' +
|
||||
'data.earliestRejectedSourceTimestamp,' +
|
||||
'data.file,data.firstSkippedPattern,data.lineBudgetBreaks,data.regexTimeouts,data.oversizedLines,data.unanchoredPatterns,data.linesRead,' +
|
||||
'data.collector,data.reason,data.errorType,' +
|
||||
'data.pendingItemCount,data.pendingBytes,data.kind,data.itemCount,data.disk_free_gb,' +
|
||||
'data.truncated,data.includedFiles,data.includedBytes,data.skippedFiles,data.skippedByReason,data.problemsByKind';
|
||||
|
||||
export interface CoverageWindow {
|
||||
/** Timestamp of the first `agent_started` — the earliest moment anything was observed live. */
|
||||
observedFrom: string | null;
|
||||
/** `session.startedAt` — back-dated to the earliest replayed IME log line when the agent arrived late. */
|
||||
sessionStartedAt: string | null;
|
||||
/** Seconds between `sessionStartedAt` and `observedFrom`; > 0 means the start was reconstructed from IME logs, not observed. */
|
||||
startedAtBackdatedSeconds: number | null;
|
||||
/** Number of `agent_started` events — every restart is a gap in observation. */
|
||||
agentStarts: number;
|
||||
/** Distinct `previousExitType` values reported by restarts (the first start has none). */
|
||||
previousExitTypes: string[];
|
||||
lateStart: {
|
||||
bootToAgentStartSeconds: number | null;
|
||||
agentUptimeSeconds: number | null;
|
||||
outcome: string | null;
|
||||
note: string | null;
|
||||
} | null;
|
||||
historicReplaySuppressed: { earliestRejectedSourceTimestamp: string | null } | null;
|
||||
}
|
||||
|
||||
export interface ImeTrackerCoverage {
|
||||
degraded: boolean;
|
||||
file?: string | null;
|
||||
firstSkippedPattern?: string | null;
|
||||
lineBudgetBreaks?: number | null;
|
||||
regexTimeouts?: number | null;
|
||||
oversizedLines?: number | null;
|
||||
unanchoredPatterns?: number | null;
|
||||
/** Whole-session counters from `ime_pattern_hits` (emitted at termination), when present. */
|
||||
sessionTotals?: {
|
||||
linesRead: number | null;
|
||||
lineBudgetBreaks: number | null;
|
||||
regexTimeouts: number | null;
|
||||
oversizedLines: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CollectorDegradation {
|
||||
collector: string;
|
||||
reason: string;
|
||||
errorType: string | null;
|
||||
at: string | null;
|
||||
}
|
||||
|
||||
export interface UploadCoverage {
|
||||
spoolPressure: boolean;
|
||||
poisonedItems: number;
|
||||
blocked: boolean;
|
||||
backpressureEpisodes: number;
|
||||
diskSpaceLow: boolean;
|
||||
}
|
||||
|
||||
export type DiagnosticsStatus = 'uploaded' | 'failed' | 'skipped' | 'not_collected';
|
||||
|
||||
export interface DiagnosticsCoverage {
|
||||
status: DiagnosticsStatus;
|
||||
errorCode?: string;
|
||||
/** Present once the agent reports packaging counters (never file paths — those live in the ZIP). */
|
||||
truncated?: boolean;
|
||||
includedFiles?: number;
|
||||
skippedFiles?: number;
|
||||
skippedByReason?: Record<string, number>;
|
||||
problemsByKind?: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SessionCoverage {
|
||||
window: CoverageWindow;
|
||||
imeTracker: ImeTrackerCoverage;
|
||||
collectors: CollectorDegradation[];
|
||||
upload: UploadCoverage;
|
||||
diagnostics: DiagnosticsCoverage;
|
||||
/** One calibrated line per observation gap; empty means nothing reported a gap. */
|
||||
gaps: string[];
|
||||
}
|
||||
|
||||
type Ev = Partial<EnrollmentEvent>;
|
||||
|
||||
function str(v: unknown): string | null {
|
||||
if (v === undefined || v === null) return null;
|
||||
const s = String(v).trim();
|
||||
return s.length > 0 ? s : null;
|
||||
}
|
||||
|
||||
function num(v: unknown): number | null {
|
||||
if (v === undefined || v === null || v === '') return null;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function bool(v: unknown): boolean {
|
||||
if (typeof v === 'boolean') return v;
|
||||
return String(v ?? '').toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
function countMap(v: unknown): Record<string, number> | undefined {
|
||||
if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined;
|
||||
const out: Record<string, number> = {};
|
||||
for (const [k, raw] of Object.entries(v as Record<string, unknown>)) {
|
||||
const n = num(raw);
|
||||
if (n !== null && n > 0) out[k] = n;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function ms(ts: unknown): number | null {
|
||||
const s = str(ts);
|
||||
if (!s) return null;
|
||||
const t = Date.parse(s);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
|
||||
function byTime(a: Ev, b: Ev): number {
|
||||
return (ms(a.timestamp) ?? 0) - (ms(b.timestamp) ?? 0) || ((a.sequence ?? 0) - (b.sequence ?? 0));
|
||||
}
|
||||
|
||||
function fmtCounts(m: Record<string, number>): string {
|
||||
return Object.entries(m).map(([k, n]) => `${k}: ${n}`).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds the session's events (already stripped of historic-replay rows by the caller) into the
|
||||
* coverage block. Pure: no fetch, no clock.
|
||||
*/
|
||||
export function buildSessionCoverage(
|
||||
session: { startedAt?: string | null },
|
||||
events: readonly Ev[],
|
||||
): SessionCoverage {
|
||||
const byType = new Map<string, Ev[]>();
|
||||
for (const e of events) {
|
||||
const et = String(e.eventType ?? '');
|
||||
if (!COVERAGE_EVENT_TYPES.has(et)) continue;
|
||||
const list = byType.get(et);
|
||||
if (list) list.push(e);
|
||||
else byType.set(et, [e]);
|
||||
}
|
||||
for (const list of byType.values()) list.sort(byTime);
|
||||
const of = (type: string): Ev[] => byType.get(type) ?? [];
|
||||
const first = (type: string): Ev | undefined => of(type)[0];
|
||||
const last = (type: string): Ev | undefined => of(type)[of(type).length - 1];
|
||||
|
||||
const gaps: string[] = [];
|
||||
|
||||
// ── Observation window ──────────────────────────────────────────────────────
|
||||
const starts = of('agent_started');
|
||||
const observedFrom = str(starts[0]?.timestamp);
|
||||
const sessionStartedAt = str(session.startedAt);
|
||||
const observedMs = ms(observedFrom);
|
||||
const startedMs = ms(sessionStartedAt);
|
||||
const startedAtBackdatedSeconds =
|
||||
observedMs !== null && startedMs !== null ? Math.max(0, Math.round((observedMs - startedMs) / 1000)) : null;
|
||||
const previousExitTypes = [...new Set(starts.slice(1).map((e) => str(e.data?.previousExitType)).filter((x): x is string => !!x))];
|
||||
|
||||
const late = last('agent_late_start');
|
||||
const lateStart = late
|
||||
? {
|
||||
bootToAgentStartSeconds: num(late.data?.bootToAgentStartSeconds),
|
||||
agentUptimeSeconds: num(late.data?.agentUptimeSeconds),
|
||||
outcome: str(late.data?.outcome),
|
||||
note: str(late.data?.note),
|
||||
}
|
||||
: null;
|
||||
|
||||
const replay = first('historic_ime_replay_detected');
|
||||
const historicReplaySuppressed = replay
|
||||
? { earliestRejectedSourceTimestamp: str(replay.data?.earliestRejectedSourceTimestamp) }
|
||||
: null;
|
||||
|
||||
if (!observedFrom) {
|
||||
gaps.push('No agent_started event in this session: the live observation window cannot be established (events may stem from a partial or replayed upload).');
|
||||
} else if (startedAtBackdatedSeconds !== null && startedAtBackdatedSeconds > 60) {
|
||||
gaps.push(
|
||||
`Session start is ${startedAtBackdatedSeconds} s before the first agent_started (${observedFrom}): the earlier part was reconstructed from IME log replay, not observed live.`,
|
||||
);
|
||||
}
|
||||
if (lateStart) {
|
||||
const detail = [
|
||||
lateStart.bootToAgentStartSeconds !== null ? `agent started ${lateStart.bootToAgentStartSeconds} s after boot` : null,
|
||||
lateStart.agentUptimeSeconds !== null ? `ran ${lateStart.agentUptimeSeconds} s` : null,
|
||||
].filter(Boolean).join(', ');
|
||||
gaps.push(`Low observation coverage${detail ? ` (${detail})` : ''}: ${lateStart.note ?? 'the agent observed only the end state of this enrollment.'}`);
|
||||
}
|
||||
if (historicReplaySuppressed) {
|
||||
gaps.push(
|
||||
`IME log content from a previous enrollment (earliest ${historicReplaySuppressed.earliestRejectedSourceTimestamp ?? 'unknown'}) was suppressed; app and script events from before this enrollment are intentionally absent.`,
|
||||
);
|
||||
}
|
||||
if (starts.length > 1) {
|
||||
const exits = previousExitTypes.length > 0 ? ` (previous exit: ${previousExitTypes.join(', ')})` : '';
|
||||
gaps.push(`The agent started ${starts.length} times${exits}: signals during the restart gaps were not observed.`);
|
||||
}
|
||||
|
||||
// ── IME log tracker ─────────────────────────────────────────────────────────
|
||||
const degradedEv = first('ime_tracker_degraded');
|
||||
const hits = last('ime_pattern_hits');
|
||||
const sessionTotals = hits
|
||||
? {
|
||||
linesRead: num(hits.data?.linesRead),
|
||||
lineBudgetBreaks: num(hits.data?.lineBudgetBreaks),
|
||||
regexTimeouts: num(hits.data?.regexTimeouts),
|
||||
oversizedLines: num(hits.data?.oversizedLines),
|
||||
}
|
||||
: undefined;
|
||||
let imeTracker: ImeTrackerCoverage;
|
||||
if (degradedEv) {
|
||||
imeTracker = {
|
||||
degraded: true,
|
||||
file: str(degradedEv.data?.file),
|
||||
firstSkippedPattern: str(degradedEv.data?.firstSkippedPattern),
|
||||
lineBudgetBreaks: num(degradedEv.data?.lineBudgetBreaks),
|
||||
regexTimeouts: num(degradedEv.data?.regexTimeouts),
|
||||
oversizedLines: num(degradedEv.data?.oversizedLines),
|
||||
unanchoredPatterns: num(degradedEv.data?.unanchoredPatterns),
|
||||
...(sessionTotals ? { sessionTotals } : {}),
|
||||
};
|
||||
const parts: string[] = [];
|
||||
const totals = sessionTotals ?? {
|
||||
linesRead: null,
|
||||
lineBudgetBreaks: imeTracker.lineBudgetBreaks,
|
||||
regexTimeouts: imeTracker.regexTimeouts,
|
||||
oversizedLines: imeTracker.oversizedLines,
|
||||
};
|
||||
if ((totals.lineBudgetBreaks ?? 0) > 0) {
|
||||
parts.push(`skipped the remaining patterns on ${totals.lineBudgetBreaks} line(s)${imeTracker.firstSkippedPattern ? ` (first skipped: ${imeTracker.firstSkippedPattern})` : ''}`);
|
||||
}
|
||||
if ((totals.regexTimeouts ?? 0) > 0) parts.push(`gave up on ${totals.regexTimeouts} line(s) after a regex timeout`);
|
||||
if ((totals.oversizedLines ?? 0) > 0) parts.push(`dropped ${totals.oversizedLines} oversized line(s)`);
|
||||
if (parts.length === 0) parts.push('skipped work on at least one line');
|
||||
gaps.push(
|
||||
`IME log tracker ${parts.join(', ')} in ${imeTracker.file ?? 'an IME log'}: the absence of an IME-derived event is not fully provable for this session.`,
|
||||
);
|
||||
} else {
|
||||
imeTracker = { degraded: false, ...(sessionTotals ? { sessionTotals } : {}) };
|
||||
}
|
||||
|
||||
// ── Collectors ──────────────────────────────────────────────────────────────
|
||||
const collectors: CollectorDegradation[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const e of of('collector_degraded')) {
|
||||
const collector = str(e.data?.collector) ?? str(e.source) ?? 'unknown';
|
||||
const reason = str(e.data?.reason) ?? 'unknown';
|
||||
const key = `${collector}|${reason}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
collectors.push({ collector, reason, errorType: str(e.data?.errorType), at: str(e.timestamp) });
|
||||
}
|
||||
for (const c of collectors) {
|
||||
gaps.push(`Collector ${c.collector} degraded (${c.reason}${c.errorType ? `, ${c.errorType}` : ''}): its signals are missing from this session, not proven negative.`);
|
||||
}
|
||||
|
||||
// ── Spool / upload ──────────────────────────────────────────────────────────
|
||||
const spool = last('spool_pressure_detected');
|
||||
const poisoned = of('telemetry_upload_poisoned');
|
||||
const poisonedItems = poisoned.reduce((n, e) => n + (num(e.data?.itemCount) ?? 1), 0);
|
||||
const blocked = of('telemetry_upload_blocked').length > 0;
|
||||
const backpressureEpisodes = of('ingress_backpressure').length;
|
||||
const disk = last('disk_space_low');
|
||||
const upload: UploadCoverage = {
|
||||
spoolPressure: !!spool,
|
||||
poisonedItems,
|
||||
blocked,
|
||||
backpressureEpisodes,
|
||||
diskSpaceLow: !!disk,
|
||||
};
|
||||
if (spool) {
|
||||
const pending = num(spool.data?.pendingItemCount);
|
||||
gaps.push(`Telemetry spool pressure${pending !== null ? ` (${pending} items pending)` : ''}: events reached the backend late; anything after the last upload may still be on the device.`);
|
||||
}
|
||||
if (poisonedItems > 0) {
|
||||
const kinds = [...new Set(poisoned.map((e) => str(e.data?.kind)).filter((x): x is string => !!x))];
|
||||
gaps.push(`${poisonedItems} telemetry item(s) were quarantined as poison and never uploaded${kinds.length > 0 ? ` (${kinds.join(', ')})` : ''}; they remain in the spool inside the diagnostics ZIP.`);
|
||||
}
|
||||
if (blocked) {
|
||||
gaps.push('Telemetry upload was blocked by a permanent backend rejection at some point; later events reached the backend only once the block cleared.');
|
||||
}
|
||||
if (disk) {
|
||||
const free = num(disk.data?.disk_free_gb);
|
||||
gaps.push(`System drive ran low on space${free !== null ? ` (${free} GB free)` : ''}: spool and diagnostics writes may have failed.`);
|
||||
}
|
||||
|
||||
// ── Diagnostics package ─────────────────────────────────────────────────────
|
||||
const uploaded = last('diagnostics_uploaded');
|
||||
const failed = last('diagnostics_upload_failed');
|
||||
const collecting = last('diagnostics_collecting');
|
||||
let diagnostics: DiagnosticsCoverage;
|
||||
if (uploaded) {
|
||||
diagnostics = { status: 'uploaded' };
|
||||
const d = uploaded.data ?? {};
|
||||
if (d.truncated !== undefined || d.includedFiles !== undefined) {
|
||||
diagnostics.truncated = bool(d.truncated);
|
||||
const included = num(d.includedFiles);
|
||||
const skipped = num(d.skippedFiles);
|
||||
if (included !== null) diagnostics.includedFiles = included;
|
||||
if (skipped !== null) diagnostics.skippedFiles = skipped;
|
||||
const byReason = countMap(d.skippedByReason);
|
||||
const problems = countMap(d.problemsByKind);
|
||||
if (byReason) diagnostics.skippedByReason = byReason;
|
||||
if (problems) diagnostics.problemsByKind = problems;
|
||||
if (diagnostics.truncated) {
|
||||
gaps.push(`Diagnostics ZIP is truncated: ${skipped ?? 'some'} file(s) skipped by the packaging caps${byReason ? ` (${fmtCounts(byReason)})` : ''}; the full list is in _TRUNCATED.txt inside the ZIP.`);
|
||||
}
|
||||
if (problems) {
|
||||
gaps.push(`Diagnostics packaging hit problems (${fmtCounts(problems)}): those files are missing from the ZIP; package-manifest.txt names them.`);
|
||||
}
|
||||
}
|
||||
} else if (failed) {
|
||||
const errorCode = str(failed.data?.errorCode) ?? 'unknown';
|
||||
if (errorCode.startsWith('diagnostics_skipped_')) {
|
||||
diagnostics = { status: 'skipped', errorCode };
|
||||
gaps.push(`No diagnostics ZIP for this session: collection was skipped by configuration (${errorCode}).`);
|
||||
} else {
|
||||
diagnostics = { status: 'failed', errorCode };
|
||||
gaps.push(`Diagnostics upload failed (${errorCode}): no ZIP is available for this session.`);
|
||||
}
|
||||
} else {
|
||||
diagnostics = { status: 'not_collected' };
|
||||
if (collecting) {
|
||||
gaps.push('Diagnostics collection started but neither an upload nor a failure was reported: the agent may have died while packaging.');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
window: {
|
||||
observedFrom,
|
||||
sessionStartedAt,
|
||||
startedAtBackdatedSeconds,
|
||||
agentStarts: starts.length,
|
||||
previousExitTypes,
|
||||
lateStart,
|
||||
historicReplaySuppressed,
|
||||
},
|
||||
imeTracker,
|
||||
collectors,
|
||||
upload,
|
||||
diagnostics,
|
||||
gaps,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { toolError } from './error-handler.js';
|
||||
import { lookupErrorCode } from '../error-code-catalog.js';
|
||||
import { assertKnownEventType, assertKnownDevicePropertyKeys } from '../resource-catalog.js';
|
||||
import { interpolateAnalysisResults } from '../interpolate-rule-template.js';
|
||||
import { buildSessionCoverage } from '../session-coverage.js';
|
||||
import { API_BASE_URL } from '../config.js';
|
||||
import type {
|
||||
AppMetricsResponse,
|
||||
@@ -483,6 +484,9 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
description:
|
||||
'Get a concise, structured summary of an enrollment session optimized for analysis. ' +
|
||||
'Returns: session overview (status, duration, device, enrollment config), ' +
|
||||
'observation coverage (coverage: from when the agent actually watched, IME log tracker / collector / ' +
|
||||
'upload / diagnostics-package health, and coverage.gaps — one line per known blind spot; read it before ' +
|
||||
'treating a missing event as proof that something did not happen), ' +
|
||||
'key events timeline (errors, warnings, phase transitions, app installs — noise filtered out, ' +
|
||||
'capped at 50 most-relevant entries; stats.keyEventsTruncated indicates if more were dropped), ' +
|
||||
'rule analysis results (probable cause, remediation), and aggregate stats. A key event that carries an ' +
|
||||
@@ -564,6 +568,10 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
// cleans all three. stats.totalEvents therefore counts non-replayed events.
|
||||
const allEvents = (eventsData.events ?? []).filter((e) => !isHistoricImeReplay(e));
|
||||
|
||||
// Observation coverage: what the agent could and could not see, folded from the
|
||||
// health/lifecycle events that the triage timeline below ranks low or drops.
|
||||
const coverage = buildSessionCoverage(s, allEvents);
|
||||
|
||||
let errorCount = 0;
|
||||
let warningCount = 0;
|
||||
let appTotal = 0;
|
||||
@@ -664,6 +672,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
|
||||
const result = {
|
||||
overview,
|
||||
coverage,
|
||||
keyEvents: mappedEvents,
|
||||
analysis,
|
||||
annotations,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ToolAnnotations } from '@modelcontextprotocol/server';
|
||||
import { z } from 'zod';
|
||||
import { PRETTY_JSON } from '../config.js';
|
||||
import { isPrettyJsonRequested } from '../client.js';
|
||||
import { COVERAGE_EVENT_FIELDS } from '../session-coverage.js';
|
||||
|
||||
/**
|
||||
* Projection sent by get_session_events when the caller omits `fields`: everything an event
|
||||
@@ -76,7 +77,10 @@ export const SUMMARY_EVENT_FIELDS =
|
||||
'data.scriptType,data.script_type,data.scriptPart,data.script_part,data.result,' +
|
||||
'data.rejectedSourceTimestamp,data.rejected_source_timestamp,' +
|
||||
'data.errorCode,data.exitCode,data.hresult,data.hresultFromWin32,data.hresultSymbol,' +
|
||||
'data.errorCodeInfo,data.exitCodeInfo,data.hresultInfo,data.hresultFromWin32Info';
|
||||
'data.errorCodeInfo,data.exitCodeInfo,data.hresultInfo,data.hresultFromWin32Info,' +
|
||||
// Observation-coverage slices (session-coverage.ts): the health/lifecycle payload keys the
|
||||
// `coverage` block folds — still per-key, never the whole payload.
|
||||
COVERAGE_EVENT_FIELDS;
|
||||
|
||||
/**
|
||||
* Zod validator for session IDs. Sessions are UUIDs and the value is
|
||||
|
||||
@@ -1096,7 +1096,7 @@
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "Controls who can access the remote MCP server. \"Disabled\" = MCP off, \"WhitelistOnly\" = GlobalAdmins + McpUsers table (default), \"AllMembers\" = any authenticated user."
|
||||
"doc": "Controls who can access the remote MCP server (evaluated by McpUserService.IsAllowedAsync). \"Disabled\" = MCP off for everyone, platform roles included. \"WhitelistOnly\" = platform roles, delegated (MSP) admins and enabled McpUsers rows (default). \"AllMembers\" = the same grants plus every account holding an effective member role (Admin / Operator / Viewer) in the tenant its token was issued for — never \"any authenticated token\": an employee without a role, or a tenant that never onboarded, is denied. An explicitly disabled McpUsers row denies under every policy."
|
||||
},
|
||||
{
|
||||
"name": "nvdApiKey",
|
||||
|
||||
@@ -138,7 +138,7 @@ export interface AdminConfiguration {
|
||||
maxDiagnosticsDownloadSizeMB: number;
|
||||
/** Timeout in seconds for the entire diagnostics download+stream operation. 0 = no timeout. Default: 120 seconds. */
|
||||
diagnosticsDownloadTimeoutSeconds: number;
|
||||
/** Controls who can access the remote MCP server. "Disabled" = MCP off, "WhitelistOnly" = GlobalAdmins + McpUsers table (default), "AllMembers" = any authenticated user. */
|
||||
/** Controls who can access the remote MCP server (evaluated by McpUserService.IsAllowedAsync). "Disabled" = MCP off for everyone, platform roles included. "WhitelistOnly" = platform roles, delegated (MSP) admins and enabled McpUsers rows (default). "AllMembers" = the same grants plus every account holding an effective member role (Admin / Operator / Viewer) in the tenant its token was issued for — never "any authenticated token": an employee without a role, or a tenant that never onboarded, is denied. An explicitly disabled McpUsers row denies under every policy. */
|
||||
mcpAccessPolicy: string;
|
||||
/** NVD API key for higher rate limits (50 req/30s vs 5 req/30s without key). Free registration at https://nvd.nist.gov/developers/request-an-api-key null = operate without API key (slower, still functional). */
|
||||
nvdApiKey: string;
|
||||
|
||||
Reference in New Issue
Block a user