mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbf6e50e2d | |||
| 062962ae41 | |||
| be08660976 | |||
| b032005f5b | |||
| 0ab568f479 | |||
| fced858a7a | |||
| ee947e2ca9 | |||
| 5f08a6ec68 | |||
| a97888ebd5 | |||
| a654c7309e |
+25
-67
@@ -1,79 +1,37 @@
|
||||
## Workflow Orchestration
|
||||
# Agent Directives: Mechanical Overrides
|
||||
# Agent Directives
|
||||
|
||||
You are operating within a constrained context window and strict system prompts. To produce production-grade code, you MUST adhere to these overrides:
|
||||
- **Plan first.** Enter plan mode for any non-trivial task (3+ steps or an architectural decision) and write the spec before building. If something goes sideways, stop and re-plan instead of pushing on.
|
||||
- **Use subagents liberally** for research, exploration, and parallel analysis — one focus each — to keep the main context clean.
|
||||
- **Verify before claiming done.** Run the tests, check the logs, show the evidence. Diff against `main` where behaviour could regress.
|
||||
- **Fix bugs autonomously.** Given a report, a failing test, or an error, resolve it — no hand-holding.
|
||||
- **Capture corrections.** After any correction from the user, record the pattern and a preventing rule in `tasks/lessons.md`; review it at session start.
|
||||
- Read files over 500 LOC in chunks. Never treat one grep as proof of absence.
|
||||
|
||||
Codex reviews your output once you are done.
|
||||
|
||||
## Code Quality
|
||||
|
||||
### 1. Plan Mode Default
|
||||
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
|
||||
- If something goes sideways, STOP and re-plan immediately — don't keep pushing
|
||||
- Use plan mode for specification steps, not just building
|
||||
- Write detailed specs upfront to reduce ambiguity
|
||||
|
||||
### 2. Subagent Strategy
|
||||
- Use subagents liberally to keep main context window clean
|
||||
- Offload research, exploration, and parallel analysis to subagents
|
||||
- For complex problems, throw more compute at it via subagents
|
||||
- One tack per subagent for focused execution
|
||||
|
||||
### 3. Self-Improvement Loop
|
||||
- After ANY correction from the user: update `tasks/lessons.md` with the pattern
|
||||
- Write rules for yourself that prevent the same mistake
|
||||
- Ruthlessly iterate on these lessons until mistake rate drops
|
||||
- Review lessons at session start for relevant project
|
||||
|
||||
### 4. Verification Before Done
|
||||
- Never mark a task complete without proving it works
|
||||
- Diff behavior between main and your changes when relevant
|
||||
- Ask yourself: "Would a staff engineer approve this?"
|
||||
- Run tests, check logs, demonstrate correctness
|
||||
|
||||
### 5. Demand Elegance (Balanced)
|
||||
- For non-trivial changes: pause and ask "is there a more elegant way?"
|
||||
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
|
||||
- Skip this for simple, obvious fixes — don't over-engineer
|
||||
- Challenge your own work before presenting it
|
||||
|
||||
### 6. Autonomous Bug Fixing
|
||||
- When given a bug report: just fix it. Don't ask for hand-holding
|
||||
- Point at logs, errors, failing tests — then resolve them
|
||||
- Zero context switching required from the user
|
||||
- Go fix failing CI tests without being told how
|
||||
|
||||
|
||||
|
||||
### Tools Execution
|
||||
- Read any file over 500 LOC in chunks using offset and limit parameters.
|
||||
- Do not assume a single grep caught everything.
|
||||
|
||||
### Code Quality
|
||||
- If architecture is flawed, state is duplicated, or patterns are inconsistent - propose and implement structural fixes. Ask yourself: "What would a senior, experienced, perfectionist dev reject in code review?" Fix all of it.
|
||||
|
||||
|
||||
Codex will review your output once you are done!
|
||||
|
||||
- Simplicity first: minimal, targeted changes. No temporary fixes — find the root cause.
|
||||
- Where architecture is flawed, state is duplicated, or patterns are inconsistent, propose and implement the structural fix. Ask what a perfectionist reviewer would reject, and fix that too.
|
||||
- On non-trivial changes, ask whether there is a more elegant way before presenting. Skip it for simple fixes — don't over-engineer.
|
||||
|
||||
## Task Management
|
||||
|
||||
1. **Plan First**: Write plan to `tasks/todo.md` with checkable items
|
||||
2. **Verify Plan**: Check in before starting implementation
|
||||
3. **Track Progress**: Mark items complete as you go
|
||||
4. **Explain Changes**: High-level summary at each step
|
||||
5. **Document Results**: Add review section to `tasks/todo.md`
|
||||
6. **Capture Lessons**: Update `tasks/lessons.md` after corrections
|
||||
Keep the plan in `tasks/todo.md` as checkable items, confirm it before implementing, tick items off as you go, and add a review section when done.
|
||||
|
||||
## Core Principles
|
||||
## Customer-Facing Claims
|
||||
|
||||
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
|
||||
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
|
||||
- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
|
||||
`src/Web/autopilot-monitor-web/app/{about,terms,privacy}/page.tsx` and `autopilotmonitor-docs/trust/*` state facts customers and security reviewers rely on. They drift silently.
|
||||
|
||||
## Technical Docs — OKF Knowledge Bundle (`docs/`)
|
||||
- Verify every claim against the code before writing it — never carry one forward because it was already on the page.
|
||||
- Update them in the same change as: runtime/framework versions, roles, notification providers, agent deployment or lifecycle, diagnostics payload, isolation/delegation model, retention caps, sub-processors, or any default governing what is collected.
|
||||
- Describe only what customers can actually use; operator-only infrastructure is not a feature.
|
||||
- Trust pages: durable phrasing over exact figures, and bump their "Last reviewed" date.
|
||||
|
||||
`docs/` is an [Open Knowledge Format (OKF) v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) knowledge bundle for contributor/AI-facing technical documentation (customer docs live in the separate autopilotmonitor-docs repo).
|
||||
## Technical Docs — OKF Bundle (`docs/`)
|
||||
|
||||
- **Consume first**: start at `docs/index.md` for progressive disclosure before opening individual documents.
|
||||
- **When new durable technical knowledge emerges** (architecture decisions, flows, non-obvious mechanisms), capture it as an OKF concept document in `docs/` — markdown with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp` (recommended).
|
||||
- **Maintain the bundle**: add every new document to `docs/index.md`, note changes in `docs/log.md` (grouped by ISO date). Use standard RELATIVE markdown links between docs — never the OKF `/`-prefixed bundle-absolute form (GitHub resolves those from the repo root and navigation breaks). `index.md` and `log.md` are reserved filenames.
|
||||
- Documents are English, structural markdown over prose (`# Schema`, `# Examples`, `# Citations` sections where applicable).
|
||||
Contributor/AI-facing docs (customer docs live in the `autopilotmonitor-docs` repo). Start at `docs/index.md` before opening individual files.
|
||||
|
||||
- New durable knowledge (architecture decisions, flows, non-obvious mechanisms) → an OKF concept doc with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp`.
|
||||
- Register it in `docs/index.md`, note the change in `docs/log.md` (ISO date). `index.md` and `log.md` are reserved names.
|
||||
- English, structural markdown (`# Schema`, `# Examples`, `# Citations`). Links between docs are RELATIVE — never `/`-prefixed, that breaks GitHub navigation.
|
||||
|
||||
@@ -34,7 +34,7 @@ For detailed information about the system architecture, components, and data flo
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available at **[autopilotmonitor.com/docs](https://www.autopilotmonitor.com/docs)**
|
||||
Full admin documentation is available at **[docs.autopilotmonitor.com](https://docs.autopilotmonitor.com)**
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -7,5 +7,6 @@ How the on-device agent works:
|
||||
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
|
||||
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
|
||||
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
|
||||
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
|
||||
|
||||
Reading order for newcomers: overview → decision engine → persistence.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
type: Concept
|
||||
title: MDM Reboot Coalescing — attributing the mid-ESP reboot and second sign-in to policy URIs
|
||||
description: How the agent observes reboot-required MDM policy flags (DM-Enterprise EventID 2800, aggregated) and how ANALYZE-ESP-005 attributes an actually-observed enrollment reboot to them — including why the raw events stay neutral and why the Shell-Core RebootCoalescing match was removed.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals
|
||||
tags:
|
||||
- agent
|
||||
- esp
|
||||
- reboot
|
||||
- policy-attribution
|
||||
- second-sign-in
|
||||
timestamp: 2026-07-20T00:00:00+02:00
|
||||
---
|
||||
|
||||
# MDM Reboot Coalescing
|
||||
|
||||
Device-assigned MDM policies whose CSP URIs match the OS reboot-required catalog
|
||||
(`HKLM\SOFTWARE\Microsoft\Provisioning\SyncML\RebootRequiredURIs`) force a **coalesced
|
||||
reboot at the end of ESP DeviceSetup** — the user sees an unexpected restart and a
|
||||
**second sign-in screen** before AccountSetup. Invisible in the Intune console; most
|
||||
admins live with it, not knowing that reassigning the profiles to user groups removes the
|
||||
reboot entirely. Source research: Patch My PC,
|
||||
["Avoid the second sign-in screen after Autopilot"](https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/).
|
||||
|
||||
The session already counts reboots (`system_reboot_detected` → `session.rebootCount`);
|
||||
this feature adds the **cause attribution**.
|
||||
|
||||
# Verified signal facts (session b2e890c1, 2026-07-20)
|
||||
|
||||
* **EventID 2800**, `Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin`,
|
||||
one record per URI, verified description shape:
|
||||
`The following URI has triggered a reboot: (./Device/Vendor/MSFT/Policy/Config/ServiceControlManager/SvchostProcessMitigation).`
|
||||
Records arrive in sub-second bursts during policy sync.
|
||||
* **Timing semantics (the critical nuance)**: 2800 fires at EVERY policy sync. Only URIs
|
||||
applied **during ESP DeviceSetup** are coalesced into the forced mid-ESP restart. In the
|
||||
verified session the flags fired during AccountSetup (after DeviceSetup completed) —
|
||||
**no reboot occurred**, and `omadmRebootRequiredFlag` read `false`. A detector that
|
||||
claims "reboot incoming" on every 2800 is therefore wrong.
|
||||
* **Shell-Core 62407 "RebootCoalescing" — REMOVED as a signal.** The token appears in the
|
||||
ROUTINE bootstrap marker on every enrollment:
|
||||
`Name: 'CommercialOOBE_BootstrapStatusCategory_SubcategoryProcessing_Started', Value: '{"message":"BootstrapStatus: Starting subcategory DeviceSetup.RebootCoalescing...","errorCode":0}'`
|
||||
— the ESP merely *processes* the RebootCoalescing subcategory (which checks whether a
|
||||
reboot is needed). A substring match on it is a false positive by construction. If a
|
||||
real coalesced-reboot record is ever captured on the E2E VM, a precise pattern can be
|
||||
added then.
|
||||
|
||||
# Detector — `MdmRebootPolicyTracker` (`MdmRebootPolicyWatcherHost`)
|
||||
|
||||
Live `EventLogWatcher` on the DM-Enterprise Admin channel (EventID 2800) + startup
|
||||
backfill (default 60 min lookback). Records are **buffered and debounced (10 s)**, then
|
||||
flushed as **ONE aggregated `mdm_policy_reboot_required` event** per burst:
|
||||
|
||||
* `Severity=Info`, neutral message ("Windows flagged N MDM policy URIs as
|
||||
reboot-required: … (+N more)") — the raw event never claims a reboot.
|
||||
* `Data`: `rebootUris` (distinct, sorted, capped 20), `uriCount` (uncapped distinct),
|
||||
`firstRebootUri` (record-time order, drives rule interpolation), `recordCount`,
|
||||
`unparsedCount`/`sampleDescription` (extraction diagnostics), `backfilled` (all-backfill),
|
||||
fail-soft `omadmRebootRequiredFlag` probe of
|
||||
`HKLM\SOFTWARE\Microsoft\Provisioning\OMADM\SyncML\RebootRequired` (null = unknown).
|
||||
* `Timestamp` = earliest record time (backfilled bursts land at their historical instant);
|
||||
`ImmediateUpload=true` (one event per burst is cheap, and a DeviceSetup burst precedes a
|
||||
reboot that kills the agent).
|
||||
* **Watermark is persisted at FLUSH, not at claim** (`mdm-reboot-watermark.json`): if the
|
||||
coalesced reboot kills the process before the debounced emit, nothing is marked — the
|
||||
post-restart backfill re-reads and emits the burst. A flushed burst is never repeated.
|
||||
|
||||
URI extraction: EventData values starting with `./` preferred, permissive regex over the
|
||||
description as fallback (trailing punctuation trimmed — the verified text wraps the URI in
|
||||
parens and ends with a period). Unparsed records still count into the aggregate.
|
||||
|
||||
Config (internal steering only, no admin UI — agent-native collector, NOT an
|
||||
`IAgentAnalyzer`): `Collectors.MdmRebootPolicyWatcherEnabled` (default true),
|
||||
`Collectors.MdmRebootPolicyBackfillLookbackMinutes` (default 60). ConfigVersion 35.
|
||||
|
||||
# Advisory — ANALYZE-ESP-005
|
||||
|
||||
The reboot **claim** lives here, and only here. Required conditions (BOTH):
|
||||
|
||||
1. `mdm_policy_reboot_required.firstRebootUri` exists (→ `{{firstRebootUri}}` interpolation)
|
||||
2. `system_reboot_detected` count ≥ 1 — **an actually-observed reboot**
|
||||
|
||||
Policy flags alone (e.g. AccountSetup user-policy sync) stay advisory-silent. Optional
|
||||
confidence: `uriCount ≥ 2` (+10), `omadmRebootRequiredFlag == true` (+15) on base 70 /
|
||||
threshold 70. Remediation: map URIs to Intune profiles, reassign device → user groups (or
|
||||
exclude the Autopilot device group), or accept + communicate the restart.
|
||||
|
||||
# Non-goals / invariants
|
||||
|
||||
* Event type **deliberately distinct from `system_reboot_detected`** — the backend
|
||||
RebootCount matches that string exactly in two places
|
||||
(`EventIngestProcessor.Classification` + terminal recount); attribution must never
|
||||
inflate the reboot count.
|
||||
* No session-level field / SessionInfoCard change in this iteration (follow-up candidate).
|
||||
* The `RebootRequiredURIs` catalog itself is not read; only the 2800 events (which carry
|
||||
the actually-triggered URIs) are consumed.
|
||||
|
||||
# Citations
|
||||
|
||||
* [Patch My PC — Avoid the second sign-in screen after Autopilot](https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/)
|
||||
* [Microsoft Learn — Enrollment Status Page tracking](https://learn.microsoft.com/en-us/autopilot/enrollment-status)
|
||||
+2
-1
@@ -18,10 +18,11 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
|
||||
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
|
||||
|
||||
# Rules
|
||||
|
||||
* [Gather Rule Phase Scoping & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch) and emitMode "on_change" result-dedup — the anti-spam pair for interval rules.
|
||||
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
|
||||
|
||||
# Backend
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## 2026-07-20
|
||||
|
||||
* **Update**: Extended `rules/gather-rule-phase-scoping.md` with the `phase_exit` trigger — the closing bookend to `phase_change`, for one-shot collection when a phase is LEFT (e.g. "state at the end of Account Setup"). Documents the evaluation-before-phase-advance rule (triggerPhase match and scope gate see the phase being left), firing into `Failed`, the disjoint `exit:` dedup key space, and why no ConfigVersion bump is needed (no new field — old agents simply never fire the rule).
|
||||
* **Update**: Reworked `agent/mdm-reboot-coalescing.md` after the first real enrollment (session b2e890c1): 2800 records are now **aggregated per burst** into ONE neutral Info event (`rebootUris[]`/`uriCount`/`firstRebootUri`; watermark persisted at flush so a pre-emit reboot kill loses nothing); the raw event no longer claims a reboot — ANALYZE-ESP-005 is gated on an actually-observed `system_reboot_detected`. **Removed** the Shell-Core `esp_reboot_coalescing` event entirely: the "RebootCoalescing" token appears in the routine `SubcategoryProcessing_Started` bootstrap marker on every enrollment (false positive by construction). Verified 2800 description shape recorded.
|
||||
* **Creation**: Added `agent/mdm-reboot-coalescing.md` — the agent now attributes the "unexpected mid-ESP reboot + second sign-in" pattern (PMPC research) to the device-assigned policy URIs that forced it: new `MdmRebootPolicyTracker` (DM-Enterprise EventID 2800, per-URI `mdm_policy_reboot_required` events, cross-restart RecordId watermark), a `RebootCoalescing` branch in `ShellCoreTracker` (`esp_reboot_coalescing`, backfill-emitting by design), and the `ANALYZE-ESP-005` advisory (reassign device → user groups). ConfigVersion 35; event types deliberately distinct from `system_reboot_detected` so RebootCount stays uninflated.
|
||||
|
||||
* **Creation**: Added `backend/business-timestamps.md` (new `backend/` section) — the 2026-07-18 migration reset every row's system Timestamp, exposing that AuditLogs/OpsEvents/Events had (unknowingly) displayed and filtered on it all along; documents the `OccurredUtc` column, the RowKey decoders and RowKey-range date/retention clauses in `BusinessTimestamp`, the GUID-guard trap, the audit/ops backfill endpoint, and the "system Timestamp is never authoritative" migration rule.
|
||||
* **Creation**: Added `rules/gather-rule-phase-scoping.md` (new `rules/` section) — gather rules gain `activePhases` (run only during listed phases), `activeFromPhase` (sticky latch from a phase onwards, never via Failed) and `emitMode: "on_change"` (canonical-hash result dedup with `suppressedPolls` observability), ConfigVersion 34. Includes the prerequisite backend fix: the custom-rule toggle PUT no longer wipes the stored rule definition (partial-merge in `GatherRuleService.UpdateRuleAsync`).
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Gather Rule Phase Scoping & Emit-on-Change
|
||||
description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase) and how emitMode "on_change" suppresses repeated identical results — the anti-spam pair for interval rules.
|
||||
title: Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change
|
||||
description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase), collect once at a phase boundary (phase_change / phase_exit), and how emitMode "on_change" suppresses repeated identical results.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs
|
||||
tags:
|
||||
- agent
|
||||
@@ -35,6 +35,35 @@ Backend validation (`GatherRulesFunction.ValidateScopeAndEmitMode`) rejects `Unk
|
||||
numeric tokens, unknown emit modes, and both scope fields set at once (400). The agent
|
||||
defensively prefers `activePhases` if both arrive anyway.
|
||||
|
||||
# One-shot collection at phase boundaries
|
||||
|
||||
Scoping answers "while *may* this rule run". The trigger answers "when does it fire". For
|
||||
one-shot collection the two phase triggers are the bookends of a phase:
|
||||
|
||||
| Trigger | Fires |
|
||||
|---|---|
|
||||
| `phase_change` | Once when `triggerPhase` is **entered** (dedup key `{ruleId}\|{phase}`). |
|
||||
| `phase_exit` | Once when `triggerPhase` is **left** (dedup key `exit:{ruleId}\|{phase}`). |
|
||||
|
||||
Empty `triggerPhase` means every transition. Both keys live in the same
|
||||
`_phaseRulesExecuted` set; the `exit:` prefix keeps the two key spaces disjoint, so an
|
||||
enter-rule and an exit-rule on the same phase never cannibalise each other's slot.
|
||||
|
||||
`phase_exit` semantics that are easy to get wrong:
|
||||
|
||||
* It is evaluated **before** `_currentPhase` advances, so both the `triggerPhase` match and
|
||||
the phase-scope gate see the phase being **left**. A rule scoped to `DeviceSetup` therefore
|
||||
still fires on its own exit, even though the phase being entered is outside that scope.
|
||||
* A transition into `Failed` **does** fire the exit rules of the failing phase — capturing
|
||||
state at the failure boundary is the point.
|
||||
* Exit of `Unknown` never fires (nothing was entered yet), and a repeated identical phase
|
||||
signal is not a transition.
|
||||
* An exit rule on `Complete` only fires if something transitions *away* from Complete. For
|
||||
"at the end of enrollment", use `phase_change` on `Complete` or `on_event` with
|
||||
`enrollment_complete`.
|
||||
* Old agents do not know the trigger value and simply never fire the rule (silent no-op) —
|
||||
which is why `phase_exit` needs no ConfigVersion bump: no new field is transported.
|
||||
|
||||
# Scope semantics (agent, `GatherRuleExecutor`)
|
||||
|
||||
* Scope applies to **all trigger types**:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ESP-005",
|
||||
"title": "MDM Policy Forced a Mid-ESP Reboot (Second Sign-In)",
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "reboot_forcing_policy_uri",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "firstRebootUri",
|
||||
"operator": "exists",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "reboot_observed",
|
||||
"source": "event_count",
|
||||
"eventType": "system_reboot_detected",
|
||||
"operator": "count_gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "multiple_reboot_uris",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "uriCount",
|
||||
"operator": "gte",
|
||||
"value": "2",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "omadm_reboot_flag_set",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "omadmRebootRequiredFlag",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "multiple_reboot_uris", "condition": "exists", "weight": 10 },
|
||||
{ "signal": "omadm_reboot_flag_set", "condition": "exists", "weight": 15 }
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "This session **rebooted during enrollment**, and Windows flagged one or more device-assigned MDM policies as **reboot-required** — the classic cause of the coalesced mid-ESP restart and the unexpected **second sign-in screen** before AccountSetup.\n\nFirst reboot-forcing policy URI: `{{firstRebootUri}}`\n\nThe complete URI list is in the `mdm_policy_reboot_required` timeline event (`rebootUris`). Policies applied DURING ESP DeviceSetup are coalesced into one forced restart at the end of DeviceSetup; the same flags raised later (e.g. user-policy sync in AccountSetup) have no immediate effect — this advisory only fires when a reboot was actually observed.\n\nThis is expected OS behavior, not a failure — but it degrades the enrollment experience, confuses users, and is invisible in the Intune console.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Reassign the reboot-forcing policies to user groups",
|
||||
"steps": [
|
||||
"Open the `mdm_policy_reboot_required` timeline event and note the CSP URIs in `rebootUris` (e.g. `./Device/Vendor/MSFT/...`)",
|
||||
"Map each URI to its Intune profile (Settings Catalog / configuration profile) — the URI path names the CSP area",
|
||||
"Reassign those profiles from device groups to user groups so they apply during/after AccountSetup instead of DeviceSetup",
|
||||
"Alternatively, exclude the Autopilot device group from the profile and deliver the setting post-enrollment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Or accept the reboot and set expectations",
|
||||
"steps": [
|
||||
"If the policy must be device-assigned (e.g. a security baseline), keep it and document the expected mid-ESP restart",
|
||||
"Communicate to users that a restart and second sign-in during provisioning is expected for these deployments"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Patch My PC: Avoid the second sign-in screen after Autopilot",
|
||||
"url": "https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page tracking",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": ["esp", "reboot", "coalescing", "second-sign-in", "policy-attribution", "user-experience"]
|
||||
}
|
||||
+99
@@ -1785,6 +1785,105 @@
|
||||
"enrollment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-ESP-005",
|
||||
"title": "MDM Policy Forced a Mid-ESP Reboot (Second Sign-In)",
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "reboot_forcing_policy_uri",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "firstRebootUri",
|
||||
"operator": "exists",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "reboot_observed",
|
||||
"source": "event_count",
|
||||
"eventType": "system_reboot_detected",
|
||||
"operator": "count_gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "multiple_reboot_uris",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "uriCount",
|
||||
"operator": "gte",
|
||||
"value": "2",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "omadm_reboot_flag_set",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "omadmRebootRequiredFlag",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "multiple_reboot_uris",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "omadm_reboot_flag_set",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "This session **rebooted during enrollment**, and Windows flagged one or more device-assigned MDM policies as **reboot-required** — the classic cause of the coalesced mid-ESP restart and the unexpected **second sign-in screen** before AccountSetup.\n\nFirst reboot-forcing policy URI: `{{firstRebootUri}}`\n\nThe complete URI list is in the `mdm_policy_reboot_required` timeline event (`rebootUris`). Policies applied DURING ESP DeviceSetup are coalesced into one forced restart at the end of DeviceSetup; the same flags raised later (e.g. user-policy sync in AccountSetup) have no immediate effect — this advisory only fires when a reboot was actually observed.\n\nThis is expected OS behavior, not a failure — but it degrades the enrollment experience, confuses users, and is invisible in the Intune console.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Reassign the reboot-forcing policies to user groups",
|
||||
"steps": [
|
||||
"Open the `mdm_policy_reboot_required` timeline event and note the CSP URIs in `rebootUris` (e.g. `./Device/Vendor/MSFT/...`)",
|
||||
"Map each URI to its Intune profile (Settings Catalog / configuration profile) — the URI path names the CSP area",
|
||||
"Reassign those profiles from device groups to user groups so they apply during/after AccountSetup instead of DeviceSetup",
|
||||
"Alternatively, exclude the Autopilot device group from the profile and deliver the setting post-enrollment"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Or accept the reboot and set expectations",
|
||||
"steps": [
|
||||
"If the policy must be device-assigned (e.g. a security baseline), keep it and document the expected mid-ESP restart",
|
||||
"Communicate to users that a restart and second sign-in during provisioning is expected for these deployments"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Patch My PC: Avoid the second sign-in screen after Autopilot",
|
||||
"url": "https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Enrollment Status Page tracking",
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/enrollment-status"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"tags": [
|
||||
"esp",
|
||||
"reboot",
|
||||
"coalescing",
|
||||
"second-sign-in",
|
||||
"policy-attribution",
|
||||
"user-experience"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-ID-001",
|
||||
"title": "Expected Machine Certificate Not Found",
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
},
|
||||
"trigger": {
|
||||
"type": "string",
|
||||
"description": "When to collect data.",
|
||||
"enum": ["startup", "phase_change", "interval", "on_event"]
|
||||
"description": "When to collect data. \"phase_change\" fires once when triggerPhase is entered, \"phase_exit\" once when it is left.",
|
||||
"enum": ["startup", "phase_change", "phase_exit", "interval", "on_event"]
|
||||
},
|
||||
"intervalSeconds": {
|
||||
"type": "integer",
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"triggerPhase": {
|
||||
"type": "string",
|
||||
"description": "Phase to trigger on (only used when trigger = \"phase_change\")."
|
||||
"description": "Phase to trigger on (used when trigger = \"phase_change\" or \"phase_exit\"). Empty = every phase transition."
|
||||
},
|
||||
"triggerEventType": {
|
||||
"type": "string",
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
@@ -80,7 +84,7 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
@@ -93,7 +97,7 @@ param(
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.1"
|
||||
$ScriptVersion = "2.2"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>phase_exit</c> trigger: a one-shot collection when a phase is LEFT — the closing
|
||||
/// bookend to <c>phase_change</c> (which fires on ENTER). The distinguishing invariant is that
|
||||
/// both the TriggerPhase match and the phase-scope gate must evaluate against the phase being
|
||||
/// left, i.e. before the executor's current-phase state moves forward.
|
||||
/// Rules use the absent-registry-path pattern so no real system state is touched.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorPhaseExitTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly object _eventsGate = new object();
|
||||
private readonly GatherRuleExecutor _executor;
|
||||
|
||||
private const string AbsentPath =
|
||||
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_9c4d2a7f-0000-0000-0000-000000000000";
|
||||
|
||||
public GatherRuleExecutorPhaseExitTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_executor = new GatherRuleExecutor(
|
||||
"sess", "tenant",
|
||||
evt => { lock (_eventsGate) _events.Add(evt); },
|
||||
logger)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private int EventCount { get { lock (_eventsGate) return _events.Count; } }
|
||||
|
||||
private bool WaitForEventCount(int count, int timeoutMs = 10000)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
if (EventCount >= count) return true;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
return EventCount >= count;
|
||||
}
|
||||
|
||||
private static GatherRule ExitRule(
|
||||
string id, string triggerPhase, List<string> activePhases = null, string fromPhase = null)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "phase_exit",
|
||||
TriggerPhase = triggerPhase,
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
ActivePhases = activePhases,
|
||||
ActiveFromPhase = fromPhase,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void FiresOnceWhenTheNamedPhaseIsLeft_NotWhenItIsEntered()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-001", "AccountSetup") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
// Entering AccountSetup must NOT fire an exit rule.
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
// Leaving it does.
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Assert.True(WaitForEventCount(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoesNotRefireWhenThePhaseIsLeftTwice()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-002", "AccountSetup") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Assert.True(WaitForEventCount(1));
|
||||
|
||||
// A phase can be re-entered (ESP re-reporting); the one-shot slot stays consumed.
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Thread.Sleep(400);
|
||||
Assert.Equal(1, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyTriggerPhase_FiresOnEveryExit()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-003", "") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); // exit of Unknown — must NOT fire
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup
|
||||
Assert.True(WaitForEventCount(1));
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup
|
||||
Assert.True(WaitForEventCount(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeverFiresOnExitOfUnknown()
|
||||
{
|
||||
// Before the first phase signal there is no phase to leave.
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-004", "") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.Start);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedSamePhaseSignal_IsNotAnExit()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-005", "") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // same phase — no transition
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiresOnTransitionIntoFailed_CapturingStateAtTheFailurePoint()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-006", "AccountSetup") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.Failed);
|
||||
|
||||
Assert.True(WaitForEventCount(1), "leaving a phase into Failed must still snapshot it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiresOnExitOfCompleteOnly_WhenCompleteIsActuallyLeft()
|
||||
{
|
||||
// "End of enrollment" is better served by phase_change on Complete / on_event; an exit
|
||||
// rule on Complete only fires if a later transition happens at all.
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-007", "Complete") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.FinalizingSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.Complete);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeGate_EvaluatesAgainstThePhaseBeingLeft()
|
||||
{
|
||||
// Scope = only during DeviceSetup. Leaving DeviceSetup must fire (the rule is still in
|
||||
// scope at that instant) even though the NEW phase is outside the scope.
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
ExitRule("GATHER-PX-010", "DeviceSetup", activePhases: new List<string> { "DeviceSetup" })
|
||||
});
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
|
||||
Assert.True(WaitForEventCount(1),
|
||||
"the scope gate must see the phase being left, not the one being entered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutOfScopeExit_DoesNotFire_AndDoesNotConsumeTheOneShotSlot()
|
||||
{
|
||||
// Scope = only during AccountSetup; the rule exits DeviceSetup while out of scope.
|
||||
var rule = ExitRule("GATHER-PX-011", "", activePhases: new List<string> { "AccountSetup" });
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup: out of scope
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup: in scope
|
||||
Assert.True(WaitForEventCount(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitAndChangeRulesForTheSamePhase_BothFireIndependently()
|
||||
{
|
||||
// The dedup key spaces must not collide: same ruleId shape, same phase, different trigger.
|
||||
var enter = new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-PX-020",
|
||||
Title = "enter",
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "phase_change",
|
||||
TriggerPhase = "AccountSetup",
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
};
|
||||
var exit = ExitRule("GATHER-PX-021", "AccountSetup");
|
||||
_executor.UpdateRules(new List<GatherRule> { enter, exit });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
Assert.True(WaitForEventCount(1)); // enter fired
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Assert.True(WaitForEventCount(2)); // exit fired
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitRuleWithEmitOnChange_StillEmitsItsFirstResult()
|
||||
{
|
||||
var rule = ExitRule("GATHER-PX-030", "AccountSetup");
|
||||
rule.EmitMode = "on_change";
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
|
||||
Assert.True(WaitForEventCount(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
+336
@@ -0,0 +1,336 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// MdmRebootPolicyTracker — aggregates DM-Enterprise EventID 2800 bursts into ONE neutral
|
||||
/// <c>mdm_policy_reboot_required</c> event (session b2e890c1 rework: per-record events were
|
||||
/// spam, and the raw event must not CLAIM a reboot — ANALYZE-ESP-005 does, gated on an
|
||||
/// observed system_reboot_detected). Drives the primitive ProcessEvent seam + explicit
|
||||
/// FlushPending (debounce 0 = manual-flush mode; no timers, no SerialThreading needed).
|
||||
/// The registry probe is always overridden — tests never touch the live registry.
|
||||
/// </summary>
|
||||
public sealed class MdmRebootPolicyTrackerTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 7, 20, 16, 20, 57, DateTimeKind.Utc);
|
||||
private const string UriSvchost = "./Device/Vendor/MSFT/Policy/Config/ServiceControlManager/SvchostProcessMitigation";
|
||||
private const string UriSystemGuard = "./Device/Vendor/MSFT/Policy/Config/DeviceGuard/ConfigureSystemGuardLaunch";
|
||||
|
||||
// Verified real 2800 description shape (session b2e890c1, 2026-07-20).
|
||||
private const string RealDescription =
|
||||
"The following URI has triggered a reboot: (./Device/Vendor/MSFT/Policy/Config/ServiceControlManager/SvchostProcessMitigation).";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink;
|
||||
private readonly MdmRebootPolicyTracker _tracker;
|
||||
private long _nextRecordId = 1;
|
||||
|
||||
public MdmRebootPolicyTrackerTests()
|
||||
{
|
||||
_sink = new FakeSignalIngressSink();
|
||||
_tracker = MakeTracker(stateDirectory: null);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private MdmRebootPolicyTracker MakeTracker(string? stateDirectory)
|
||||
{
|
||||
var post = new InformationalEventPost(_sink, new VirtualClock(At));
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
var tracker = new MdmRebootPolicyTracker(
|
||||
sessionId: "sess-mdm",
|
||||
tenantId: "tenant-mdm",
|
||||
post: post,
|
||||
logger: logger,
|
||||
backfillEnabled: false,
|
||||
stateDirectory: stateDirectory,
|
||||
debounceMilliseconds: 0); // manual-flush mode — tests call FlushPending explicitly
|
||||
tracker.RebootRequiredFlagProbe = () => null; // registry untouched by default
|
||||
return tracker;
|
||||
}
|
||||
|
||||
private void Buffer(string? rebootUri, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null, string? description = null)
|
||||
{
|
||||
_tracker.ProcessEvent(
|
||||
eventId: MdmRebootPolicyTracker.EventId_PolicyRebootRequired,
|
||||
recordId: recordId ?? _nextRecordId++,
|
||||
timeCreatedUtc: timeCreatedUtc ?? At,
|
||||
rebootUri: rebootUri,
|
||||
formattedDescription: description,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
private IReadOnlyList<FakeSignalIngressSink.PostedSignal> Emitted() =>
|
||||
_sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == Constants.EventTypes.MdmPolicyRebootRequired).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
[Fact]
|
||||
public void Burst_FlushesAsOneAggregatedEvent_InfoSeverity_ImmediateUpload()
|
||||
{
|
||||
Buffer(UriSvchost, timeCreatedUtc: At);
|
||||
Buffer(UriSystemGuard, timeCreatedUtc: At.AddMilliseconds(120));
|
||||
_tracker.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
|
||||
var data = Data(s);
|
||||
Assert.Equal(2, data["uriCount"]);
|
||||
Assert.Equal(2, data["recordCount"]);
|
||||
Assert.Equal(UriSvchost, data["firstRebootUri"]);
|
||||
var uris = Assert.IsType<List<string>>(data["rebootUris"]);
|
||||
Assert.Equal(2, uris.Count);
|
||||
Assert.Contains(UriSvchost, uris);
|
||||
Assert.Contains(UriSystemGuard, uris);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Message_IsNeutralObservation_NoRebootClaim()
|
||||
{
|
||||
// Session b2e890c1: flags raised during AccountSetup have no effect — the raw event
|
||||
// must state the observation only, never a future consequence.
|
||||
Buffer(UriSvchost);
|
||||
_tracker.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
var message = s.Payload![SignalPayloadKeys.Message];
|
||||
Assert.Contains("flagged", message);
|
||||
Assert.DoesNotContain("will restart", message);
|
||||
Assert.DoesNotContain("second sign-in", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlushPending_Empty_EmitsNothing()
|
||||
{
|
||||
_tracker.FlushPending();
|
||||
Assert.Empty(Emitted());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AggregateTimestamp_IsEarliestRecordTime()
|
||||
{
|
||||
var early = At.AddMinutes(-30);
|
||||
Buffer(UriSystemGuard, timeCreatedUtc: At, isBackfill: true);
|
||||
Buffer(UriSvchost, timeCreatedUtc: early, isBackfill: true);
|
||||
_tracker.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
Assert.Equal(early, s.OccurredAtUtc);
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
// firstRebootUri follows record-time order, not buffer order.
|
||||
Assert.Equal(UriSvchost, Data(s)["firstRebootUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfilled_False_WhenAnyRecordIsLive()
|
||||
{
|
||||
Buffer(UriSvchost, isBackfill: true);
|
||||
Buffer(UriSystemGuard, isBackfill: false);
|
||||
_tracker.FlushPending();
|
||||
|
||||
Assert.Equal(false, Data(Assert.Single(Emitted()))["backfilled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateUris_AreDistinctInAggregate()
|
||||
{
|
||||
Buffer(UriSvchost);
|
||||
Buffer(UriSvchost);
|
||||
_tracker.FlushPending();
|
||||
|
||||
var data = Data(Assert.Single(Emitted()));
|
||||
Assert.Equal(1, data["uriCount"]);
|
||||
Assert.Equal(2, data["recordCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnparsedRecords_CountedWithSampleDescription_StillEmitted()
|
||||
{
|
||||
Buffer(rebootUri: null, description: "Some unexpected 2800 wording");
|
||||
_tracker.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
var data = Data(s);
|
||||
Assert.False(data.ContainsKey("rebootUris"));
|
||||
Assert.False(data.ContainsKey("firstRebootUri"));
|
||||
Assert.Equal(0, data["uriCount"]);
|
||||
Assert.Equal(1, data["unparsedCount"]);
|
||||
Assert.Equal("Some unexpected 2800 wording", data["sampleDescription"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameRecordId_BufferedTwice_CountsOnce()
|
||||
{
|
||||
Buffer(UriSvchost, recordId: 42);
|
||||
Buffer(UriSvchost, recordId: 42);
|
||||
_tracker.FlushPending();
|
||||
|
||||
Assert.Equal(1, Data(Assert.Single(Emitted()))["recordCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordAfterFlush_GoesIntoNextAggregate()
|
||||
{
|
||||
Buffer(UriSvchost, recordId: 10);
|
||||
_tracker.FlushPending();
|
||||
Buffer(UriSystemGuard, recordId: 11);
|
||||
_tracker.FlushPending();
|
||||
|
||||
Assert.Equal(2, Emitted().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfillRecordWithLowerRecordId_AfterLiveRecord_IsStillBuffered()
|
||||
{
|
||||
// Live watcher is armed BEFORE the backfill scan — an older, never-seen backfill
|
||||
// record must not be suppressed by a high-water mark.
|
||||
Buffer(UriSvchost, recordId: 100);
|
||||
Buffer(UriSystemGuard, recordId: 50, isBackfill: true);
|
||||
_tracker.FlushPending();
|
||||
|
||||
Assert.Equal(2, Data(Assert.Single(Emitted()))["recordCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeRecordId_IsNeverDeduped()
|
||||
{
|
||||
Buffer(UriSvchost, recordId: -1);
|
||||
Buffer(UriSvchost, recordId: -1);
|
||||
_tracker.FlushPending();
|
||||
|
||||
Assert.Equal(2, Data(Assert.Single(Emitted()))["recordCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistedAtFlush_SkipsReReadAcrossInstances()
|
||||
{
|
||||
// The coalesced reboot restarts the agent; the post-reboot backfill re-reads the same
|
||||
// records — a FLUSHED record must be skipped by the next instance.
|
||||
var first = MakeTracker(stateDirectory: _tmp.Path);
|
||||
first.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: false);
|
||||
first.FlushPending();
|
||||
|
||||
var second = MakeTracker(stateDirectory: _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
second.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: true);
|
||||
second.FlushPending();
|
||||
|
||||
Assert.Single(Emitted());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_NotPersisted_WhenBufferedButNeverFlushed()
|
||||
{
|
||||
// Process killed by the very reboot the records announce: nothing was flushed, so the
|
||||
// next instance must re-read and emit the records.
|
||||
var first = MakeTracker(stateDirectory: _tmp.Path);
|
||||
first.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: false);
|
||||
// no FlushPending — simulated reboot kill
|
||||
|
||||
var second = MakeTracker(stateDirectory: _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
second.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: true);
|
||||
second.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebootRequiredFlagProbe_True_AddsField_Null_OmitsIt()
|
||||
{
|
||||
_tracker.RebootRequiredFlagProbe = () => true;
|
||||
Buffer(UriSvchost, recordId: 1);
|
||||
_tracker.FlushPending();
|
||||
|
||||
_tracker.RebootRequiredFlagProbe = () => null;
|
||||
Buffer(UriSystemGuard, recordId: 2);
|
||||
_tracker.FlushPending();
|
||||
|
||||
var emitted = Emitted();
|
||||
Assert.Equal(2, emitted.Count);
|
||||
Assert.Equal(true, Data(emitted[0])["omadmRebootRequiredFlag"]);
|
||||
Assert.False(Data(emitted[1]).ContainsKey("omadmRebootRequiredFlag"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebootRequiredFlagProbe_Throwing_IsFailSoft()
|
||||
{
|
||||
_tracker.RebootRequiredFlagProbe = () => throw new InvalidOperationException("boom");
|
||||
Buffer(UriSvchost);
|
||||
_tracker.FlushPending();
|
||||
|
||||
Assert.False(Data(Assert.Single(Emitted())).ContainsKey("omadmRebootRequiredFlag"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildXPath_Targets2800()
|
||||
{
|
||||
Assert.Equal("*[System[(EventID=2800)]]", MdmRebootPolicyTracker.BuildXPath());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ URI extraction
|
||||
|
||||
[Fact]
|
||||
public void ExtractRebootUri_FromVerifiedRealDescription()
|
||||
{
|
||||
// Real 2800 text wraps the URI in parentheses and ends with a period — both must be
|
||||
// excluded from the extracted URI.
|
||||
var uri = MdmRebootPolicyTracker.ExtractRebootUri(null, RealDescription);
|
||||
Assert.Equal(UriSvchost, uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractRebootUri_PrefersEventDataValue_OverDescription()
|
||||
{
|
||||
var eventData = new Dictionary<string, string> { ["Data0"] = " " + UriSystemGuard + " " };
|
||||
var uri = MdmRebootPolicyTracker.ExtractRebootUri(eventData, RealDescription);
|
||||
Assert.Equal(UriSystemGuard, uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractRebootUri_ReturnsNull_WhenNothingUriLike()
|
||||
{
|
||||
Assert.Null(MdmRebootPolicyTracker.ExtractRebootUri(
|
||||
new Dictionary<string, string> { ["Data0"] = "plain" }, "no uri here"));
|
||||
Assert.Null(MdmRebootPolicyTracker.ExtractRebootUri(null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseEventData_CollectsNamedAndPositionalData()
|
||||
{
|
||||
const string xml =
|
||||
"<Event xmlns='http://schemas.microsoft.com/win/2004/08/events/event'>" +
|
||||
"<System><EventID>2800</EventID></System>" +
|
||||
"<EventData><Data Name='Uri'>./Device/Vendor/MSFT/X</Data><Data>positional</Data></EventData>" +
|
||||
"</Event>";
|
||||
|
||||
var parsed = MdmRebootPolicyTracker.ParseEventData(xml);
|
||||
Assert.Equal("./Device/Vendor/MSFT/X", parsed["Uri"]);
|
||||
Assert.Equal("positional", parsed["Data0"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
-2
@@ -17,13 +17,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
FakeTelemetryTransport transport,
|
||||
out EventSequenceCounter counter,
|
||||
string sessionId = "sess-1",
|
||||
string tenantId = "tenant-1")
|
||||
string tenantId = "tenant-1",
|
||||
Func<bool>? traceEventsEnabled = null)
|
||||
{
|
||||
var tmp = new TempDirectory();
|
||||
counter = new EventSequenceCounter(new EventSequencePersistence(tmp.File("seq.json")));
|
||||
return new TelemetryEventEmitter(transport, counter, sessionId, tenantId);
|
||||
return new TelemetryEventEmitter(transport, counter, sessionId, tenantId, traceEventsEnabled);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent NewTraceEvent(string eventType = "outbound_ip") =>
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventType = eventType,
|
||||
Severity = EventSeverity.Trace,
|
||||
Source = "test",
|
||||
Timestamp = At,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = "trace message",
|
||||
};
|
||||
|
||||
private static EnrollmentEvent NewTestEvent(string eventType = "enrollment_complete") =>
|
||||
new EnrollmentEvent
|
||||
{
|
||||
@@ -49,6 +61,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
Assert.Equal("sess-1", evt.SessionId);
|
||||
Assert.Equal("tenant-1", evt.TenantId);
|
||||
|
||||
Assert.NotNull(item);
|
||||
Assert.Equal(TelemetryItemKind.Event, item.Kind);
|
||||
Assert.Equal("tenant-1_sess-1", item.PartitionKey);
|
||||
Assert.Equal(evt.RowKey, item.RowKey);
|
||||
@@ -174,5 +187,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
Assert.Equal("test-reason", (string?)data["reason"]);
|
||||
Assert.Equal("Strong", (string?)data["wgConfidence"]);
|
||||
}
|
||||
|
||||
// ============================================================ Trace-event gate
|
||||
|
||||
[Fact]
|
||||
public void Trace_event_is_dropped_when_gate_is_off()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var emitter = Build(transport, out _, traceEventsEnabled: () => false);
|
||||
|
||||
var item = emitter.Emit(NewTraceEvent());
|
||||
|
||||
Assert.Null(item);
|
||||
Assert.Equal(0, transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trace_event_flows_when_gate_is_on()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var emitter = Build(transport, out _, traceEventsEnabled: () => true);
|
||||
|
||||
Assert.NotNull(emitter.Emit(NewTraceEvent()));
|
||||
Assert.Equal(1, transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trace_event_flows_when_no_gate_is_wired()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var emitter = Build(transport, out _);
|
||||
|
||||
Assert.NotNull(emitter.Emit(NewTraceEvent()));
|
||||
Assert.Equal(1, transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_off_does_not_suppress_non_trace_events()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var emitter = Build(transport, out _, traceEventsEnabled: () => false);
|
||||
|
||||
foreach (var severity in new[] { EventSeverity.Info, EventSeverity.Warning, EventSeverity.Error })
|
||||
{
|
||||
var evt = NewTestEvent();
|
||||
evt.Severity = severity;
|
||||
Assert.NotNull(emitter.Emit(evt));
|
||||
}
|
||||
|
||||
Assert.Equal(3, transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dropped_trace_event_does_not_consume_a_sequence_number()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var traceAllowed = true;
|
||||
// ReSharper disable once AccessToModifiedClosure — deliberate: the gate is read
|
||||
// per event, mirroring a mid-session remote-config merge.
|
||||
var emitter = Build(transport, out _, traceEventsEnabled: () => traceAllowed);
|
||||
|
||||
emitter.Emit(NewTestEvent()); // seq 1
|
||||
traceAllowed = false;
|
||||
emitter.Emit(NewTraceEvent()); // dropped — must not burn seq 2
|
||||
emitter.Emit(NewTestEvent()); // seq 2
|
||||
|
||||
Assert.Equal(2, transport.EnqueueCount);
|
||||
var first = JObject.Parse(transport.Enqueued[0].PayloadJson);
|
||||
var second = JObject.Parse(transport.Enqueued[1].PayloadJson);
|
||||
Assert.Equal(1, (long)first["Sequence"]!);
|
||||
Assert.Equal(2, (long)second["Sequence"]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throwing_gate_fails_open_so_diagnostics_are_never_silently_lost()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var emitter = Build(transport, out _, traceEventsEnabled: () => throw new InvalidOperationException("boom"));
|
||||
|
||||
Assert.NotNull(emitter.Emit(NewTraceEvent()));
|
||||
Assert.Equal(1, transport.EnqueueCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+628
@@ -0,0 +1,628 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Eventing.Reader;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches <c>Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin</c> for
|
||||
/// EventID 2800 — logged once per MDM policy URI that matches the OS reboot-required catalog —
|
||||
/// and emits ONE aggregated <c>mdm_policy_reboot_required</c> event per burst carrying the URI
|
||||
/// list (<c>rebootUris</c>, <c>uriCount</c>, <c>firstRebootUri</c>).
|
||||
/// <para>
|
||||
/// Verified against session b2e890c1 (2026-07-20): the 2800 description reads
|
||||
/// <c>The following URI has triggered a reboot: (./Device/...).</c> and records arrive in
|
||||
/// sub-second bursts during policy sync — hence the debounce aggregation instead of per-record
|
||||
/// events. CRITICAL semantics: only URIs applied DURING ESP DeviceSetup cause the coalesced
|
||||
/// mid-ESP reboot ("second sign-in", PMPC research); later syncs (AccountSetup and beyond)
|
||||
/// merely flag the requirement with no forced reboot. The event is therefore a NEUTRAL
|
||||
/// observation (Info) — the reboot claim lives in ANALYZE-ESP-005, which requires an
|
||||
/// actually-observed <c>system_reboot_detected</c> before attributing anything.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Backfill + watermark.</b> Device policies can apply before the agent starts, and a
|
||||
/// genuine coalesced reboot kills the agent BEFORE the debounced emit — in both cases the
|
||||
/// post-(re)start backfill re-reads the .evtx and emits the aggregate then (historical
|
||||
/// timestamps). The cross-restart RecordId watermark is persisted only at successful flush, so
|
||||
/// a pre-emit death loses nothing and a completed emit is never repeated.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class MdmRebootPolicyTracker : IDisposable
|
||||
{
|
||||
internal const string Channel = "Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin";
|
||||
internal const int EventId_PolicyRebootRequired = 2800;
|
||||
|
||||
internal const string WatermarkStateFileName = "mdm-reboot-watermark.json";
|
||||
|
||||
/// <summary>Cap for the emitted <c>rebootUris</c> list — <c>uriCount</c> always carries the uncapped distinct total.</summary>
|
||||
internal const int MaxUrisPerEvent = 20;
|
||||
|
||||
/// <summary>Default quiet-period before a burst of 2800 records is flushed as one event.</summary>
|
||||
internal const int DefaultDebounceMilliseconds = 10000;
|
||||
|
||||
/// <summary>Registry key whose <c>RebootRequired</c> value/subkey flags a pending OMA-DM coalesced reboot.</summary>
|
||||
internal const string OmadmSyncMlKeyPath = @"SOFTWARE\Microsoft\Provisioning\OMADM\SyncML";
|
||||
|
||||
// Permissive OMA-DM URI shape ("./Device/Vendor/MSFT/...", "./User/..."). The verified 2800
|
||||
// text wraps the URI in parentheses — ')' is deliberately outside the class so the match
|
||||
// stops cleanly; trailing sentence punctuation is trimmed after the match.
|
||||
private static readonly Regex UriPattern = new Regex(
|
||||
@"\./[A-Za-z0-9_\-./\[\]{}%~+]+", RegexOptions.Compiled);
|
||||
|
||||
private readonly AgentLogger _logger;
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly InformationalEventPost _post;
|
||||
private readonly bool _backfillEnabled;
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly int _debounceMilliseconds;
|
||||
|
||||
private EventLogWatcher _watcher;
|
||||
private Timer _debounceTimer;
|
||||
|
||||
private sealed class PendingRecord
|
||||
{
|
||||
public long RecordId;
|
||||
public DateTime TimeUtc;
|
||||
public string Uri; // null when extraction failed
|
||||
public string Description; // may be null
|
||||
public bool IsBackfill;
|
||||
}
|
||||
|
||||
// Pending burst buffer + dedup state, all guarded by _stateLock. Same claim contract as
|
||||
// WindowsUpdateTracker (immutable cross-restart boundary + intra-run HashSet), with ONE
|
||||
// deliberate difference: the watermark is persisted at FLUSH time, not at claim time —
|
||||
// records buffered but never flushed (process killed by the very reboot they announce)
|
||||
// must be re-read and emitted by the post-restart backfill.
|
||||
private readonly List<PendingRecord> _pending = new List<PendingRecord>();
|
||||
private long _restartWatermark = -1;
|
||||
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
|
||||
private long _maxPersistedRecordId = -1;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Fail-soft corroboration probe for the OMA-DM RebootRequired flag — null = unknown
|
||||
/// (key absent or unreadable). Settable test seam; the default reads the live registry.
|
||||
/// </summary>
|
||||
internal Func<bool?> RebootRequiredFlagProbe { get; set; } = ReadOmadmRebootRequiredFlag;
|
||||
|
||||
public MdmRebootPolicyTracker(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
InformationalEventPost post,
|
||||
AgentLogger logger,
|
||||
bool backfillEnabled = true,
|
||||
int backfillLookbackMinutes = 60,
|
||||
string stateDirectory = null,
|
||||
int debounceMilliseconds = DefaultDebounceMilliseconds)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
_post = post ?? throw new ArgumentNullException(nameof(post));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_debounceMilliseconds = debounceMilliseconds;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
LoadWatermark();
|
||||
StartWatcher();
|
||||
|
||||
if (_backfillEnabled && _backfillLookbackMinutes > 0)
|
||||
{
|
||||
BackfillRecentEvents();
|
||||
// The backfill batch is complete — flush immediately instead of waiting out the
|
||||
// debounce (live records that raced in are simply included).
|
||||
FlushPending();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("MdmRebootPolicy backfill disabled by config");
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
// Flush any un-emitted burst before tearing down (normal agent shutdown path).
|
||||
try { FlushPending(); }
|
||||
catch (Exception ex) { _logger.Warning($"MdmRebootPolicy flush on stop failed: {ex.Message}"); }
|
||||
|
||||
var timer = Interlocked.Exchange(ref _debounceTimer, null);
|
||||
if (timer != null)
|
||||
{
|
||||
try { timer.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
if (_watcher == null) return;
|
||||
try
|
||||
{
|
||||
_watcher.Enabled = false;
|
||||
_watcher.Dispose();
|
||||
_logger.Info("MdmRebootPolicy watcher stopped");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error stopping MdmRebootPolicy watcher", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_watcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watcher lifecycle
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void StartWatcher()
|
||||
{
|
||||
try
|
||||
{
|
||||
var query = new EventLogQuery(Channel, PathType.LogName, BuildXPath());
|
||||
_watcher = new EventLogWatcher(query);
|
||||
_watcher.EventRecordWritten += OnEventRecordWritten;
|
||||
_watcher.Enabled = true;
|
||||
_logger.Info($"MdmRebootPolicy watcher started: {Channel} (EventID {EventId_PolicyRebootRequired})");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"MdmRebootPolicy event log not found: {Channel} (normal on non-MDM-enrolled test environments)");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"MdmRebootPolicy watcher access denied for {Channel}: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error($"Failed to start MdmRebootPolicy watcher for {Channel}", ex);
|
||||
// MON-D1: surface a dead watcher as one-shot telemetry, not just a local log line.
|
||||
CollectorDegradationReporter.Report(_post, _sessionId, _tenantId,
|
||||
collectorName: "MdmRebootPolicyTracker", reason: $"watcher_arm_failed:{Channel}", ex: ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Targeted-EventID XPath filter. Exposed as internal for tests.</summary>
|
||||
internal static string BuildXPath() =>
|
||||
$"*[System[(EventID={EventId_PolicyRebootRequired})]]";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event processing — buffer + debounce
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
|
||||
{
|
||||
if (e.EventRecord == null) return;
|
||||
try
|
||||
{
|
||||
ProcessRecord(e.EventRecord, isBackfill: false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Error("Error processing MdmRebootPolicy event", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessRecord(EventRecord record, bool isBackfill)
|
||||
{
|
||||
var recordId = record.RecordId ?? -1;
|
||||
|
||||
// Cheap dedup gate BEFORE the expensive XML/FormatDescription work.
|
||||
if (IsAlreadyProcessed(recordId))
|
||||
return;
|
||||
|
||||
string xml = null;
|
||||
try { xml = record.ToXml(); }
|
||||
catch { /* fall back to description-only parse below */ }
|
||||
|
||||
string description = null;
|
||||
try { description = record.FormatDescription(); }
|
||||
catch { /* some events lack formatting resources */ }
|
||||
|
||||
var rebootUri = ExtractRebootUri(ParseEventData(xml), description);
|
||||
|
||||
ProcessEvent(
|
||||
eventId: record.Id,
|
||||
recordId: recordId,
|
||||
timeCreatedUtc: record.TimeCreated?.ToUniversalTime(),
|
||||
rebootUri: rebootUri,
|
||||
formattedDescription: description,
|
||||
isBackfill: isBackfill);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Buffers one 2800 record into the pending burst (primitive-input test seam, mirroring the
|
||||
/// WindowsUpdateTracker pattern). The aggregated event is emitted by <see cref="FlushPending"/>
|
||||
/// — after the debounce quiet-period, at backfill completion, or at <see cref="Stop"/>.
|
||||
/// </summary>
|
||||
internal void ProcessEvent(
|
||||
int eventId,
|
||||
long recordId,
|
||||
DateTime? timeCreatedUtc,
|
||||
string rebootUri,
|
||||
string formattedDescription,
|
||||
bool isBackfill)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (recordId >= 0)
|
||||
{
|
||||
// Already emitted in a prior run, or already buffered/emitted this run.
|
||||
if (recordId <= _restartWatermark || !_seenThisRun.Add(recordId))
|
||||
return;
|
||||
}
|
||||
|
||||
_pending.Add(new PendingRecord
|
||||
{
|
||||
RecordId = recordId,
|
||||
TimeUtc = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
Uri = string.IsNullOrEmpty(rebootUri) ? null : rebootUri,
|
||||
Description = formattedDescription,
|
||||
IsBackfill = isBackfill,
|
||||
});
|
||||
}
|
||||
|
||||
ArmDebounce();
|
||||
}
|
||||
|
||||
private void ArmDebounce()
|
||||
{
|
||||
if (_debounceMilliseconds <= 0) return; // manual-flush mode (tests; backfill/Stop flush explicitly)
|
||||
|
||||
var timer = _debounceTimer;
|
||||
if (timer == null)
|
||||
{
|
||||
var created = new Timer(_ =>
|
||||
{
|
||||
try { FlushPending(); }
|
||||
catch (Exception ex) { _logger.Warning($"MdmRebootPolicy debounce flush failed: {ex.Message}"); }
|
||||
}, null, Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
timer = Interlocked.CompareExchange(ref _debounceTimer, created, null) ?? created;
|
||||
if (!ReferenceEquals(timer, created))
|
||||
{
|
||||
try { created.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
try { timer.Change(_debounceMilliseconds, Timeout.Infinite); }
|
||||
catch (ObjectDisposedException) { /* raced Stop() — Stop's flush covers the buffer */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the pending burst as ONE aggregated <c>mdm_policy_reboot_required</c> event and
|
||||
/// persists the watermark. No-op when nothing is pending. Internal for tests.
|
||||
/// </summary>
|
||||
internal void FlushPending()
|
||||
{
|
||||
List<PendingRecord> batch;
|
||||
long watermarkToPersist = -1;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_pending.Count == 0) return;
|
||||
batch = new List<PendingRecord>(_pending);
|
||||
_pending.Clear();
|
||||
|
||||
foreach (var r in batch)
|
||||
{
|
||||
if (r.RecordId > _maxPersistedRecordId)
|
||||
_maxPersistedRecordId = r.RecordId;
|
||||
}
|
||||
watermarkToPersist = _maxPersistedRecordId;
|
||||
}
|
||||
|
||||
if (watermarkToPersist >= 0) PersistWatermark(watermarkToPersist);
|
||||
|
||||
batch.Sort((a, b) => a.TimeUtc.CompareTo(b.TimeUtc));
|
||||
|
||||
var distinctUris = batch
|
||||
.Where(r => r.Uri != null)
|
||||
.Select(r => r.Uri)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var firstUri = batch.FirstOrDefault(r => r.Uri != null)?.Uri;
|
||||
var unparsedCount = batch.Count(r => r.Uri == null);
|
||||
var allBackfill = batch.All(r => r.IsBackfill);
|
||||
var earliest = batch[0].TimeUtc;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "windowsEventId", EventId_PolicyRebootRequired },
|
||||
{ "eventLogChannel", Channel },
|
||||
{ "recordCount", batch.Count },
|
||||
{ "uriCount", distinctUris.Count },
|
||||
{ "backfilled", allBackfill },
|
||||
};
|
||||
if (distinctUris.Count > 0)
|
||||
{
|
||||
data["rebootUris"] = distinctUris
|
||||
.OrderBy(u => u, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxUrisPerEvent)
|
||||
.ToList();
|
||||
data["firstRebootUri"] = firstUri;
|
||||
}
|
||||
if (unparsedCount > 0)
|
||||
{
|
||||
data["unparsedCount"] = unparsedCount;
|
||||
var sample = batch.FirstOrDefault(r => r.Uri == null && !string.IsNullOrEmpty(r.Description))?.Description;
|
||||
if (sample != null)
|
||||
data["sampleDescription"] = sample.Length > 500 ? sample.Substring(0, 500) + "…" : sample;
|
||||
}
|
||||
|
||||
var flag = SafeProbeRebootRequiredFlag();
|
||||
if (flag.HasValue) data["omadmRebootRequiredFlag"] = flag.Value;
|
||||
|
||||
string message;
|
||||
if (distinctUris.Count == 1)
|
||||
message = $"Windows flagged an MDM policy URI as reboot-required: {firstUri}";
|
||||
else if (distinctUris.Count > 1)
|
||||
message = $"Windows flagged {distinctUris.Count} MDM policy URIs as reboot-required: {firstUri} (+{distinctUris.Count - 1} more)";
|
||||
else
|
||||
message = $"Windows flagged {batch.Count} MDM policy record(s) as reboot-required (URI not parsed — see sampleDescription)";
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
// Earliest record time, not UtcNow: backfilled (and post-reboot re-read) bursts
|
||||
// must land on the timeline at when the policies were actually flagged.
|
||||
Timestamp = earliest,
|
||||
EventType = Constants.EventTypes.MdmPolicyRebootRequired,
|
||||
// Neutral observation — reboot-required flags outside ESP DeviceSetup have no
|
||||
// immediate effect. The reboot CLAIM is ANALYZE-ESP-005's job, gated on an
|
||||
// actually-observed system_reboot_detected.
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "MdmRebootPolicyWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = message,
|
||||
Data = data,
|
||||
// Cheap (one event per burst) and still time-critical: a burst DURING DeviceSetup
|
||||
// precedes a coalesced reboot that kills the agent.
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
|
||||
_logger.Info($"MdmRebootPolicy: flushed {batch.Count} record(s) as one event " +
|
||||
$"(uris={distinctUris.Count}, unparsed={unparsedCount}, backfilled={allBackfill})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the reboot-forcing OMA-DM URI. Preference order: (1) a structured EventData
|
||||
/// value that starts with <c>./</c>, (2) a permissive regex over the formatted description
|
||||
/// (verified shape: <c>The following URI has triggered a reboot: (./Device/...).</c>).
|
||||
/// Returns null when nothing URI-like is found — the record still counts into the
|
||||
/// aggregate (<c>unparsedCount</c> + <c>sampleDescription</c>).
|
||||
/// </summary>
|
||||
internal static string ExtractRebootUri(Dictionary<string, string> eventData, string description)
|
||||
{
|
||||
if (eventData != null)
|
||||
{
|
||||
foreach (var value in eventData.Values)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
if (!string.IsNullOrEmpty(trimmed) && trimmed.StartsWith("./", StringComparison.Ordinal))
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(description))
|
||||
{
|
||||
var match = UriPattern.Match(description);
|
||||
if (match.Success)
|
||||
{
|
||||
// The permissive pattern can swallow sentence punctuation trailing the URI.
|
||||
// OMA-DM URI segments never end in punctuation — trim it.
|
||||
var candidate = match.Value.TrimEnd('.', ',', ';', ':', '\'', '"', ')');
|
||||
if (candidate.Length > 2) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts <c><Data Name="...">value</Data></c> pairs from an event's rendered
|
||||
/// XML, keyed case-insensitively. Also collects UNNAMED Data elements under positional keys
|
||||
/// (<c>Data0</c>, <c>Data1</c>, …) — DM-Enterprise events frequently carry positional-only
|
||||
/// payloads. Namespace-agnostic; returns an empty map on null/malformed XML — never throws.
|
||||
/// </summary>
|
||||
internal static Dictionary<string, string> ParseEventData(string xml)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrEmpty(xml)) return result;
|
||||
|
||||
try
|
||||
{
|
||||
var doc = XDocument.Parse(xml);
|
||||
var positionalIndex = 0;
|
||||
foreach (var el in doc.Descendants().Where(e => e.Name.LocalName == "Data"))
|
||||
{
|
||||
var nameAttr = el.Attribute("Name");
|
||||
var key = nameAttr != null && !string.IsNullOrEmpty(nameAttr.Value)
|
||||
? nameAttr.Value
|
||||
: "Data" + positionalIndex++;
|
||||
if (!result.ContainsKey(key))
|
||||
result[key] = el.Value;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed / unexpected XML — best effort, return what we have.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// OMA-DM RebootRequired flag probe (fail-soft corroboration)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private bool? SafeProbeRebootRequiredFlag()
|
||||
{
|
||||
try
|
||||
{
|
||||
return RebootRequiredFlagProbe?.Invoke();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null; // corroboration only — never let the probe break the emit
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the pending-reboot flag under <see cref="OmadmSyncMlKeyPath"/>. Both a value and a
|
||||
/// subkey named <c>RebootRequired</c> count as "pending". Fail-soft: null = unknown
|
||||
/// (key absent / unreadable), matching the EspSkipConfigurationProbe convention.
|
||||
/// </summary>
|
||||
internal static bool? ReadOmadmRebootRequiredFlag()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(OmadmSyncMlKeyPath))
|
||||
{
|
||||
if (key == null) return null;
|
||||
if (key.GetValue("RebootRequired") != null) return true;
|
||||
using (var sub = key.OpenSubKey("RebootRequired"))
|
||||
{
|
||||
return sub != null ? (bool?)true : false;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Backfill
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void BackfillRecentEvents()
|
||||
{
|
||||
try
|
||||
{
|
||||
var lookbackMs = (long)_backfillLookbackMinutes * 60 * 1000;
|
||||
var xpath = $"*[System[(EventID={EventId_PolicyRebootRequired}) and TimeCreated[timediff(@SystemTime) <= {lookbackMs}]]]";
|
||||
|
||||
_logger.Info($"MdmRebootPolicy backfill: scanning {Channel} " +
|
||||
$"(lookback={_backfillLookbackMinutes}min, restartWatermark={_restartWatermark})");
|
||||
|
||||
var query = new EventLogQuery(Channel, PathType.LogName, xpath);
|
||||
|
||||
int processed = 0;
|
||||
using (var reader = new EventLogReader(query))
|
||||
{
|
||||
EventRecord record;
|
||||
while ((record = reader.ReadEvent()) != null)
|
||||
{
|
||||
using (record)
|
||||
{
|
||||
ProcessRecord(record, isBackfill: true);
|
||||
processed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (processed == 0)
|
||||
_logger.Debug($"MdmRebootPolicy backfill: no EventID {EventId_PolicyRebootRequired} records in last {_backfillLookbackMinutes} minutes");
|
||||
else
|
||||
_logger.Info($"MdmRebootPolicy backfill: scanned {processed} record(s)");
|
||||
}
|
||||
catch (EventLogNotFoundException)
|
||||
{
|
||||
_logger.Warning($"MdmRebootPolicy event log not found during backfill: {Channel} (normal on non-MDM-enrolled test environments)");
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
_logger.Warning($"MdmRebootPolicy backfill access denied: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"MdmRebootPolicy backfill failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watermark dedup (cross-restart) — persisted at flush, not at claim
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private bool IsAlreadyProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
|
||||
lock (_stateLock)
|
||||
{
|
||||
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
internal void LoadWatermark()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
|
||||
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
|
||||
if (!File.Exists(filePath)) return;
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var state = JsonConvert.DeserializeObject<WatermarkState>(json);
|
||||
if (state != null)
|
||||
{
|
||||
lock (_stateLock)
|
||||
{
|
||||
_restartWatermark = state.LastRecordId;
|
||||
_maxPersistedRecordId = state.LastRecordId;
|
||||
}
|
||||
_logger.Info($"MdmRebootPolicy watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to load MdmRebootPolicy watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistWatermark(long recordId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(_stateDirectory);
|
||||
var filePath = Path.Combine(_stateDirectory, WatermarkStateFileName);
|
||||
var state = new WatermarkState { LastRecordId = recordId, PersistedUtc = DateTime.UtcNow };
|
||||
var json = JsonConvert.SerializeObject(state, Formatting.Indented);
|
||||
var tempPath = filePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Copy(tempPath, filePath, overwrite: true);
|
||||
try { File.Delete(tempPath); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.Warning($"Failed to persist MdmRebootPolicy watermark: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class WatermarkState
|
||||
{
|
||||
public long LastRecordId { get; set; }
|
||||
public DateTime PersistedUtc { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -17,6 +17,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// CommercialOOBE_ESPProgress_WhiteGlove_Success — WhiteGlove complete
|
||||
/// CommercialOOBE_ESPProgress_Failure/_Timeout/_Abort/WhiteGlove_Failed — ESP failure
|
||||
///
|
||||
/// NOTE (session b2e890c1, 2026-07-20): a bare "RebootCoalescing" substring match on 62407 was
|
||||
/// tried and REMOVED — the token appears in the ROUTINE SubcategoryProcessing_Started marker
|
||||
/// ("Starting subcategory DeviceSetup.RebootCoalescing...") on every enrollment, so it proves
|
||||
/// nothing about an actual coalesced reboot. Policy-reboot attribution lives entirely in
|
||||
/// MdmRebootPolicyTracker (EventID 2800) + the ANALYZE-ESP-005 rule gated on an observed reboot.
|
||||
///
|
||||
/// Raises <see cref="FinalizingSetupPhaseTriggered"/>, <see cref="WhiteGloveCompleted"/>,
|
||||
/// and <see cref="EspFailureDetected"/>. Cross-notifies the <see cref="HelloTracker"/>
|
||||
/// on Hello wizard start and ESP exit so Hello timers can react.
|
||||
|
||||
+48
-1
@@ -176,12 +176,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
/// <summary>
|
||||
/// Called when a phase change event occurs - executes rules triggered by phase changes
|
||||
/// (<c>phase_change</c> on entering the new phase, <c>phase_exit</c> on leaving the old one).
|
||||
/// </summary>
|
||||
public void OnPhaseChanged(EnrollmentPhase newPhase)
|
||||
{
|
||||
var phaseName = newPhase.ToString();
|
||||
|
||||
// Update the phase + from-phase latches FIRST so deferred startup activation and
|
||||
// phase_exit runs FIRST and against the phase being LEFT: both the TriggerPhase match
|
||||
// and the scope gate must see the old phase, so this block has to complete before
|
||||
// _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
|
||||
// phase that failed — that snapshot is the whole point at a failure boundary.
|
||||
List<GatherRule> exitRules = null;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
var previousPhase = _currentPhase;
|
||||
if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
|
||||
{
|
||||
var previousPhaseName = previousPhase.ToString();
|
||||
exitRules = new List<GatherRule>();
|
||||
|
||||
foreach (var rule in _activeRules)
|
||||
{
|
||||
if (rule.Trigger != "phase_exit") continue;
|
||||
if (!string.IsNullOrEmpty(rule.TriggerPhase)
|
||||
&& !string.Equals(rule.TriggerPhase, previousPhaseName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// Scope gate before the dedup add — an out-of-scope pass must not consume
|
||||
// the once-per-(rule, phase) slot. IsRuleInScopeLocked still sees the old phase.
|
||||
if (!IsRuleInScopeLocked(rule)) continue;
|
||||
|
||||
// "exit:" prefix keeps this key space disjoint from the phase_change keys.
|
||||
if (!_phaseRulesExecuted.Add($"exit:{rule.RuleId}|{previousPhaseName}"))
|
||||
{
|
||||
_logger.Debug($"Phase-exit rule {rule.RuleId} already executed for exit of {previousPhaseName}, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Phase exit triggered rule {rule.RuleId} (left phase: {previousPhaseName}, now: {phaseName})");
|
||||
exitRules.Add(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (exitRules != null)
|
||||
{
|
||||
foreach (var rule in exitRules)
|
||||
{
|
||||
var exitRule = rule;
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(exitRule));
|
||||
}
|
||||
}
|
||||
|
||||
// Update the phase + from-phase latches so deferred startup activation and
|
||||
// the phase_change rules below all evaluate against the NEW phase.
|
||||
List<GatherRule> deferredStartup;
|
||||
lock (_scopeLock)
|
||||
|
||||
@@ -335,6 +335,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
osBuildChangedProvider: () => osBuildChangeHost.BuildChanged));
|
||||
}
|
||||
|
||||
// MDM reboot-policy watcher — subscribes to DeviceManagement-Enterprise-Diagnostics-
|
||||
// Provider/Admin EventID 2800 and attributes the "unexpected reboot + second sign-in"
|
||||
// pattern to the device-assigned policy URIs that forced the coalesced reboot.
|
||||
// Agent-native collector (NOT an IAgentAnalyzer); the config flag is an internal
|
||||
// steering lever only.
|
||||
if (collectors.MdmRebootPolicyWatcherEnabled)
|
||||
{
|
||||
hosts.Add(new MdmRebootPolicyWatcherHost(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
logger: logger,
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
backfillLookbackMinutes: collectors.MdmRebootPolicyBackfillLookbackMinutes,
|
||||
stateDirectory: _stateDirectory));
|
||||
}
|
||||
|
||||
// ----- Peripheral hosts (event-only; driven by remote-config toggles) --------------
|
||||
|
||||
// V1 parity (PeriodicCollectorManager) — combine Performance + AgentSelfMetrics under
|
||||
|
||||
@@ -86,6 +86,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Production always passes null → Start() creates the real DeadlineScheduler.
|
||||
private readonly IDeadlineScheduler? _schedulerOverride;
|
||||
|
||||
// Live accessor for TenantConfiguration.SendTraceEvents — read per event by the
|
||||
// TelemetryEventEmitter so a mid-session remote-config merge takes effect. Null
|
||||
// (tests / legacy callers) means trace events always flow.
|
||||
private readonly Func<bool>? _traceEventsEnabled;
|
||||
|
||||
// Max-lifetime watchdog (M4.6.α). Timer is armed in Start() when _agentMaxLifetime != null.
|
||||
private System.Threading.Timer? _maxLifetimeTimer;
|
||||
private int _terminatedFired;
|
||||
@@ -166,7 +171,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
TimeSpan? terminalDrainTimeout = null,
|
||||
TimeSpan? agentMaxLifetime = null,
|
||||
int uploadBatchSize = 100,
|
||||
IDeadlineScheduler? schedulerOverride = null)
|
||||
IDeadlineScheduler? schedulerOverride = null,
|
||||
Func<bool>? traceEventsEnabled = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
@@ -206,6 +212,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_uploadBatchSize = uploadBatchSize;
|
||||
|
||||
_schedulerOverride = schedulerOverride;
|
||||
_traceEventsEnabled = traceEventsEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -402,7 +409,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// strukturelle Abhängigkeit erlischt damit, Architektur-Baseline-Test gate
|
||||
// shrinks to exactly two permitted callers.
|
||||
_eventSequenceCounter = new EventSequenceCounter(_eventSequencePersistence);
|
||||
var eventEmitter = new TelemetryEventEmitter(_transport, _eventSequenceCounter, _sessionId, _tenantId);
|
||||
var eventEmitter = new TelemetryEventEmitter(
|
||||
_transport, _eventSequenceCounter, _sessionId, _tenantId, _traceEventsEnabled);
|
||||
_signalEmitter = new TelemetrySignalEmitter(_transport, _sessionId, _tenantId);
|
||||
_transitionEmitter = new TelemetryTransitionEmitter(_transport, _sessionId, _tenantId);
|
||||
_timelineEmitter = new EventTimelineEmitter(eventEmitter);
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
{
|
||||
/// <summary>
|
||||
/// Standalone host for the <see cref="MdmRebootPolicyTracker"/> — watches
|
||||
/// <c>Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin</c> (EventID
|
||||
/// 2800) for MDM policies that force a coalesced reboot during ESP DeviceSetup and forwards
|
||||
/// them via the shared single-rail ingress. Mirrors <see cref="WindowsUpdateWatcherHost"/>:
|
||||
/// builds its own <see cref="InformationalEventPost"/> and delegates Start/Stop to the tracker.
|
||||
/// </summary>
|
||||
internal sealed class MdmRebootPolicyWatcherHost : ICollectorHost
|
||||
{
|
||||
public string Name => "MdmRebootPolicyTracker";
|
||||
|
||||
private readonly MdmRebootPolicyTracker _tracker;
|
||||
private readonly AgentLogger _logger;
|
||||
private int _disposed;
|
||||
|
||||
public MdmRebootPolicyWatcherHost(
|
||||
string sessionId,
|
||||
string tenantId,
|
||||
AgentLogger logger,
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
int backfillLookbackMinutes,
|
||||
string stateDirectory)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
var post = new InformationalEventPost(ingress, clock, logger);
|
||||
_tracker = new MdmRebootPolicyTracker(
|
||||
sessionId: sessionId,
|
||||
tenantId: tenantId,
|
||||
post: post,
|
||||
logger: logger,
|
||||
backfillEnabled: backfillLookbackMinutes > 0,
|
||||
backfillLookbackMinutes: backfillLookbackMinutes,
|
||||
stateDirectory: stateDirectory);
|
||||
}
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_tracker.Start();
|
||||
_logger.Info("MdmRebootPolicyWatcherHost: started.");
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
try { _tracker.Stop(); }
|
||||
catch (Exception ex) { _logger.Warning($"MdmRebootPolicyWatcherHost: stop failed: {ex.Message}"); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;
|
||||
Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,12 +30,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly string _partitionKey;
|
||||
private readonly Func<bool>? _traceEventsEnabled;
|
||||
|
||||
public TelemetryEventEmitter(
|
||||
ITelemetryTransport transport,
|
||||
EventSequenceCounter sequenceCounter,
|
||||
string sessionId,
|
||||
string tenantId)
|
||||
string tenantId,
|
||||
Func<bool>? traceEventsEnabled = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
@@ -44,13 +46,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_partitionKey = $"{tenantId}_{sessionId}";
|
||||
_traceEventsEnabled = traceEventsEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weist Sequence zu, serialisiert im Legacy-Wire-Format und enqueued via Transport.
|
||||
/// Thread-safe (EventSequenceCounter + ITelemetryTransport.Enqueue beide unter Lock).
|
||||
/// <para>
|
||||
/// Returns <c>null</c> when the event was suppressed by the trace-event gate — the
|
||||
/// only drop path. Both permitted callers ignore the return value.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal TelemetryItem Emit(EnrollmentEvent evt)
|
||||
internal TelemetryItem? Emit(EnrollmentEvent evt)
|
||||
{
|
||||
if (evt == null) throw new ArgumentNullException(nameof(evt));
|
||||
if (string.IsNullOrEmpty(evt.EventType))
|
||||
@@ -58,6 +65,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
throw new ArgumentException("EnrollmentEvent.EventType is mandatory.", nameof(evt));
|
||||
}
|
||||
|
||||
// Trace-event gate (TenantConfiguration.SendTraceEvents, default on). Dropped
|
||||
// before the sequence counter advances, so suppression leaves no gaps in the
|
||||
// per-session sequence and the timeline stays contiguous. Evaluated per event
|
||||
// rather than captured at construction: remote config is merged into the live
|
||||
// AgentConfiguration mid-session, and the gate must follow it.
|
||||
if (evt.Severity == EventSeverity.Trace && !TraceEventsEnabled())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
evt.Sequence = _sequenceCounter.Next();
|
||||
if (string.IsNullOrEmpty(evt.SessionId)) evt.SessionId = _sessionId;
|
||||
if (string.IsNullOrEmpty(evt.TenantId)) evt.TenantId = _tenantId;
|
||||
@@ -77,5 +94,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
|
||||
return _transport.Enqueue(draft);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-open: no gate wired (legacy ctor / tests) or a throwing accessor means trace
|
||||
/// events flow. The gate suppresses diagnostics, so a broken accessor must not be able
|
||||
/// to silently blind the timeline.
|
||||
/// </summary>
|
||||
private bool TraceEventsEnabled()
|
||||
{
|
||||
if (_traceEventsEnabled == null) return true;
|
||||
try
|
||||
{
|
||||
return _traceEventsEnabled();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
|
||||
drainInterval: drainInterval,
|
||||
agentMaxLifetime: agentMaxLifetime,
|
||||
uploadBatchSize: uploadBatchSize))
|
||||
uploadBatchSize: uploadBatchSize,
|
||||
// Live read: remote config is merged into agentConfig mid-session, so the
|
||||
// trace-event gate must follow it rather than snapshot at construction.
|
||||
traceEventsEnabled: () => agentConfig.SendTraceEvents))
|
||||
{
|
||||
using (var shutdown = new ManualResetEventSlim(false))
|
||||
using (var shutdownComplete = new ManualResetEventSlim(false))
|
||||
|
||||
@@ -270,7 +270,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 34, // Gather rule phase scoping (ActivePhases/ActiveFromPhase) + EmitMode on_change anti-spam
|
||||
ConfigVersion = 35, // MDM reboot-policy watcher (mdm_policy_reboot_required / esp_reboot_coalescing)
|
||||
UploadIntervalSeconds = 10,
|
||||
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
|
||||
KeepLogFile = tenantConfig.KeepLogFile ?? false,
|
||||
|
||||
@@ -24,6 +24,7 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'esp_failure_settle_started',
|
||||
'esp_failure_settle_recovered',
|
||||
'esp_exiting',
|
||||
'mdm_policy_reboot_required',
|
||||
'esp_config_detected',
|
||||
'completion_check',
|
||||
'completion_waiting',
|
||||
|
||||
@@ -405,6 +405,18 @@ namespace AutopilotMonitor.Shared
|
||||
public const string OsBuildChanged = "os_build_changed"; // OS build (CurrentBuild.UBR) differs across an agent restart — an update was installed during enrollment
|
||||
public const string WindowsUpdateChannelCensus = "windows_update_channel_census"; // One-shot EventID histogram of WU Client + UpdateOrchestrator channels when os_build_changed but zero targeted WU events captured
|
||||
|
||||
// MDM reboot-required policy flags (MdmRebootPolicyTracker; PMPC "unexpected reboot /
|
||||
// second sign-in" research). DM-Enterprise EventID 2800 fires once per policy URI that
|
||||
// matches the OS reboot-required catalog ("The following URI has triggered a reboot:
|
||||
// (./Device/...)."; verified session b2e890c1). Only URIs applied DURING ESP DeviceSetup
|
||||
// cause the coalesced mid-ESP reboot — later syncs (AccountSetup+) merely flag, no forced
|
||||
// reboot. The tracker therefore emits ONE aggregated Info event per burst with the URI
|
||||
// list; the reboot CLAIM lives in ANALYZE-ESP-005, gated on an actually-observed
|
||||
// system_reboot_detected. Deliberately DISTINCT from system_reboot_detected: the backend
|
||||
// RebootCount matches that string exactly (EventIngestProcessor.Classification + terminal
|
||||
// recount) — this attribution event must never inflate the reboot count.
|
||||
public const string MdmPolicyRebootRequired = "mdm_policy_reboot_required"; // Aggregated per burst — Data.rebootUris[] + uriCount + firstRebootUri
|
||||
|
||||
// TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
|
||||
public const string ShadowDiscrepancy = "shadow_discrepancy";
|
||||
|
||||
|
||||
@@ -482,6 +482,28 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool WindowsUpdateChannelCensusEnabled { get; set; } = true;
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// MDM reboot-policy watcher (DeviceManagement-Enterprise-Diagnostics-Provider/Admin)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Master switch for the MDM reboot-policy watcher. When enabled the agent subscribes to
|
||||
/// <c>Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin</c> EventID
|
||||
/// 2800 and emits one <c>mdm_policy_reboot_required</c> event per device-assigned policy
|
||||
/// URI that forces a coalesced reboot during ESP DeviceSetup (the "unexpected reboot +
|
||||
/// second sign-in" pattern). Internal steering lever only — not surfaced in any admin UI;
|
||||
/// disable by pushing config server-side. Default: true.
|
||||
/// </summary>
|
||||
public bool MdmRebootPolicyWatcherEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Lookback window in minutes for the MDM reboot-policy backfill scan on startup. Device
|
||||
/// policies can apply before the agent starts, and after the coalesced reboot the restarted
|
||||
/// agent must re-scan past the reboot gap (the cross-restart watermark keeps re-reads from
|
||||
/// re-emitting). 0 = backfill disabled. Default: 60 minutes.
|
||||
/// </summary>
|
||||
public int MdmRebootPolicyBackfillLookbackMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Creates default collector configuration
|
||||
/// </summary>
|
||||
|
||||
@@ -107,7 +107,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
// ===== WHEN TO COLLECT =====
|
||||
|
||||
/// <summary>
|
||||
/// Trigger type: "startup", "phase_change", "interval", "on_event"
|
||||
/// Trigger type: "startup", "phase_change", "phase_exit", "interval", "on_event".
|
||||
/// <para>
|
||||
/// "phase_change" fires once when the phase in <see cref="TriggerPhase"/> is ENTERED,
|
||||
/// "phase_exit" once when it is LEFT — the two one-shot bookends of a phase.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string Trigger { get; set; } = default!;
|
||||
|
||||
@@ -117,8 +121,9 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int? IntervalSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase to trigger on (only used when Trigger = "phase_change")
|
||||
/// e.g., "Identity", "MdmEnrollment", "AppInstallation"
|
||||
/// Phase to trigger on (used when Trigger = "phase_change" or "phase_exit").
|
||||
/// Canonical tokens are the <see cref="EnrollmentPhase"/> enum names, e.g.
|
||||
/// "DeviceSetup", "AccountSetup", "Complete". Empty = every phase transition.
|
||||
/// </summary>
|
||||
public string TriggerPhase { get; set; } = default!;
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ const FEATURES = [
|
||||
{
|
||||
title: "Diagnostics Collection",
|
||||
description:
|
||||
"Trigger on-demand diagnostic uploads directly from the portal. Collect ETL logs, event logs, IME logs, and system information from any enrolled device without local access.",
|
||||
"Collect agent logs, IME logs, agent state, and device information as a ZIP bundle at the end of an enrollment — then download it from the session view without touching the device.",
|
||||
color: "green",
|
||||
bullets: ["On-demand ZIP bundle upload", "ETL, event & IME log collection", "Configurable log path rules"],
|
||||
bullets: ["Configurable upload: off, always, or on failure", "Agent, IME & device information bundle", "Configurable additional log paths"],
|
||||
},
|
||||
{
|
||||
title: "Detailed Event Timeline",
|
||||
@@ -40,7 +40,7 @@ const FEATURES = [
|
||||
{
|
||||
title: "Audit Logging & Compliance",
|
||||
description:
|
||||
"Complete audit trail of all administrative actions and configuration changes. Meet compliance requirements with detailed, tamper-evident records and configurable data retention.",
|
||||
"Complete audit trail of all administrative actions and configuration changes. Meet compliance requirements with detailed, tenant-scoped records and configurable data retention.",
|
||||
color: "red",
|
||||
bullets: ["Admin action history", "Configurable retention policies", "Tenant-scoped audit log"],
|
||||
},
|
||||
@@ -138,7 +138,7 @@ export default function AboutPage() {
|
||||
{
|
||||
title: "MSPs & Enterprise Teams",
|
||||
description:
|
||||
"Autopilot Monitor supports multi-tenant deployments — each customer tenant runs its own isolated instance. MSPs with login access to a customer environment can use the portal for that tenant independently.",
|
||||
"Autopilot Monitor is a multi-tenant service with strict per-tenant data isolation — telemetry, configuration, and diagnostics are partitioned and access-scoped to each tenant. MSPs can be granted delegated read access across several customer tenants from a single login.",
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.title} className="rounded-lg bg-blue-50 border border-blue-100 p-4">
|
||||
@@ -165,7 +165,7 @@ export default function AboutPage() {
|
||||
{ step: "2", text: "The bootstrapper installs the Autopilot Monitor Agent on each enrolling device." },
|
||||
{ step: "3", text: "The agent captures live enrollment events and uploads them to the backend pipeline." },
|
||||
{ step: "4", text: "The portal displays real-time session data, analyze rule results, and fleet health metrics." },
|
||||
{ step: "5", text: "On completion, the agent optionally self-destructs and uploads a diagnostics bundle." },
|
||||
{ step: "5", text: "On completion, the agent uploads a diagnostics bundle if configured, then removes itself." },
|
||||
].map((item) => (
|
||||
<li key={item.step} className="flex items-start gap-3 text-sm text-gray-700">
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white text-xs font-bold">
|
||||
@@ -189,7 +189,7 @@ export default function AboutPage() {
|
||||
{
|
||||
title: "Backend",
|
||||
items: [
|
||||
"Azure Functions (.NET 8 Isolated) — serverless, scalable API",
|
||||
"Azure Functions (.NET 10 Isolated, Flex Consumption) — serverless, scalable API",
|
||||
"Azure Table Storage — high-throughput event ingestion",
|
||||
"Azure Blob Storage — diagnostics and log bundle storage",
|
||||
"Azure SignalR Service — real-time push to the portal",
|
||||
@@ -198,10 +198,10 @@ export default function AboutPage() {
|
||||
{
|
||||
title: "Portal (Web Frontend)",
|
||||
items: [
|
||||
"Next.js 18 + TypeScript — fast, server-rendered React app",
|
||||
"Next.js 15 (React 18) + TypeScript — fast, server-rendered React app",
|
||||
"Microsoft Entra ID (MSAL) — secure authentication",
|
||||
"Role-based access control (Admin / Operator)",
|
||||
"Multi-tenant architecture",
|
||||
"Role-based access control (Admin / Operator / Viewer)",
|
||||
"Multi-tenant architecture with delegated MSP access",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -209,16 +209,16 @@ export default function AboutPage() {
|
||||
items: [
|
||||
".NET binary — lightweight, low-overhead monitoring",
|
||||
"Runs via scheduled task (no Windows service — easy, residue-free removal)",
|
||||
"Deployed via Intune bootstrapper script (Win32 app)",
|
||||
"Client certificate authentication",
|
||||
"Optional self-destruct on enrollment completion",
|
||||
"Deployed via an Intune platform script (PowerShell bootstrapper)",
|
||||
"Mutual TLS using the existing Intune MDM device certificate",
|
||||
"Self-destruct on enrollment completion (on by default — removes task and files)",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Integrations",
|
||||
items: [
|
||||
"Microsoft Intune — agent deployment target",
|
||||
"Microsoft Teams — enrollment failure notifications",
|
||||
"Microsoft Teams, Slack & generic JSON webhooks — start, success, failure and SLA alerts",
|
||||
"Intune Management Extension (IME) log — event source for log pattern detection",
|
||||
"WMI & Registry — extended data gather rules",
|
||||
],
|
||||
@@ -249,11 +249,17 @@ export default function AboutPage() {
|
||||
extended by the wider Windows Autopilot community.
|
||||
</p>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
Autopilot Monitor was created by{" "}
|
||||
Autopilot Monitor was created and is maintained by{" "}
|
||||
<strong>Oliver Kieselbach</strong>, a Microsoft MVP and long-time contributor to the Windows
|
||||
Autopilot and Microsoft Intune community. The project is driven by real-world enterprise
|
||||
deployment experience and community feedback.
|
||||
</p>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
The hosted service is operated by <strong>glueckkanja AG</strong> — see the{" "}
|
||||
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 underline">Imprint</a> for company
|
||||
details and the <Link href="/terms" className="text-blue-600 hover:text-blue-800 underline">Terms of Use</Link>{" "}
|
||||
for what each plan does and does not commit to.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 mt-5">
|
||||
<a
|
||||
href="https://github.com/okieselbach/Autopilot-Monitor"
|
||||
|
||||
@@ -293,7 +293,16 @@ export function GatherRuleCard({
|
||||
)}
|
||||
{rule.trigger === "phase_change" && rule.triggerPhase && (
|
||||
<span className="text-gray-500">
|
||||
on phase: <code className="px-1 bg-gray-200 rounded text-xs">{rule.triggerPhase}</code>
|
||||
entering <code className="px-1 bg-gray-200 rounded text-xs">{formatGatherPhase(rule.triggerPhase)}</code>
|
||||
</span>
|
||||
)}
|
||||
{rule.trigger === "phase_exit" && (
|
||||
<span className="text-gray-500">
|
||||
{rule.triggerPhase ? (
|
||||
<>leaving <code className="px-1 bg-gray-200 rounded text-xs">{formatGatherPhase(rule.triggerPhase)}</code></>
|
||||
) : (
|
||||
"on every phase exit"
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{rule.trigger === "on_event" && rule.triggerEventType && (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TARGET_PLACEHOLDERS,
|
||||
TARGET_HINTS,
|
||||
GATHER_PHASES,
|
||||
PHASE_TRIGGERS,
|
||||
EMIT_MODES,
|
||||
formatTrigger,
|
||||
} from "../types";
|
||||
@@ -425,7 +426,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.trigger === "phase_change" && (
|
||||
{PHASE_TRIGGERS.includes(form.trigger) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger Phase</label>
|
||||
<select
|
||||
@@ -433,7 +434,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, triggerPhase: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
>
|
||||
<option value="">Any phase change</option>
|
||||
<option value="">Any phase</option>
|
||||
{form.triggerPhase && !GATHER_PHASES.some((p) => p.value === form.triggerPhase) && (
|
||||
<option value={form.triggerPhase}>{form.triggerPhase} (legacy value)</option>
|
||||
)}
|
||||
@@ -441,7 +442,11 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">Run this rule when the enrollment reaches this phase. "Any phase change" fires once per phase transition.</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{form.trigger === "phase_exit"
|
||||
? "Collect once when the enrollment LEAVES this phase — a snapshot of its end state. \"Any phase\" fires on every phase transition. Also fires when the phase is left because enrollment failed."
|
||||
: "Collect once when the enrollment REACHES this phase. \"Any phase\" fires on every phase transition."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJs
|
||||
import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks";
|
||||
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
|
||||
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
|
||||
import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, withDerivedScopeMode } from "./types";
|
||||
import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, PHASE_TRIGGERS, withDerivedScopeMode } from "./types";
|
||||
import { GatherRuleFormFields } from "./components/GatherRuleFormFields";
|
||||
import { GatherRuleCard } from "./components/GatherRuleCard";
|
||||
|
||||
@@ -209,7 +209,7 @@ export default function GatherRulesPage() {
|
||||
parameters: buildParameters(form),
|
||||
trigger: form.trigger,
|
||||
intervalSeconds: form.trigger === "interval" ? form.intervalSeconds : null,
|
||||
triggerPhase: form.trigger === "phase_change" ? form.triggerPhase : null,
|
||||
triggerPhase: PHASE_TRIGGERS.includes(form.trigger) ? form.triggerPhase : null,
|
||||
triggerEventType: form.trigger === "on_event" ? form.triggerEventType : null,
|
||||
activePhases: form.scopeMode === "during" && form.activePhases.length > 0 ? form.activePhases : null,
|
||||
activeFromPhase: form.scopeMode === "from" && form.activeFromPhase ? form.activeFromPhase : null,
|
||||
@@ -296,7 +296,7 @@ export default function GatherRulesPage() {
|
||||
parameters: buildParameters(form),
|
||||
trigger: form.trigger,
|
||||
intervalSeconds: form.trigger === "interval" ? form.intervalSeconds : null,
|
||||
triggerPhase: form.trigger === "phase_change" ? form.triggerPhase : null,
|
||||
triggerPhase: PHASE_TRIGGERS.includes(form.trigger) ? form.triggerPhase : null,
|
||||
triggerEventType: form.trigger === "on_event" ? form.triggerEventType : null,
|
||||
activePhases: form.scopeMode === "during" && form.activePhases.length > 0 ? form.activePhases : null,
|
||||
activeFromPhase: form.scopeMode === "from" && form.activeFromPhase ? form.activeFromPhase : null,
|
||||
|
||||
@@ -61,7 +61,10 @@ export interface NewRuleForm {
|
||||
|
||||
export const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enrollment"] as const;
|
||||
export const COLLECTOR_TYPES = ["registry", "eventlog", "wmi", "file", "command_allowlisted", "logparser", "json", "xml"] as const;
|
||||
export const TRIGGERS = ["startup", "phase_change", "interval", "on_event"] as const;
|
||||
export const TRIGGERS = ["startup", "phase_change", "phase_exit", "interval", "on_event"] as const;
|
||||
|
||||
/** Triggers that collect at a phase boundary and therefore use the triggerPhase field. */
|
||||
export const PHASE_TRIGGERS: ReadonlyArray<string> = ["phase_change", "phase_exit"];
|
||||
export const SEVERITIES = ["info", "warning", "error", "critical"] as const;
|
||||
|
||||
// Canonical phase-scope tokens: the backend EnrollmentPhase enum NAMES from Start(0) through
|
||||
@@ -168,7 +171,8 @@ export const EMPTY_FORM: NewRuleForm = {
|
||||
|
||||
export function formatTrigger(trigger: string) {
|
||||
switch (trigger) {
|
||||
case "phase_change": return "Phase Change";
|
||||
case "phase_change": return "Phase Start";
|
||||
case "phase_exit": return "Phase End";
|
||||
case "on_event": return "On Event";
|
||||
default: return trigger.charAt(0).toUpperCase() + trigger.slice(1);
|
||||
}
|
||||
|
||||
@@ -838,6 +838,7 @@ export default function LandingPage() {
|
||||
<ul className="space-y-1.5">
|
||||
<li><a href="/privacy" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Privacy Policy</a></li>
|
||||
<li><a href="/terms" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Terms of Use</a></li>
|
||||
<li><a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Imprint</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -847,7 +848,7 @@ export default function LandingPage() {
|
||||
<div className="mt-8 pt-5 border-t border-gray-200 flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-gray-400">
|
||||
© 2026 Autopilot Monitor. Built with ❤️ by Oliver Kieselbach.{" "}
|
||||
<span className="inline-block">Powered by Azure.</span>
|
||||
<span className="inline-block">Hosted on Azure by glueckkanja AG.</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Open source. Star us on{' '}
|
||||
|
||||
@@ -1,96 +1,212 @@
|
||||
import { PublicPageHeader } from "../../components/PublicPageHeader";
|
||||
|
||||
const LAST_UPDATED = "21 July 2026";
|
||||
const DOCS_SECURITY_FAQ = "https://docs.autopilotmonitor.com/trust/security-faq";
|
||||
const DOCS_SUBPROCESSORS = "https://docs.autopilotmonitor.com/trust/subprocessors";
|
||||
|
||||
function DocsLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:text-indigo-800 underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<PublicPageHeader title="Privacy Policy" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Data Collection</h2>
|
||||
<p className="text-sm text-gray-500">Last updated: {LAST_UPDATED}</p>
|
||||
<p className="text-gray-700">
|
||||
This service collects and processes the following data to provide monitoring functionality:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>Device hardware information (manufacturer, model, serial number)</li>
|
||||
<li>Autopilot provisioning session data (status, events, timestamps)</li>
|
||||
<li>Azure AD/Entra ID tenant information</li>
|
||||
<li>User authentication information (UPN, display name, tenant ID)</li>
|
||||
<li>Operational telemetry and audit logs</li>
|
||||
<li>Anonymized usage telemetry (via Azure Application Insights, no cookies) — used to understand which features are used and improve the service. No personal data is transmitted.</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Data Processing Context</h2>
|
||||
<p className="text-gray-700">
|
||||
During an Autopilot enrollment, the user authenticates solely to verify their identity and initiate the process.
|
||||
After that, the user is not actively interacting with the device while provisioning runs. As a result, the data
|
||||
collected by the monitoring agent consists exclusively of <strong>technical enrollment events</strong> — no user
|
||||
activity, browsing data, or personal content is captured.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Tenant administrators retain full control over collected data through the following options:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Data Retention</strong> — configurable retention period per tenant (default 90 days); expired sessions are automatically purged</li>
|
||||
<li><strong>Delete Session</strong> — delete individual monitoring sessions on demand</li>
|
||||
<li><strong>Offboard Tenant</strong> — remove all data and configurations for a tenant from the service</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
These controls ensure that no personal information accumulates in the backend beyond what is necessary for
|
||||
enrollment monitoring. The service is designed for operational transparency, not user surveillance.
|
||||
Autopilot Monitor collects technical telemetry about Windows Autopilot enrollments so that IT teams can see
|
||||
what happened during a device provisioning and why it failed. This policy explains what is collected, where it
|
||||
is stored, who can reach it, and how long it is kept. The technical detail behind every statement here is
|
||||
published in the <DocsLink href={DOCS_SECURITY_FAQ}>Security & Privacy FAQ</DocsLink>.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Data Storage & Security</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Roles: Who Is Responsible for What</h2>
|
||||
<p className="text-gray-700">
|
||||
The platform is built with a layered security architecture designed to protect data at every level:
|
||||
For enrollment telemetry, <strong>your organization is the controller and Autopilot Monitor acts as your
|
||||
processor</strong>. You decide which devices are monitored, what additional data your gather rules collect,
|
||||
how long it is retained, and when it is deleted.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
For operating the service itself — administrator accounts, the audit trail of portal actions, and operational
|
||||
telemetry — <strong>glueckkanja AG</strong> is the controller.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
glueckkanja AG, a German company certified to ISO/IEC 27001, operates Autopilot Monitor for{" "}
|
||||
<strong>both plans</strong> — the same service, the same infrastructure, the same protection measures. Company
|
||||
details are in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>.
|
||||
The project is maintained by Oliver Kieselbach, who acts in that role on behalf of glueckkanja AG and is the
|
||||
contact for the open-source project and the Community edition.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
A <strong>data processing agreement (DPA / AVV) is available on request</strong>, concluded with
|
||||
glueckkanja AG. On the Enterprise plan it forms part of the written agreement.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">Authentication & Device Identity</h3>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">What We Collect</h2>
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">From enrolling devices</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>Device agents authenticate via <strong>Intune MDM client certificates</strong>, validated against the embedded Intune CA chain</li>
|
||||
<li>Web users authenticate via <strong>Microsoft Entra ID (Azure AD)</strong> with multi-tenant JWT validation</li>
|
||||
<li><strong>Autopilot device validation</strong> via Microsoft Graph — only registered Autopilot devices are accepted</li>
|
||||
<li>Optional <strong>hardware whitelist</strong> for additional device verification</li>
|
||||
<li>Per-device <strong>rate limiting</strong> (sliding window) to prevent abuse</li>
|
||||
<li><strong>Device identity</strong> — serial number, device name, manufacturer, model, and the Entra ID tenant the device enrolls into</li>
|
||||
<li><strong>Enrollment progress</strong> — phases, ESP stages, application and script results, policy activity, reboots, timings, and failure codes</li>
|
||||
<li><strong>Device context</strong> — OS build, hardware characteristics, disk and network state, plus whatever your own gather rules request</li>
|
||||
<li><strong>Approximate location</strong> — country, region, city, and approximate coordinates, if geolocation is enabled for your tenant</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">Tenant Isolation</h3>
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">From portal users</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>Strict <strong>multi-tenant data isolation</strong> — all storage queries are partitioned by Tenant ID</li>
|
||||
<li>Real-time channels (SignalR) are scoped to <strong>tenant-specific groups</strong></li>
|
||||
<li>Independent configuration, audit logs, and device management per tenant</li>
|
||||
<li>Sign-in identity from Microsoft Entra ID — user principal name, display name, and tenant ID</li>
|
||||
<li>Audit records of administrative actions, including who performed them and when</li>
|
||||
<li>Operational request telemetry used to run and support the service</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">Transport & Data Protection</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>All communication encrypted via <strong>HTTPS/TLS</strong>; real-time updates via secure WebSocket</li>
|
||||
<li>Diagnostics upload URLs are issued on-demand, and never persisted on the device</li>
|
||||
<li>Azure Storage encryption at rest for all persisted data</li>
|
||||
<li>PII logging disabled in production environments</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">Access Control</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Role-based access</strong>: Tenant Admin (full tenant management), Operator, Users</li>
|
||||
<li>Device blocking capabilities for compromised or unauthorized devices</li>
|
||||
<li>Comprehensive <strong>audit logging</strong> of administrative actions</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Data Sharing</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">What We Do Not Collect</h2>
|
||||
<p className="text-gray-700">
|
||||
Your data is not shared with third parties. Access is restricted to:
|
||||
During Autopilot provisioning the user signs in once to start the process and then does not interact with the
|
||||
device while it provisions. The agent captures <strong>no browsing history, no file or document content, no
|
||||
keystrokes, no screen content, and no application usage tracking</strong>. It removes itself when enrollment
|
||||
finishes. The service is designed for operational transparency, not user surveillance.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Gather rules cannot be used to widen this: <code className="text-sm bg-gray-100 px-1 py-0.5 rounded">C:\Users</code>{" "}
|
||||
is always blocked for privacy reasons, and downloading files, creating users, manipulating boot configuration,
|
||||
and establishing persistence are hard-blocked and cannot be enabled by configuration.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">IP Addresses and Geolocation</h2>
|
||||
<p className="text-gray-700">
|
||||
Geolocation is a tenant setting and is <strong>enabled by default</strong>. While it is enabled:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>Authenticated users within your tenant</li>
|
||||
<li>Global Administrators (for platform operations and support)</li>
|
||||
<li>The <strong>session record</strong> stores only the derived location — country, region, city, and approximate coordinates. The IP is deliberately excluded from the location event shown in the timeline.</li>
|
||||
<li>The <strong>outbound public IP is additionally stored once per session as a separate diagnostic event.</strong> It is hidden from the timeline view by default, but it is retained and queryable, and it expires with your retention period like any other event.</li>
|
||||
<li>Service request telemetry does not carry device IP addresses; the platform masks them and the application never sets them.</li>
|
||||
<li>A source IP is also stored on <strong>distress reports</strong> — the emergency channel an agent uses to report that it is failing — as part of that incident record.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
<strong>Disabling geolocation stops all of this</strong> — no IP and no location data is collected. It is a
|
||||
tenant setting and also an agent command-line switch. Only the Geographic Performance view is affected.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Where Your Data Is Stored</h2>
|
||||
<p className="text-gray-700">
|
||||
All customer data — sessions, events, configuration, audit logs, diagnostics, and backups — is stored in
|
||||
Microsoft Azure in <strong>Germany West Central</strong>. The only component outside that region is the portal
|
||||
front-end, served as static assets from West Europe; it stores no customer data. There is no cross-region
|
||||
replication and no transfer of customer data outside the EU by the platform.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Who Can Access Your Data</h2>
|
||||
<p className="text-gray-700">
|
||||
Your data is not sold, and it is not shared with third parties for their own purposes. Access is limited to:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Authenticated users in your own tenant</strong>, according to their role — Admin, Operator, Viewer, or a role-less Member limited to the Progress Portal</li>
|
||||
<li><strong>Platform operators</strong> — Global Admin for operations and support, and Global Reader for read-only support with configuration secrets redacted</li>
|
||||
<li><strong>Delegated (MSP) administrators you or your provider have been granted</strong> — read-only, limited to exactly the tenants in scope, with configuration secrets redacted, and with every grant and revocation written to <em>your</em> tenant's audit log so you can always see who was given access to your data</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
Delegated administration is an Enterprise capability and is off unless explicitly granted. Full detail on the
|
||||
isolation model is in the <DocsLink href={DOCS_SECURITY_FAQ}>Security & Privacy FAQ</DocsLink>.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Sub-processors and Outbound Data Flows</h2>
|
||||
<p className="text-gray-700">
|
||||
<strong>Microsoft Azure is the only sub-processor that stores your data.</strong> Resend is used solely to
|
||||
deliver the onboarding approval email when a tenant is activated — it receives an administrator's email
|
||||
address and the tenant domain, never enrollment telemetry. Vulnerability reference data is pulled inbound from
|
||||
NVD, the CISA KEV catalog, and MSRC; no customer data is sent to them.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Some data flows exist only because you configure them: your own Azure storage account for diagnostics,
|
||||
notification channels such as Teams, Slack, or a generic webhook, and your own AI assistant if a user connects
|
||||
one through the MCP integration. The platform itself makes no calls to any AI or LLM provider. The complete
|
||||
list is published as <DocsLink href={DOCS_SUBPROCESSORS}>Sub-processors</DocsLink>, and new sub-processors are
|
||||
announced there before they are introduced.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Diagnostics Uploads</h2>
|
||||
<p className="text-gray-700">
|
||||
Diagnostics upload is <strong>off by default</strong>. When enabled, the default destination is{" "}
|
||||
<strong>your own Azure storage account</strong> — the package never reaches our infrastructure. Hosted upload
|
||||
exists as an alternative but is opt-in only and requires an explicit administrator action behind a clearly
|
||||
marked disclosure that data leaves your tenant. It is never enabled silently.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">How Long We Keep Data</h2>
|
||||
<p className="text-gray-700">
|
||||
Retention is <strong>configured by you per tenant, defaulting to 90 days</strong> — 7 to 90 days on the
|
||||
Community plan and 7 to 365 days on Enterprise. Expired sessions are purged automatically. You additionally
|
||||
control:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Delete session</strong> — remove an individual monitoring session on demand</li>
|
||||
<li><strong>Offboard tenant</strong> — remove your tenant's data and configuration from the service entirely, as a verified multi-phase cascade</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
Two things intentionally survive tenant offboarding, neither of which contains enrollment telemetry or
|
||||
personal data:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Product feedback</strong> you submitted — it is not tied to enrollment data and is what improves the product.</li>
|
||||
<li><strong>Custom rules and IME log patterns</strong> you authored are archived rather than deleted. Detection knowledge is what makes this product useful, so contributed rules and patterns are subject to entering the community pool — the next organization hitting the same enrollment failure gets a diagnosis instead of a mystery. A rule is a detection definition, not device data.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
Either can be removed on request.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">How Your Data Is Protected</h2>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Device authentication by mutual TLS</strong> using the Intune MDM client certificate, validated against pinned Intune root CAs rather than the operating system trust store — and rejected if no trust anchor loads</li>
|
||||
<li><strong>Autopilot device validation via Microsoft Graph</strong> — only devices registered in your tenant are accepted, with an optional hardware allow-list on top</li>
|
||||
<li><strong>Entra ID authentication</strong> for portal users with a restricted signing-algorithm allow-list and identity PII logging disabled</li>
|
||||
<li><strong>Fail-closed authorization</strong> — every API route must be registered with an access policy; an unregistered route is unreachable</li>
|
||||
<li><strong>Structural tenant isolation</strong> — storage partitioning by tenant, with the tenant identity taken from the validated token and never from a client-supplied header</li>
|
||||
<li><strong>Encryption</strong> — HTTPS with a TLS 1.2 floor in transit, Azure Storage encryption at rest with platform-managed keys</li>
|
||||
<li><strong>Managed identity instead of storage keys</strong>, and secret-less OIDC deployment pipelines</li>
|
||||
<li><strong>Rate limiting</strong> per device, per portal user, and per MCP user</li>
|
||||
<li><strong>Verifiable agent binaries</strong> — Sigstore build attestation plus a four-stage integrity chain through download, backend cross-check, and runtime self-verification</li>
|
||||
<li><strong>Manifest-first deletion</strong> — what will be deleted is captured, restorably, before anything is removed</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Your Rights</h2>
|
||||
<p className="text-gray-700">
|
||||
As this is an environment operated under best-effort principles, formal data subject rights (access, deletion, portability)
|
||||
are not guaranteed. However, we will make reasonable efforts to accommodate such requests on a case-by-case basis.
|
||||
Where the GDPR applies, you have the right to access, correction, deletion, restriction of processing,
|
||||
portability, and to object to processing. We act on such requests within the statutory time limits. In
|
||||
practice most requests resolve immediately in the portal, because deletion of individual sessions, full tenant
|
||||
offboarding, and the retention period are all controls you hold yourself.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Where Autopilot Monitor acts as your processor, requests from your own employees should be directed to your
|
||||
organization; we support you in fulfilling them.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Changes to This Policy</h2>
|
||||
<p className="text-gray-700">
|
||||
Material changes are reflected in the "last updated" date above and announced through the service
|
||||
announcements in the portal. If you hold a signed data processing agreement, notification follows the terms of
|
||||
that agreement.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Contact</h2>
|
||||
<p className="text-gray-700">
|
||||
For privacy questions, a data processing agreement, or a data subject request, contact glueckkanja AG using
|
||||
the details in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>.
|
||||
For the open-source project and the Community edition you can also reach the maintainer via{" "}
|
||||
<a href="https://www.linkedin.com/in/oliver-kieselbach" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">LinkedIn</a>{" "}
|
||||
or open a{" "}
|
||||
<a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">GitHub issue</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,47 +1,145 @@
|
||||
import { PublicPageHeader } from "../../components/PublicPageHeader";
|
||||
|
||||
const LAST_UPDATED = "21 July 2026";
|
||||
const DOCS_SECURITY_FAQ = "https://docs.autopilotmonitor.com/trust/security-faq";
|
||||
const DOCS_PLANS = "https://docs.autopilotmonitor.com/plans";
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<PublicPageHeader title="Terms of Use" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500">Last updated: {LAST_UPDATED}</p>
|
||||
<p className="text-gray-700">
|
||||
By using this service, you agree to the following terms:
|
||||
Autopilot Monitor is provided by <strong>glueckkanja AG</strong>, a German company certified to ISO/IEC
|
||||
27001 — see the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>.
|
||||
Both plans run on the same service and the same infrastructure; the plan determines operating limits,
|
||||
support, and contractual commitments, not how the service works.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
The project was created and is maintained by <strong>Oliver Kieselbach</strong>, who is also the contact for
|
||||
the open-source project and the Community edition. He acts in that role on behalf of glueckkanja AG; he is
|
||||
not a separate contracting party.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 mt-4">
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">Community</h3>
|
||||
<p className="text-sm text-gray-700">
|
||||
Free, invite-only, currently in Private Preview, and maintained by Oliver Kieselbach as an open
|
||||
community contribution. Provided <strong>without any commitment by glueckkanja AG</strong> as to
|
||||
availability, support, or fitness for a particular purpose. Support is community-based via GitHub.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">Enterprise</h3>
|
||||
<p className="text-sm text-gray-700">
|
||||
Commercial plan under a written agreement with glueckkanja AG. Includes support and reliability
|
||||
commitments, a data processing agreement, higher operating limits, extended retention, and delegated
|
||||
(MSP) administration. Where that agreement differs from these terms, the agreement prevails.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700">
|
||||
The sections below apply to both plans unless stated otherwise. Plan details are documented under{" "}
|
||||
<a href={DOCS_PLANS} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Plans</a>.
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded">
|
||||
<p className="text-blue-900 text-sm">
|
||||
<strong>Who can use it.</strong> Autopilot Monitor requires a Microsoft Entra ID tenant with Intune and
|
||||
registered Autopilot devices. The <strong>Community</strong> plan is open to anyone with such a tenant —
|
||||
organizations, non-profits, and individuals running their own lab or personal tenant alike. The{" "}
|
||||
<strong>Enterprise</strong> plan is a commercial agreement and is offered to businesses and organizations
|
||||
acting in a commercial or professional capacity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Acceptable Use</h2>
|
||||
<p className="text-gray-700">By using this service, you agree that:</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>The service is used to monitor and troubleshoot Windows Autopilot deployments for devices your organization is entitled to manage.</li>
|
||||
<li>You are responsible for ensuring your use complies with your organization's policies, your works council or employee representation obligations, and applicable law — including informing your users about monitoring where required.</li>
|
||||
<li>Access requires authentication via Microsoft Entra ID, and each tenant's data is accessible only to authorized users of that tenant, or to parties you have granted delegated access.</li>
|
||||
<li>You will not attempt to access another tenant's data, circumvent security controls, or probe the service for vulnerabilities without prior written agreement.</li>
|
||||
<li>You will not use gather rules or any other configuration to collect data unrelated to enrollment diagnostics, or to monitor individual employees.</li>
|
||||
<li>You will not use the service to build a competing product, or resell access without an agreement permitting it.</li>
|
||||
<li>Automated access, including through the MCP integration, stays within the published rate limits and quotas.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
Responsible disclosure of a security issue is welcome and will not be treated as a violation — report it
|
||||
privately rather than publicly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Delegated (MSP) Administration</h2>
|
||||
<p className="text-gray-700">
|
||||
Delegated administration lets a managing organization see a defined set of customer tenants from one place. It
|
||||
is an Enterprise capability and applies only where access has been granted.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>This service is provided for monitoring and troubleshooting Windows Autopilot deployments in your organization.</li>
|
||||
<li>You are responsible for ensuring that your use of this service complies with your organization's policies and applicable laws.</li>
|
||||
<li>Access to this service requires proper authentication via Azure AD/Entra ID.</li>
|
||||
<li>Each tenant's data is isolated and accessible only to authorized users of that tenant.</li>
|
||||
<li>You must not attempt to access data belonging to other tenants or circumvent security measures.</li>
|
||||
<li>The service operator reserves the right to suspend or terminate access at any time without prior notice.</li>
|
||||
<li>Delegated access is <strong>read-only</strong> and limited to exactly the tenants in scope. Configuration secrets are redacted.</li>
|
||||
<li>A grant is either assigned by platform operators or delegated by the customer's own tenant admin; customer-initiated delegations require approval before they take effect.</li>
|
||||
<li>Every grant, revocation, and disablement is recorded in the <strong>managed customer tenant's</strong> audit log, so a customer can always determine who holds access to their data.</li>
|
||||
<li>A managing organization acts as a processor or sub-processor of its customers and is responsible for having the necessary agreements in place with them.</li>
|
||||
<li>Access can be revoked at any time by the customer or by platform operators and takes effect within seconds.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Legal Disclaimer</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Availability, Support and Data</h2>
|
||||
|
||||
<div className="bg-amber-50 border-l-4 border-amber-500 p-4 rounded">
|
||||
<h3 className="font-semibold text-amber-900 mb-2">AS-IS, No Warranty</h3>
|
||||
<h3 className="font-semibold text-amber-900 mb-2">Community plan: provided "AS-IS", no warranty</h3>
|
||||
<p className="text-amber-800 text-sm">
|
||||
This software is provided "AS-IS" without any warranty of any kind, either expressed or implied,
|
||||
including but not limited to the implied warranties of merchantability and fitness for a particular purpose.
|
||||
The Community plan is provided free of charge, "AS-IS", and without warranty of any kind, express
|
||||
or implied, including the implied warranties of merchantability and fitness for a particular purpose. It is
|
||||
currently in Private Preview: updates are frequent, availability is not guaranteed, and data structures may
|
||||
change. Production use is permitted and intended — with that trade-off understood.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-gray-700">
|
||||
<p><strong>No Liability:</strong> The service operator shall not be liable for any damages, including but not limited to direct, indirect, incidental, special, consequential, or punitive damages arising out of the use or inability to use this service.</p>
|
||||
<p><strong>No Service Level Agreement (SLA):</strong> This service is provided without any guaranteed uptime or availability. Service interruptions, maintenance windows, or complete shutdown may occur without prior notice.</p>
|
||||
<p><strong>No Data Backup Guarantee:</strong> While we implement data retention policies, we do not guarantee the preservation of your data. You are responsible for maintaining your own backups and records.</p>
|
||||
<p><strong>Best Effort Support:</strong> Support is provided on a best-effort basis with no guaranteed response times or resolution commitments.</p>
|
||||
<p><strong>Use at Your Own Risk:</strong> By using this service, you acknowledge that you do so at your own risk and that you are solely responsible for any consequences arising from your use of the service.</p>
|
||||
<p><strong>Active Development Notice:</strong> This project is under active development. Service interruptions or unexpected behavior can occur at any time, especially when updates are deployed to the backend, web application, or agent components.</p>
|
||||
<p><strong>Availability.</strong> The Community plan carries no uptime or availability commitment; interruptions, maintenance, and changes can occur without prior notice. The Enterprise plan carries the availability commitments set out in its agreement.</p>
|
||||
<p><strong>Support.</strong> Community support is best-effort via <a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">GitHub issues</a>, provided by the project maintainer and the community, with no guaranteed response or resolution time; built-in rules and IME log patterns are community-maintained. Enterprise support follows the response commitments in its agreement.</p>
|
||||
<p><strong>Data durability.</strong> Autopilot Monitor is a monitoring system, not a system of record. Configuration, authorization, and rule data is backed up daily; session and event telemetry is time-bounded operational data and is <strong>not</strong> backed up. Retain anything you need for compliance or reporting purposes in your own systems.</p>
|
||||
<p><strong>Liability.</strong> To the extent permitted by law, glueckkanja AG is not liable for indirect, incidental, special, consequential, or punitive damages, or for loss of data, profit, or business, arising from use of or inability to use the service. For the Community plan, which is provided free of charge, liability is limited to intent and gross negligence. Liability for injury to life, body or health and under mandatory statutory provisions remains unaffected. For the Enterprise plan, the liability provisions of the written agreement apply.</p>
|
||||
<p><strong>Use at your own risk.</strong> The service reports on enrollments; it does not perform them. Operational decisions you take based on its output remain your responsibility.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Third-Party Data Sources & Attributions</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Your Data, Suspension and Termination</h2>
|
||||
<div className="space-y-2 text-gray-700">
|
||||
<p><strong>Ownership.</strong> Your enrollment telemetry remains yours. Autopilot Monitor processes it to provide the service, as described in the <a href="/privacy" className="text-indigo-600 hover:text-indigo-800 underline">Privacy Policy</a>.</p>
|
||||
<p><strong>Your controls.</strong> You set the retention period, delete individual sessions, and offboard your tenant entirely at any time — no support ticket required.</p>
|
||||
<p><strong>Rules you contribute.</strong> Analyze rules, gather rules, and IME log patterns you author are detection definitions, not device data. By creating them you grant a non-exclusive, royalty-free right to retain them and to include them in the shared community rule pool, so that other organizations can benefit from a detection you built. This is the reciprocal side of a product whose built-in rules are community-maintained. You keep the right to use your own rules however you like, and you can request removal from the pool at any time.</p>
|
||||
<p><strong>Suspension.</strong> Access may be suspended where use threatens the integrity, security, or availability of the service or of other tenants — for example abusive request volumes or attempts to reach other tenants' data. Where circumstances permit, notice is given first.</p>
|
||||
<p><strong>Termination.</strong> You may stop using the service and offboard at any time. For the Community plan, the operator may discontinue the service or an individual tenant's access; reasonable advance notice will be given except where security requires immediate action. Enterprise termination follows its agreement.</p>
|
||||
<p><strong>After offboarding.</strong> Your tenant's data is removed through a verified multi-phase cascade. Product feedback you submitted, and custom rules and IME log patterns you authored, are intentionally retained — neither contains enrollment telemetry or personal data. Both are removed on request.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Security and Transparency</h2>
|
||||
<p className="text-gray-700">
|
||||
The vulnerability correlation feature uses the following external data sources to identify known vulnerabilities in installed software:
|
||||
The security architecture, data residency, sub-processors, retention and deletion behaviour, and an explicit
|
||||
statement of what the service does <em>not</em> do are published in the{" "}
|
||||
<a href={DOCS_SECURITY_FAQ} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Security & Privacy FAQ</a>.
|
||||
A signed data processing agreement is part of the Enterprise plan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Third-Party Data Sources & Attributions</h2>
|
||||
<p className="text-gray-700">
|
||||
The vulnerability correlation feature uses the following external data sources to identify known
|
||||
vulnerabilities in installed software. These are inbound reference-data pulls — no customer data is sent to
|
||||
them.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
@@ -71,6 +169,15 @@ export default function TermsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Changes and Governing Law</h2>
|
||||
<div className="space-y-2 text-gray-700">
|
||||
<p><strong>Changes.</strong> These terms may be updated; the "last updated" date above reflects the current version, and material changes are announced through the service announcements in the portal. Continued use after a change constitutes acceptance.</p>
|
||||
<p><strong>Governing law.</strong> These terms are governed by German law. For the Enterprise plan, the governing-law and venue provisions of the written agreement apply.</p>
|
||||
<p><strong>Severability.</strong> If a provision is found unenforceable, the remaining provisions stay in effect.</p>
|
||||
<p><strong>Contact.</strong> Company details are in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>. For the project and the Community edition, reach the maintainer via <a href="https://www.linkedin.com/in/oliver-kieselbach" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">LinkedIn</a> or open a <a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">GitHub issue</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -536,6 +536,19 @@ export default function Navbar() {
|
||||
</svg>
|
||||
<span>Terms of Use</span>
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href="https://www.glueckkanja.com/en/imprint"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowHelp(false)}
|
||||
>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span>Imprint</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -700,6 +713,10 @@ export default function Navbar() {
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
<span>Terms of Use</span>
|
||||
</Link>
|
||||
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
|
||||
<span>Imprint</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user