Adds a tenant-scoped, informational overview of devices that failed enrollment
with HTTP 403 because they were not in the tenant's Autopilot / Corporate
Identifier registry. Mirrors the Hardware Whitelist rejection insights but
aggregates per device (serial number), matching how Autopilot registers
individual devices rather than models. Read-only, no webhook.
Backend
- GetDeviceNotRegisteredFunction: route audit/device-not-registered, MemberRead,
filters distress reports on ErrorType == "DeviceNotRegistered", aggregates by
serial (case-insensitive; empty serial -> single bucket); 14-day horizon is
the existing distress-report retention.
- Register route in EndpointAccessPolicyCatalog (fail-closed).
- 8 unit tests for BuildAggregatedResult.
Frontend
- api.distress.deviceNotRegistered().
- NotRegisteredDevicesInsights component (serial/mfr/model/attempts/first/last
seen, green empty-state, unverified-data notice).
- Wired below AutopilotValidationSection.
DeviceNotRegistered (403) covers both Autopilot and Corporate Identifier
validation; the agent cannot distinguish which failed, so copy is neutral.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
UI — Office in Install Progress:
- The office_install_* lifecycle now renders in the existing InstallProgress
card as a first-class install row (live elapsed timer while installing +
final duration). page.tsx feeds office_install_started/completed/failed into
the component; InstallProgress maps them onto the same Installing/Installed/
Failed flow via isStarted/isCompleted/isFailed (Office has no postponed/
skipped; appName "Microsoft 365 Apps").
Fix — completed-before-started (field session a7525e97, Office CSP / Win32
wrapper):
- When Office is already on disk as the C2R worker runs (CSP install / a Win32
package that re-runs C2R), the host's OfficeBinaryWatcher initial scan
completes the lifecycle synchronously. BeginIfIdle armed that watcher (via
ObserveInstallationPath) BEFORE emitting started, so completed was emitted
first. Reorder: emit started, then observe the path — guaranteed started ->
completed ordering. Start is still gated by real evidence (process / registry
Scenario\INSTALL / DO job); only the emit order changed.
- InstallProgress defensive guard: a late started on an already-Installed item
backfills startedAt and recomputes durationMs instead of dropping it, so
out-of-order / network-reordered delivery still yields a correct duration.
V2-only + web. ConfigVersion unchanged. V2.Core 1264/1264, web 452/452, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The progressive paged event fetch replaced the list with the first 200
rows on every SignalR/30s refresh, then re-grew it page by page. On
sessions with >200 events this shrink-then-grow changed the page height
repeatedly, making the scrollbar/viewport jump while reading and dropping
open "Details" panels.
Make the event list append-only and monotonic via mergeNewEvents: only
genuinely new events (keyed identically to the React row key) are appended
at the bottom; existing rows are never replaced. When a refresh brings
nothing new the same array reference is returned, so React skips the
re-render and scroll position + open details stay put. Native scroll
anchoring then keeps the viewport pinned as new events append below.
Session telemetry is append-only/immutable, so keeping existing rows is
safe. The transient-empty-refresh guard is now subsumed by the merge; the
dead sessionIdRef param (only used by the removed warning) is dropped.
Adds mergeNewEvents unit tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ANALYZE-SEC-002 fired Warning (confidence 90) on every normal user-driven
Entra-join enrollment. During ESP, Windows writes AutoAdminLogon=1 +
DefaultUserName + AutoLogonSID + no DefaultPassword for the enrolling user —
the exact fingerprint the sysinternals_autologon_suspected heuristic targeted
(enabled && user && !password). AutoLogon-enabled is registry-indistinguishable
from a Sysinternals/kiosk setup during Autopilot, so the heuristic and SEC-002
could never tell a benign enrollment from a real one.
Declare AutoLogon only when a plaintext password is actually present:
- delete ANALYZE-SEC-002 (broad AutoLogon-active warning)
- remove the non-discriminating sysinternals heuristic from AutoLogonAnalyzer
(computation + finding label + checks.sysinternals_autologon_suspected)
- strip the sysinternals condition+factor from SEC-004 (kiosk allow-list, still
a disabled opt-in template) and refresh its now-stale SEC-002 guidance
- ANALYZE-SEC-003 (plaintext DefaultPassword -> high) is now the sole default-on
AutoLogon grade; the autologon_analysis event still carries all raw facts at Info
Tests: assert SEC-002 absent + no rule references the sysinternals signal; add
analyzer regression guard for enabled+user+no-password. V2-only, no ConfigVersion
bump. Build 0 err, AutoLogon 19/19, BuiltInRulesTests 8/8, web tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces the real Microsoft 365 Apps Click-to-Run install lifecycle that the
Intune "integrated" app hides (IME reports done in 1-2 min while C2R keeps
streaming in the background). V2-only.
Event-driven, no idle polling:
- OfficeProcessWatcher: WMI Win32_ProcessStartTrace push on OfficeC2RClient.exe
(+ startup probe) for start; Process.Exited for stop, debounced by a settle
window (OfficeInstallSettleSeconds, default 10s) so short worker gaps in one
install don't trigger a premature office_install_failed.
- RegistryChangeWatcher: RegNotifyChangeKeyValue push on ...\Office\ClickToRun
(Configuration value + Scenario subkey churn); bootstrap fallback watches the
nearest existing ancestor non-recursive + name-only until ClickToRun appears.
- OfficeInstallDetector: pure state machine (Idle->Active->Terminal), emits
office_install_started/progress/completed/failed (progress on real change only,
no heartbeat). Error code only from non-zero numeric/hex Scenario values.
Office Delivery Optimization:
- DeliveryOptimizationCollector extended with a 2nd wake source (Office active),
classifies non-IME Office-CDN jobs and folds aggregated DO bytes/peers/real
download-% into the office_install_* events (not emitted as download_progress
to avoid a phantom app in the backend AppInstallSummary).
Plumbing: office_install_* event-type constants, EnableOfficeInstallDetector +
OfficeInstallSettleSeconds config (ConfigVersion 28->29), MCP + web event-type
catalogs, ANALYZE-OFFICE-001 (office_install_failed -> Warning). 22 detector unit
tests; full V2 suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the client-side aggregation (drain up to 200k raw sessions over up
to 200 HTTP round-trips, then 7 useMemo passes on the main thread) with a
single call to the new /api/metrics/fleet-health (+ global variant).
- New useFleetHealth hook mirrors useDashboardStats: initial fetch on
scope/range change + 3s-debounced refetch on in-scope SignalR
newSession/newevents + reconnect recovery + stale-race generation guard.
- page.tsx drops the sessions state, the eager-pagination loop
(MAX_EAGER_PAGES/FLEET_PAGE_SIZE) and all aggregation useMemos; it now
renders the server payload. Presentation-only derivations (bar maxima,
axis labels) stay client-side. App-metrics (slowest/failing apps) is
unchanged. Displayed numbers are unchanged (semantics preserved).
- api.ts: metrics.fleetHealth / metrics.globalFleetHealth.
Net effect is runtime/network, not bundle: one small JSON response instead
of a multi-megabyte session drain + main-thread aggregation on large
tenants. Verified: tsc clean, next build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a third row to the session detail summary card:
- Reboots: session.rebootCount (V2 only; 0 for V1 / pre-feature sessions)
- Enrollment Type: "Device Preparation" (v2) / "Autopilot" (v1), with a
"(PreProvisioned)" qualifier for White Glove; "Gather Rules" for diagnostic runs
- Join Type: "Hybrid Join" / "Entra Join" from the session's isHybridJoin flag
Status moves to the third row so the grid stays a clean 4-column flow. These
complement (do not duplicate) the DeviceDetailsCard System/Autopilot-Profile
sections. Adds rebootCount to the Session web type.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-up to the diagnosis waterfall fix. With eager tenant
hydration the session and analysis fetches now run in parallel, but
fetchAnalysisResults alone cleared `loading`. A fast analysis response (or
a slow/failed session fetch) could clear loading while `session` was still
null, briefly flashing a wrong IN PROGRESS / empty state.
Track sessionFetchDone + analysisFetchDone and only end loading once both
have settled (success or failure). Both flags are reset on session
navigation. As a bonus this also clears the spinner on a failed session
fetch in the non-GA path instead of waiting indefinitely.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The diagnosis page fetched the ENTIRE session list (api.sessions.list /
globalSessions.list) just to .find() one session client-side, then gated
the events + analysis fetches on the resolved tenant — a 2-stage waterfall
on the critical render path, plus a large over-fetch.
Switch to the direct single-session endpoint (api.sessions.get); the
backend resolves the tenant via FindSessionTenantIdAsync for global admins
when tenantId is omitted. Eager-hydrate sessionTenantId from TenantContext
so fetchEvents/fetchAnalysisResults fire in parallel with the session
fetch instead of after it. Mirrors the proven pattern in
app/sessions/[sessionId]/hooks/useSessionDetail.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Defer the largest client-only libraries so they no longer ship in the
initial route chunk, and give navigations an instant Suspense fallback.
Code-splitting (measured via `ANALYZE=1 npm run build`, First Load JS):
- /sla: 351 kB -> 249 kB (recharts now dynamic, ssr:false)
- /sessions/[sessionId]/inspector: 269 kB -> 184 kB (@xyflow/react +
dagre lazy-loaded; only the "graph" tab needs them)
- fflate moved to on-demand `await import()` at the zip call sites
(ReportSessionModal, SectionSubmitLogs): -5 kB on /sessions/[sessionId]
and /settings/tenant/[section]
next.config: experimental.optimizePackageImports for recharts,
@xyflow/react, @microsoft/signalr, @azure/msal-react (better tree-shaking).
Perceived performance: add a shared PageSkeleton and route-level
loading.tsx for dashboard, fleet-health, sessions/[sessionId],
diagnosis/[sessionId] and apps/[appName] so App Router streams a skeleton
during navigation instead of a dead frame. Purely additive, no change to
existing client logic.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire @next/bundle-analyzer into next.config.ts behind an ANALYZE=1 env
gate so `ANALYZE=1 npm run build` emits client/server/edge treemaps into
.next/analyze/. Measurement infrastructure only -- no runtime behaviour
change. Establishes a baseline before bundle-splitting optimizations.
Baseline First Load JS (next build): shared 103 kB; heaviest routes
/sla 351 kB, /sessions/[sessionId] 316 kB, /dashboard 275 kB,
/sessions/[sessionId]/inspector 269 kB.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The apps/list endpoints returned the full per-app list in one shot, so there was
no way to page a large fleet's apps (e.g. for a future MCP consumer).
Backend (apps/list + global/apps/list):
- AppsAnalyticsHelper.BuildAppsListResponse gains opt-in pagination: no pageSize
keeps the legacy full-array body (the web UI pages client-side); pageSize
returns an offset-paginated envelope with count/offset/pageSize/nextLink.
- The list is aggregated in-memory from a deterministic sort, so a plain integer
offset is a stable cursor — no HMAC continuation token needed (unlike the
Azure-Table-backed config/all surface). Added an appName tiebreaker so the sort
is fully deterministic across pages.
- New ParseAppsPaging validates pageSize (1..1000) / skip; both functions 400 on
bad input and build a route-specific nextLink (global echoes tenantId).
Web (Installs tab): client-side pagination (pageSize 20) over the filtered/sorted
apps, matching the Inventory tab — sort/search stay across the full set.
Tests: AppsAnalyticsHelperPagingTests (legacy vs paginated, deterministic order,
last-page nextLink null). Backend 2206/2206, web 446, tsc clean, next build OK.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- DO rollup no longer drops legacy telemetry. MetricsMath.BuildAppMetricsPayload
now delegates to DoAggregator.Compute (the existing single source), which falls
back to peers + http when DoTotalBytesDownloaded is 0 and filters to rows that
actually carry DO telemetry (DoDownloadMode >= 0). Removes the duplicate
summation that used DoTotalBytesDownloaded directly as the denominator.
- InstallsTab DO card now renders whenever any DO source bytes exist (effective
total = max(total, peers+MCC+http)) instead of hiding on totalBytesDownloaded <= 0.
- get_software_inventory: guard the GA inventory path — a Global Admin call with
scope="inventory" and no tenantId now returns actionable guidance instead of a
raw backend 400.
Tests: new MetricsMath fallback test; DO test rows set DoDownloadMode so they pass
the telemetry filter. Backend 2203/2203, MCP 220, web 446, all tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/apps becomes a Software hub with three lens tabs synced to ?tab=
(installs | inventory | vulnerabilities) — client-side tabs to avoid a
route collision with the existing /apps/[appName] detail page; drill-down
preserved. Nav label "App Health" -> "Software".
- Installs tab: existing app-health list + a Delivery Optimization card that
renders the deliveryOptimization rollup from /api/metrics/app (peer/MCC
offload %, bytes saved, Peers/MCC/CDN bar).
- Inventory tab: tenant's installed-software inventory (GA picks a tenant via
the scope selector; the inventory endpoint is per-tenant).
- Vulnerabilities tab: tenant-facing CVE/KEV exposure (first time customers
can see their own exposure; was GA-only).
The three lenses key differently (IME app name / Registry identity / CPE-CVE)
so they are merged at the navigation level, not row-joined.
Shared groundwork (de-dupes existing copies):
- lib/formatting.ts (canonical formatBytes/Duration/Throughput/Percent),
lib/vulnerabilitySeverity.ts (severity chips + KEV).
- components/DoBreakdownBar.tsx — DownloadProgress refactored onto it
(behaviour-preserving via the total override).
- components/VulnerabilityExposurePanel.tsx — the GA VulnerabilityDataSection
refactored onto it (removes the inline exposure JSX + local severity map).
- api.ts: metrics.vulnerability, metrics.softwareInventory,
vulnerability.softwareInventory.
Verified: tsc clean, 446 web tests, next build OK.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks PPKG detection to one aggregate event the rules parse as an array,
adds a default + customizable allow-list, and makes the event storage-safe.
Rule engine:
- New condition source event_data_array + ItemField: iterates an array field
(any-element match), with dot-path dataField support. (RuleEngine.ConditionEvaluators)
Agent:
- Drop per-package provisioning_package_detected events (noise: a clean Win11 VM
ships ~22 OS-inbox .ppkg). One provisioning_package_scan carries an artifacts[]
array; anyPpkgFound derived from it (consistent, incl. recovery residue).
- Size-based chunking: split artifacts across multiple scan events so each event's
Newtonsoft DataJson stays < 24k (well under the 30k Table-Storage truncation that
would blind the rule); never drops a tail artifact. Per-artifact string fields
(identity/dir/rank/installTime) hard-capped at 256; scanErrors capped.
Rules:
- ANALYZE-SEC-005 (default on) + SEC-006 (template) fire on artifacts.identity
not matching an anchored allow-list (^(?:...)\b) that covers OS-inbox families.
- ANALYZE-SEC-007 alarms when a per-source collection cap was hit (scanTruncated).
Web/MCP/docs: event_data_array in the rule form (types + fields), analyze-rules
docs section + example, event-type catalogs.
Tests: rule-engine array/anchor/dot-path/SEC-007; agent artifacts/chunking/field-cap
/worst-case-size. Build green; V2.Core 1236; backend rule + web tsc green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
V2-only AutoLogonAnalyzer that reads the Winlogon AutoLogon configuration and
emits an autologon_analysis event with raw facts at Info severity. AutoLogon can
be a legitimate kiosk/shared-device setup or a way to automate enrollment/OOBE;
the agent reports facts only and never reads the DefaultPassword value (presence
detected via value-name enumeration). Backend analyze-rules grade the facts.
- AutoLogonAnalyzer (always-on, no tenant toggle): startup no-op, runs at
DeviceSetup-phase completion (new AutoLogonDeviceSetupTrigger on
EspAndHelloHost.DeviceSetupProvisioningComplete) and at final shutdown.
- Sysinternals Autologon heuristic (AutoLogon enabled + default user + no
registry password = LSA-secret credential); never reads the LSA secret.
- Analyze rules: ANALYZE-SEC-002 (AutoLogon enabled -> warning, default-on),
ANALYZE-SEC-003 (plaintext DefaultPassword -> high), ANALYZE-SEC-004 (kiosk
allow-list template). Sysinternals graded via a real confidence condition
inside SEC-002/004 so the allow-list still suppresses approved kiosks.
- autologon_analysis added to all event-type catalogs; rule-builder picker also
gains the previously-missing integrity_bypass_analysis + local_admin_analysis.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
V2 PPKG scanner that runs once at DeviceSetup-phase start and reports raw facts
only — .ppkg file presence, Provisioning\Packages registry metadata,
Recovery\Customizations residue, and best-effort per-package content indicators.
PPKGs can come from legitimate bulk enrollment or be a pre/in-enrollment
manipulation vector; intent is judged by backend analyze-rules, not the agent.
- ProvisioningPackageCollector + ProvisioningPackageHost, wired as a kernel host
in DefaultComponentFactory (one-shot at DeviceSetup start, not at agent start).
- Events: provisioning_package_scan (aggregate) + provisioning_package_detected
(one scalar event per artifact so analyze rules can match per package).
- Analyze rules ANALYZE-SEC-005/006 incl. a per-tenant allow-list template.
- Event-type catalogs kept in sync (Constants.EventTypes, MCP resource-catalog,
rule-builder eventTypes).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The get_audit_logs MCP tool could only narrow by tenant + date window, even
though the audit entity already stores Action/PerformedBy/EntityType/EntityId.
Add exact-match server-side filtering across all three layers so a filtered
query never falls back to in-memory scanning.
- Shared: AuditLogQueryFilters carrier + extended IMaintenanceRepository
- Storage: shared AppendAuditFieldFilters (OData-escaped eq clauses) in both
the tenant and global fan-out filter builders; threaded through page +
legacy methods
- Functions: new shared AuditLogFilterRequest parser folds filters into the
pagination fingerprint AND echoes them on nextLink (cross-filter token
replay rejected, follow-up pages round-trip), identical for tenant + global
- MCP: four optional params on get_audit_logs + pass-through
- Tests: AuditLogFieldFilterTests (eq clauses, escaping, coexistence with
performer suppression + deletion exclusion, extras ordering)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "All (excl. deletions)" audit view filtered deletions client-side AFTER a
fixed 20-row backend page. During/after a cleanup sweep the table fills with
deletion_started/deletion_completed rows (written with the operator UPN, so the
existing System.Maintenance exclusion misses them); a 20-row page came back
all-deletions and the client stripped it to an empty list while real entries
sat on later pages.
Push the exclusion into the OData query (mirroring the System.Maintenance
clause) and back-fill the page: a server-side `Action ne ...` exclusion can make
a single Azure page return far fewer than pageSize matches, so follow the opaque
continuation until pageSize matches or the window is exhausted (accumulate whole
Azure pages to keep the token gap-free; cap at 20 round-trips). The flag is
bound into the continuation fingerprint scope (audit:tenant:nodel) so a token
cannot be replayed across views. Threaded through interface -> repo -> storage
with a defaulted param; global GA fan-out path covered too.
Frontend sends excludeDeletions=true when the filter is DEFAULT and refetches
page 1 on toggle.
Tests: 2172/2172 backend green + 4 new AuditLogDeletionExclusionTests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dedupe the copy-pasted Global-Admin scope apparatus across 8 pages (audit finding
#4). New shared primitives: useTenantList (the byte-identical tenant fetch+sort),
useGlobalAdminScope (override variant), useAggregatedAdminScope (aggregated 'All
tenants' variant with URL seed), plus GlobalAdminBanner + TenantScopeSelector.
Migrated: gather-rules, analyze-rules, sla, usage-metrics (override) and
fleet-health, geographic-performance, apps, apps/[appName] (aggregated, incl.
?global=1 deep-link). Behaviour preserved (endpoints, aggregated view, dynamic
banner) after reading every page in full to prove the variant taxonomy.
Review fixes: useAggregatedAdminScope re-defaults selection on GA-mode toggle via
render-time state adjustment (no committed render exposes a stale scope -> no
aggregated/own-tenant fetch race) and exposes a stable scopeKey the page fetch
effects depend on; TenantScopeSelector gains an opt-in 'themed' prop so the SLA
dark header keeps its dark-mode classes without affecting the light-only pages.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Intune health-script detections (scriptType=remediation, scriptPart=detection/
post-detection) that report complianceResult=True, exitCode=0 were emitted as
script_failed/Severity=Error whenever the detection PowerShell leaked a benign
probe line to stderr (e.g. Get-ChildItem path-not-found). On green sessions this
inflated errorCount (8 on a successful session) and ranked the rows #1 in
search_events, diluting usage.
Root cause: the hasStderr check short-circuited the failure decision before the
existing isHealthComplianceReport exemption. Exit-code was already exempt for
detection phases; stderr was not. The flawed rule was hand-duplicated in the
agent emit and the web reducer, and propagated into MCP errorCount + ranking.
Fix (verdict is authoritative for detection/post-detection phases):
- Agent ImeLogTrackerAdapter.EmitScriptCompleted: fold hasStderr under
!isHealthComplianceReport. Platform scripts, the remediation phase itself, and
explicit result="Failed" still route to script_failed/Error.
- Web reducer scriptExecutions.ts: same exemption (kept in sync). Non-compliant
detections render amber via isNonCompliantReport, not red.
- MCP read-side guard isBenignHealthDetectionReport() in tools/shared.ts, applied
in search.ts severityIntentFactor (damp 0.6, no failure boost) and sessions.ts
errorCount/keyEvents. Non-destructive; de-pollutes already-stored rows before
the agent build rolls out per-enrollment.
Tests: Agent 70 pass (retargeted enrichment test + new compliant-stderr
regression), MCP search 61 pass (helper + ranker damp), Web reducer 66 pass
(flipped detection-stderr case). MCP + Web tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The maintenance sweep graduated a stalled session to terminal Failed via the repo
(MaintenanceService Stage 2, SessionTimeoutHours=5h default) but wrote no event into
the session stream and never enqueued analyze. The analyze pipeline fires on stream
events, so a server-timed-out session analyzed to totalIssues:0 — unlike the agent
max-lifetime path, which emits a real enrollment_failed and is correctly analyzed.
- New server-authored event type Constants.EventTypes.SessionTimeout = "session_timeout"
(+ MCP termination_events catalog; drift test green).
- MaintenanceService Stage 2 now captures the UpdateSessionStatusAsync result and, only
on the real first transition (idempotent — the sweep never re-collects a Failed
session), emits a session_timeout event (new pure static BuildSessionTimeoutEvent:
Sequence = max(session events)+1 so it sorts last, Phase=Unknown, Source=System.
Maintenance, Severity=Error) and enqueues auto-analyze with ReasonEnrollmentFailed.
Best-effort try/catch — never blocks the timeout transition. Producer injected.
- RuleEngine: EvaluatePrecondition gains a pure event-type-presence gate (no-dataField
exists/not_exists) — additive (no rule used dataField-less preconditions), enables a
session-level suppression with no shared join field.
- New rule ANALYZE-ENRL-002 "Session Timed Out": fires on session_timeout, suppressed
when enrollment_complete OR enrollment_failed exists (5h<6h late-completion race +
no double-report with ANALYZE-ENRL-001). dist regenerated via combine.js.
- Web: session_timeout treated as terminal for stale-status refetch (the sweep sends
no SignalR delta).
Tests: +3 RuleEngine pure-type precondition, SessionTimeoutRuleTests (real shipped rule
fire/suppress), MaintenanceServiceSessionTimeoutEventTests (shape/sequence). Backend
2168 pass, web 434 pass, MCP 177 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The MCP server health probe previously sat inside PerformAllChecksAsync's
Task.WhenAll, so /health/detailed blocked on the MCP timeout before returning
any card, and a scaled-to-zero cold start was reported as an immediate warning.
Backend:
- Move the MCP probe out of the blocking all-checks batch onto its own
GET /api/health/mcp endpoint (AuthenticatedUser; visible to all, not GA-only).
- CheckMcpServerAsync now uses a wake-capable timeout (McpServerHealthTimeoutSeconds,
default 30s). Azure Container Apps holds the request during cold-start activation,
so the probe doubles as the wake signal: a successful wake -> healthy ("woke from
idle" when >2.5s), only a failed wake -> warning, a reachable-but-erroring server
(non-2xx) -> unhealthy.
- Register health/mcp in EndpointAccessPolicyCatalog (fail-closed).
Frontend:
- Fetch the MCP check on its own track, concurrently with and independent of the
fast checks. The card renders a blue "checking..." state immediately and updates
in place when the probe resolves; the other cards never wait on it.
- The MCP result folds into the overall banner/counts only once resolved, so a slow
cold start no longer flips the page to "Warnings detected".
Test: warning-path assertion updated for the new wording (10 HealthCheckService
tests green; 61 policy/endpoint tests green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two contract gaps in the raw-event MCP readers, surfaced by an MCP-server review:
1. No fields= projection. query_raw_events/get_session_events returned full
EnrollmentEvent rows including the heavy Data dict (a single app_install_failed
event can be tens of KB), so lean counting/aggregation was impossible. Add a
shared EventFieldProjection helper (mirrors query_raw_sessions fields=) wired
into both endpoints; Data is dropped unless explicitly requested, and
ErrorCodeEnricher is skipped when Data will be projected away.
2. tenantId contract inconsistency. query_raw_events hard-rejected a sessionId
query without tenantId, while get_session_events auto-resolves it for GA. Now
query_raw_events resolves tenantId via FindSessionTenantIdAsync on the global
scope only (tenant scope keeps its JWT-bound tenantId). The resolved tenantId
is NOT echoed into nextLink, keeping the filterTenantId=null continuation
fingerprint stable across pages.
Adds EventFieldProjectionTests; updates MCP tool schemas/descriptions and the
docs row. Backend 2127/2127, MCP 145 pass, web tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "hybrid semantic" event search selected candidate event types via vector
embeddings, but the per-event scorer (scoreEvent) was a lexical AND-gate that
returned null for any event with zero literal keyword overlap — silently
discarding the very events the semantic layer just fetched. So intent-only
queries ("machine restarted unexpectedly" -> system_reboot_detected) returned
0 results despite the right types being scanned.
- scoreEvent: add a cosine-scaled semantic floor for events whose type was
selected semantically, so they survive and rank even with no keyword match.
Pure-keyword behavior is unchanged when no semantic-score map is passed
(single-session / legacy paths).
- selectEventTypeCandidates returns { candidates, semanticTypeScores }; the
map is threaded through fetchSessionEvents into scoreEvent.
- Synonym-aware matching (reboot/restart, timeout/stall/stuck, hello/whfb,
download/transfer, install/setup) counted as one keyword (no normalization
dilution).
- Merge search_events_semantic + deep_search_events into one search_events
tool with depth=fast|deep (removed the byte-identical-twins problem).
- Honest descriptions + envelope: searchBackend="hybrid-keyword-semantic",
depth, semanticOnlyCount, per-result semanticOnly flag. Updated index.ts
instructions, prompts.ts, admin.ts, sessions.ts, and the web docs.
Tests: tools-search.test.ts 33/33 (semantic-floor regression, synonyms,
cosine scaling, lexical>semantic-only). Full MCP suite 129 passed/39 skipped.
Requires rebuild+redeploy of the MCP Container App to take effect.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Architecture rides the existing hardware_spec telemetry event (alongside
CPU/RAM/GPU), so backend storage needs zero changes - it lands in the generic
Props_hardware_spec JSON blob and flows through the dynamic prop.* filter.
- Agent V2: CollectCpuInfo() adds Architecture to the existing Win32_Processor
SELECT and emits cpuArchitecture via new MapProcessorArchitecture helper
(0->x86, 5->ARM, 6->ia64, 9->x64, 12->ARM64). 64-bit WMI host reports true
HW arch even under ARM64 emulation, unlike PROCESSOR_ARCHITECTURE.
- Backend: trailing-* prefix wildcard in MatchesDevicePropertyFilter so
cpuArchitecture=ARM* matches ARM + ARM64 in one query (generic, exact/numeric
behavior unchanged).
- Web: Architecture DetailRow in DeviceDetailsCard hardware section.
- MCP: resource-catalog entry + wildcard hint in search_sessions description.
- Tests: ProcessorArchitectureMappingTests (9), DevicePropertyFilterWildcardTests (15).
V2-only; old V1 agents and pre-existing sessions simply lack the key (no backfill).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a second, higher threshold to the runaway-session detector that
auto-blocks or auto-kills the device (admin-selectable mode), so the
existing 2000-event warn keeps observing while the harder action runs
unattended at e.g. 2500 events. Both tiers stay independently
idempotent per session and the maintenance batch picks them up in a
single query pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When an ExcessiveSessionEvents (or other session-bound) ops alert fires,
the operator only has a sessionId in hand. Looking up tenant + serial to
fill the manual Block form was a friction point during incident response.
- Backend: POST /api/devices/block now accepts optional blockedSessionId
and forwards it to the existing BlockedDeviceService session-aware path;
audit log + warn log carry the session id.
- Web (Device Block form): new "Session ID" shortcut input + Resolve
button fills Tenant + Serial via GET /api/sessions/{id} (GA cross-tenant
lookup already supported); resolved-device confirmation pill; URL
deep-linking via ?sessionId=, ?action=, ?reason= for auto-fill on mount;
blockedSessionId only forwarded when the resolved device still matches
Serial+Tenant so manual edits don't bind to a stale session.
- Web (Ops Events modal): when the event details JSON carries a sessionId,
surface "Block this device" / "Kill this device" deep-link buttons with
an auto-generated reason scoped to the event type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codex review (P2): ANALYZE-APP-013's `failed_app_name` + `detection_hresult`
optional conditions scanned all app_install_failed events independently of
the required condition. In a session with multiple app failures the
template could interpolate {{appName}} / {{errorCode}} from a different
event than the one that matched `failureType == esp_apps_detection_failure`.
Two coordinated changes:
1. `interpolateRuleTemplate` gains an "auto-field" resolution tier between
the explicit byField path and the bySignal fallback. The backend's
`RuleEngine.AddDataFieldsToEvidence` already injects a whitelist of
identifying fields (appId, appName, errorPatternId, errorCode, exitCode,
status) onto every matched-condition evidence dict — the interpolator
now reads those, pinning to the FIRST matched condition (= the required
condition) so a rule with a single required match resolves all four
tokens against the same source event.
2. ANALYZE-APP-013 is trimmed to a single required condition; the redundant
optional conditions for appName / errorCode are removed because the
auto-field tier already exposes them from the required match. Same
pattern applied to ANALYZE-ENRL-001's `failed_app` condition.
Coverage: 4 new vitest cases (multi-failure correct interpolation, first-
condition-wins shadowing, explicit-field precedence, internal-field leakage
guard). 413/413 web tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The InstallProgress component previously showed an orange "Likely stuck"
badge for any app where the agent promoted state Installing -> Error on
terminal ESP-Apps failure, with a tooltip claiming "ESP timed out while
this app was still installing". Session 080edee9 had that badge attached
to Microsoft Apps for Enterprise even though the actual session only ran
27 min (not 180), and the failure was a confirmed Intune detection-rule
mismatch (HRESULT 0x87D1041C), not a timeout.
Match the new agent failureType taxonomy from
`AppFailureTypes.ClassifyEspAppsFailure`:
- `esp_apps_detection_failure` -> red "Detection failed" badge
(tooltip: Intune detection rule did not match after install)
- `esp_apps_install_failure` -> red "Install failed" badge
(tooltip: installer returned an error before completing)
- `esp_apps_timeout` -> orange "Likely stuck" badge unchanged
(tooltip clarified to "no per-app HRESULT was available")
Also surface the ESP-level HRESULT (DataJson.errorCode) as a "ESP HRESULT:
0x87d1041c" pill with the description from the shared error-codes catalog,
distinct from the per-app hresultFromWin32 pill - so the user can see at
a glance which cross-app failure caused the ESP termination.
Coverage: 409/409 vitest, tsc clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds getEnrichedOrLookup() helper that returns the backend-provided
{description, confidence, source} sibling when present (set by the
backend ErrorCodeEnricher on event responses), otherwise looks up via
the local catalog. EventTimeline event badges now read the enriched
field first — older API responses without enrichment fall through to
the existing local-catalog path so there is no regression.
InstallProgress + apps/[appName] pages consume UI-model rows (not raw
event.Data) and are not touched — they continue to use the local
catalog which already covers their inputs. 5 new helper tests; 409/409
web tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifts the Windows/MSI/Intune HRESULT catalog out of the web app into
src/Shared/AutopilotMonitor.Shared/Resources/error-codes.json so backend
and web load from the same authoritative file. Web syncs the JSON at
prebuild time. Adds ErrorCodeCatalog C# service with normalised-key
lookup (hex case, signed-decimal HRESULT, leading-zero padding) and a
parity test on both sides. No behavior change yet — this is groundwork
for backend read-time enrichment so MCP/API responses can carry
errorCodeInfo (description + confidence + source). 11 C# + 14 web tests
green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Section headers (Vulnerability Report, Analysis Results, Integrity Bypass)
and the page action-bar packed icon + title + counter + severity badges +
action button + chevron into a single non-wrapping flex row. On phone-width
viewports that row was wider than the card, widening <main> past the viewport
and making <body> horizontally scrollable. iOS Safari then mis-sized the
fixed-position mobile drawer/overlay, so the drawer no longer covered the
full screen.
- globals.css: overflow-x: clip on <html> as a safety net (clip, not hidden,
so it does not create a containing block for the fixed drawer/overlay).
- The three section headers + page header now wrap (flex-wrap + gap), with
shrink-0 on icons/action groups and break-all on the mono finding string.
tsc clean; vitest 390/390 pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two findings from the post-commit Codex review of PR2 (single-row restore):
Medium — CriticalTableRestoreService.CommitRowAsync now maps the
MaintenanceLeaseHolder's renewal-loss path to BackupTerminalException
"MaintenanceLeaseLost" (HTTP 409 Conflict), instead of letting the
storage SDK's OperationCanceledException bubble up to the HTTP layer's
generic catch as a 500 InternalError. The catch condition gates on
!ct.IsCancellationRequested so a real client cancel still re-throws.
Low — RestoreInputRow (admin/backups/[backupId]/page.tsx) used to
disable the Preview button on empty pk/rk. The backend contract
(RestoreTablePreflightValidator) explicitly allows empty-string keys
(Azure Tables permits them) and only rejects null. UI now mirrors that.
Added a regression test pinning the empty-keys-allowed contract.
1980/1980 backend tests + 390/390 web tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/global/backups/{backupId}/restore-row (GA-only) with preview|commit
modes. Preview scans the ETag-pinned NDJSON blob for the requested (pk, rk),
diffs against the live row, and returns rowSha256 + currentETag for echo.
Commit re-validates everything under a short maintenance lease and writes via
UpdateEntity(IfMatch=etag, Replace) or AddEntity; 412/404/409 → CurrentRowChanged.
Backend:
- RestoreTablePreflightValidator (no I/O) + BackupRestoreInputValidator (manifest
parse, entry check, BlobName canonical, ETag-pin + SHA in one stream read)
- CriticalTableRestoreService with PreviewRowAsync (no lease) + CommitRowAsync
(MaintenanceLeaseHolder, 45 s renewal, deterministic finally release)
- BlobBackupStore.OpenNdjsonReadAsync — GetProperties + OpenRead(IfMatch=etag)
- BackupTerminalException carries a stable Code for HTTP envelope mapping
- OpsEventService.RecordBackupRowRestoredAsync (Warning; payload is
container + blobName only, never a SAS URL)
- 27 new xUnit tests; full backend suite 1979/1979 green
Web:
- /admin/backups list page with newest-first list + "Run backup now" trigger
and async job-status poller
- /admin/backups/{backupId} detail page with per-table breakdown + single-row
restore trigger
- RestoreRowDiffModal with Added/Removed/Changed/Unchanged pills and
auth-table security banner (GlobalAdmins/TenantAdmins/McpUsers)
- api.backups namespace + matching DTO types
- globalNavConfig: ga-backups under Ops
- BackupRowRestored registered in OPS_EVENT_TYPES (dual-register policy)
- 6 new vitest cases; full web suite 390/390 green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the full backup pipeline that snapshots 15 critical tables (Admin/Tenant
config, rules, identities, audit, preview) into a logically-isolated blob
container, daily at 04:00 UTC and on operator demand. Restore endpoints follow
in PR2/PR3.
Architecture:
* Daily TimerTrigger + manual POST /api/global/backups/trigger (GA-only,
fail-hard producer). HTTP returns 202 + jobId on enqueue, 500 if the queue
write fails (and the BackupJobs row is rolled to Failed).
* BackgroundService queue worker (not QueueTrigger — matches the Analyze/
IndexReconcile pattern that avoids the Functions-host connection-binding
app-setting). Worker holds the maintenance lease for the whole run; service
is intentionally lease-unaware so it stays unit-testable.
* BlobBackupStore wraps the new 'critical-table-backups' container with a
shared maintenance-lease sentinel at '_lock/maintenance.lock' that
serialises every backup or restore op across worker/timer/single-row paths.
* CriticalTableBackupService streams each table through CryptoStream(SHA256)
into per-table NDJSON blobs, then writes manifest.json LAST as the
durability anchor. Per-table failures degrade Outcome to Partial without
aborting; per-run budget (50 min) → Skipped status.
* BackupJobsRepository persists the six-state machine
(Queued/Running/Completed/Failed/Skipped/BlockedTerminal) with ETag-CAS
updates. Enum→string mapping rejects unknown values on read so refactors
surface loudly instead of silently shifting state.
* BackupJobWatchdog (30 min timer) heals stuck rows via a probe lease on the
shared sentinel: Queued > 60 min → Failed, Running with stale heartbeat
AND free lease → Failed (lease-busy means a live worker — leave alone).
* OpsEvents: distinct CriticalTableBackupPartial (Warning) vs Failed (Error)
so operators can wire softer Telegram rules for partial captures.
* MaintenanceService.MonitoredPoisonQueues + OPS_EVENT_TYPES catalog
registered (dual-register memory).
Test coverage (15 new tests, 1952/1952 backend xUnit green):
* BackupManifestJsonTests: enum-as-string (allowIntegerValues=false rejects
numeric values), DictionaryKeyPolicy unset (Azure column names preserved
case-faithfully), manifest field round-trip.
* BackupJobsRepositoryMappingTests: full entity↔domain round-trip, unknown
Kind/State/BackupOutcome rejected, legacy BackupOutcome="Failed" rejected.
* CriticalTableBackupServiceTests: happy/partial/empty/budget-exceeded paths
exercise the per-table loop with in-memory table + blob fakes.
Setup: scripts/Backup/setup-backup-container.ps1 — idempotent az-CLI bootstrap
for container + lifecycle rule (90 d delete on prefix). Soft-Delete and
Versioning are NOT applied automatically (storage-account-wide blast radius
— operator must validate per separate doc note).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Session 4fa5a2d4 in tenant c9787ba2 hit 199/202 false-positive enrollment_failed events: ESP
profile sets BlockInStatusPage=6 (ContinueAnyway=true), Windows transitioned to AccountSetup,
then wrote DeviceSetupCategory=failed (Certificates) 13 min later — the reducer terminated
even though the device demonstrably kept progressing. PR1 Tier-1 defang downgrades these
EspTerminalFailure signals to a non-terminal esp_failure_advisory event while keeping
monitoring armed (Hello/Desktop/IME completion paths continue to drive the session).
DecisionEngine:
- New SignalFact<DateTime>? EspAdvisoryFailureRecordedUtc on DecisionState as the fire-once
gate (additive ctor param, optional at end — preserves Newtonsoft positional-binding for
pre-PR1 v4 snapshots).
- HandleEspTerminalFailureV1: when ScenarioObservations.EspAllowContinueAnyway==true AND
AccountSetupEnteredUtc!=null, emit esp_failure_advisory (severity=Warning,
advisoryReason=esp_failure_defanged_continueanyway_with_accountsetup) instead of
transitioning to Failed. Stage / deadlines / outcome stay untouched.
- Duplicate handling: a second EspTerminalFailure dead-ends UNLESS the duplicate carries
registry-specific keys (errorCode/failedSubcategory/category) — those only appear in the
ProvisioningStatusTracker path and indicate enrichment of an earlier ShellCoreTracker
advisory. failureType alone is NOT a registry marker (both adapters emit it).
- New BuildEspFailureAuditTrail helper merges a whitelisted set of failure-context keys into
the audit-trail typedPayload so HRESULT/subcategory survive the wire. Closes a pre-existing
gap on the enrollment_failed path too.
Bootstrap:
- EnrollmentOrchestrator.PostEspConfigDetectedBootstrap switched from EspSkipConfigurationProbe.
Read() (tuple SkipUser/SkipDevice) to ReadFull() so EspAllowContinueAnyway +
EspSyncFailureTimeoutMinutes are synchronously available before any EspTerminalFailure
signal — defeats the fire-and-forget DeviceInfoCollector.CollectAll race that would have
left Tier 1 with ScenarioObservations.EspAllowContinueAnyway==null.
Tests:
- EspFailureAdvisoryDefangTests (9 new): advisory path, fire-once idempotency, enrichment
on registry-detail second-fire, dead-end on ShellCore-only duplicate, three negative paths
(ContinueAnyway false / unknown / no AccountSetup), TypedPayload whitelist merge on both
advisory + Failed paths.
- SerializationTests: backcompat test for old v4 snapshots without EspAdvisoryFailureRecordedUtc.
- DecisionStateSnapshotBuilderTests: fact count bumped from 14 to 15.
- EdgeScenarioTests + esp-failure-advisory-continueanyway-v1.jsonl: end-to-end replay against
the canonical Session 4fa5a2d4 signal stream including a duplicate dead-end.
- EnrollmentOrchestratorEspConfigBootstrapTests: 4 existing tests migrated to ScopedFullOverride,
4 new tests cover ContinueAnyway + SyncFailureTimeout propagation, class gated behind
[Collection("SerialThreading")] since the override targets a static probe slot.
UI:
- EventTimeline renders esp_failure_advisory with an amber "Advisory (ContinueAnyway)" badge
alongside the HRESULT/subcategory chips so analysts can distinguish defanged from real
failures at a glance.
- gather-rules/eventTypes.ts mirrors the new constant for autocomplete.
Build: dotnet build AutopilotMonitor.sln green; xUnit Backend(1937) + V2.Core(1139) +
Agent.Core(538) + DecisionCore(357) + SummaryDialog(44) = 4015 passed, 0 failed.
Web tsc clean, vitest 384/384 passed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dependabot alert #45: @nevware21/ts-utils <=0.13.0 is vulnerable to
prototype pollution via objDeepCopy/objCopyProps (for...in without
hasOwnProperty). Pulled in transitively by 10 @microsoft/applicationinsights-*
packages.
Parent constraint is >= 0.12.6 < 2.x, so an npm override to ^0.14.0 lifts
all transitives to the patched release without bumping the parent.
npm audit reports 0 vulnerabilities; tsc + 384 vitest tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- rules/guardrails.json: add new "RealmJoin" category covering
SYSTEM\CurrentControlSet\Services\realmjoin and SOFTWARE\RealmJoin. Hive-agnostic
match covers HKLM\SYSTEM\..., HKLM\SOFTWARE\RealmJoin, and HKU\<sid>\SOFTWARE\RealmJoin
so operator-authored Gather-rules can probe the RJ keys (the RealmJoinWatcher
itself bypasses GatherRuleGuards and watches the registry directly).
- src/Web/autopilot-monitor-web/lib/guardrails.generated.ts: regenerated via
node rules/scripts/combine.js so the Web admin UI surface matches the new
registry allowlist.
- buildcounter.txt (V2 + V1): auto-incremented by the Phase A-D builds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>