Compare commits

...

122 Commits

Author SHA1 Message Date
Oliver Kieselbach b8312ee0c6 bump pointer: internal (D-384 released: installer 2.9, bootstrap MSI 1.4.0, docs, web, MCP)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 02:49:58 +02:00
Oliver Kieselbach d639d9dda9 Bootstrap MSI 1.4: the action runs only the exact loader the package embeds (SHA-256 baked in at build time)
The automated security review of the previous commit found that a publisher pin alone
would run any validly signed script of ours placed at the copy's path, such as an older
loader without the held handle. The build now bakes the SHA-256 of the signed loader into
the action (wix build -d LoaderSha256=...; the package refuses to build without it), and
the action compares the bytes it read against it before the signature check.

- Package.wxs: hash check, preprocessor guard, comment
- build-msi.yml: the build step hashes the signed loader and passes it to wix
- Bootstrap.Integration.Tests.ps1: the harness substitutes the hash of the loader it serves;
  a copy that is not the embedded loader, however validly signed, runs nothing
- BootstrapContractTests: the hash condition, the guard and the build step are pinned
- internal: D-384, footprint doc and plan updated

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 02:09:56 +02:00
Oliver Kieselbach f44e8aadb0 Bootstrap MSI 1.4 keeps its loader copy under ProgramData and runs only what it checked; installer 2.9 removes the copy (D-384)
MSI 1.3 (D-377) installed the loader under Program Files, where nothing removed it: a
customer found the file on every device. Everything of ours lives under ProgramData.

- Package.wxs 1.4.0: the loader copy goes back to <base>\Msi (new component GUID); the
  deferred SYSTEM action, started in System32, reads the copy once, checks our signature
  and the publisher pin on those bytes and runs those bytes from memory, so a file a user
  planted at that path leads to nothing and no handle keeps the installer from removing
  the copy. Every bracket and brace is escaped for the Windows Installer formatter.
- Install-AutopilotMonitor.ps1 2.9: Remove-MsiTransportFolder removes the copy in the
  agent-present branch (install and skip clear the base already) and an agent folder that
  is then empty; Remove-LegacyMsiLoaderFolder removes MSI 1.3's folder under Program Files
  in every outcome while devices still enroll with 1.3.
- Tests: installer unit tests for both functions; the loader's MSI contract; the harness
  asserts after every run that nothing exists outside ProgramData, runs the MSI 1.4 action
  read from Package.wxs (valid, foreign, unsigned, changed; install, skip, marker only,
  agent present) and expects MSI 1.3's folder gone; BootstrapContractTests pin the
  directory chain, the publisher pin, the escapes and the working directory.
- build-msi.yml: smoke job installs the built MSI on windows-2022/2025 with the deployment
  marker set and expects nothing under ProgramData or Program Files, then uninstalls.
- internal: D-384, footprint doc, plan, lessons, backlog.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-23 02:01:15 +02:00
Oliver Kieselbach e5b7003799 bump pointer: internal (ConfigMgr Watcher opt-in, D-382 updated)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:45:59 +02:00
Oliver Kieselbach 6db3cb9741 ConfigMgr Watcher: the ConfigMgr client check is opt-in per tenant (D-382)
The check from d4e67cdb now runs only for tenants that enable it, like the
RealmJoin Watcher: TenantConfiguration.EnableConfigMgrWatcher feeds
AnalyzerConfiguration.EnableConfigMgrWatcher (default off, ConfigVersion
42). With the setting off, DefaultComponentFactory does not create the
ConfigMgrClientHost, so other tenants' devices run no check at all.

- Portal: toggle "ConfigMgr Watcher" in Agent Settings, section field map,
  tenant config report.
- Privacy page: the check is named as optional.
- Shared manifest and the web and MCP wire types regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 01:45:22 +02:00
Oliver Kieselbach a213290eb6 bump pointer: internal (D-382 V1-style ConfigMgr detection, D-384 dropped, B-b2u, lesson)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:45:12 +02:00
Oliver Kieselbach d4e67cdbb5 ConfigMgr client detection is back in the V2 agent (D-382)
The V1 agent reported whether the Configuration Manager client is
installed; the check was lost when the V2 agent replaced the V1 enrollment
tracker. ConfigMgrClientProbe reads what V1 read (the CCM key, CCMSetup
LastValidVersion, the assigned site code, the %windir%\CCM folder, the
CcmExec service state) and keeps the V1 confidence score.
ConfigMgrClientDetector, polled by ConfigMgrClientHost every minute, emits
configmgr_client_detected with the V1 payload when the client is first seen
and again when its reported state changes, at most 10 times per run; a
restart into the same state stays silent.

- Session Info shows the latest detection; the V1 sentence about disturbed
  event sequences is gone.
- Privacy page: device context names the Configuration Manager client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:44:16 +02:00
Oliver Kieselbach e4d3e8ed30 bump pointer: internal (D-382 to D-384, provider-stall concept doc, configmgr-client-status plan phase 1a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:55:23 +02:00
Oliver Kieselbach d4774ed043 Provider-stall tripwire counts device-preparation providers only while device preparation is open (D-383)
A ConfigMgr client installed as a Win32 app registers its device-preparation
policy provider after its own install, with InstallationState=1, and nothing
completes it. The tripwire then reported a stall long after device
preparation had ended.

- EspPhaseSnapshot.DevicePreparationOpen decides whether device preparation
  can still hold the ESP (it runs or failed, or the phase is unknown).
  EvaluateStallTransition takes it as a parameter; the detector reads the
  phase through an injectable reader.
- The cross-restart fingerprint applies the same filter, so a restart does
  not report the same stall again.
- The message names the wait: Setup/Apps providers hold the ESP without any
  timeout, device-preparation providers until their timeout.
- The diagnosis names a stalled provider only for a failure in its ESP
  category; the waiting list of an unfinished session stays unfiltered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:55:00 +02:00
Oliver Kieselbach b9cc515258 bump pointer: internal (deploy of D-379, D-380, D-381 recorded)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 23:14:11 +02:00
Oliver Kieselbach fef1f54c09 bump pointer: internal (configmgr-client-status plan draft)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:25 +02:00
Oliver Kieselbach 60dc850317 web: AGENTS.md heading level, page lastmod regenerated for /about and /privacy
Both were left uncommitted in the working tree; committed as they stand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:59:25 +02:00
Oliver Kieselbach da58ac0a27 bump pointer: internal (B-ne6 join volume)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:51:57 +02:00
Oliver Kieselbach d1d15db4fe bump pointer: internal (backlog B-ne6 from the security review of D-381)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:49:30 +02:00
Oliver Kieselbach a3810b74a7 bump pointer: internal (SignalR revoke bound to the sign-in tenant, D-381)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:40:47 +02:00
Oliver Kieselbach 80d50a6d92 A live SignalR connection is addressed by its sign-in tenant; a revoke reaches only the tenant whose grant it ends (D-381)
Security finding (CWE-863): the SignalR user id was the bare UPN and every revoke cut that UPN across the
hub. A tenant admin could add any UPN (a Global Admin, another customer's operator) and remove, disable or
demote it in a loop, restarting that person's live connections in every tenant.

- Negotiate binds the user id to the sign-in tenant plus principal key from the validated request context
  (SignalRGroupHelper.UserId); a caller without a principal key negotiates without a user id, since every
  revocable join gate needs the key.
- ISignalRNotificationService.DisconnectUserAsync takes the tenant and the key. LiveStreamRevoker is its
  only caller and names the tenant each grant resolved under: member routes the route's tenant,
  self-service routes the managing tenant, Global Admin routes the tenant of the UPN's identity binding.
  Replacing or deleting a binding now cuts the streams signed in under the previous one.
- A self-service assignment confers scope only while its assignee is bound to the owning tenant, so a
  self-service grant resolves under the owner alone and the owner's revokes never need another tenant.
- Remove, disable and enable of a UPN without a row in the tenant answer 404 and change nothing: no audit,
  no push revoke, no end of an assignment, no cut.
- GlobalAdminService.DisableGlobalAdminAsync and its repository method had no caller and are removed.
- Tests pin the finding's attack, the 404s, the self-service gate, the binding routes and the call sites;
  each part was mutated back to the old behaviour and failed its tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 22:39:26 +02:00
Oliver Kieselbach 5371de3856 bump pointer: internal (D-380 verify items in B-pxg)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 22:24:55 +02:00
Oliver Kieselbach 483dc3de94 The onboarding seat belongs to the first activation and to the recorded member alone (D-380)
Two follow-up findings of the security scan against D-378.

- IsPromotableUpn is a shape test (contains '@', no whitespace, no #EXT#); the "System" prefix
  test is gone. It rejected real members such as systemadmin@… although every sentinel already
  fails the '@' test, so such a tenant got no seat and fell back to first-caller-wins.
- BuildAuthResult keys the first-user guard off a recorded OnboardedBy, not off its promotability:
  a recorded value nobody can seat still blocks every other roleless caller.
- RevokeAsync records the revoke (PreviewWhitelist row "revoked") before it deletes the activation,
  fail-loud; a re-approve never clears it, and activation seats nobody while it exists. A member
  the tenant removed stays removed whatever a Global Admin's revoke + re-approve does.
- Every successful first-admin seat (activation and first sign-in) writes the tenant's audit entry
  CREATE TenantAdmin, fail-soft, through a new longest TenantAdminsService constructor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 22:22:57 +02:00
Oliver Kieselbach 06b69aa0d8 The logparser pattern test ends at the first timeout or a 2 s budget, honours cancellation, caps the pattern at 2,000 characters, runs interpreted and admits two tests per instance (D-379)
Security finding (ReDoS / CWE-1333): a Tenant Admin of any tenant or a Global Reader could send a catastrophic pattern with 200 non-matching lines to POST rules/gather/test-pattern and cost ~200 s of a worker thread per request (one swallowed 1 s timeout per line), repeat it up to the portal budget and disconnect at once — nothing cancelled the work, nothing limited concurrency, the pattern length was bounded only by the body cap, and RegexOptions.Compiled emitted IL for every caller pattern.

- TestLogPatternFunction: the first RegexMatchTimeoutException ends the test, a 2 s EvaluationBudget ends it when every line is merely slow, and the invocation CancellationToken (client disconnect, host shutdown) ends it between two lines — without throwing. Lines after the stop get no row; LogPatternTestResult.StopReason (regex_timeout | time_budget | cancelled) and a note name the stop and the unevaluated range. A timeout on the last line is a complete test.
- ValidateRequest rejects patterns over 2,000 characters (the MCP schema's cap) before any Regex is built.
- BuildAgentRegex keeps the agent's semantic options and its 1 s match timeout but drops Compiled: the flag changes speed only, never a match result.
- TestLogPatternGate (singleton, limit 2) is entered before the regex is built; without a free slot the function answers 429 RateLimited with Retry-After: 3 and never queues. Stops and refusals log at Warning.
- Shared: LogPatternTestResult.StopReason (additive); manifests and wire types regenerated. MCP: the tool description says that the first timeout ends the test.
- Tests: pattern cap, interpreted construction with the agent's timeout, catastrophic pattern stops after one timeout (was ~200 s), last-line timeout is no stop, budget and cancellation stops, gate slots.

Submodule pointers: internal (plan, D-379, backlog B-66p/B-j42), .claude/commands (platform-health marker).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 21:35:14 +02:00
Oliver Kieselbach 351c8d4b3a bump pointer: internal (activation race fix live, D-378)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 21:01:23 +02:00
Oliver Kieselbach e704aa7359 Activation seats the onboarding admin before it activates the tenant; the first-user seat belongs to that member alone (D-378)
Security finding (CWE-367): the activation wrote the approved row first and the OnboardedBy
admin row a few storage round-trips later. In between the tenant was activated with no
members, and auth/me seated any roleless caller of that moment as Admin; every member of the
customer's Entra tenant could poll auth/me across the auto-approve moment.

- TenantApprovalService: uncached pre-check, then the OnboardedBy Admin row (conditional
  insert, best-effort, fresh config read), then the activation row (still the arbiter), then
  the welcome mail. The two tables cannot share a batch, so "before" is the guarantee.
- AuthFunction.BuildAuthResult: with a promotable OnboardedBy the first-user auto-admin goes
  to that UPN only; other roleless callers stay roleless. Without one (rows from before the
  field existed) the first sign-in still becomes the admin. The grant logs at Warning.
- Both first-admin writers use IAdminRepository.TryAddTenantMemberAsync (AddEntityAsync,
  409 = left untouched) instead of an upsert; TenantAdminsService.TryAddTenantAdminAsync
  replaces AddTenantAdminAsync. PreviewWhitelistService.IsApprovedFreshAsync is the pre-check.
- Dead after this: IsTenantAdminAsync on the service, the repository interface and the table
  repository, plus its test.
- Tests pin the write order, the designated-member guard (unit and auth/me end to end) and the
  conditional write; each part was mutated back to the old code and failed its tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 20:47:53 +02:00
Oliver Kieselbach 622a2437a0 bump pointer: internal (plan note on the customer wording correction, D-377)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 19:01:17 +02:00
Oliver Kieselbach 5ceab7e611 bump pointer: internal (bootstrap security findings released, D-377)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 18:52:47 +02:00
Oliver Kieselbach 8a7cd5ed98 bump pointer: internal (MSI field check and lab name both MSI paths)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:51:18 +02:00
Oliver Kieselbach 15804e2817 bump pointer: internal (bootstrap MSI agent start report closed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 18:20:39 +02:00
Oliver Kieselbach 194958d3cd bump pointer: internal (bootstrap security findings built, D-377)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 18:07:54 +02:00
Oliver Kieselbach 40323ca57e Bootstrap hardening after a security review, and the MSI channel installs again (D-377)
Findings of a Claude security scan against b4af8c7, each measured first, plus
the MSI channel incident a parallel session found.

- Installer 2.8 extracts the agent ZIP with its own containment check
  (Expand-AgentPayload): every entry is resolved against the payload folder
  before the first write, a rooted or escaping name refuses the package. The
  inbox Expand-Archive of Windows PowerShell 5.1 already refuses such entries
  (measured), so the finding was not exploitable as reported; the guarantee is
  now the script's own, pinned by tests, and Expand-Archive is banned.
- Installer 2.8 also moves PowerShell's own location out of the agent folder
  (Set-Location): on the MSI channel the location pointed at the deleted Msi
  folder and the agent start failed with error 267 since 2.7 went live. When
  the agent program never started, the Agent folder is removed again so a later
  run can install; the stderr line is flattened to one line.
- Run folders younger than an hour stay (a loader may still run there); the
  loader's log is taken only from a run folder that is ours.
- Loader 1.4 (minimum installer 2.8) checks before every download attempt that
  its run folder is still a real folder of ours, opens the download for reading
  before the signature and identity checks and holds it until the run ends, so
  what it checked is what runs and nothing can replace or delete it meanwhile.
- Agent: the protected folder security names Administrators as owner; a folder
  with another owner or a reparse point is not taken as protected, reparse points
  are refused, the base is created protected, install mode protects base and
  Agent and refuses to install when it cannot; every start re-checks both.
- Bootstrap MSI 1.3.0 installs its loader under Program Files, where a standard
  user cannot create the folder first.
- Tests: containment table, hour rule, held handle, owner and junction tests
  (ElevatedFact for the owner assignment), MSI location contract; harness
  scenarios for an escaping package (installer and OOBE script), the hour rule,
  the Program Files layout and an agent that does not start, with the install
  stand-in starting a real program from PowerShell's location. OOBE golden
  regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-22 18:07:51 +02:00
Oliver Kieselbach 1b4a87d467 bump pointer: internal (onboarding role fix live, D-376)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:53:00 +02:00
Oliver Kieselbach a43a4e474a New tenants' first admin keeps the Admin role right after activation (D-376)
After auto-activation, a third of new tenants' first admins were answered by
auth/me without a role and sent from the activation page to /progress. The
auto-approve worker promotes the onboarding member on another instance, so the
portal's caches never hear of it: auth/me combined a cached "no row" (refreshed
1.5 s after every activation poll by feedback/status) with a fresh member list,
and neither the role nor the first-user auto-admin applied.

- TenantAdminsService caches only an enabled membership; a missing or disabled
  row is read on every call. IsTenantAdminAsync reads through the same cache.
- auth/me takes the caller's row and "the tenant has any row" from one uncached
  partition read (GetMembershipSnapshotAsync); disabled and application rows
  still count as members.
- PreviewWhitelistService caches only an activation; "not activated" is read on
  every call, so the activation page leaves at the first poll after approval.
  IsApprovedFreshAsync is folded into IsApprovedAsync.
- Removed the dead ClearCacheForTenant, GetMemberRoleAsync and
  CanManageBootstrapAsync.

Tests reproduce the sequence over the real caches (auth/me and the first
dashboard request); each part fails against the old caching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:11:10 +02:00
Oliver Kieselbach 82dafc5e8f bump pointer: internal (bootstrap loader 1.3 / installer 2.7 released, D-375)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:33:56 +02:00
Oliver Kieselbach b4af8c77be Bootstrap integration tests read the publish script's output as text
GitHub Actions runs powershell steps with $ErrorActionPreference = 'stop', and
under Windows PowerShell 5.1 a stderr line of a native process redirected with
2>&1 then ends the test: the three publish refusals failed in CI although the
publish refused as it should. The publish dry runs now start pwsh through
System.Diagnostics.Process like every other scenario. Local run under 'stop':
199 of 199.

[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:59:45 +02:00
Oliver Kieselbach 0bfab326e5 bump pointer: internal (bootstrap loader 1.3 / installer 2.7 built, D-375)
[skip actions]: the scripts go out by a dispatch of publish-scripts.yml, channel dev first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:35:10 +02:00
Oliver Kieselbach 3a898b5071 Bootstrap leaves nothing behind: loader 1.3 and installer 2.7 (D-375)
- Loader 1.3 downloads into a protected run folder of its own
  (%ProgramData%\AutopilotMonitor-Loader-<8 hex>), runs the installer only when it
  is signed by the pinned publisher and declares the installer role and version
  2.7 or later, removes the folder after a successful run and never touches the
  agent folder. Unbound arguments now reach the installer by name; the array
  splat bound them by position.
- Installer 2.7 refuses without elevation and decides before it writes: guards
  read HKLM through the 64-bit view (a 32-bit host never saw the deployment
  marker), guard 5 is covered by the agent check. An agent's folder stays as it
  is, a skip without agent removes the folder, an install starts from an emptied,
  protected folder and stages the ZIP in a run folder. It moves the MSI's working
  directory out, writes stderr only on failure and removes the copy older loaders
  left at the fixed path.
- Help texts of both scripts state what customers need; .CHANGELOG, which made
  Get-Help ignore the help, gives way to the Platform Changelog.
- OOBE go-script: the installer's shared helpers, its own elevation check
  (#Requires is ignored under irm | iex), a run folder.
- Publish: version read line-anchored, the loader's own check on the exact
  publish bytes, installers before loaders, -Channel dev (dispatch input).
- Bootstrap MSI 1.2.0; Test-ShouldBootstrapAgent reads the 64-bit view;
  Uninstall removes loader run folders and keeps the Deployed marker.
- Tests: Pester unit suites, an integration harness running the real scripts
  (loader 1.2 and the MSI 1.0 runner frozen in TestData), BootstrapContractTests;
  the gate runs on windows-2022 and windows-2025, once more as SYSTEM, and against
  a real standard user.

[skip actions]: published by a dispatch of publish-scripts.yml, channel dev first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 13:34:54 +02:00
Oliver Kieselbach 9377671349 Customers metrics: each new customer links to its tenant in Tenant Management
The signup list under the chart now links every domain to
/admin/tenants/management?tenantId=…, the deep link the ops events
"View tenant" button already uses, so the list opens narrowed to that tenant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:31:27 +02:00
Oliver Kieselbach 902560692b bump pointer: internal (agent v2.0.1490 docs, web and MCP deployed, D-374)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:20:44 +02:00
Oliver Kieselbach 3946eddcea bump pointer: .claude/commands (ux-insights expected values)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:18:30 +02:00
Oliver Kieselbach 48e27fb2d6 bump pointer: internal (Mark as Succeeded released as agent v2.0.1490, D-374)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:29:17 +02:00
Oliver Kieselbach 41b33895c5 bump pointer: internal (Mark as Succeeded ends a running agent, D-374)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:53:18 +02:00
Oliver Kieselbach 157273726d Mark as Succeeded ends a running agent, and the agent acts on an admin mark once (D-374)
The backend repeats AdminAction on every telemetry response while the session
row carries it. The agent handled each echo anew: a new admin_marked_session
with an immediate upload, whose response carried the next echo. With a running
agent on a Mark-Succeeded session that looped at the device rate limit until
the agent ended; Mark-Failed ended the agent and stayed bounded.

- ServerControlPlane: AdminActionLatch admits the first AdminAction of a
  process; every later one only logs at Debug.
- AdminAction=Succeeded runs the agent's own termination pipeline with outcome
  Succeeded, like Failed: what the portal's confirmation dialog and the docs
  promise, and what the restart path already did.
- HandleAdminActionTests: Succeeded terminates; repeated echoes, a later
  different outcome and concurrent callers are handled once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 08:51:49 +02:00
Oliver Kieselbach 0d994773d8 bump pointer: internal, .claude/commands (entry channel released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:30:14 +02:00
Oliver Kieselbach 7dd9f81c31 bump pointer: internal, .claude/commands (entry channel, D-373)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:23:50 +02:00
Oliver Kieselbach 7d07cb7805 Marketing clicks and page views on www carry the channel their page load came in through (D-373)
On the public host a telemetry initializer adds the referrer's host and
the utm_source, utm_medium and utm_campaign tags of the entry URL to every
page view and custom event. Both come from the document itself
(document.referrer and the navigation entry), so nothing is stored and no
identifier is added; metrics, dependencies and the portal stay as they are.

The hero and final get-started buttons and the three Pro plan links now
navigate client-side, so landing, plans, get-started and the sign-in click
stay one page load. A guard fails on a plain <a> to /get-started or /plans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:23:03 +02:00
Oliver Kieselbach 485a5b4604 bump pointer: internal (TPM-PSS precision released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:08:15 +02:00
Oliver Kieselbach 2109133aee bump pointer: internal, .claude/commands (TPM-PSS TLS probe, D-372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:23:07 +02:00
Oliver Kieselbach 7ac7a5e8ba TPM-PSS distress carries a TLS probe: protocol, whether the certificate went out, who answered (D-372)
tls= was empty on every report: .NET Framework's SecureChannelFailure
carries no inner Win32 code. After a terminal SecureChannelFailure the
agent now opens one direct SslStream connection to the backend host with
the client certificate (system-default protocols, normal server
validation, never a proxy) and sends one GET of the agent config with the
agent's headers - the platform asks for the certificate only once it has
seen the path. The message carries tlsp=<protocol>,<m|->,<answer>:
b<status> only when the probe's own correlation id comes back (shared
ResponseOriginClassifier), cc<status> for the platform's "Client
Certificate Required" page, st/x/io otherwise; or unreachable,
hs(<SSPI code>), hs(srvcert), timeout, error. A server certificate not
issued by Microsoft appends ,i=<issuer CN> (TLS inspection).

The probe runs beside the TPM and Schannel reads, all three within 10 s
(4 s per network step), on the failure path only. The Schannel window now
ends when the failure is detected (ReadBetween), so the probe's own
handshake never shows up in sch=. A connect that fails after its timeout
is observed (an unobserved task exception would write crash artefacts);
an answer that began is kept when the server does not close.

Live, .NET Framework 4.8 against production without a certificate:
tls12,-,cc403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:22:56 +02:00
Oliver Kieselbach 014b3e1e83 bump pointer: .claude/commands (platform-health catalog notes)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:18:26 +02:00
Oliver Kieselbach b948d32c9a bump pointer: internal, .claude/commands (TPM-PSS precision, D-372)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:26:43 +02:00
Oliver Kieselbach 40d3922fe0 TPM-PSS distress says where the device stood when the failure began: ESP phase, OOBE flag, boot; a later success compares reboot and certificate (D-372)
The first reports with context all carried OobeState=completed: Windows'
OOBE flag flips at the desktop, was read at send time after minutes of
retries, and marks the later successful starts too, so it separated
nothing.

Agent: EspPhaseSnapshot reads the ESP registry once with the tracker's
own parser (device_preparation / device_setup / account_setup while
running, <category>_failed, *_done, esp_done, none, unavailable).
DistressDeviceContext (OOBE flag, ESP phase, boot from the uptime
counter) is captured before a registration's first attempt and travels
with the TPM-PSS report; other reports sample it at send time. The
reporter takes a DistressDetail (TPM version, context). sch= is always
set: the events, none, unreadable or timeout. The failed-run record keeps
the ESP phase and OOBE flag, and beside it - never on the wire - the boot
time and the client-certificate thumbprint; the next successful start's
agent_started reports priorRegistrationLastEspPhase, LastOobeState,
RebootedSince (boots more than 2 min apart) and CertChanged, and every
start reports espPhaseAtAgentStart.

Shared/backend: DistressReport.EspPhase (kept as [a-z_] up to 32) and
DeviceBootUtc (kept within 30 days before and 5 minutes after the report),
stored, logged and on the custom event; the TPM panel item carries the ESP
phase. The GA distress detail names the OOBE flag as such and shows ESP
phase and device boot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 11:26:41 +02:00
Oliver Kieselbach 37bd4c73f5 chore(deps-dev): bump vite to 8.3.4 in web and MCP
Closes Dependabot alerts #123 and #124 (GHSA-698v-4r3x-3vcv, launch-editor
escapeCmdArgs() omits % escaping on Windows). Lockfile-only: vite is a
transitive dev dependency of vitest, whose range already allows 8.3.x.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:21:07 +02:00
dependabot[bot] 9f4c1efe26 chore(deps): bump hono (#190)
Bumps the mcp-security group with 1 update in the /src/McpServer/autopilot-monitor-mcp directory: [hono](https://github.com/honojs/hono).


Updates `hono` from 4.13.7 to 4.13.13
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.13.7...v4.13.13)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.13.13
  dependency-type: indirect
  dependency-group: mcp-security
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 21:11:26 +02:00
Oliver Kieselbach 13a34fb7f1 bump pointer: internal (CI credential isolation released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:44:57 +02:00
Oliver Kieselbach 60a41e9778 CI: signing jobs run in the release environment
The code-signing identity trusts only environment:release now, so build-agent
release, build-msi sign-loader and release, and publish-scripts publish run in it.
Every job that federates to Azure carries an environment; the ref is no longer a
trusted subject. Help texts of the storage and endpoint helpers follow: the local
build publishes nothing since D-354.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:30:02 +02:00
Oliver Kieselbach 9aa5e46e22 bump pointer: internal (TPM-PSS distress released, D-370)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:36:32 +02:00
Oliver Kieselbach 3ba4a10031 bump pointer: internal, .claude/commands (TPM-PSS distress, D-370)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:56:56 +02:00
Oliver Kieselbach fd0ae98868 TPM-PSS distress says when and where, the tenant panel shows devices that connected later (D-370)
A TpmPssUnsupported report classifies the registration start that failed,
not the device: devices with older TPM firmware fail during setup and
connect after it, on 24H2, 25H2 and 26H2 alike.

Agent: every distress report carries the OS build and the Windows setup
state at send time; the TPM-PSS report adds the TPM manufacturer and
firmware and a language-neutral message with the NCrypt codes of the PSS
signs, the native code of the failed handshake and up to two Schannel
System-log events since the registration started (numeric properties
only). The TPM and System-log reads run in parallel for at most 5 s. The
send is awaited, since the run exits right after it. An attempt that got
an answer resets the last exception, so the probe never classifies an
earlier attempt. The run's last error carries the native code. Win32_Tpm
is read by one shared TpmInfoReader (tpm_status unchanged).

Shared/backend: one body cap for both sides (DistressReportMaxBytes 2048);
the agent shortens its message to stay within it. The endpoint keeps OS
build and setup state only in their format, stores the three fields and
adds them to the log and the custom event. GET audit/tpm-pss-unsupported
returns TPM version, OS build, setup state and connectedAt: the last
upload of a certificate-authenticated session of the serial after the
device's last report (bootstrap sessions excluded, six serials per
SessionsIndex query). The bell says the device could not connect and may
connect after setup and a restart.

Web: the Incompatible TPM panel shows TPM and Status columns, uses the
generated types and no longer says "never" or names a Windows version; the
GA distress detail shows the new fields, Cloud PC and the certificate.

Tests: probe message, Schannel tokens, native codes, payload cap, awaited
send, context bound, last-exception reset, connectedAt rule, filter
comparison limit, repository round trip, panel render. A device serial
from production data in a test fixture is replaced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:56:43 +02:00
Oliver Kieselbach 8101aa1e50 bump pointer: internal (push encryption claim in the docs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:02:55 +02:00
Oliver Kieselbach cf3647da49 bump pointer: internal (docs descriptions within GitBook's 200-character limit)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:34:40 +02:00
Oliver Kieselbach cafb1e9a33 bump pointer: internal (push customer release live, D-368, D-369)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:28:33 +02:00
Oliver Kieselbach d38265071d bump pointer: internal, .claude/commands (push customer release built, D-368, D-369)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:14:12 +02:00
Oliver Kieselbach 40d88ef1e0 Push notifications for every tenant: one Push channel per scope that gates every send, app-role members equal, alerts name the tenant (D-368, D-369)
- Tenant Admins add the Push channel; Telegram and e-mail stay Global-Admin-only
- One Push channel per scope on the tenant and ops paths; the dispatcher fans Push out once per send
- The channel also gates session-watch completions and the device test; paired devices stay listed while it is off
- Members whose role comes from an Entra app role pair and receive like table members; a paused device resumes at the owner's next sign-in
- SLA and failure-streak alerts name the tenant; the id moves to a Tenant ID fact
- The legacy single webhook accepts URL webhook providers only
- Portal: Conditional Access hint in the channel editor, neutral role texts
- Landing: push section with the Notify me when done animation and the hero announcement; SignalR is "live updates"
- Privacy, about and FAQ describe push notifications

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:13:58 +02:00
Oliver Kieselbach 14d1266b89 bump pointer: internal (target prefetch removal verified in production, D-366)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:48:43 +02:00
Oliver Kieselbach 0730373e9b bump pointer: internal (target prefetch removed, D-366)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:31:17 +02:00
Oliver Kieselbach 03f4470db6 The sign-in return no longer prefetches the target route (D-366)
Measured in production with 300 ms latency, router.prefetch loaded only the route tree while auth/me was in flight, and the replace still fetched the route payload after auth/me: no gain on the critical path, one to three extra requests per sign-in return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:31:16 +02:00
Oliver Kieselbach 697077c7e5 bump pointer: internal (sign-in return and preflight cache released, D-366, D-367)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 10:18:35 +02:00
dependabot[bot] befc101d8f chore(deps-dev): bump knip (#187)
Bumps the web group in /src/Web/autopilot-monitor-web with 1 update: [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip).


Updates `knip` from 6.40.0 to 6.41.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.41.0/packages/knip)

---
updated-dependencies:
- dependency-name: knip
  dependency-version: 6.41.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 10:03:08 +02:00
dependabot[bot] 7250baca02 chore(deps-dev): bump jscpd (#189)
Bumps the quality-tooling group in /tools/quality with 1 update: [jscpd](https://github.com/kucherenko/jscpd/tree/HEAD/rust/jscpd).


Updates `jscpd` from 5.4.0 to 5.4.1
- [Release notes](https://github.com/kucherenko/jscpd/releases)
- [Commits](https://github.com/kucherenko/jscpd/commits/v5.4.1/rust/jscpd)

---
updated-dependencies:
- dependency-name: jscpd
  dependency-version: 5.4.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: quality-tooling
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 10:02:53 +02:00
dependabot[bot] fa1809d910 chore(deps): bump the mcp group (#188)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 4 updates: [express](https://github.com/expressjs/express), [zod](https://github.com/colinhacks/zod), [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `express` from 5.2.1 to 5.3.0
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/v5.2.1...v5.3.0)

Updates `zod` from 4.6.5 to 4.6.7
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.6.5...v4.6.7)

Updates `knip` from 6.40.0 to 6.41.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.41.0/packages/knip)

Updates `typescript-eslint` from 8.71.1 to 8.71.2
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.2/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: express
  dependency-version: 5.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: zod
  dependency-version: 4.6.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: knip
  dependency-version: 6.41.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: typescript-eslint
  dependency-version: 8.71.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 10:02:41 +02:00
Oliver Kieselbach 9a718539fd bump pointer: internal, .claude/commands (sign-in return and preflight cache, D-366, D-367)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:46:15 +02:00
Oliver Kieselbach a1ba1fd1ab Sign-in returns no longer show the public landing, and API preflights are cached for two hours (D-366, D-367)
- MSAL's in-flight redirect state stays in its tab. storeAuthStateInCookie mirrored it into origin-wide cookies that MSAL reads first, so two tabs signing in at once overwrote each other and the first one failed with state_mismatch, stuck on the landing. A guard test pins the cache config.
- AuthGate keeps the auth-pending overlay for as long as the browser is signed in, so it lifts only once the target route has committed; it used to lift when loading ended and exposed the landing for the whole client navigation. The target's route payload is prefetched while auth/me is in flight.
- A sign-in return that carried a code but ends without an account starts one fresh sign-in from "/", keeping the saved deep link; any further failure in the tab hands over to ProtectedRoute's sign-in card on the deep link, which waits for a click. The auth response is read at module load (the router writes its hydration-time URL back), the tab's temporary MSAL keys are purged before a retry and before the card's sign-in, and loading holds while a redirect leaves the page. New events: auth_redirect_recovered, auth_redirect_recovery_exhausted, auth_redirect_failed (error codes only).
- host.json adds Access-Control-Max-Age: 7200 through the http customHeaders. The Functions host answers every preflight itself and its CORS policy carries no max-age; its custom-headers middleware runs outside the CORS middleware, so the header lands on the preflight too. A guard test pins the entry to exactly that header. The CORS startup log goes: it read settings this host does not use, at a level that never reached the telemetry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:46:03 +02:00
Oliver Kieselbach 94399170d8 bump pointer: internal (GA Customers dashboard released, D-365)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:36:02 +02:00
Oliver Kieselbach 824bc64f96 bump pointer: internal (GA Customers dashboard, D-365; IME diff note)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:17:21 +02:00
Oliver Kieselbach a7c3c16054 Global Admin sees new customers per day and the growth over time under Metrics > Customers (D-365)
The section reads the tenant configurations from config/all and derives everything in the browser: no endpoint, no counter, no backfill.

- Customers: new tenants per UTC day (per Monday-anchored week beyond 90 days), the window's best day, weekly average, the last 7 days, the total and the cumulative gain; 7/30/90/365 days in ?days=; clicking a column lists that bucket's tenants with their edition. A signup is a configuration row with a domain. The section is the first Metrics entry and the target of /admin/metrics.
- Charts: AppBarChart takes a category formatter, a custom tooltip, a column click and whole-number ticks; AppLineChart can hide its legend and use whole-number ticks. Existing callers get the same props as before.
- The Monday-of-week helper moves from the Fleet Health timeline into lib/timeWindow; the Stat tile duplicated in two metrics sections becomes one component, and the duplicate baseline loses that entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:15:29 +02:00
Oliver Kieselbach 525be338b8 bump pointer: internal (self-service assignee membership gate released, D-364)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:52:44 +02:00
Oliver Kieselbach 734ca2b8fc bump pointer: internal (D-364 dead code removed, texts approved, lesson)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:33:23 +02:00
Oliver Kieselbach 2a74395874 Dead delegated-admin and repository members go: status transition, single-row reads, MCP-user flag
None of them had a production caller. DelegatedAdminService.GetAssignmentsForUpnAsync and GetAssigneesForTenantAsync, DelegatedAdminService.SetStatusAsync with IAdminRepository.SetDelegatedAdminStatusAsync (only a test called it; the operator revoke is RemoveAsync), and IAdminRepository.GetDelegatedAdminAsync and IsMcpUserAsync, which only had their own implementation.

Revoke_InvalidatesCache now pins the revoke the operator route runs (RemoveAsync); Disable_InvalidatesCache covers SetEnabledAsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 17:32:55 +02:00
Oliver Kieselbach 012eb8c057 bump pointer: internal (self-service assignee membership gate, D-364)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:52:56 +02:00
Oliver Kieselbach 0c1b83268c A self-service assignee reads the managed tenants only while an enabled member of the managing tenant (D-364)
Removing or disabling a member under Access Management left their assignment to the tenant's self-service group in place, and the delegated scope never re-checked the membership, so the person kept reading every managed customer.

- Scope: an assignment to msp-{owner} confers the group's tenants only while the assignee holds an enabled TenantAdmins row in the owner (inside the cached row resolution, fail-closed, Warning when ignored). Operator groups and direct grants are unchanged.
- Access Management: remove and disable end the self-service assignment (DelegatedGroupAccess DELETE under every managed tenant, reason member-removed / member-disabled) before the existing stream cut. Re-enabling restores nothing.
- Assigning requires an enabled member and re-reads the membership after the write; the Global Admin group route follows the same rule for a self-service group (enabled member of the owner, bound to it, read-only).
- The SignalR group join resolves the delegated scope uncached, so the re-join right after a revoke cannot be admitted by another instance's warm cache.
- Web: Delegated Access offers only enabled members and says that removing or disabling a member ends their access; the Global Admin page hides the role select for self-service groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 16:52:39 +02:00
Oliver Kieselbach 1088b36b8b A malformed device error code no longer fails the session events read
ErrorCodeCatalog.TryLookupDetailed normalised any 0x-prefixed value without
checking its characters and then ran int.Parse on the low word, so a value
such as 0x8007zzzz threw FormatException. The backend enricher runs that
lookup over device-authored event data on every GET /api/sessions/{id}/events,
so one such event answered HTTP 500 for every reader of the session timeline.

- The catalog parses the hex body once with uint.TryParse (hex digits only)
  and derives the HRESULT_FROM_WIN32 low word from the number; it now returns
  null for any input instead of throwing.
- ErrorCodeEnricher.EnrichEvents isolates each event: a failed lookup leaves
  that event unenriched, logs a warning and the page still returns 200.
- Web errorCodeMap.ts gets the same hex-only check: parseInt read
  0x8007643z as 1603 and labelled the garbage ERROR_INSTALL_FAILURE.
  MCP already validated; a test pins it.

Tests: the reported shapes return null, an exhaustive sweep of short tails
after every prefix never throws, and the enricher keeps the rest of the page
enriched when one event fails. Against the previous catalog 10 of the new
tests fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:02:53 +02:00
Oliver Kieselbach dfd1b86e01 bump pointer: internal (delegated reach on operator routes live, D-363)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:21:56 +02:00
Oliver Kieselbach b8a663e11d bump pointer: internal (delegated reach on operator routes, D-363)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:03:58 +02:00
Oliver Kieselbach 38b499b80b Operator reads on global routes stay with platform roles; the middleware decides on the tenantId the handler reads (D-363)
Five platform reads take ?tenantId= only as a filter, but their QueryParam
scoping also admitted a delegated (MSP) reader of the named tenant:
global/session-reports, global/rule-submissions, global/metrics/mcp-usage,
global/metrics/mcp-usage/daily and global/metrics/verdict-calibration. They
carry operator data (admin notes, contact data and reviews, an organization's
accounts, classifier diagnostics), so they are catalogued None now; the
handlers still read ?tenantId= themselves, and Global Admins and Global
Readers keep the filter.

- A catalog ratchet in PolicyEnforcementMiddlewareTests lists every platform
  route a delegated caller reaches; a new one fails the build until it is
  added on purpose. The TenantScoping.QueryParam doc states the rule.
- The middleware took the cross-tenant decision on the first ?tenantId=
  value while the handlers read all values comma-joined. It now decides on
  the joined string, with ASP.NET Core's parser (the handlers parse Url.Query,
  where System.Uri has already escaped a stray '%').
- The rule pages show a delegated reader the admin-only hint instead of the
  submission list, as they already did for an Operator or Viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 14:03:47 +02:00
Oliver Kieselbach 9252b8081f bump pointer: internal (rating feedback ops event live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:44:17 +02:00
Oliver Kieselbach ba92811f45 bump pointer: internal, .claude/commands (rating feedback ops event, D-362)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:24:38 +02:00
Oliver Kieselbach 0b813891e4 Rating feedback raises its own ops event; Alert Rules in the Ops menu with a search (D-362)
A rating from the feedback bubble reached only the direct Telegram ping: it was the one customer
submission without an ops event and a bell. It now raises FeedbackRatingReceived (Tenant, Info) and a
Global Admin bell, so an alert rule can route it to the Push channel. Message and details carry the
stars and the comment length, never the comment or the sender; the Telegram ping stays until it is
retired. The answer route admits members only (MemberRead, like feedback/general): it now feeds an
operator alert, and the bubble only ever asks members.

The alert rules page was linked from no menu. Ops > Alert Rules points at it, and a search in its
header narrows the rules by event type and category; it filters the display only, so a save keeps
every rule. navSectionReachability.test.ts holds the sidebar and the section registries together in
both directions. The ops events page and the rules search share one SearchInput.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:24:18 +02:00
Oliver Kieselbach dc4f6ed28b bump pointer: internal (tenant onboarding checklist)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:56:06 +02:00
Oliver Kieselbach 3564e5ba88 bump pointer: internal (app failure-rate base live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:43:08 +02:00
Oliver Kieselbach de00471da9 bump pointer: internal (app failure-rate base released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:07:31 +02:00
Oliver Kieselbach 9b3e9e3900 fix: app failure rates show the finished installs they are computed from
The device model table on the app detail page put the terminal-only failure
rate (failed / (failed + succeeded)) next to an Installs count that also held
in-progress, incomplete and skipped rows, so 11 failed of 28 read as 50%
without a way to check it.

- Version and device-model rows carry Succeeded on the wire.
- The device model table shows Finished (the rate's base) with a muted
  "+N not counted"; the Total card names the same rows.
- The PDF report gets Finished and Not counted columns in both tables and
  the hint on Total installs.
- The device-model sample floor counts exactly the rate's base; skipped rows
  no longer count toward it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:06:37 +02:00
Oliver Kieselbach 58e9f19f82 fix(web): a What's new link opens the panel after the sign-in round trip too
A portal link with ?whats-new= opened without a session flashed the panel,
went through the sign-in, and came back without it: MSAL lands on the
origin root and AuthGate reaches the saved link by a client-side
navigation, while WhatsNewPanelHost (root layout, stays mounted) read the
parameter only once on mount. It now follows the search parameter and
opens once per arrival; a protected page waits for the session, so the
panel no longer flashes before the redirect or counts the open twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:01:10 +02:00
Oliver Kieselbach 8e000f1849 bump pointer: internal (usage metrics breakdown released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:40:09 +02:00
Oliver Kieselbach 77de8c92c6 bump pointer: internal (usage metrics breakdown, D-361)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:20:56 +02:00
Oliver Kieselbach 17fc23f8cb feat: usage metrics split enrollments by type, join and deployment mode (D-361)
Usage Metrics (tenant page and the platform section) shows the window's
sessions in three groups that each sum to the session count:
Autopilot v1 or Device Preparation, Entra or hybrid joined, and the
Autopilot deployment mode (user-driven, self-deploying, pre-provisioned).
Type and mode follow the fleet enrollment class; user-driven no longer
counts the IsUserDriven flag, which every registration sets. The counts
come from the session rows on each request, so every retained session is
covered without a counter.

- DeploymentTypeMetrics gains the new counts and shares; the usage
  projection reads EnrollmentType, IsSelfDeployingProfile, IsHybridJoin
- platform and tenant compute share one builder per block
- Platform Usage takes ?days= like the other analytics pages
- the usage pages share their blocks (components/UsageMetricsBlocks.tsx);
  enrollment class labels and the time-attribution segments each live in
  one place, the duplication baseline shrinks by seven clones

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:19:48 +02:00
Oliver Kieselbach e046571350 bump pointer: internal (push deep links confirmed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:45:47 +02:00
Oliver Kieselbach 6b5c55998f bump pointer: internal (push deep links live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:38:49 +02:00
Oliver Kieselbach 6df57459c2 bump pointer: internal (push deep links, D-360)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:19:34 +02:00
Oliver Kieselbach caeb7a3e58 feat: every alert links to its subject; ops alerts open their own event (D-360)
A push carries only category, event and severity for an ops alert, so the
portal link is the way to the details, and ops alerts had none: the event
id was minted inside the save and never reached the dispatch.

Backend: SaveOpsEventAsync sets OpsEventEntry.Id (OpsEventId,
{Category}_{RowKey}); WriteAsync hands it to the dispatch, and BuildAlert
adds "Open in the portal" -> /admin/ops/?eventId=<id> on the plain and the
payload path, so every ops channel (push, Telegram, Teams, Slack, Discord,
e-mail, JSON) gets it. GET global/ops-events?id= is a point read (400 for a
malformed id, an empty list for a deleted one). hardware_rejected links to
the hardware whitelist (one path constant, also for the bell); the session
watch link names the session's tenant; TenantSignup's portalPath keeps its
tenant query. Only a session link makes a push session-bound: tag and Topic
no longer key on any "id=" in the link.

Web: /admin/ops?eventId= opens that event's detail from the point read
(matched by id), and the detail links to the tenant and to the event's
related page (same-origin paths only) besides the session, which now
carries the tenant. CopyButton and the cursor pager footer are shared
components instead of four and three copies; the duplicate baseline shrinks
by five entries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:19:22 +02:00
Oliver Kieselbach bf37fe46c1 bump pointer: internal (session block visibility RELEASED)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:43:24 +02:00
Oliver Kieselbach 3678995a9b bump pointer: internal (session block visibility, D-359)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:27:56 +02:00
Oliver Kieselbach 7a917eeaee feat: a session the platform blocked says so (D-359)
When the event limit or the platform operator blocks a session's device, the
session now carries a block record: Block* fields on the session and its index
row, a server-authored device_blocked timeline event, an interim analyze run for
that event and, on the auto path, an audit entry. The block list itself stays
platform-only.

- Portal: a card above the failure snapshot says the monitoring was paused
  (orange) or ended (red), when, until when and, for the event limit, the
  session's event count against the limit. The Blocked pill reads the record for
  every role; Global Admin mode still adds the live block list.
- A Kill is device-wide until it expires, so its record carries the end and the
  card says new enrollments on the device are not monitored until then.
- Status: the stalled and timeout reasons name the block instead of a silent
  agent; the classification is unchanged.
- Rules: ANALYZE-ENRL-006 explains the pause right after it (on_event
  device_blocked); ANALYZE-ENRL-002 no longer fires for a blocked session.
- MCP: the coverage block of get_session_summary names the block as a gap.
- The agent-facing block message no longer says "by an administrator".
- BlockCause == event_limit replaces ExcessiveEventsAutoActioned as the
  auto-action's idempotency marker.
- Privacy page: the event limit next to rate limiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:27:55 +02:00
dependabot[bot] d5a83374d0 chore(deps): bump the web group (#186)
Bumps the web group in /src/Web/autopilot-monitor-web with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/applicationinsights-web](https://github.com/microsoft/ApplicationInsights-JS) | `3.4.4` | `3.4.5` |
| [next](https://github.com/vercel/next.js) | `16.3.8` | `16.4.0` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.3.8` | `16.4.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.3.8` | `16.4.0` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.39.0` | `6.40.0` |


Updates `@microsoft/applicationinsights-web` from 3.4.4 to 3.4.5
- [Release notes](https://github.com/microsoft/ApplicationInsights-JS/releases)
- [Changelog](https://github.com/microsoft/ApplicationInsights-JS/blob/main/RELEASES.md)
- [Commits](https://github.com/microsoft/ApplicationInsights-JS/compare/v3.4.4...v3.4.5)

Updates `next` from 16.3.8 to 16.4.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.8...v16.4.0)

Updates `@next/bundle-analyzer` from 16.3.8 to 16.4.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.4.0/packages/next-bundle-analyzer)

Updates `eslint-config-next` from 16.3.8 to 16.4.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.4.0/packages/eslint-config-next)

Updates `knip` from 6.39.0 to 6.40.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.40.0/packages/knip)

---
updated-dependencies:
- dependency-name: "@microsoft/applicationinsights-web"
  dependency-version: 3.4.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: next
  dependency-version: 16.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: knip
  dependency-version: 6.40.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 15:12:49 +02:00
dependabot[bot] 596a9ffc2b chore(deps): bump the mcp group (#185)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 4 updates: [@modelcontextprotocol/server](https://github.com/modelcontextprotocol/typescript-sdk), [@modelcontextprotocol/client](https://github.com/modelcontextprotocol/typescript-sdk), [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `@modelcontextprotocol/server` from 2.3.0 to 2.3.1
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/@modelcontextprotocol/server@2.3.0...@modelcontextprotocol/server@2.3.1)

Updates `@modelcontextprotocol/client` from 2.3.0 to 2.3.1
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/@modelcontextprotocol/client@2.3.0...@modelcontextprotocol/client@2.3.1)

Updates `knip` from 6.39.0 to 6.40.0
- [Release notes](https://github.com/webpro-nl/knip/releases)
- [Commits](https://github.com/webpro-nl/knip/commits/knip@6.40.0/packages/knip)

Updates `typescript-eslint` from 8.71.0 to 8.71.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.71.1/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/server"
  dependency-version: 2.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: "@modelcontextprotocol/client"
  dependency-version: 2.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: knip
  dependency-version: 6.40.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: typescript-eslint
  dependency-version: 8.71.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-14 15:12:31 +02:00
Oliver Kieselbach 0e1428922f bump pointer: internal (diagnosis plan RELEASED)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:36:43 +02:00
Oliver Kieselbach 10314cd8f2 bump pointer: internal (diagnosis plan: live, visual check open)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:35:28 +02:00
Oliver Kieselbach 9ee882ab68 bump pointer: internal (diagnosis plan: customer docs approved and committed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 10:55:13 +02:00
Oliver Kieselbach eefa365b6c bump pointer: internal (diagnosis plan: docs drafted)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:30:35 +02:00
Oliver Kieselbach 21ca9eb068 bump pointer: internal (only an InProgress session stalls)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:28:53 +02:00
Oliver Kieselbach af86e8f7cc Sessions: only an In Progress session stalls
A stall report never overwrote a guard: the ingest branch for session_stalled
runs after the completion and failure chain, and the transition gate let
Stalled through over every status but Pending. An offline batch carrying
session_stalled next to enrollment_complete left a finished enrollment
Stalled until the timeout sweep reclassified it, and a sweep that lost the
race against a completion could do the same.

IsTerminalTransitionAllowed now allows Stalled only over InProgress, on the
normal and the ETag-retry path alike; the separate Pending guard is folded
into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:28:52 +02:00
Oliver Kieselbach 8c5fbd5739 bump pointer: internal (diagnosis headline concept doc, backlog side findings, plan phase 6 internal), .claude/commands (rule-builder presentation)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:19:30 +02:00
Oliver Kieselbach 55b667ad2c bump pointer: internal (D-358, diagnosis plan phase 5), .claude/commands (GetAppFleetContext marker, open_fleet_sessions)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:14:56 +02:00
Oliver Kieselbach 325632e780 Diagnosis: the culprit app in the tenant's other enrollments, and the rule line beside each finding (D-358)
GET apps/fleet-context (MemberRead, QueryParam) counts one app over the
tenant's last 7 days: installed, skipped, failed, incomplete, in progress,
collision rows left out, the three most frequent failure codes. It reads
the apps dashboard's partition query, leaves out the asking session and,
with the culprit's app id, another app of the same name; no session point
reads. AppFleetContextService caches the app's rows per tenant, channel,
name and minute for 60 s (single flight).

The classification is the analytics view's: MetricsMath.IsInstalled
replaces six inline copies, AppsAnalyticsHelper.IsSameApp and
TopFailureCodes are shared by analytics, sessions and the fleet context.

The diagnosis page shows the result as a tile in "Around the failure"
only when other enrollments exist (the read fails soft), with "Show
sessions" on the app page where that page shows the viewed tenant. An
expanded finding shows "Fired in N enrollments in the last 14 days"
through useRuleFireStats and RecentFiresLine, both extracted from the
session page, whose cross-tenant check now ignores case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 03:14:55 +02:00
Oliver Kieselbach 9ad3cb2df1 bump pointer: internal (diagnosis plan phase 4, D-356/D-357 amended, drift guards), .claude/commands (diagnosis telemetry rows)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:30:53 +02:00
Oliver Kieselbach 127d5c1173 Diagnosis: the page answers what happened, for every status with a verdict (D-356, D-357)
The diagnosis page is rebuilt on the server headline instead of rule
confidence: a verdict card per status and cause (culprit tiles, ESP
phases, what an unfinished enrollment still waits for), the
circumstances around it, the steps of the finding that explains it,
where the time went, all open findings grouped by role, team notes and
the deduplicated errors and warnings. It reuses the session page's hook
set, refetches the headline on live activity and opens for Failed,
Stalled, AwaitingUser, Incomplete and Succeeded with open findings, also
from read-only and cross-tenant views.

Rule text: lib/ruleText.ts prunes a finding's explanation and steps like
the notifications do (shared cases in tests/fixtures/rule-text-display,
also run by RuleTemplateInterpolator.InterpolateForNotification), then
parses the markdown and fills values into text nodes only. The session
page's analysis section, the rule card and the dry run render through it;
components/findings/EvidenceChips replaces the two evidence-link copies
and covers correlation pairs.

Vocabularies: DiagnosisPrerequisites names the engine's completion
prerequisites (manifest diagnosis.prerequisites, parity fact against
DecisionEngine.CompletionPrerequisites); the page's texts for every
diagnosis vocabulary are exhaustive over the manifest types.

Fixed on the way: useSessionSignalR held the first render's callbacks,
so ruleResultsReady was lost on an already connected hub; a deep link to
an event dropped its hash before the event loaded; the global analyze
rule update answered 500 for an invalid presentation (now 400); the
rule editor kept the presentation block on create and in JSON mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 02:30:29 +02:00
Oliver Kieselbach 6df88abdb3 bump pointer: internal (D-357, diagnosis plan phase 3), .claude/commands (GetSessionDiagnosis marker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:48:07 +02:00
Oliver Kieselbach 5c3d158d85 Sessions: GET sessions/{id}/diagnosis returns the session's verdict (D-357)
A typed headline built on demand from the session row and every event:
outcome = status; cause from the verdict path (derived for older rows,
flagged as a caveat), and for an explicit failure from the ESP evidence
(failed step, error code, culprit apps, AppX candidate, stuck policy
providers). Plus the ESP phase rollup, the ESP profile, what an unfinished
session still waited for, and observation caveats. Never from rule
confidence: Stalled, AwaitingUser and reconciled sessions get no terminal
rule run.

- App facts come from the session's own app events (the app table has no
  intent or targeting); only required apps count, a later install clears
  an app, a zero error code is none, and a 0x87d1041c ESP failure ranks
  the NotDetected app first (real IME payload shape).
- The event read is strict: a storage error is a 500, never a verdict
  built from no evidence. 400 for a non-GUID id, 404 for an unknown session.
- Helpers/EventDataReader replaces three private Data-string copies
  (classifier, failure snapshot, time attribution) and the list parsing in
  EspBlockingSets; EspBlockingSets.FromLatest replaces two backward walks.
- Six vocabularies in the shared manifest section `diagnosis`; parity facts
  pin the full and the minimal shape; route in the policy catalog.
- MCP get_session_summary carries it as `headline` right after coverage,
  null when the call fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 00:48:05 +02:00
Oliver Kieselbach 3bedc65ed5 bump pointer: internal (D-356 amended, diagnosis plan phase 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:36:33 +02:00
Oliver Kieselbach e5cb3c4f5e Rules: every analyze rule carries a presentation block (D-356)
All 54 shipped analyze rules get role, summary, subject and key facts as
{{signal.key}} templates, each with a minor version bump so the self-seed
picks them up. Values were checked against stored findings, the rows that
receive the block at read time.

- ENRL-001 repeats the outcome only: the 0x87D1041C paragraph and the
  placeholder note are gone, labels read "ESP step that failed" / "ESP
  error code", remediation is "Find the cause". This changes its alert text.
- ENRL-002 says the sweep "closed" the session (it can end Incomplete).
- ID-002 and ID-004 gain evidence-only conditions (unexpected account
  names; IME token failure count and codes); firing and confidence are
  unchanged.
- Resolution: facts sharing a label are alternatives, an exact repeat of
  label and value is left out (new shared fixture case, web module and
  its generated MCP copy).
- Ratchet: every built-in presentation passes the save validator, uses
  qualified tokens only, and each token names a condition of the rule and
  a key its source records (RuleTemplateInterpolator.TokenRegex internal).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:36:21 +02:00
Oliver Kieselbach d7b094852d bump pointer: internal (D-356, diagnosis plan phase 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:49:00 +02:00
Oliver Kieselbach 516cdc71a7 Rules: optional presentation block, {{signal.key}} tokens, one resolution for web and MCP (D-356)
Analyze rules can describe how a finding is shown: a role (outcome, cause, context,
advisory), a one-line summary, the subject it is about and up to six key facts. Like the
explanation, the block is stored raw on the result and resolved where it is rendered.

- Templates: a qualified token {{signal.key}} reads one key of one condition's evidence,
  so counts, durations and correlation fields are reachable. RuleEvidenceKeys (Shared,
  exported to the manifest) lists the keys each condition source records; tests pin it
  against the evaluators.
- Backend: PresentationJson on AnalyzeRules and RuleResults, RuleVersion on results,
  ValidatePresentation on create/update, and results stored without a presentation get
  the current rule's at read time.
- Web + MCP: lib/rulePresentation.ts resolves the block (unrecorded facts left out, summary
  falls back to the explanation). The MCP now runs generated copies of the web modules
  instead of a hand-kept mirror (npm run generate:mcp-logic, freshness tests in both
  suites); the duplicate baseline shrinks by one.
- MCP output: get_session and get_session_summary return the resolved block (issues gain
  ruleId, role, summary, subject, facts); test_analyze_rule previews it; validate_rule
  checks qualified tokens against the key catalog and lints the subject.
- Shared case files: rule-template-interpolation gains the qualified-token cases,
  rule-presentation is new.

No rule carries a presentation yet; renderers behave as before until rules gain one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 22:48:46 +02:00
Oliver Kieselbach 4a822767d4 bump pointer: internal (catalog check opens files exclusively, B-ute)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:33:02 +02:00
Oliver Kieselbach f82eabd6eb bump pointer: internal (payload catalog released, B-5q2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:21 +02:00
Oliver Kieselbach 1892da30e4 Agent: the bootstrap provenance also reads the log names from before 2026-08-18
The first device on 2.0.1486 reported no bootstrap log at all. Installers up to v2.3 write
bootstrap_agent.log, and the MSI's own downloader (before the loader of 2026-09-08)
msi-bootstrap.log; the reader knew only the names from the rename of 2026-08-18 on, so the
old directly assigned installers that agent_started is meant to count read as "no bootstrap".

- The installer log is the more recently written of bootstrap-script.log and
  bootstrap_agent.log; the MSI channel also covers msi-bootstrap.log, which has no version.
- Tests: the old installer name, both installer logs (the newer one counts), the old MSI
  downloader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:04:06 +02:00
Oliver Kieselbach fd44518e14 Bootstrap: installer v2.6 and the OOBE go-script verify the payload catalog (D-354, phase B)
- Install-AutopilotMonitor.ps1 v2.6: Get-AgentPayloadRejections (Test-FileCatalog
  -Detailed; signature Valid, signer -like the pin, files exactly as catalogued) and
  Install-VerifiedAgentPayload (extract into Agent-Install, verify there, then move into
  the agent folder; a rejection deletes the staging folder, so the next run starts clean).
  Replaces the name-filtered Get-UnverifiedAgentBinaries; the dead "already installed"
  branch behind guard 5 is gone.
- OOBE go-script: embeds the installer's marked block verbatim (EmbeddedResource) and
  verifies before --install; pin and catalog name come from the Shared constants; the
  golden is updated, new tests pin the embedding and the order check-before-install.
- build-agent.yml: the release verify step dot-sources the installer and runs its own
  function on the final ZIP.
- Pester: every decision branch, a real unsigned catalog, the staging flow (64/64).
- Privacy page: the catalog claim; LAST_UPDATED.

Merge only after a catalog ZIP is live on stable (plan step V0): pushing this to main
publishes the installer to every loader on its next run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:43:25 +02:00
519 changed files with 33307 additions and 5461 deletions
+6 -5
View File
@@ -4,15 +4,16 @@
.DESCRIPTION
CI's member of the URL registry (Shared Constants.cs, web lib/config.ts, MCP src/config.ts):
every own host the workflows, the .github/scripts helpers and the local release build talk to,
plus the Authenticode timestamp server, is defined here once. Assert-HostsFromRegistry.ps1
(Workflow gates) fails on any of these hosts spelled out elsewhere under .github/.
every own host the workflows and the .github/scripts helpers talk to, plus the Authenticode
timestamp server, is defined here once. Assert-HostsFromRegistry.ps1 (Workflow gates) fails
on any of these hosts spelled out elsewhere under .github/.
The storage URLs derive from the account name, so blob and table can never point at two
different accounts.
A file in the repository rather than a GitHub variable: the local release build reads it too,
and bearer tokens are sent to these URLs -- where a token goes stays a reviewed line in git.
A file in the repository rather than a GitHub variable: the helpers read it themselves, so
they behave the same in every job and in a local run, and bearer tokens are sent to these
URLs -- where a token goes stays a reviewed line in git.
.PARAMETER ExportToGitHubEnv
Write every entry to $GITHUB_ENV instead of returning it, so the following steps of the job
+2 -2
View File
@@ -9,8 +9,8 @@
live here so they cannot drift between call sites.
Identity comes from whatever `az login` established: the federated GitHub OIDC principal
in CI (azure/login, subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main), the
operator's own account for the local build. No secret is read, stored or passed around.
in CI (azure/login in a job of the `production` or `release` environment), the operator's
own account when a helper runs locally. No secret is read, stored or passed around.
Under GitHub Actions the token is masked in the run log before it is returned.
x-ms-version is MANDATORY here, not decoration: Entra-authorized requests are rejected
@@ -0,0 +1,72 @@
<#
.SYNOPSIS
Runs the bootstrap integration tests once as SYSTEM, the account the bootstrap runs as on a device.
.DESCRIPTION
Registers a one-time scheduled task as SYSTEM that runs
scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 under Windows PowerShell 5.1, waits for it,
prints its output and fails when the run did not happen as SYSTEM or a test failed. Run as
SYSTEM, the tests use the real folder permissions, including Administrators as owner.
CI only (bootstrap-script-gates.yml): it needs an elevated runner and Pester installed for all
users.
#>
[CmdletBinding()]
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path,
[int]$TimeoutMinutes = 20
)
$ErrorActionPreference = 'Stop'
$work = Join-Path ([System.IO.Path]::GetTempPath()) "apm-system-pass-$PID"
New-Item -ItemType Directory -Force -Path $work | Out-Null
$runner = Join-Path $work 'run.ps1'
$output = Join-Path $work 'output.txt'
$result = Join-Path $work 'result.txt'
$tests = Join-Path $RepoRoot 'scripts\Bootstrap\Bootstrap.Integration.Tests.ps1'
$runnerText = @'
$ErrorActionPreference = 'Stop'
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
try {
Import-Module Pester -MinimumVersion 5.5.0
$configuration = New-PesterConfiguration
$configuration.Run.Path = '__TESTS__'
$configuration.Run.PassThru = $true
$configuration.Output.Verbosity = 'Detailed'
$run = Invoke-Pester -Configuration $configuration
Set-Content -LiteralPath '__RESULT__' -Value ('{0}|{1}|{2}' -f $identity, $run.PassedCount, $run.FailedCount)
} catch {
Set-Content -LiteralPath '__RESULT__' -Value ('{0}|error|{1}' -f $identity, $_.Exception.Message)
}
'@
Set-Content -LiteralPath $runner -Encoding ASCII -Value ($runnerText.Replace('__TESTS__', $tests).Replace('__RESULT__', $result))
$taskName = "apm-bootstrap-system-pass-$PID"
$arguments = "-NoProfile -ExecutionPolicy Bypass -Command `"& '$runner' *> '$output'`""
$action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument $arguments
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
try {
Start-ScheduledTask -TaskName $taskName
$deadline = (Get-Date).AddMinutes($TimeoutMinutes)
do {
Start-Sleep -Seconds 5
$state = (Get-ScheduledTask -TaskName $taskName).State
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
if ($state -eq 'Running') {
Stop-ScheduledTask -TaskName $taskName
throw "The SYSTEM pass did not finish within $TimeoutMinutes minutes."
}
} finally {
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
}
if (Test-Path -LiteralPath $output) { Get-Content -LiteralPath $output }
if (-not (Test-Path -LiteralPath $result)) { throw 'The SYSTEM pass wrote no result.' }
$identity, $passed, $failed = (Get-Content -LiteralPath $result -Raw).Trim().Split('|', 3)
if ($identity -ne 'S-1-5-18') { throw "The SYSTEM pass ran as $identity, not as SYSTEM." }
if ($passed -eq 'error') { throw "The SYSTEM pass could not run the tests: $failed" }
if ([int]$failed -gt 0 -or [int]$passed -eq 0) { throw "SYSTEM pass: $passed passed, $failed failed." }
Write-Host "SYSTEM pass: $passed passed, 0 failed."
+60 -18
View File
@@ -17,7 +17,7 @@
cosmetics.
Steps, in order:
1. Parse $ScriptVersion from the loader and the bootstrap source.
1. Parse the $ScriptVersion line from the loader and the bootstrap source.
2. Render the -Dev variants by literal substitution of the URL/manifest
defaults. A missing anchor is a hard failure -- a silently un-substituted
dev script would point the dev fleet at the stable agent.
@@ -25,17 +25,21 @@
carries the same $ScriptVersion, abort. Otherwise the docs badge,
version.json.bootstrapVersion and the portal's "your script is outdated"
hint would all keep asserting a version that is no longer what ships.
4. Build the publish set (loader, bootstrap, both dev renders, the extras).
4. Build the publish set: both installers, then both loaders, then the extras.
A loader that went live before the installer it requires would refuse the
installer still being served. -Channel dev keeps only the two -Dev renders.
5. Authenticode-sign every script (see -SigningToken). Signing is the LAST
transformation: it covers the exact CRLF/UTF-8 bytes that get uploaded,
and nothing may touch them afterwards or the signature breaks.
6. Upload every script blob with Cache-Control: no-cache (they rotate in
6. Run the loader's own identity check on the exact bytes of both installers:
what fails here, every device would refuse.
7. Upload every script blob with Cache-Control: no-cache (they rotate in
place), mirrored fail-soft to the legacy account.
7. Reconcile the version oracles: version.json.bootstrapVersion and
.loaderVersion (read-modify-write under If-Match, so a concurrent agent
8. Reconcile the version oracles (release channel only): version.json.bootstrapVersion
and .loaderVersion (read-modify-write under If-Match, so a concurrent agent
release cannot lose its agent fields) and
AdminConfiguration.LatestBootstrapV2ScriptVersion.
8. Verify through the alias -- re-download each blob and compare SHA-256.
9. Verify through the alias -- re-download each blob and compare SHA-256.
Comparisons against a published script always strip its signature block first
(Get-ComparableText). A signature carries a timestamp, so signed bytes differ on
@@ -67,6 +71,11 @@
Optional directory that receives a copy of every published file exactly as uploaded.
Used by CI to offer the signed scripts as a build artifact for local inspection.
.PARAMETER Channel
release (default): every script, plus version.json and the portal's script version.
dev: only Start-AutopilotMonitor-Dev.ps1 and Install-AutopilotMonitor-Dev.ps1, so a
script change can run on dev devices, signed, before it goes to customers.
.EXAMPLE
./Publish-BootstrapScripts.ps1 -DryRun
@@ -83,6 +92,8 @@ param(
[string]$CertificateName,
[string]$ExpectedPublisher,
[string]$SignedOutputDir,
[ValidateSet('release', 'dev')]
[string]$Channel = 'release',
[switch]$DryRun
)
@@ -293,7 +304,9 @@ function Assert-VersionBump {
function Get-BootstrapScriptVersion {
param([string]$Content, [string]$Origin, [switch]$Optional)
if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] }
# The assignment line itself, as the loader reads it: a comment or help text that mentions
# $ScriptVersion must not count.
if ($Content -match '(?m)^\$ScriptVersion[ \t]*=[ \t]*"([\d\.\-a-zA-Z]+)"[ \t]*\r?$') { return $Matches[1] }
# Unreadable SOURCE is fatal. An unreadable PUBLISHED copy must not be: that is exactly
# the state a republish is meant to repair, and a guard that blocks the repair is worse
# than no guard.
@@ -301,6 +314,19 @@ function Get-BootstrapScriptVersion {
throw "Could not parse ScriptVersion from $Origin"
}
# The loader's own check, run on the exact bytes that will be served: dot-sourced in this
# function's scope, so none of the loader's variables reach the publisher's.
function Assert-LoaderAcceptsInstaller {
param([object]$Item, [string]$WorkDir)
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
$path = Join-Path $WorkDir $Item.BlobName
[System.IO.File]::WriteAllBytes($path, [byte[]]$Item.Bytes)
. $LoaderSource
if (-not (Test-InstallerIdentity -Path $path)) {
throw "The loader would refuse $($Item.BlobName) (role or minimum version). Nothing was uploaded."
}
}
# What the script actually DOES, with comments and layout removed: the PowerShell token
# stream minus comments, newlines and line continuations. Lets the version-bump guard tell
# a behaviour change (must be versioned -- consumers act on the version) apart from a
@@ -353,8 +379,11 @@ $devLoaderContent = $loaderContent.Replace($stableLoaderLiteral, $devLoaderLiter
Write-Host "Rendered $LoaderDevBlob (points at $DevBlob)"
# ------------------------------------------------------------------ 3. bump guards
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
# The versions are what customers act on, so the guard watches the release blobs only.
if ($Channel -eq 'release') {
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
}
# ------------------------------------------------------------------ 4. publish set
# Sources beyond the bootstrap pair, read straight from the repo.
@@ -369,10 +398,15 @@ $extraSources = @(
# Text is the unsigned publish form and stays the reference for every comparison against a
# published copy; Bytes is what goes on the wire and carries the signature after step 4.
$publishSet = [System.Collections.Generic.List[object]]::new()
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Text = $sourceContent; Bytes = $sourceBytes })
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Text = $devContent; Bytes = (Get-PublishBytes $devContent) })
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
if ($Channel -eq 'dev') {
$publishSet = [System.Collections.Generic.List[object]]@($publishSet | Where-Object { $_.BlobName -in @($DevBlob, $LoaderDevBlob) })
$extraSources = @()
Write-Host "Channel dev: publishing $DevBlob and $LoaderDevBlob only"
}
foreach ($extra in $extraSources) {
$extraPath = Join-Path $RepoRoot $extra.Path
if (-not (Test-Path $extraPath)) {
@@ -391,13 +425,18 @@ if ($SigningEnabled) {
Write-Host '::warning::No signing token supplied -- dry run continues with unsigned scripts.'
}
# ------------------------------------------------------------------ 6. loader check
foreach ($item in @($publishSet | Where-Object { $_.BlobName -in @($BootstrapBlob, $DevBlob) })) {
Assert-LoaderAcceptsInstaller -Item $item -WorkDir (Join-Path ([System.IO.Path]::GetTempPath()) "apm-publish-check-$PID")
}
$legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' }
# Acquired lazily: a dry run must stay runnable without any Azure login -- the PR gate
# (bootstrap-script-gates.yml) executes this script with -DryRun and never logs in.
$authHeaders = if ($DryRun) { $null } else { & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1') }
# ------------------------------------------------------------------ 6. upload
# ------------------------------------------------------------------ 7. upload
foreach ($item in $publishSet) {
# Explicit type, not a convenience: an Object[] body is uploaded as space-separated
# decimals rather than raw bytes, and every hash check still passes because [byte[]]
@@ -464,11 +503,12 @@ foreach ($item in $publishSet) {
}
}
# ------------------------------------------------------------------ 7. version oracles
# ------------------------------------------------------------------ 8. version oracles
# version.json is the agent manifest; only bootstrapVersion belongs to us. Read from the
# blob (not the alias) for an authoritative ETag, write back under If-Match so a concurrent
# agent release cannot lose its version/sha256 fields.
if (-not $DryRun) {
# agent release cannot lose its version/sha256 fields. They describe the release scripts, so
# a dev publish leaves them alone.
if (-not $DryRun -and $Channel -eq 'release') {
try {
$manifestResp = Invoke-WebRequest -Uri "$ContainerUrl/version.json" -Method Get -UseBasicParsing
$manifestRaw = if ($manifestResp.Content -is [byte[]]) {
@@ -512,7 +552,9 @@ if (-not $DryRun) {
}
}
if ($DryRun) {
if ($Channel -ne 'release') {
Write-Host "Channel dev: version.json and AdminConfiguration.LatestBootstrapV2ScriptVersion stay as they are"
} elseif ($DryRun) {
Write-Host " [dry-run] would set AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
} else {
$tableHeaders = @{
@@ -525,7 +567,7 @@ if ($DryRun) {
Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
}
# ------------------------------------------------------------------ 8. verify via alias
# ------------------------------------------------------------------ 9. verify via alias
if (-not $DryRun) {
$pending = [System.Collections.ArrayList]::new()
$publishSet | ForEach-Object { [void]$pending.Add($_) }
@@ -555,7 +597,7 @@ if (-not $DryRun) {
if ($env:GITHUB_STEP_SUMMARY) {
$mode = if ($DryRun) { 'DRY RUN -- nothing uploaded' } else { 'published + verified via the download alias' }
$summary = @(
"### Bootstrap scripts: $mode",
"### Bootstrap scripts: $mode (channel $Channel)",
'',
"Loader script version: **$loaderVersion** | Bootstrap script version: **$scriptVersion**",
"Signed: **$(if ($SigningEnabled) { "yes, certificate '$CertificateName'" } else { 'NO (unsigned dry run)' })**",
+176
View File
@@ -0,0 +1,176 @@
<#
.SYNOPSIS
Checks the bootstrap's folders against a real standard user.
.DESCRIPTION
Creates a temporary local standard user and, with that user's token, tries what any local user
on an enrolling device could try against the folders the loader and the installer create:
write, change, rename and delete files, add entries, move or delete the folder. Every attempt
must be denied. Folders the user creates first, or entries the user plants in an unprotected
agent folder of an older installer, must make the installer stop instead of installing into
them. A control attempt proves the user can write where users may.
The folders come from the installer's own functions, in a scratch copy of ProgramData that
carries the real ProgramData permissions.
CI only (bootstrap-script-gates.yml): it needs an elevated runner, and it creates a local
account and removes it again.
#>
[CmdletBinding()]
param(
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
)
$ErrorActionPreference = 'Stop'
# The user's token, for impersonation in this process (a CI helper; customer scripts never
# compile code).
Add-Type -Namespace ApmCi -Name Native -MemberDefinition @'
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool LogonUser(string user, string domain, string password, int logonType, int logonProvider, out Microsoft.Win32.SafeHandles.SafeAccessTokenHandle token);
'@
. (Join-Path $RepoRoot 'scripts\Bootstrap\Install-AutopilotMonitor.ps1')
$results = New-Object System.Collections.Generic.List[object]
function Invoke-AsUser {
param([scriptblock]$Action)
[System.Security.Principal.WindowsIdentity]::RunImpersonated($script:token, [Action]$Action)
}
# An attempt the user must not get through: it has to fail with "access denied", not with
# any other error that would only hide a broken test.
function Assert-Denied {
param([string]$Name, [scriptblock]$Attempt)
$outcome = 'allowed'
try {
Invoke-AsUser $Attempt
} catch {
$base = $_.Exception.GetBaseException()
$outcome = if ($base -is [System.UnauthorizedAccessException] -or $base.HResult -eq -2147024891) { 'denied' } else { "error: $($base.Message)" }
}
$results.Add([pscustomobject]@{ Check = $Name; Expected = 'denied'; Actual = $outcome })
}
function Assert-That {
param([string]$Name, [bool]$Condition, [string]$Detail = '')
$results.Add([pscustomobject]@{ Check = $Name; Expected = 'true'; Actual = $(if ($Condition) { 'true' } else { "false $Detail".Trim() }) })
}
# What a bootstrap running as SYSTEM leaves as owner; the default for other admin-created
# entries differs between Windows editions.
function Set-AdministratorsOwner {
param([string]$Path)
$security = (Get-Item -LiteralPath $Path -Force).GetAccessControl('Owner')
$security.SetOwner((New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')))
(Get-Item -LiteralPath $Path -Force).SetAccessControl($security)
}
$name = 'apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
$password = [guid]::NewGuid().ToString('N') + 'Aa1!'
$root = Join-Path $env:SystemDrive ('apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
$pd = Join-Path $root 'ProgramData'
New-LocalUser -Name $name -Password (ConvertTo-SecureString $password -AsPlainText -Force) -AccountNeverExpires -PasswordNeverExpires | Out-Null
try {
try { Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $name } catch { if ($_.FullyQualifiedErrorId -notlike 'MemberExists*') { throw } }
$token = $null
# Interactive logon first (what a local user has); network logon where a server allows no
# local logon for Users.
if (-not [ApmCi.Native]::LogonUser($name, '.', $password, 2, 0, [ref]$token) -and
-not [ApmCi.Native]::LogonUser($name, '.', $password, 3, 0, [ref]$token)) {
throw "LogonUser failed with Win32 error $([System.Runtime.InteropServices.Marshal]::GetLastWin32Error())."
}
$script:token = $token
$userSid = (New-Object System.Security.Principal.NTAccount($name)).Translate([System.Security.Principal.SecurityIdentifier])
$script:probe = $null
Invoke-AsUser { $script:probe = [System.Security.Principal.WindowsIdentity]::GetCurrent() }
Assert-That 'impersonation runs as the test user' ($script:probe.User -eq $userSid) "$($script:probe.User)"
Assert-That 'the test user is no administrator' (-not (New-Object System.Security.Principal.WindowsPrincipal($script:probe)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator))
# A scratch ProgramData with the real one's permissions.
New-Item -ItemType Directory -Path $pd -Force | Out-Null
$security = New-Object System.Security.AccessControl.DirectorySecurity
$security.SetSecurityDescriptorSddlForm((Get-Acl -LiteralPath $env:ProgramData).GetSecurityDescriptorSddlForm('Access'), 'Access')
# Nothing from the drive root, whose default lets authenticated users change subfolders.
$security.SetAccessRuleProtection($true, $false)
(Get-Item -LiteralPath $pd).SetAccessControl($security)
# Control: the user may create in ProgramData, so every denial below is the folders' doing.
$control = Join-Path $pd 'user-may-create-here'
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($control) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $control 'x.txt'), 'x') }
Assert-That 'control: the user can create in ProgramData' (Test-Path -LiteralPath (Join-Path $control 'x.txt'))
# The loader's run folder with a downloaded installer in it.
$run = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-'
$download = Join-Path $run 'Install-AutopilotMonitor.ps1'
[System.IO.File]::WriteAllText($download, 'verified installer')
Assert-Denied 'run folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $run 'planted.ps1'), 'x') }
Assert-Denied 'run folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $run 'planted')) | Out-Null }
Assert-Denied 'run folder: change the download' { [System.IO.File]::AppendAllText($download, 'tampered') }
Assert-Denied 'run folder: replace the download' { [System.IO.File]::WriteAllText($download, 'replaced') }
Assert-Denied 'run folder: rename the download' { [System.IO.File]::Move($download, $download + '.old') }
Assert-Denied 'run folder: delete the download' { [System.IO.File]::Delete($download) }
Assert-Denied 'run folder: rename the folder' { [System.IO.Directory]::Move($run, $run + '-moved') }
Assert-Denied 'run folder: delete the folder' { [System.IO.Directory]::Delete($run, $true) }
Assert-That 'run folder: the download is unchanged' ([System.IO.File]::ReadAllText($download) -eq 'verified installer')
# The agent folder an install prepares.
$base = Join-Path $pd 'AutopilotMonitor'
Initialize-AgentBase -Path $base
$logs = [System.IO.Directory]::CreateDirectory((Join-Path $base 'Logs')).FullName
[System.IO.File]::WriteAllText((Join-Path $logs 'bootstrap-script.log'), 'log')
Assert-Denied 'agent folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
Assert-Denied 'agent folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $base 'Agent')) | Out-Null }
Assert-Denied 'agent folder: change a log' { [System.IO.File]::AppendAllText((Join-Path $logs 'bootstrap-script.log'), 'x') }
Assert-Denied 'agent folder: delete a log' { [System.IO.File]::Delete((Join-Path $logs 'bootstrap-script.log')) }
Assert-Denied 'agent folder: rename the folder' { [System.IO.Directory]::Move($base, $base + '-moved') }
$owner = (Get-Item -LiteralPath $base).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]).Value
Assert-That 'agent folder: owned by Administrators' ($owner -eq 'S-1-5-32-544') $owner
[System.IO.Directory]::Delete($base, $true)
# The user creates the agent folder first: the installer must neither install into it nor
# remove it.
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($base) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
Assert-That 'squatted agent folder: seen as another account''s' ((Get-FolderState $base) -eq 'Foreign')
$refused = $false
try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*(Foreign); not installing into it.' }
Assert-That 'squatted agent folder: the install refuses it' $refused
Assert-That 'squatted agent folder: a skip leaves it' ((Remove-AgentBaseOnSkip -Path $base) -like '*left as it is (Foreign).')
Assert-That 'squatted agent folder: the planted file stays' (Test-Path -LiteralPath (Join-Path $base 'planted.dll'))
[System.IO.Directory]::Delete($base, $true)
# An agent folder an older installer created without protection: the user plants a file,
# the install protects the folder and stops at the planted file.
[System.IO.Directory]::CreateDirectory($base) | Out-Null
[System.IO.File]::WriteAllText((Join-Path $base 'old-installer.log'), 'old')
Set-AdministratorsOwner $base
Set-AdministratorsOwner (Join-Path $base 'old-installer.log')
Invoke-AsUser { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
$refused = $false
try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*holds entries another account created*' }
Assert-That 'planted entry: the install stops' $refused
Assert-That 'planted entry: own leftovers are gone, the planted file stays' ((-not (Test-Path -LiteralPath (Join-Path $base 'old-installer.log'))) -and (Test-Path -LiteralPath (Join-Path $base 'planted.dll')))
Assert-Denied 'planted entry: the protected folder takes no more' { [System.IO.File]::WriteAllText((Join-Path $base 'second.dll'), 'x') }
[System.IO.Directory]::Delete($base, $true)
# The user creates a run folder name first: it is never used.
$squatted = Join-Path $pd 'AutopilotMonitor-Loader-00000000'
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($squatted) | Out-Null }
$script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('00000000', '11111111'))
function New-RunFolderName { return $script:names.Dequeue() }
$fresh = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-'
Assert-That 'squatted run folder: a new name is drawn' ($fresh -eq (Join-Path $pd 'AutopilotMonitor-Loader-11111111')) $fresh
$squattedOwner = (Get-Item -LiteralPath $squatted).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier])
Assert-That 'squatted run folder: still the user''s, untouched' ($squattedOwner -eq $userSid)
} finally {
if ($script:token) { $script:token.Dispose() }
Remove-LocalUser -Name $name -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
$results | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
$failed = @($results | Where-Object { $_.Actual -ne $_.Expected })
if ($failed.Count -gt 0) { throw "Attacker test: $($failed.Count) of $($results.Count) checks failed." }
Write-Host "Attacker test: all $($results.Count) checks passed."
+30 -5
View File
@@ -15,21 +15,28 @@ permissions:
jobs:
gates:
name: Bootstrap guard tests (PS 5.1)
runs-on: windows-latest
name: Bootstrap guard tests (PS 5.1, ${{ matrix.os }})
strategy:
fail-fast: false
matrix:
# Folder permissions and Windows PowerShell 5.1 come with the OS, so both server images.
os: [windows-2022, windows-2025]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# For all users: the SYSTEM pass below runs Pester as SYSTEM.
- name: Install Pester 5+
shell: powershell
run: |
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope AllUsers | Out-Null
Install-Module Pester -Scope AllUsers -Force -SkipPublisherCheck -MinimumVersion 5.5.0
# Windows PowerShell 5.1 on purpose: that is the runtime IME uses to execute
# platform scripts, so guard/relax logic is verified without resetting a device.
# The runner is elevated, so the integration tests use the real folder permissions.
- name: Run Pester
shell: powershell
run: |
@@ -40,11 +47,20 @@ jobs:
$c.Output.Verbosity = 'Detailed'
Invoke-Pester -Configuration $c
# On a device the bootstrap runs as SYSTEM: the integration tests once more as SYSTEM.
- name: Integration tests as SYSTEM
shell: powershell
run: ./.github/scripts/Invoke-BootstrapSystemPass.ps1
- name: Folder permissions against a real standard user
shell: powershell
run: ./.github/scripts/Test-BootstrapAttacker.ps1
- name: Guard scripts stay pure ASCII
shell: powershell
run: |
$failed = $false
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' -Recurse | ForEach-Object {
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
for ($i = 0; $i -lt $bytes.Length; $i++) {
if ($bytes[$i] -gt 127) {
@@ -57,6 +73,15 @@ jobs:
if ($failed) { exit 1 }
Write-Host 'All bootstrap scripts are pure ASCII.'
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
# The names and log lines the scripts share with the agent, checked against the agent's
# own code.
- name: Script contracts with the agent
run: dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests --configuration Release --filter "Category=BootstrapContract" --logger "console;verbosity=normal"
# The publish chain renders the -Dev variant by literal substitution and refuses to
# ship when an anchor moved. Running it dry here surfaces that (and any drift against
# the alias) on the PR instead of on the push that would have published.
+20 -28
View File
@@ -258,12 +258,14 @@ jobs:
# run here -- never a restore or build. The build output arrives as data. The runner
# image's Windows SDK signtool is used read-only, to verify third-party signatures.
#
# No `environment:` yet: the code-signing credential is still bound to the ref, and a job
# carries exactly one OIDC subject, so an environment here would break the signing login.
# Runs in the `release` environment: both identities trust its subject, and it admits main
# only. No federated credential trusts the ref, so a job without an environment can neither
# sign nor write storage, whatever its permissions say.
release:
name: Sign and publish
needs: [reserve, build]
runs-on: windows-latest
environment: release
permissions:
contents: write # tag + GitHub Release
id-token: write # code-signing and storage logins, keyless Sigstore signing
@@ -615,8 +617,8 @@ jobs:
# The devices' check, run on the FINAL ZIP rather than the staging folder: whatever the
# archiver did (Compress-Archive skips hidden files) fails here instead of on a device.
# Windows PowerShell 5.1, the edition installer and OOBE script verify with; the pin is
# the installer's own literal, the value devices compare against.
# Windows PowerShell 5.1 and the installer's own functions (dot-sourced; its entry guard
# keeps that side-effect free) -- the very code the installer and the OOBE go-script run.
- name: Verify payload catalog (device check)
shell: powershell
env:
@@ -629,32 +631,22 @@ jobs:
if (Test-Path $payload) { throw "$payload already exists." }
[System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $env:RELEASE_ZIP).Path, $payload)
$pinLine = Select-String -Path $env:BOOTSTRAP_SOURCE -Pattern '^\$ExpectedPublisher = "([^"]+)"' | Select-Object -First 1
if (-not $pinLine) { throw "Could not read the publisher pin from $env:BOOTSTRAP_SOURCE." }
$pin = $pinLine.Matches[0].Groups[1].Value
if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $pin -ne $env:CODE_SIGN_EXPECTED_SUBJECT) {
throw "Installer pin '$pin' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'."
. (Resolve-Path $env:BOOTSTRAP_SOURCE).Path
if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $ExpectedPublisher -ne $env:CODE_SIGN_EXPECTED_SUBJECT) {
throw "Installer pin '$ExpectedPublisher' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'."
}
if ($AgentCatalogName -ne $env:PAYLOAD_CATALOG) {
throw "Installer catalog name '$AgentCatalogName' differs from PAYLOAD_CATALOG '$env:PAYLOAD_CATALOG'."
}
$catalog = Join-Path $payload $env:PAYLOAD_CATALOG
if (-not (Test-Path $catalog -PathType Leaf)) { throw "$env:PAYLOAD_CATALOG is missing from the ZIP." }
$result = Test-FileCatalog -Path $payload -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed
# Assign first, then flatten: the function returns its list as ONE object (comma
# operator), so @(Get-AgentPayloadRejections ...) would nest it inside another array.
$problems = Get-AgentPayloadRejections -Path $payload -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
$problems = @($problems)
$problems = @()
if ($result.Status -ne 'Valid') {
$expected = $result.CatalogItems
$actual = $result.PathItems
$missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) })
$extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) })
$changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] })
$problems += "payload differs from the catalog (missing: $($missing -join ', '); extra: $($extra -join ', '); changed: $($changed -join ', '))"
}
$signature = $result.Signature
if ($signature.Status -ne 'Valid') {
$problems += "catalog signature status is '$($signature.Status)'"
} elseif ($signature.SignerCertificate.Subject -notlike $pin) {
$problems += "catalog signed by '$($signature.SignerCertificate.Subject)', expected '$pin'"
}
# Release-only checks a device need not repeat: a timestamp (without it the signature
# dies with the certificate) and the same certificate as the binaries.
$signature = Get-AuthenticodeSignature (Join-Path $payload $AgentCatalogName)
if (-not $signature.TimeStamperCertificate) {
$problems += 'catalog signature carries no RFC-3161 timestamp -- it would expire with the certificate'
}
@@ -667,7 +659,7 @@ jobs:
$problems | ForEach-Object { Write-Host "::error::$_" }
throw "Payload catalog verification failed. Nothing was published."
}
Write-Host "Payload catalog verified on the final ZIP: $($result.CatalogItems.Count) files, signer $($signature.SignerCertificate.Subject)"
Write-Host "Payload catalog verified on the final ZIP with the installer's own check, signer $($signature.SignerCertificate.Subject)"
- name: Upload to Azure Blob Storage
if: inputs.upload_blob
+91 -6
View File
@@ -39,11 +39,11 @@ jobs:
# need the embedded loader to carry a signature of its own.
#
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool run
# here. No `environment:` yet: the code-signing credential is still bound to the ref, and a
# job carries exactly one OIDC subject.
# here. The `release` environment is the only subject the code-signing identity trusts.
sign-loader:
name: Sign bootstrap loader
runs-on: windows-latest
environment: release
permissions:
contents: read
id-token: write # code-signing login
@@ -181,11 +181,16 @@ jobs:
- name: Build MSI
shell: pwsh
run: |
# The MSI's action runs only the exact bytes the package embeds: the hash of the
# signed loader goes into the command here, so no other signed script of ours runs
# in its place on a device.
$loaderSha256 = (Get-FileHash -LiteralPath $env:RUNNER_SCRIPT -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Host "Embedded loader SHA-256: $loaderSha256"
# wix resolves File/@Source relative to the working directory, not the wxs —
# build from inside the project dir (same as the local build documented in the wxs).
$out = Join-Path (Get-Location) $env:STABLE_MSI
Push-Location $env:MSI_PROJECT_DIR
wix build Package.wxs -arch x64 -o $out
wix build Package.wxs -arch x64 -d LoaderSha256=$loaderSha256 -o $out
Pop-Location
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
@@ -198,12 +203,92 @@ jobs:
if-no-files-found: error
retention-days: 1
# Trust zone: signs, verifies and publishes the MSI as data; never builds. No
# `environment:` yet, for the same reason as sign-loader.
# The real chain on a runner, before anything is signed or published: msiexec installs the
# MSI from the build, its action checks the loader copy and runs it, the loader fetches the
# published installer, which finds the deployment marker set here and removes the copy. The
# marker is the installer's first guard, so no agent is ever installed on the runner. Needs
# the published installer to be 2.9 or later, which removes the copy in every outcome.
smoke:
name: Install on a runner (${{ matrix.os }})
needs: build
strategy:
fail-fast: false
matrix:
os: [windows-2022, windows-2025]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download unsigned MSI
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bootstrap-msi-unsigned
path: ${{ runner.temp }}/msi
- name: Install with the deployment marker set, check the footprint, uninstall
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$msi = Join-Path $env:RUNNER_TEMP "msi/$env:STABLE_MSI"
$wxs = Get-Content "$env:MSI_PROJECT_DIR/Package.wxs" -Raw
if ($wxs -notmatch 'Version="(\d+\.\d+\.\d+)"') { throw 'Could not parse Version from Package.wxs' }
$version = $Matches[1]
New-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Force | Out-Null
Set-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -Value ((Get-Date).ToUniversalTime().ToString('o'))
function Invoke-Msiexec {
param([string]$Verb, [string]$Log)
$p = Start-Process -FilePath msiexec.exe -ArgumentList "/$Verb `"$msi`" /qn /l*v `"$Log`"" -Wait -PassThru
if ($p.ExitCode -ne 0) { Get-Content $Log | Select-Object -Last 60; throw "msiexec /$Verb exited with $($p.ExitCode)" }
}
function Get-Registration {
Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | Get-ItemProperty | Where-Object { $_.DisplayName -eq 'Autopilot Monitor Bootstrap' }
}
function Show-Tree {
param([string]$Path)
Get-ChildItem -LiteralPath $Path -Recurse -Force | ForEach-Object { Write-Host " $($_.FullName)" }
}
$installLog = Join-Path $env:RUNNER_TEMP 'msi-install.log'
Invoke-Msiexec -Verb i -Log $installLog
$registered = Get-Registration
if (-not $registered) { throw 'The product is not registered after the install.' }
if ($registered.DisplayVersion -ne $version) { throw "Registered version $($registered.DisplayVersion), Package.wxs says $version." }
if ((Get-Content $installLog -Raw) -notmatch 'RunBootstrap') { throw 'The install log does not show the action.' }
Write-Host "Registered: $($registered.DisplayName) $($registered.DisplayVersion)"
# Only the installer removes the copy the MSI put under ProgramData: its absence
# proves the action ran the loader and the loader ran the installer.
foreach ($path in @("$env:ProgramW6432\AutopilotMonitor", "$env:ProgramData\AutopilotMonitor")) {
if (Test-Path -LiteralPath $path) { Show-Tree $path; throw "$path exists after the run." }
}
$runFolders = @(Get-ChildItem -LiteralPath $env:ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*' -Force)
if ($runFolders.Count -ne 0) {
foreach ($folder in $runFolders) {
Show-Tree $folder.FullName
Get-ChildItem -LiteralPath $folder.FullName -Filter '*.log' | ForEach-Object { Get-Content -LiteralPath $_.FullName }
}
throw 'A loader run folder stays: the loader did not end with exit 0.'
}
Write-Host 'After the install: nothing under ProgramData or Program Files.'
Invoke-Msiexec -Verb x -Log (Join-Path $env:RUNNER_TEMP 'msi-uninstall.log')
if (Get-Registration) { throw 'The product is still registered after the uninstall.' }
Remove-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Recurse -Force
Write-Host 'Uninstalled; the registration is gone.'
# Trust zone: signs, verifies and publishes the MSI as data; never builds. Environment
# `release`, like sign-loader.
release:
name: Sign and publish MSI
needs: build
needs: smoke
runs-on: windows-latest
environment: release
permissions:
contents: read
id-token: write # code-signing and storage logins, keyless Sigstore signing
+5
View File
@@ -14,7 +14,12 @@ on:
- 'scripts/Bootstrap/**'
- 'scripts/CustomerSetup/**'
- '.github/scripts/Publish-BootstrapScripts.ps1'
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
- '.github/scripts/Test-BootstrapAttacker.ps1'
- '.github/workflows/bootstrap-script-gates.yml'
# The integration tests run the generated OOBE script and the MSI's command line.
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
workflow_dispatch:
permissions:
+14 -3
View File
@@ -23,6 +23,14 @@ on:
required: true
type: boolean
default: false
channel:
description: 'release: every script. dev: only the two -Dev scripts, to try a change on dev devices first'
required: true
type: choice
options:
- release
- dev
default: release
permissions:
contents: read
@@ -54,6 +62,8 @@ jobs:
name: Publish to the download alias
needs: gates
runs-on: windows-latest
# Both identities trust only the `release` environment, which admits main only.
environment: release
# Job-scoped: only the publish job federates to Azure, the gates job never holds an
# OIDC token it does not need.
permissions:
@@ -74,9 +84,7 @@ jobs:
}
# First, and only to fetch the Key Vault token: azure/login sets the default az
# account and the storage login below has to be the one that stays. See D-223 --
# both federated credentials are bound to the ref, so this job must never carry a
# GitHub `environment:`.
# account and the storage login below has to be the one that stays.
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
@@ -113,13 +121,16 @@ jobs:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
# A push publishes the release channel; only a dispatch can choose dev.
- name: Publish bootstrap + customer scripts
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
PUBLISH_CHANNEL: ${{ inputs.channel || 'release' }}
run: |
$dryRun = '${{ inputs.dry_run }}' -eq 'true'
./.github/scripts/Publish-BootstrapScripts.ps1 `
-Channel $env:PUBLISH_CHANNEL `
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
"severity": "warning",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -38,6 +38,26 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{detection_failure.appName}} was not detected after its install",
"subject": {
"kind": "app",
"key": "{{detection_failure.appId}}",
"name": "{{detection_failure.appName}}"
},
"facts": [
{
"label": "Detection",
"value": "{{detection_failure.value}}"
},
{
"label": "Error",
"value": "{{detection_failure.errorCode}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"detection",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects app installation failures with specific exit codes or HRESULT error codes and suggests fixes.",
"severity": "high",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -41,6 +41,27 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{install_error.appName}} failed to install",
"subject": {
"kind": "app",
"key": "{{install_error.appId}}",
"name": "{{install_error.appName}}"
},
"facts": [
{
"label": "Exit code",
"value": "{{install_error.exitCode}}",
"format": "code"
},
{
"label": "Error",
"value": "{{install_error.errorCode}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"msi",
+17 -1
View File
@@ -33,9 +33,25 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{dependency_failure.appName}} failed because a dependency did not install",
"subject": {
"kind": "app",
"key": "{{dependency_failure.appId}}",
"name": "{{dependency_failure.appName}}"
},
"facts": [
{
"label": "Error",
"value": "{{dependency_failure.errorCode}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"dependency"
],
"version": "1.1.0"
"version": "1.2.0"
}
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when disk space is critically low during enrollment, which can cause app installation failures. Confidence increases when app installations actually failed while disk space was low.",
"severity": "critical",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -71,6 +71,27 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "Free disk space dropped to {{low_disk.value}} GB",
"subject": {
"kind": "device",
"key": "disk",
"name": "Disk space"
},
"facts": [
{
"label": "Free space (GB)",
"value": "{{low_disk.value}}",
"format": "number"
},
{
"label": "Measured",
"value": "{{low_disk.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"apps",
"disk-space",
+17 -1
View File
@@ -33,10 +33,26 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "The content download of {{download_timeout.appName}} timed out or failed",
"subject": {
"kind": "app",
"key": "{{download_timeout.appId}}",
"name": "{{download_timeout.appName}}"
},
"facts": [
{
"label": "Error",
"value": "{{download_timeout.errorCode}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"download",
"timeout"
],
"version": "1.1.0"
"version": "1.2.0"
}
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the same app installation is attempted 3 or more times during one enrollment — a retry loop caused by unhonored reboot requests, repeated failures, or detection mismatches.",
"severity": "high",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -35,6 +35,27 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times",
"subject": {
"kind": "app",
"key": "{{install_retry_loop.groupKey}}",
"name": "{{install_retry_loop.appName}}"
},
"facts": [
{
"label": "Install starts",
"value": "{{install_retry_loop.count}}",
"format": "number"
},
{
"label": "First start",
"value": "{{install_retry_loop.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"apps",
"reboot",
+12 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the app tracking summary reports multiple failed app installations.",
"severity": "high",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -50,6 +50,17 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{summary_errors.value}} app installations failed",
"facts": [
{
"label": "Failed apps",
"value": "{{summary_errors.value}}",
"format": "number"
}
]
},
"tags": [
"apps",
"summary",
+22 -1
View File
@@ -32,11 +32,32 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "{{slow_app_install.appName}} took {{slow_app_install.durationFormatted}} to install",
"subject": {
"kind": "app",
"key": "{{slow_app_install.appId}}",
"name": "{{slow_app_install.appName}}"
},
"facts": [
{
"label": "Install time",
"value": "{{slow_app_install.durationSeconds}}",
"format": "duration"
},
{
"label": "Started",
"value": "{{slow_app_install.startTimestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"apps",
"performance",
"slow-install",
"warning"
],
"version": "1.1.0"
"version": "1.2.0"
}
+27
View File
@@ -1,6 +1,7 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-APP-010",
"version": "1.1.0",
"title": "App Reverted to Error After Successful Install",
"description": "Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
"severity": "warning",
@@ -56,5 +57,31 @@
{ "title": "Troubleshoot Win32 app installations", "url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install" },
{ "title": "Intune Management Extension logs", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension" }
],
"presentation": {
"role": "cause",
"summary": "{{app_regression.eventA_appName}} installed, then reported an error",
"subject": {
"kind": "app",
"key": "{{app_regression.joinValue}}",
"name": "{{app_regression.eventA_appName}}"
},
"facts": [
{
"label": "Installed",
"value": "{{app_regression.eventA_timestamp}}",
"format": "timestamp"
},
{
"label": "Error after",
"value": "{{app_regression.timeDeltaSeconds}}",
"format": "duration"
},
{
"label": "Error",
"value": "{{app_regression.eventB_errorCode}}",
"format": "errorCode"
}
]
},
"tags": ["apps", "win32", "enforcement", "post-install", "correlation"]
}
+27 -1
View File
@@ -43,6 +43,32 @@
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management#step-4-return-codes"
}
],
"presentation": {
"role": "advisory",
"summary": "{{enforcement_error_resolved.eventA_appName}} reported an install error but was detected as installed",
"subject": {
"kind": "app",
"key": "{{enforcement_error_resolved.joinValue}}",
"name": "{{enforcement_error_resolved.eventA_appName}}"
},
"facts": [
{
"label": "Exit code",
"value": "{{enforcement_error_resolved.eventA_exitCode}}",
"format": "code"
},
{
"label": "Error",
"value": "{{enforcement_error_resolved.eventA_errorCode}}",
"format": "errorCode"
},
{
"label": "Detected installed after",
"value": "{{enforcement_error_resolved.timeDeltaSeconds}}",
"format": "duration"
}
]
},
"tags": [
"apps",
"enforcement",
@@ -50,5 +76,5 @@
"unmapped",
"resolved"
],
"version": "1.1.0"
"version": "1.2.0"
}
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
"severity": "critical",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -82,6 +82,26 @@
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
}
],
"presentation": {
"role": "cause",
"summary": "{{app_with_detection_failure.appName}} installed, but its detection rule did not find it",
"subject": {
"kind": "app",
"key": "{{app_with_detection_failure.appId}}",
"name": "{{app_with_detection_failure.appName}}"
},
"facts": [
{
"label": "ESP error",
"value": "{{esp_failure_errorcode.value}}",
"format": "errorCode"
},
{
"label": "Detection",
"value": "{{app_with_detection_failure.value}}"
}
]
},
"tags": [
"apps",
"detection",
+31 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
"severity": "warning",
"category": "apps",
"version": "1.2.1",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -123,6 +123,36 @@
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/store-apps-microsoft"
}
],
"presentation": {
"role": "cause",
"summary": "A Store or MSIX app failed during the ESP: {{appx_candidate.value}}",
"subject": {
"kind": "app",
"key": "{{appx_candidate.value}}",
"name": "{{appx_candidate.value}}"
},
"facts": [
{
"label": "ESP error",
"value": "{{appx_esp_errorcode.value}}",
"format": "errorCode"
},
{
"label": "ESP phase",
"value": "{{appx_esp_category.value}}"
},
{
"label": "Package",
"value": "{{appx_top_candidate_package.value}}",
"format": "code"
},
{
"label": "Candidates",
"value": "{{appx_candidate_count.value}}",
"format": "number"
}
]
},
"tags": [
"apps",
"appx",
+16 -1
View File
@@ -5,7 +5,7 @@
"description": "The IME reported its user-session completion line, but at least one required app stayed pending — without ever failing or even starting to install — so the agent kept deferring the enrollment completion signal. Typically an assignment/requirement problem (requirement rules never met, dependency never satisfied, or the app needs a context that is not available during ESP, e.g. a VPN client waiting for user sign-in to a portal).",
"severity": "high",
"category": "apps",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "correlation",
@@ -76,5 +76,20 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "Completion waited on {{apps_pending.value}} pending app(s)",
"facts": [
{
"label": "Pending apps",
"value": "{{apps_pending.value}}",
"format": "number"
},
{
"label": "Apps",
"value": "{{pending_app_names.value}}"
}
]
},
"tags": ["apps", "esp", "completion", "assignment", "pending"]
}
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "One or more required user-ESP apps never started installing while the ESP AccountSetup apps gate waited on them — the agent observed the ESP page exit with those apps still in a non-terminal, never-started state. Unlike ANALYZE-APP-015 (which reports that completion was DEFERRED because of a pending app, and stays silent once the session reaches a terminal verdict) this rule reports the delivery gap itself and fires regardless of the final outcome: an enrollment that Windows called done while required apps never ran is a successful enrollment with missing software, and the operator must see it.",
"severity": "warning",
"category": "apps",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -102,6 +102,26 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "Required apps never started installing; first: {{appName.value}}",
"subject": {
"kind": "app",
"key": "{{appName.appId}}",
"name": "{{appName.value}}"
},
"facts": [
{
"label": "First app",
"value": "{{appName.value}}"
},
{
"label": "State",
"value": "{{state.value}}",
"format": "code"
}
]
},
"tags": [
"apps",
"esp",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
"severity": "high",
"category": "apps",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 70,
@@ -81,6 +81,27 @@
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
}
],
"presentation": {
"role": "cause",
"summary": "{{mutex_blocked_install.appName}} was blocked by another installation",
"subject": {
"kind": "app",
"key": "{{mutex_blocked_install.appId}}",
"name": "{{mutex_blocked_install.appName}}"
},
"facts": [
{
"label": "Exit code",
"value": "{{installer_exit_1618.value}}",
"format": "code"
},
{
"label": "ESP error",
"value": "{{esp_failed_with_mutex_code.value}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"msiexec",
+26 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects Win32 apps whose installer exit code maps to Soft reboot or Hard reboot in the app's Intune return-code table (the '[Win32App] lpExitCode is defined as ...' line). Hard reboot stops app processing and restarts the device in Device Setup - the mid-enrollment restart and second sign-in; Soft reboot leaves the app pending until the next restart while the ESP moves on. Confidence rises for Hard reboot and when a reboot was actually observed in the session.",
"severity": "warning",
"category": "apps",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -86,6 +86,31 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "{{reboot_return_code.appName}} asked for a restart",
"subject": {
"kind": "app",
"key": "{{reboot_return_code.appId}}",
"name": "{{reboot_return_code.appName}}"
},
"facts": [
{
"label": "Return code",
"value": "{{reboot_return_code.exitCode}}",
"format": "code"
},
{
"label": "Return code class",
"value": "{{reboot_return_code.value}}"
},
{
"label": "Restarts",
"value": "{{reboot_observed.count}}",
"format": "number"
}
]
},
"tags": [
"apps",
"reboot",
+25 -1
View File
@@ -5,7 +5,7 @@
"description": "Correlates an active proxy configuration with app content download failures (Delivery Optimization timeout or download error), suggesting the proxy is blocking or throttling Intune content delivery.",
"severity": "high",
"category": "apps",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -67,6 +67,30 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "App downloads failed while a {{proxy_active.value}} proxy was configured",
"subject": {
"kind": "network",
"key": "proxy",
"name": "Proxy"
},
"facts": [
{
"label": "Proxy type",
"value": "{{proxy_active.value}}"
},
{
"label": "Failed app",
"value": "{{download_failure.appName}}"
},
{
"label": "Error",
"value": "{{download_failure.errorCode}}",
"format": "errorCode"
}
]
},
"tags": [
"correlation",
"apps",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when Windows Hello for Business provisioning did not start within the expected timeframe after ESP completion.",
"severity": "warning",
"category": "device",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"baseConfidence": 60,
"conditions": [
@@ -30,5 +30,21 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "Windows Hello provisioning did not start in time",
"subject": {
"kind": "device",
"key": "windows-hello",
"name": "Windows Hello"
},
"facts": [
{
"label": "Timed out",
"value": "{{hello_timeout.timestamp}}",
"format": "timestamp"
}
]
},
"tags": ["device", "hello", "timeout"]
}
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when memory usage stays above 90% across at least 3 performance snapshots during enrollment, indicating sustained memory pressure that can cause app installation failures or system instability.",
"severity": "warning",
"category": "device",
"version": "2.1.0",
"version": "2.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -65,6 +65,22 @@
]
}
],
"presentation": {
"role": "context",
"summary": "Memory use was above 90% in {{sustained_high_memory.count}} snapshots",
"subject": {
"kind": "device",
"key": "memory",
"name": "Memory"
},
"facts": [
{
"label": "Snapshots above 90%",
"value": "{{sustained_high_memory.count}}",
"format": "number"
}
]
},
"tags": [
"device",
"memory",
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects devices enrolling with an OS build that has reached end of support: Windows 11 22H2 (build 22621) or older, which includes every Windows 10 build. Maintenance note: raise the build cutoff to 22631 after Windows 11 23H2 reaches end of support on 2026-11-10.",
"severity": "warning",
"category": "device",
"version": "2.1.0",
"version": "2.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -56,6 +56,26 @@
"url": "https://learn.microsoft.com/en-us/lifecycle/products/windows-11-home-and-pro"
}
],
"presentation": {
"role": "advisory",
"summary": "Windows build {{old_build.value}} is out of support",
"subject": {
"kind": "device",
"key": "os",
"name": "Windows version"
},
"facts": [
{
"label": "OS",
"value": "{{os_version.value}}"
},
{
"label": "Build",
"value": "{{old_build.value}}",
"format": "code"
}
]
},
"tags": [
"device",
"os-version",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
"severity": "high",
"category": "device",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -113,6 +113,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "A Windows update failed during setup: {{wu_update_title.value}}",
"subject": {
"kind": "device",
"key": "windows-update",
"name": "Windows Update"
},
"facts": [
{
"label": "Error",
"value": "{{wu_hresult.value}}",
"format": "errorCode"
},
{
"label": "Symbol",
"value": "{{wu_hresult_symbol.value}}",
"format": "code"
}
]
},
"tags": [
"device",
"windows-update",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Informational: a Windows quality/cumulative update installed during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 19). Surfaces updates applied mid-enrollment — useful context when correlating enrollment duration, reboots, or later issues with a specific KB.",
"severity": "info",
"category": "device",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -61,6 +61,22 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "A Windows update installed during setup: {{wu_update_title.value}}",
"subject": {
"kind": "device",
"key": "windows-update",
"name": "Windows Update"
},
"facts": [
{
"label": "Installed",
"value": "{{wu_installed.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"device",
"windows-update",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Informational: the OS build (CurrentBuild.UBR) changed across a reboot during enrollment — an update was installed, regardless of which servicing path delivered it. Deterministic corroboration that works even when the Windows Update event channel shows nothing (registry comparison across restart cannot be lost to timing or logging-path differences).",
"severity": "info",
"category": "device",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -69,6 +69,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "Windows was updated from build {{previous_build.value}} to {{current_build.value}}",
"subject": {
"kind": "device",
"key": "os",
"name": "Windows version"
},
"facts": [
{
"label": "Build before",
"value": "{{previous_build.value}}",
"format": "code"
},
{
"label": "Build after",
"value": "{{current_build.value}}",
"format": "code"
}
]
},
"tags": [
"device",
"windows-update",
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts.",
"severity": "warning",
"category": "device",
"version": "1.1.1",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -59,6 +59,26 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"subject": {
"kind": "device",
"key": "clock",
"name": "System clock"
},
"facts": [
{
"label": "Clock offset (s)",
"value": "{{clock_behind.value}}",
"format": "number"
},
{
"label": "Clock offset (s)",
"value": "{{clock_ahead.value}}",
"format": "number"
}
]
},
"tags": [
"device",
"ntp",
+21 -1
View File
@@ -5,7 +5,7 @@
"description": "The device's clock changed by five or more minutes mid-session (clock jump) or ran five or more minutes off server time for the whole session (sustained offset), measured against the server receive frame of every uploaded event. Complements ANALYZE-DEV-007, which is a one-shot NTP check at agent startup and misses devices where NTP was unreachable or the clock changed later. IME-log-derived events are excluded from the measurement.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -57,6 +57,26 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"subject": {
"kind": "device",
"key": "clock",
"name": "System clock"
},
"facts": [
{
"label": "Clock jump (min)",
"value": "{{clock_jump.jumpMinutes}}",
"format": "number"
},
{
"label": "Clock offset (min)",
"value": "{{sustained_offset.offsetMinutes}}",
"format": "number"
}
]
},
"tags": [
"device",
"clock-skew",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the device's battery dropped to 15% or less while enrolling on battery power (power_state_change threshold event from the agent's live power watcher). A device dying mid-enrollment leaves a half-provisioned state that typically needs a full reset; this rule surfaces the risk while there is still time to plug the device in. Uses a single required condition on the agent-stamped thresholdPercent field (the data field 'transition' on the event is unrelated to this rule's 'trigger' property).",
"severity": "high",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -39,6 +39,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "The battery fell below {{battery_threshold_15.value}}% during setup",
"subject": {
"kind": "device",
"key": "power",
"name": "Power"
},
"facts": [
{
"label": "Battery below (%)",
"value": "{{battery_threshold_15.value}}",
"format": "number"
},
{
"label": "Reported",
"value": "{{battery_threshold_15.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"device",
"power",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the device lost AC power and switched to battery while the enrollment was running (power_state_change event with transition ac_to_battery from the agent's live power watcher). Uses a single required condition so the match is pinned to one event instance; the event payload carries the battery percentage at the moment of the switch. The agent debounces power flapping (5s) so a brief unplug/replug does not produce this event.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -39,6 +39,22 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "The device switched from AC to battery power during setup",
"subject": {
"kind": "device",
"key": "power",
"name": "Power"
},
"facts": [
{
"label": "Switched",
"value": "{{switched_to_battery.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"device",
"power",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log). The watcher also backfills pre-agent episodes from the last 24 h for timeline context (backfilled=true); those ended before the enrollment started and are excluded via the same-event filter backfilled equals false — a separate condition could not do that, because each condition scans all events on its own and a pre-session hibernate would still satisfy the duration test. A single required condition keeps the match pinned to one event instance so the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
"severity": "info",
"category": "device",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -42,6 +42,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "The device went to sleep during setup",
"subject": {
"kind": "device",
"key": "power",
"name": "Power"
},
"facts": [
{
"label": "Asleep for",
"value": "{{slept_during_enrollment.value}}",
"format": "duration"
},
{
"label": "Recorded",
"value": "{{slept_during_enrollment.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"device",
"power",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
@@ -109,6 +109,22 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "The Windows quality update at the end of OOBE failed",
"subject": {
"kind": "device",
"key": "oobe-update",
"name": "OOBE quality update"
},
"facts": [
{
"label": "Failed step",
"value": "{{update_failure.value}}",
"format": "code"
}
]
},
"tags": [
"device",
"windows-update",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
@@ -110,6 +110,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "No quality update was offered although the device is {{days_out_of_date.value}} days out of date",
"subject": {
"kind": "device",
"key": "oobe-update",
"name": "OOBE quality update"
},
"facts": [
{
"label": "Days out of date",
"value": "{{days_out_of_date.value}}",
"format": "number"
},
{
"label": "Earlier update failures",
"value": "{{previous_failures.value}}",
"format": "number"
}
]
},
"tags": [
"device",
"windows-update",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
"severity": "warning",
"category": "device",
"version": "1.0.0",
"version": "1.1.0",
"enabled": true,
"evaluateOn": [
"enrollment_end"
@@ -108,6 +108,22 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "The OOBE update check ended without a result",
"subject": {
"kind": "device",
"key": "oobe-update",
"name": "OOBE quality update"
},
"facts": [
{
"label": "Days out of date",
"value": "{{days_out_of_date.value}}",
"format": "number"
}
]
},
"tags": [
"device",
"windows-update",
+30 -9
View File
@@ -5,7 +5,7 @@
"description": "Detects when the Autopilot enrollment has explicitly failed.",
"severity": "critical",
"category": "enrollment",
"version": "1.1.2",
"version": "1.2.0",
"enabled": true,
"baseConfidence": 95,
"conditions": [
@@ -57,19 +57,40 @@
"confidenceFactors": [
{ "signal": "failed_app", "condition": "exists", "weight": 5 }
],
"explanation": "The Autopilot enrollment has explicitly failed.\n\n**Key facts extracted from this session's events:**\n\n- **Failure reason:** `{{reason}}`\n- **First failed app:** `{{appName}}`\n- **ESP failed subcategory:** `{{failedSubcategory}}`\n- **ESP HRESULT:** `{{errorCode}}`\n\nWhen an ESP Apps-subcategory failure is paired with HRESULT `0x87D1041C`, the device-side install most likely completed but the Intune detection rule did not match afterwards — see the **App Detection Failure During ESP** rule (ANALYZE-APP-013) in this same analysis for the actionable root cause and remediation steps. Any field still shown as a literal `{{…}}` placeholder was simply not recorded for this session — for example, a non-ESP failure path has no ESP failed-subcategory or HRESULT.",
"explanation": "The Autopilot enrollment has explicitly failed.\n\n**Key facts from this session's events:**\n\n- **Failure reason:** `{{reason}}`\n- **First failed app:** `{{appName}}`\n- **ESP step that failed:** `{{failedSubcategory}}`\n- **ESP error code:** `{{errorCode}}`\n\nA more specific finding in this analysis usually names the cause, such as an app, ESP or timeout finding.",
"remediation": [
{
"title": "Investigate enrollment failure",
"title": "Find the cause",
"steps": [
"Start with the more specific ANALYZE-APP-* / ANALYZE-ESP-* rules that fired alongside this one — they correlate specific HRESULTs to actionable causes (e.g. 0x87D1041C → detection rule mismatch in ANALYZE-APP-013)",
"Read the `reason` field on the enrollment_failed event for the immediate trigger (e.g. esp_terminal_failure, hello_timeout, max_lifetime)",
"If a HRESULT is present, look it up against the ESP error catalog (Microsoft Learn: Autopilot ESP error codes)",
"Review other analyze rule results for correlated issues (disk space, network, app failures)",
"Check the device event timeline for the sequence of events leading to failure",
"Verify the Autopilot profile and ESP configuration in Intune"
"Open the finding in this analysis that names the cause (an app, ESP or timeout finding) and follow its steps",
"If no other finding fired, look up the ESP error code and check the failed ESP step in the session timeline",
"Verify the Autopilot profile and the ESP settings in Intune"
]
}
],
"presentation": {
"role": "outcome",
"summary": "The enrollment failed at the ESP step {{esp_failure_subcategory.value}} with {{esp_failure_errorcode.value}}",
"facts": [
{
"label": "Reason",
"value": "{{enrollment_failed_reason.value}}",
"format": "code"
},
{
"label": "ESP step",
"value": "{{esp_failure_subcategory.value}}"
},
{
"label": "ESP error",
"value": "{{esp_failure_errorcode.value}}",
"format": "errorCode"
},
{
"label": "First failed app",
"value": "{{failed_app.appName}}"
}
]
},
"tags": ["enrollment", "failed", "critical"]
}
+25 -3
View File
@@ -2,10 +2,10 @@
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-ENRL-002",
"title": "Session Timed Out",
"description": "Detects when the enrollment session was force-failed by the server-side inactivity timeout (no agent telemetry for the configured SessionTimeoutHours window) without ever completing or explicitly failing.",
"description": "Detects when the server-side inactivity timeout closed the enrollment session (no agent telemetry for the configured SessionTimeoutHours window) before it ever completed or explicitly failed.",
"severity": "high",
"category": "enrollment",
"version": "1.2.0",
"version": "1.4.0",
"enabled": true,
"baseConfidence": 80,
"preconditions": [
@@ -20,6 +20,12 @@
"eventType": "enrollment_failed",
"operator": "not_exists",
"description": "Suppress when an explicit enrollment_failed exists — that path is already covered by ANALYZE-ENRL-001; avoid double-reporting the same session."
},
{
"source": "event_data",
"eventType": "device_blocked",
"operator": "not_exists",
"description": "Suppress when the platform blocked the session's device: the silence is the refused uploads, which ANALYZE-ENRL-006 explains — 'the agent died or the device went offline' would be wrong."
}
],
"conditions": [
@@ -58,7 +64,7 @@
"required": false
}
],
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
"explanation": "The server-side maintenance sweep closed this session after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
"remediation": [
{
"title": "Investigate the silent session",
@@ -78,5 +84,21 @@
]
}
],
"presentation": {
"role": "outcome",
"summary": "No completion or failure arrived within {{timeoutHours.value}} hours",
"facts": [
{
"label": "Timeout (hours)",
"value": "{{timeoutHours.value}}",
"format": "number"
},
{
"label": "Desktop reached",
"value": "{{desktop_was_reached.timestamp}}",
"format": "timestamp"
}
]
},
"tags": ["enrollment", "timeout", "stalled"]
}
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "A TPM-attestation-related error code documented in the Microsoft Autopilot known-issues list was observed (0x800705B4, 0x801C03EA, 0x81039001, 0x81039023, 0x81039024, 0x80070490). These block self-deploying and pre-provisioning deployments during 'Securing your hardware'. Observability: attestation precedes MDM enrollment, so in the normal deployment model (agent installed via IME after enrollment) these codes are rarely visible — the rule primarily serves bootstrap-token deployments.",
"severity": "high",
"category": "enrollment",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -80,6 +80,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "TPM attestation failed with a known Autopilot error code",
"subject": {
"kind": "device",
"key": "tpm",
"name": "TPM attestation"
},
"facts": [
{
"label": "Error code",
"value": "{{tpm_hresult_esp_failure.value}}",
"format": "errorCode"
},
{
"label": "Error code",
"value": "{{tpm_hresult_esp_advisory.value}}",
"format": "errorCode"
}
]
},
"tags": [
"enrollment",
"tpm",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "An enrollment-blocking error code documented in the Microsoft Autopilot known-issues/troubleshooting pages was observed (0x80180014, 0x80180018, 0xC1036501, 0x801C03F3, 0x80070774). Each has a specific, documented cause and fix. Observability: a blocked MDM enrollment normally means no agent gets installed at all (IME install happens after enrollment) — this rule can realistically only fire for bootstrap-token deployments (pre-MDM reporting) or mixed retry scenarios.",
"severity": "high",
"category": "enrollment",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -78,6 +78,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "MDM enrollment was blocked with a known Autopilot error code",
"subject": {
"kind": "device",
"key": "mdm-enrollment",
"name": "MDM enrollment"
},
"facts": [
{
"label": "Error code",
"value": "{{enrl_hresult_esp_failure.value}}",
"format": "errorCode"
},
{
"label": "Error code",
"value": "{{enrl_hresult_esp_advisory.value}}",
"format": "errorCode"
}
]
},
"tags": [
"enrollment",
"mdm",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "A Microsoft Entra hybrid join Autopilot deployment hit error 0x80004005 — a documented build-dependent known issue, resolved by KB5065789 (25H2, build >= 26200.6725), KB5065426 (24H2, build >= 26100.6584), and KB5070312 (23H2, build >= 22631.6276).",
"severity": "high",
"category": "enrollment",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -103,6 +103,27 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "Hybrid join hit 0x80004005, a known timeout fixed by Windows updates",
"subject": {
"kind": "device",
"key": "hybrid-join",
"name": "Hybrid join"
},
"facts": [
{
"label": "Error code",
"value": "{{e_fail_esp_failure.value}}",
"format": "errorCode"
},
{
"label": "Error code",
"value": "{{e_fail_esp_advisory.value}}",
"format": "errorCode"
}
]
},
"tags": [
"enrollment",
"hybrid-join",
+107
View File
@@ -0,0 +1,107 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-ENRL-006",
"title": "Monitoring Paused by a Protective Limit",
"description": "Detects when the platform blocked the session's device — automatically after an unusually high event volume, or by the platform operator — so the session's telemetry stopped reaching Autopilot Monitor.",
"severity": "warning",
"category": "enrollment",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
"enrollment_end",
"on_event:device_blocked"
],
"trigger": "single",
"baseConfidence": 90,
"confidenceThreshold": 50,
"conditions": [
{
"signal": "device_blocked",
"source": "event_type",
"eventType": "device_blocked",
"operator": "exists",
"value": "",
"required": true
},
{
"signal": "event_count",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "eventCount",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "event_limit",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "threshold",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "blocked_until",
"source": "event_data",
"eventType": "device_blocked",
"dataField": "blockedUntil",
"operator": "exists",
"value": "",
"required": false
}
],
"explanation": "Autopilot Monitor stopped accepting telemetry from this session. A protective limit keeps a single device that sends far more events than an enrollment normally does from distorting your fleet data or overloading the platform. When a session crosses it, the device's uploads are paused for a limited time; in severe cases the agent ends monitoring and removes itself. The platform operator can apply the same pause by hand.\n\nA pause does not affect the enrollment itself: the agent keeps running and only holds back its uploads, so Windows Autopilot and Intune carry on as usual. While the pause lasts, no events from this session reach Autopilot Monitor, so the timeline and the final status reflect the last state the agent reported before it. A paused session does not affect the device's next enrollment, which starts unblocked. When the agent ended monitoring, the device stays unmonitored until the end time shown on the session page, new enrollments included.",
"remediation": [
{
"title": "Find what made the session so busy",
"steps": [
"Open the event timeline and look at which event type repeats most often before the pause — for example a network connection that drops and reconnects every few minutes, or a download that reports progress continuously",
"Fix the underlying condition (for example a loose dock, cable or Wi-Fi), then re-enroll the device if you need a complete record of the enrollment",
"If no event type stands out, report the session from its page so it can be looked at"
]
},
{
"title": "What happens next",
"steps": [
"A pause ends on its own at the time shown on the session page; an agent that is still running then resumes uploading",
"After a pause, a new enrollment on this device starts unblocked",
"After the agent ended monitoring, wait for the end time shown on the session page before re-enrolling if you want the new enrollment monitored"
]
}
],
"relatedDocs": [
{
"title": "Autopilot Monitor: Protective Limits",
"url": "https://docs.autopilotmonitor.com/concepts/protective-limits"
}
],
"presentation": {
"role": "outcome",
"summary": "Monitoring was paused by a protective limit",
"facts": [
{
"label": "Paused at",
"value": "{{device_blocked.timestamp}}",
"format": "timestamp"
},
{
"label": "Events in this session",
"value": "{{event_count.value}}",
"format": "number"
},
{
"label": "Event limit",
"value": "{{event_limit.value}}",
"format": "number"
},
{
"label": "Paused until",
"value": "{{blocked_until.value}}",
"format": "timestamp"
}
]
},
"tags": ["enrollment", "protective-limit", "blocked"]
}
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the DeviceSetup phase of the ESP took longer than 30 minutes, indicating a stuck or very slow blocking app. Confidence increases when the phase exceeds 60 minutes.",
"severity": "high",
"category": "esp",
"version": "1.2.0",
"version": "1.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -47,6 +47,27 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "Device setup took {{esp_stalled.durationFormatted}}",
"subject": {
"kind": "esp",
"key": "{{esp_stalled.phase}}",
"name": "Device setup"
},
"facts": [
{
"label": "Duration",
"value": "{{esp_stalled.durationSeconds}}",
"format": "duration"
},
{
"label": "Started",
"value": "{{esp_stalled.phaseStartTimestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"esp",
"blocking",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
"severity": "high",
"category": "esp",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -58,6 +58,22 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "The ESP certificates step failed",
"subject": {
"kind": "esp",
"key": "Certificates",
"name": "Certificates"
},
"facts": [
{
"label": "Failed at",
"value": "{{certificates_subcategory_failed.timestamp}}",
"format": "timestamp"
}
]
},
"tags": [
"esp",
"certificates",
+26 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects an ESP terminal failure where the ESP gave up while a blocking app was still installing (the agent's own esp_apps_timeout verdict — no per-app HRESULT was reported) on a profile that allows 'Continue anyway'. The enrollment is reported as failed, but the user most likely dismissed the error screen and reached the desktop — a soft failure rather than an unusable device. An ESP failure that carries a specific error code (a failed installer, a detection mismatch) is a different case and stays with the rule that explains that code.",
"severity": "warning",
"category": "esp",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"baseConfidence": 80,
"conditions": [
@@ -81,5 +81,30 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "The ESP gave up while {{appName.value}} was still installing ('Continue anyway' allowed)",
"subject": {
"kind": "app",
"key": "{{appName.appId}}",
"name": "{{appName.value}}"
},
"facts": [
{
"label": "ESP error",
"value": "{{enrollment_failed.errorCode}}",
"format": "errorCode"
},
{
"label": "ESP time limit (min)",
"value": "{{espSyncFailureTimeoutMinutes.value}}",
"format": "number"
},
{
"label": "ESP step",
"value": "{{failedSubcategory.value}}"
}
]
},
"tags": ["esp", "timeout", "continue-anyway", "soft-failure", "enrollment"]
}
+26 -1
View File
@@ -5,7 +5,7 @@
"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.3.0",
"version": "1.4.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -94,6 +94,31 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "context",
"summary": "A device policy forced a restart during setup: {{reboot_forcing_policy_uri.value}}",
"subject": {
"kind": "policy",
"key": "{{reboot_forcing_policy_uri.value}}"
},
"facts": [
{
"label": "First policy",
"value": "{{reboot_forcing_policy_uri.value}}",
"format": "code"
},
{
"label": "Policies",
"value": "{{multiple_reboot_uris.value}}",
"format": "number"
},
{
"label": "Restarts",
"value": "{{reboot_observed.count}}",
"format": "number"
}
]
},
"tags": [
"esp",
"reboot",
+29 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
"severity": "info",
"category": "esp",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -98,6 +98,34 @@
]
}
],
"presentation": {
"role": "context",
"summary": "The ESP failed in the {{category.value}} phase, then recovered",
"subject": {
"kind": "esp",
"key": "{{failedSubcategory.value}}",
"name": "{{failedSubcategory.value}}"
},
"facts": [
{
"label": "ESP phase",
"value": "{{category.value}}"
},
{
"label": "ESP step",
"value": "{{failedSubcategory.value}}"
},
{
"label": "Likely apps",
"value": "{{likelyCulpritApps.value}}"
},
{
"label": "Recovered after (min)",
"value": "{{minutesSinceFailure.value}}",
"format": "number"
}
]
},
"tags": [
"esp",
"recovery",
+10 -1
View File
@@ -12,7 +12,7 @@
"enrollment_end",
"whiteglove_sealed"
],
"version": "1.3.0",
"version": "1.4.0",
"baseConfidence": 75,
"conditions": [
{
@@ -55,6 +55,15 @@
]
}
],
"presentation": {
"role": "advisory",
"summary": "The expected machine certificate was not found",
"subject": {
"kind": "device",
"key": "machine-certificate",
"name": "Machine certificate"
}
},
"tags": [
"identity",
"certificates",
+45 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
"severity": "high",
"category": "identity",
"version": "1.3.0",
"version": "1.4.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -41,6 +41,30 @@
"operator": "equals",
"value": "error",
"required": false
},
{
"signal": "unexpected_accounts",
"source": "event_data",
"eventType": "local_admin_analysis",
"dataField": "checks.unexpected_accounts",
"operator": "not_equals",
"value": "",
"filterField": "finding",
"filterOperator": "equals",
"filterValue": "unexpected_local_admins_detected",
"required": false
},
{
"signal": "unexpected_admin_members",
"source": "event_data",
"eventType": "local_admin_analysis",
"dataField": "checks.unexpected_admin_members",
"operator": "not_equals",
"value": "",
"filterField": "finding",
"filterOperator": "equals",
"filterValue": "unexpected_local_admins_detected",
"required": false
}
],
"confidenceFactors": [
@@ -85,6 +109,26 @@
}
],
"relatedDocs": [],
"presentation": {
"role": "advisory",
"summary": "Unexpected local accounts: {{unexpected_accounts.value}}",
"subject": {
"kind": "account",
"key": "{{unexpected_accounts.value}}"
},
"facts": [
{
"label": "Unexpected accounts",
"value": "{{unexpected_accounts.value}}",
"format": "code"
},
{
"label": "In Administrators",
"value": "{{unexpected_admin_members.value}}",
"format": "code"
}
]
},
"tags": [
"identity",
"security",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Fires when the vulnerability correlation finds critical-severity CVEs or CISA KEV entries in installed software. This indicates the device has software with known, actively exploited vulnerabilities.",
"severity": "critical",
"category": "security",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -72,6 +72,27 @@
"url": "https://nvd.nist.gov/"
}
],
"presentation": {
"role": "advisory",
"summary": "Installed software has {{critical_vulnerabilities.value}} critical vulnerabilities",
"subject": {
"kind": "device",
"key": "vulnerabilities",
"name": "Installed software"
},
"facts": [
{
"label": "Critical CVEs",
"value": "{{critical_vulnerabilities.value}}",
"format": "number"
},
{
"label": "Known exploited",
"value": "{{kev_matches.value}}",
"format": "number"
}
]
},
"tags": [
"vulnerability",
"cve",
+50 -1
View File
@@ -5,7 +5,7 @@
"description": "On a hybrid-joined device the real user reached the desktop, but the Intune Management Extension never acquired a Microsoft Entra user token for that user within the wait window — repeatedly across reboots when the warning recurs. Without a user token the signed-in user has no Entra user affinity: user-targeted apps and policies cannot apply, Windows Hello for Business cannot provision, and with a user ESP the Account Setup page re-runs on every logon.",
"severity": "high",
"category": "identity",
"version": "2.0.0",
"version": "2.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "correlation",
@@ -44,6 +44,24 @@
"operator": "not_exists",
"value": "",
"required": true
},
{
"signal": "token_failure_count",
"source": "event_data",
"eventType": "entra_user_affinity_pending",
"dataField": "tokenFailureCount",
"operator": "exists",
"value": "",
"required": false
},
{
"signal": "token_failure_codes",
"source": "event_data",
"eventType": "entra_user_affinity_pending",
"dataField": "tokenFailureCodes",
"operator": "not_equals",
"value": "",
"required": false
}
],
"confidenceFactors": [
@@ -96,5 +114,36 @@
}
],
"markSessionAsFailedDefault": false,
"presentation": {
"role": "cause",
"summary": "The user reached the desktop, but Entra user affinity never formed",
"subject": {
"kind": "account",
"key": "entra-user-affinity",
"name": "Entra user affinity"
},
"facts": [
{
"label": "Token failures",
"value": "{{token_failure_count.value}}",
"format": "number"
},
{
"label": "Token error codes",
"value": "{{token_failure_codes.value}}",
"format": "code"
},
{
"label": "Reported in agent runs",
"value": "{{user_affinity_pending.count}}",
"format": "number"
},
{
"label": "Desktop reached",
"value": "{{user_desktop_reached.timestamp}}",
"format": "timestamp"
}
]
},
"tags": ["identity", "hybrid-join", "entra-id", "user-affinity", "esp"]
}
+10 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
"severity": "warning",
"category": "network",
"version": "1.0.0",
"version": "1.1.0",
"author": "Autopilot Monitor",
"enabled": true,
"baseConfidence": 75,
@@ -71,6 +71,15 @@
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
}
],
"presentation": {
"role": "advisory",
"summary": "A web security gateway blocked the diagnostics upload",
"subject": {
"kind": "network",
"key": "security-gateway",
"name": "Web security gateway"
}
},
"tags": [
"network",
"proxy",
+17 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) install failed. The V2 OfficeInstallDetector watches the C2R client directly (registry, Delivery Optimization, process) and emits office_install_failed when the C2R INSTALL scenario records an error code. Completion is proven by the core Office binaries on disk; an install window that closes without that proof ends silently, never as a failure. This catches Office failures the deployment status hides: the Intune built-in app type is marked done once the configuration is delivered, and a Win32 or RealmJoin package once its installer exits, while C2R keeps installing in the background.",
"severity": "warning",
"category": "apps",
"version": "1.1.1",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -52,6 +52,22 @@
]
}
],
"presentation": {
"role": "cause",
"summary": "Microsoft 365 Apps failed to install",
"subject": {
"kind": "app",
"key": "office-c2r",
"name": "Microsoft 365 Apps"
},
"facts": [
{
"label": "Error",
"value": "{{error_code_present.value}}",
"format": "errorCode"
}
]
},
"tags": [
"apps",
"office",
+30 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
"severity": "warning",
"category": "security",
"version": "3.1.0",
"version": "3.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -132,6 +132,35 @@
"url": "https://support.microsoft.com/en-us/topic/kb5036210"
}
],
"presentation": {
"role": "advisory",
"summary": "The Windows UEFI CA 2023 certificate is not deployed",
"subject": {
"kind": "device",
"key": "secure-boot",
"name": "Secure Boot"
},
"facts": [
{
"label": "CA 2023 status",
"value": "{{ca2023_not_updated.value}}",
"format": "code"
},
{
"label": "Error",
"value": "{{ca2023_error.value}}",
"format": "code"
},
{
"label": "CA 2023 in firmware DB",
"value": "{{db_ca2023_firmware_missing.value}}"
},
{
"label": "KEK 2023 in firmware",
"value": "{{kek_2023_missing.value}}"
}
]
},
"tags": [
"security",
"secureboot",
+10 -1
View File
@@ -5,7 +5,7 @@
"description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).",
"severity": "high",
"category": "security",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -49,6 +49,15 @@
"url": "https://learn.microsoft.com/en-us/sysinternals/downloads/autologon"
}
],
"presentation": {
"role": "advisory",
"summary": "A plaintext AutoLogon password is stored in the registry",
"subject": {
"kind": "device",
"key": "autologon",
"name": "AutoLogon"
}
},
"tags": [
"security",
"autologon",
+16 -1
View File
@@ -5,7 +5,7 @@
"description": "Optional kiosk allow-list for AutoLogon. Fires when AutoLogon is enabled for a default user that is NOT on your configured list of approved kiosk / shared-device accounts. Disabled until configured because a normal Autopilot enrollment leaves Windows' own ESP auto-logon for the enrolling user (AutoLogon enabled, a default user, no plaintext password) — so AutoLogon-enabled alone is not graded by default; only a plaintext DefaultPassword on disk (ANALYZE-SEC-003) is. Enable this rule with your expected kiosk account name(s) if you want to surface AutoLogon configured for any other user.",
"severity": "warning",
"category": "security",
"version": "1.1.0",
"version": "1.2.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
@@ -59,6 +59,21 @@
"url": "https://learn.microsoft.com/en-us/troubleshoot/windows-server/user-profiles-and-logon/turn-on-automatic-logon"
}
],
"presentation": {
"role": "advisory",
"summary": "AutoLogon is enabled for an unexpected user: {{unexpected_autologon_user.value}}",
"subject": {
"kind": "account",
"key": "{{unexpected_autologon_user.value}}"
},
"facts": [
{
"label": "AutoLogon user",
"value": "{{unexpected_autologon_user.value}}",
"format": "code"
}
]
},
"tags": [
"security",
"autologon",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects provisioning packages (PPKG) that are not on the built-in allow-list. The agent emits one or more provisioning_package_scan events whose artifacts[] arrays list every PPKG (registry package, .ppkg file, or Recovery\\Customizations residue; a large set is split across several events). This rule iterates that array across all of them and fires for any artifact whose identity does not match the allow-list regex. The default allow-list covers the Windows OS-inbox packages (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) plus common OEM factory-preload families (Intel PPM power profiles, AMD Power profiles, Lenovo ThinkPad power/battery packages, Microsoft Surface defaults and carrier packages) so clean factory devices stay quiet. OEM imaging leftovers (e.g. USMT.PPKG, InstallOrder.ini) and generically named packages deliberately still alert — on a pure-Autopilot device they are worth a look. To allow your own vendor/recovery packages, enable the template rule ANALYZE-SEC-006, extend its allow-list, then disable this rule.",
"severity": "warning",
"category": "security",
"version": "2.2.0",
"version": "2.3.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -38,6 +38,27 @@
]
}
],
"presentation": {
"role": "advisory",
"summary": "Provisioning package not on the allow list: {{ppkg_not_allowlisted.value}}",
"subject": {
"kind": "device",
"key": "provisioning-packages",
"name": "Provisioning packages"
},
"facts": [
{
"label": "Package",
"value": "{{ppkg_not_allowlisted.value}}",
"format": "code"
},
{
"label": "Unexpected packages",
"value": "{{ppkg_not_allowlisted.matchCount}}",
"format": "number"
}
]
},
"tags": [
"security",
"provisioning-package",
+22 -1
View File
@@ -5,7 +5,7 @@
"description": "Template version of ANALYZE-SEC-005 with a customizable allow-list. Iterates the provisioning_package_scan artifacts[] array and fires for any PPKG whose identity is not on YOUR allow-list regex. The placeholder ships with the same OS-inbox defaults as ANALYZE-SEC-005 — extend it with your own vendor/recovery packages. This rule is OFF by default: enable it, set your allow-list, then disable ANALYZE-SEC-005 so only packages outside your list alert.",
"severity": "warning",
"category": "security",
"version": "2.2.0",
"version": "2.3.0",
"enabled": false,
"evaluateOn": [
"enrollment_end",
@@ -45,6 +45,27 @@
]
}
],
"presentation": {
"role": "advisory",
"summary": "Provisioning package not on the allow list: {{ppkg_not_allowlisted.value}}",
"subject": {
"kind": "device",
"key": "provisioning-packages",
"name": "Provisioning packages"
},
"facts": [
{
"label": "Package",
"value": "{{ppkg_not_allowlisted.value}}",
"format": "code"
},
{
"label": "Unexpected packages",
"value": "{{ppkg_not_allowlisted.matchCount}}",
"format": "number"
}
]
},
"tags": [
"security",
"provisioning-package",
+10 -1
View File
@@ -5,7 +5,7 @@
"description": "Fires when the provisioning-package scan hit a per-source collection cap (too many .ppkg files, registry packages, or Recovery\\Customizations residue files to capture them all). When that happens the allow-list rules (ANALYZE-SEC-005/006) could not evaluate every package, so a non-allow-listed package may have gone unseen. A device with that many provisioning packages is itself unusual and worth a manual look.",
"severity": "warning",
"category": "security",
"version": "1.1.0",
"version": "1.2.0",
"enabled": true,
"evaluateOn": [
"enrollment_end",
@@ -36,6 +36,15 @@
]
}
],
"presentation": {
"role": "advisory",
"summary": "The provisioning package scan hit its capture limit",
"subject": {
"kind": "device",
"key": "provisioning-packages",
"name": "Provisioning packages"
}
},
"tags": [
"security",
"provisioning-package",
+10 -1
View File
@@ -5,7 +5,7 @@
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
"severity": "warning",
"category": "security",
"version": "1.1.0",
"version": "1.2.0",
"author": "Autopilot Monitor",
"enabled": true,
"evaluateOn": [
@@ -82,6 +82,15 @@
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
}
],
"presentation": {
"role": "advisory",
"summary": "Secure Boot is disabled",
"subject": {
"kind": "device",
"key": "secure-boot",
"name": "Secure Boot"
}
},
"tags": [
"security",
"secureboot",
+1271 -63
View File
File diff suppressed because it is too large Load Diff
+78
View File
@@ -133,6 +133,7 @@
"items": { "$ref": "#/$defs/relatedDoc" },
"default": []
},
"presentation": { "$ref": "#/$defs/rulePresentation" },
"tags": {
"type": "array",
"description": "Tags for filtering and categorization.",
@@ -352,6 +353,83 @@
"required": ["title", "url"],
"additionalProperties": false
},
"rulePresentation": {
"type": "object",
"description": "How a finding of this rule is shown: its role, a one-line summary, what it is about and its key facts. Every string is a {{token}} template resolved from the finding's evidence when it is rendered; a fact whose value was not recorded is left out.",
"properties": {
"role": {
"type": "string",
"enum": ["outcome", "cause", "context", "advisory"],
"description": "outcome: restates the session outcome (shown in the verdict, not as a finding row). cause: can explain why a session failed or is stuck. context: a circumstance such as standby, a restart or battery power. advisory: hygiene or security without bearing on the outcome."
},
"summary": {
"type": "string",
"minLength": 1,
"maxLength": 160,
"description": "One line for the finding row, for example \"{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times\". When a token was not recorded, the first sentence of the explanation is shown instead."
},
"subject": { "$ref": "#/$defs/ruleSubject" },
"facts": {
"type": "array",
"description": "Key facts shown as label/value pairs, in this order.",
"maxItems": 6,
"items": { "$ref": "#/$defs/ruleFact" }
}
},
"required": ["role"],
"additionalProperties": false
},
"ruleSubject": {
"type": "object",
"description": "What the finding is about. Findings with the same kind and resolved key are grouped, for example every finding about one app. Use qualified tokens of one signal for key and name, such as {{failed_app.appId}} and {{failed_app.appName}}, so both come from the same event.",
"properties": {
"kind": {
"type": "string",
"enum": ["app", "policy", "account", "script", "device", "network", "esp", "session"],
"description": "The kind of thing the key identifies."
},
"key": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Template of the grouping key."
},
"name": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Template of the display name; the key is shown when it is missing."
}
},
"required": ["kind", "key"],
"additionalProperties": false
},
"ruleFact": {
"type": "object",
"description": "One key fact of a finding.",
"properties": {
"label": {
"type": "string",
"minLength": 1,
"maxLength": 40,
"description": "Short label, for example \"Install starts\"."
},
"value": {
"type": "string",
"minLength": 1,
"maxLength": 200,
"description": "Template of the value, for example {{install_retry_loop.count}}."
},
"format": {
"type": "string",
"enum": ["text", "code", "errorCode", "duration", "number", "timestamp"],
"description": "How the value is shown. errorCode adds the error-code catalog meaning, duration reads seconds, timestamp an ISO 8601 time.",
"default": "text"
}
},
"required": ["label", "value"],
"additionalProperties": false
},
"templateVariable": {
"type": "object",
"description": "A variable in a rule condition that must be customized per-tenant before the rule can be used.",
@@ -0,0 +1,991 @@
# Integration tests (Pester 5+) for the bootstrap chain: the real loader and installer, each
# started the way Intune and the bootstrap MSI start them -- powershell.exe -File, in a process
# of its own -- against a scratch ProgramData and TEMP. Loader 1.2 and the MSI 1.0.x runner run
# from TestData, frozen from git: devices keep running them until customers replace them.
#
# What these tests do not cover is swapped out by global aliases, which win over cmdlets and
# over functions a script defines: downloads (served from a scratch folder), signatures, the
# catalog check, the guard decision, the deployment marker, the agent task, the agent's
# --install (a real program start from PowerShell's location, with the agent's work left out)
# and sleeps. Every swap writes a trace line, and the tests check the lines they rely on, so a
# swap that stops working turns a test red instead of reaching the real machine.
#
# Run elevated (CI also runs it as SYSTEM), the folder permissions are the real ones. Without
# elevation the test account may not make Administrators the owner, so the protected DACL is
# swapped for one without that owner change, and folders the account owns count as ours.
# Entries whose name starts with "foreign" count as another account's.
#
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
BeforeAll {
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$script:loaderPath = Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1'
$script:installerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1'
$script:loader12Path = Join-Path $PSScriptRoot 'TestData\Start-AutopilotMonitor-1.2.ps1'
$script:msiRunnerPath = Join-Path $PSScriptRoot 'TestData\Invoke-BootstrapDownload-1.0.ps1'
# The backend's pin of the generated OOBE script, test values filled in.
$script:oobeScriptPath = Join-Path $script:repoRoot 'src\Backend\AutopilotMonitor.Functions.Tests\TestData\oobe-bootstrap-golden.ps1'
$script:powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
$script:isElevated = (New-Object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
$script:payloadFiles = @('AutopilotMonitor.Agent.Core.dll', 'AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.exe.config')
# The agent package: text files under the agent's names. --install is swapped out, so
# nothing in it ever runs.
$payload = Join-Path $TestDrive 'payload'
New-Item -ItemType Directory -Path $payload | Out-Null
foreach ($name in $script:payloadFiles) { Set-Content -LiteralPath (Join-Path $payload $name) -Value "stub $name" }
$script:agentZip = Join-Path $TestDrive 'AutopilotMonitor-Agent.zip'
Compress-Archive -Path (Join-Path $payload '*') -DestinationPath $script:agentZip
$script:agentZipSha256 = (Get-FileHash -LiteralPath $script:agentZip -Algorithm SHA256).Hash.ToLowerInvariant()
$script:wrapperText = @'
$global:HarnessDir = $PSScriptRoot
$global:HarnessOptions = [System.IO.File]::ReadAllText((Join-Path $PSScriptRoot 'options.json')) | ConvertFrom-Json
function global:Write-HarnessTrace {
param([string]$Name, [string]$Detail)
[System.IO.File]::AppendAllText((Join-Path $global:HarnessDir 'trace.txt'), "$Name|$Detail`r`n")
}
function global:Get-HarnessServerFile {
param([string]$Uri)
$source = Join-Path $global:HarnessOptions.Server ($Uri.Substring($Uri.LastIndexOf('/') + 1))
if (-not [System.IO.File]::Exists($source)) { throw "The remote server returned an error: (404) Not Found. ($Uri)" }
return $source
}
function global:HarnessInvokeWebRequest {
[CmdletBinding()]
param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing, [int]$TimeoutSec, [string]$Method)
Write-HarnessTrace 'Invoke-WebRequest' $Uri
[System.IO.File]::Copy((Get-HarnessServerFile $Uri), $OutFile, $true)
}
function global:HarnessInvokeRestMethod {
[CmdletBinding()]
param([string]$Uri, [switch]$UseBasicParsing, [int]$TimeoutSec)
Write-HarnessTrace 'Invoke-RestMethod' $Uri
return ([System.IO.File]::ReadAllText((Get-HarnessServerFile $Uri)) | ConvertFrom-Json)
}
function global:HarnessGetAuthenticodeSignature {
[CmdletBinding()]
param([string]$FilePath, [byte[]]$Content, [string[]]$SourcePathOrExtension)
Write-HarnessTrace 'Get-AuthenticodeSignature' $(if ($Content) { "content:$($Content.Length)" } else { $FilePath })
return [pscustomobject]@{
Status = $global:HarnessOptions.SignatureStatus
SignerCertificate = [pscustomobject]@{ Subject = $global:HarnessOptions.SignerSubject; Thumbprint = 'HARNESS' }
}
}
function global:HarnessTestFileCatalog {
[CmdletBinding()]
param([string]$Path, [string]$CatalogFilePath, [string[]]$FilesToSkip, [switch]$Detailed)
Write-HarnessTrace 'Test-FileCatalog' $Path
return [pscustomobject]@{
Status = $(if ($global:HarnessOptions.CatalogValid) { 'Valid' } else { 'ValidationFailed' })
Signature = [pscustomobject]@{ Status = 'Valid'; SignerCertificate = [pscustomobject]@{ Subject = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' } }
CatalogItems = @{}
PathItems = @{}
}
}
function global:HarnessGetBootstrapDecision {
param([int]$MaxBootstrapWindowHours, [int]$OobeProfileMaxAgeMinutes)
Write-HarnessTrace 'Get-BootstrapDecision' "MaxBootstrapWindowHours=$MaxBootstrapWindowHours"
$install = [bool]$global:HarnessOptions.Install
return [pscustomobject]@{ Install = $install; ReasonCode = $(if ($install) { $null } else { 'ProfilesFound' }); RelaxActive = $false }
}
function global:HarnessGetRegistryValue64 {
param([string]$SubKey, [string]$Name)
Write-HarnessTrace 'Get-RegistryValue64' "$SubKey\$Name"
if ($SubKey -eq 'SOFTWARE\AutopilotMonitor' -and $Name -eq 'Deployed') { return $global:HarnessOptions.Marker }
return $null
}
function global:HarnessGetScheduledTask {
[CmdletBinding()]
param([string]$TaskName)
Write-HarnessTrace 'Get-ScheduledTask' $TaskName
if ($global:HarnessOptions.TaskPresent) { return [pscustomobject]@{ TaskName = $TaskName } }
throw (New-Object System.Management.Automation.ErrorRecord((New-Object System.Exception "No MSFT_ScheduledTask objects found with property 'TaskName' equal to '$TaskName'."), 'CmdletizationQuery_NotFound_TaskName,Get-ScheduledTask', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $TaskName))
}
function global:HarnessInvokeAgentInstallMode {
param([string]$ExePath, [string[]]$Arguments = @('--install'))
Write-HarnessTrace 'Invoke-AgentInstallMode' ('{0} {1} exists={2}' -f $ExePath, ($Arguments -join ' '), [System.IO.File]::Exists($ExePath))
# A real program start from PowerShell's own location, as the agent start is: a location
# that no longer exists fails here with error 267, as it did on the MSI channel. With
# AgentStartFails the stub payload itself is started, a text file that never runs.
if ($global:HarnessOptions.AgentStartFails) {
& $ExePath @Arguments | Out-Host
return $LASTEXITCODE
}
& "$env:SystemRoot\System32\cmd.exe" /c "exit $([int]$global:HarnessOptions.InstallExitCode)" | Out-Host
return $LASTEXITCODE
}
function global:HarnessGetProcess {
[CmdletBinding()]
param([string[]]$Name)
if ($Name -contains 'AutopilotMonitor.Agent') {
Write-HarnessTrace 'Get-Process' 'AutopilotMonitor.Agent'
return [pscustomobject]@{ Id = 4242; StartTime = Get-Date }
}
return Microsoft.PowerShell.Management\Get-Process @PSBoundParameters
}
function global:HarnessStartSleep {
[CmdletBinding()]
param([int]$Seconds, [int]$Milliseconds)
Write-HarnessTrace 'Start-Sleep' "$Seconds s $Milliseconds ms"
}
function global:HarnessTestIsElevated {
Write-HarnessTrace 'Test-IsElevated' "$($global:HarnessOptions.Elevated)"
return [bool]$global:HarnessOptions.Elevated
}
# The protected DACL without the owner change a standard account may not make.
function global:HarnessNewProtectedDirectorySecurity {
$security = New-Object System.Security.AccessControl.DirectorySecurity
$security.SetAccessRuleProtection($true, $false)
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
$propagation = [System.Security.AccessControl.PropagationFlags]::None
foreach ($sid in @([System.Security.Principal.WindowsIdentity]::GetCurrent().User, (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')))) {
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', $inherit, $propagation, 'Allow')))
}
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule((New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-545')), 'ReadAndExecute', $inherit, $propagation, 'Allow')))
return $security
}
function global:HarnessTestIsOwnedByUs {
param([string]$Path)
if ([System.IO.Path]::GetFileName($Path) -like 'foreign*') { return $false }
try {
$owner = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop).GetAccessControl([System.Security.AccessControl.AccessControlSections]::Owner).GetOwner([System.Security.Principal.SecurityIdentifier])
return ($owner.Value -eq 'S-1-5-18' -or $owner.Value -eq 'S-1-5-32-544' -or $owner -eq [System.Security.Principal.WindowsIdentity]::GetCurrent().User)
} catch {
return $false
}
}
$swaps = @{
'Invoke-WebRequest' = 'HarnessInvokeWebRequest'
'Invoke-RestMethod' = 'HarnessInvokeRestMethod'
'Get-AuthenticodeSignature' = 'HarnessGetAuthenticodeSignature'
'Test-FileCatalog' = 'HarnessTestFileCatalog'
'Get-BootstrapDecision' = 'HarnessGetBootstrapDecision'
'Get-RegistryValue64' = 'HarnessGetRegistryValue64'
'Get-ScheduledTask' = 'HarnessGetScheduledTask'
'Invoke-AgentInstallMode' = 'HarnessInvokeAgentInstallMode'
'Get-Process' = 'HarnessGetProcess'
'Start-Sleep' = 'HarnessStartSleep'
'Test-IsOwnedByUs' = 'HarnessTestIsOwnedByUs'
}
if ($global:HarnessOptions.SwapElevation) { $swaps['Test-IsElevated'] = 'HarnessTestIsElevated' }
if ($global:HarnessOptions.SwapDacl) { $swaps['New-ProtectedDirectorySecurity'] = 'HarnessNewProtectedDirectorySecurity' }
foreach ($name in $swaps.Keys) { Set-Alias -Name $name -Value $swaps[$name] -Scope Global -Force }
# The bootstrap MSI's action is a -Command line; the OOBE script arrives as irm | iex, which
# ignores #Requires.
if ($global:HarnessOptions.CommandText) {
Invoke-Expression $global:HarnessOptions.CommandText
} elseif ($global:HarnessOptions.Iex) {
Invoke-Expression ([System.IO.File]::ReadAllText($global:HarnessOptions.Script))
} else {
& $global:HarnessOptions.Script @args
}
exit $LASTEXITCODE
'@
# A ZIP with exactly these entry names.
function New-ZipWithEntries {
param([string]$Path, [string[]]$Names)
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Create)
try {
foreach ($name in $Names) {
$writer = New-Object System.IO.StreamWriter($archive.CreateEntry($name).Open())
try { $writer.Write("content of $name") } finally { $writer.Dispose() }
}
} finally { $archive.Dispose() }
}
# Serves a package with one entry that points outside the payload folder, with the
# matching manifest hash, under the stable names.
function Set-EscapingPackage {
param($Scenario)
$zip = Join-Path $Scenario.Server 'AutopilotMonitor-Agent.zip'
Remove-Item -LiteralPath $zip
New-ZipWithEntries -Path $zip -Names @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', '../../../outside.txt')
$sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
Set-Content -LiteralPath (Join-Path $Scenario.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $sha + '"}')
}
function New-Scenario {
$root = Join-Path $TestDrive ('s' + [guid]::NewGuid().ToString('N').Substring(0, 7))
$scenario = [pscustomobject]@{
Root = $root
ProgramData = Join-Path $root 'ProgramData'
Base = Join-Path $root 'ProgramData\AutopilotMonitor'
ProgramFiles = Join-Path $root 'ProgramFiles'
Temp = Join-Path $root 'Temp'
Server = Join-Path $root 'Server'
Outside = Join-Path $root 'Outside'
Ime = Join-Path $root 'Ime'
}
foreach ($dir in @($scenario.ProgramData, $scenario.Temp, $scenario.Server, $scenario.Outside, $scenario.Ime)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
Copy-Item -LiteralPath $script:installerPath -Destination (Join-Path $scenario.Server 'Install-AutopilotMonitor.ps1')
Copy-Item -LiteralPath $script:agentZip -Destination (Join-Path $scenario.Server 'AutopilotMonitor-Agent.zip')
Set-Content -LiteralPath (Join-Path $scenario.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
return $scenario
}
function Invoke-Scenario {
param(
$Scenario,
[string]$Script,
[string[]]$Arguments = @(),
[string]$WorkingDirectory,
[hashtable]$Options = @{}
)
$settings = @{
Script = $Script
Server = $Scenario.Server
Install = $true
Marker = $null
TaskPresent = $false
InstallExitCode = 0
SignatureStatus = 'Valid'
SignerSubject = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
CatalogValid = $true
Elevated = $true
Iex = $false
CommandText = $null
AgentStartFails = $false
}
foreach ($key in $Options.Keys) { $settings[$key] = $Options[$key] }
$script:promiseScenario = $Scenario
$settings.SwapElevation = ([bool]$settings.Elevated -ne $script:isElevated)
$settings.SwapDacl = -not $script:isElevated
$harness = Join-Path $Scenario.Root 'Harness'
New-Item -ItemType Directory -Path $harness -Force | Out-Null
$tracePath = Join-Path $harness 'trace.txt'
if (Test-Path -LiteralPath $tracePath) { Remove-Item -LiteralPath $tracePath }
[System.IO.File]::WriteAllText((Join-Path $harness 'options.json'), ($settings | ConvertTo-Json))
[System.IO.File]::WriteAllText((Join-Path $harness 'run.ps1'), $script:wrapperText)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $script:powershell
$quoted = @($Arguments | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } })
$psi.Arguments = ('-NoProfile -ExecutionPolicy Bypass -File "{0}" {1}' -f (Join-Path $harness 'run.ps1'), ($quoted -join ' ')).Trim()
$psi.WorkingDirectory = $(if ($WorkingDirectory) { $WorkingDirectory } else { $Scenario.Root })
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.EnvironmentVariables['ProgramData'] = $Scenario.ProgramData
$psi.EnvironmentVariables['ALLUSERSPROFILE'] = $Scenario.ProgramData
$psi.EnvironmentVariables['TEMP'] = $Scenario.Temp
$psi.EnvironmentVariables['TMP'] = $Scenario.Temp
$psi.EnvironmentVariables['ProgramW6432'] = $Scenario.ProgramFiles
$process = [System.Diagnostics.Process]::Start($psi)
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit(300000)) { $process.Kill(); throw 'The harness run did not end within five minutes.' }
$process.WaitForExit()
return [pscustomobject]@{
ExitCode = $process.ExitCode
StdOut = $stdout.Result
StdErr = $stderr.Result
Trace = @(if (Test-Path -LiteralPath $tracePath) { Get-Content -LiteralPath $tracePath })
}
}
# Every entry below $Root, links as links and never followed; run-folder ids as <id>.
# -Content adds a hash per file, for "exactly as it was".
function Get-Tree {
param([string]$Root, [switch]$Content)
$entries = New-Object System.Collections.Generic.List[string]
$walk = {
param([string]$Folder)
foreach ($entry in [System.IO.Directory]::GetFileSystemEntries($Folder)) {
$relative = $entry.Substring($Root.Length + 1) -replace '-[0-9a-f]{8}(?=\\|$)', '-<id>'
$attributes = [System.IO.File]::GetAttributes($entry)
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { $entries.Add("$relative -> link"); continue }
if (($attributes -band [System.IO.FileAttributes]::Directory) -ne 0) { $entries.Add("$relative\"); & $walk $entry; continue }
if ($Content) { $relative += ' ' + (Get-FileHash -LiteralPath $entry -Algorithm SHA256).Hash.Substring(0, 16) }
$entries.Add($relative)
}
}
if ([System.IO.Directory]::Exists($Root)) { & $walk $Root }
return (Join-Tree $entries)
}
function Join-Tree {
param([string[]]$Entries)
$sorted = [string[]]@($Entries | Where-Object { $_ })
[System.Array]::Sort($sorted, [System.StringComparer]::Ordinal)
return ($sorted -join "`n")
}
# The promise every run keeps, whatever the scenario asserts about ProgramData: nothing of
# ours anywhere else. The scenario's own fixture folders are the only other entries.
function Assert-NothingOutsideProgramData {
param($Scenario)
$fixtures = @('ProgramData', 'ProgramFiles', 'Temp', 'Server', 'Outside', 'Ime', 'Harness')
$others = @([System.IO.Directory]::GetFileSystemEntries($Scenario.Root) | ForEach-Object { [System.IO.Path]::GetFileName($_) } | Where-Object { $_ -notin $fixtures })
$others | Should -BeNullOrEmpty -Because 'the scripts write nowhere but ProgramData'
Get-Tree $Scenario.ProgramFiles | Should -BeNullOrEmpty -Because 'nothing of ours stays under Program Files'
Get-Tree $Scenario.Temp | Should -BeNullOrEmpty -Because 'the scripts never stage in the shared temp folder'
}
# Every Describe runs this after each test: the last run of the test kept the promise.
function Assert-PromiseOfLastRun {
if (-not $script:promiseScenario) { return }
$scenario = $script:promiseScenario
$script:promiseScenario = $null
Assert-NothingOutsideProgramData $scenario
}
function Get-StdErrLines {
param($Run)
return @($Run.StdErr -split "`r?`n" | Where-Object { $_ })
}
function New-Junction {
param([string]$Link, [string]$Target)
cmd.exe /c mklink /J "$Link" "$Target" | Out-Null
}
function Add-File {
param([string]$Path, [string]$Value = 'x')
New-Item -ItemType Directory -Path (Split-Path $Path) -Force | Out-Null
Set-Content -LiteralPath $Path -Value $Value
}
# A base with what earlier bootstrap versions and agents could leave behind, plus a file
# no version knows yet and a junction out of it.
function Add-Leftovers {
param($Scenario)
Add-File (Join-Path $Scenario.Base 'Install-AutopilotMonitor.ps1')
Add-File (Join-Path $Scenario.Base 'Logs\bootstrap-loader.log')
Add-File (Join-Path $Scenario.Base 'Logs\bootstrap-script.log')
Add-File (Join-Path $Scenario.Base 'Agent-Install\Agent\AutopilotMonitor.Agent.Core.dll')
Add-File (Join-Path $Scenario.Base 'future-version-file.bin')
Add-File (Join-Path $Scenario.Outside 'target\sentinel.txt') 'must survive'
New-Junction -Link (Join-Path $Scenario.Base 'linked') -Target (Join-Path $Scenario.Outside 'target')
}
$script:installedTree = @(
'AutopilotMonitor\'
'AutopilotMonitor\Agent\'
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.Core.dll'
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.cat'
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe.config'
'AutopilotMonitor\Logs\'
)
}
Describe 'loader 1.4' {
AfterEach { Assert-PromiseOfLastRun }
It 'installs and leaves only the agent folder, with its log carried over' {
$s = New-Scenario
$run = Invoke-Scenario $s -Script $script:loaderPath -Arguments @('-MaxBootstrapWindowHours', '24')
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
Get-Tree $s.Temp | Should -BeNullOrEmpty
$run.Trace | Should -Contain 'Get-BootstrapDecision|MaxBootstrapWindowHours=24'
$run.Trace | Should -Contain 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1'
$run.Trace | Should -Contain 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip'
$run.Trace | Should -Contain 'Invoke-RestMethod|https://download.autopilotmonitor.com/agent/version.json'
@($run.Trace | Where-Object { $_ -like 'Test-FileCatalog|*' }).Count | Should -Be 1
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*\AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe --install exists=True' }).Count | Should -Be 1
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-loader.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.4 started'
if ($script:isElevated) {
(Get-Item -LiteralPath $s.Base).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]).Value | Should -Be 'S-1-5-32-544'
}
}
It 'skips and leaves nothing behind, also what older versions left and files no version knows' {
$s = New-Scenario
Add-Leftovers $s
$run = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Install = $false }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
$run.StdOut | Should -Match 'No agent on this device: .* removed\.'
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0
}
It 'leaves the folder of an agent exactly as it is, self-update leftovers included' {
$s = New-Scenario
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.Core.dll.old') 'previous version'
Add-File (Join-Path $s.Base 'Updates\AutopilotMonitor-Agent-Update.zip') 'update'
Add-File (Join-Path $s.Base 'Agent-Update\AutopilotMonitor.Agent.Core.dll') 'staged'
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
Add-File (Join-Path $s.Base 'State\session.id') 'session'
$before = Get-Tree $s.ProgramData -Content
$run = Invoke-Scenario $s -Script $script:loaderPath
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData -Content | Should -Be $before
$run.StdOut | Should -Match 'Agent present \(agent executable\)'
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0
}
It 'leaves the folder alone when only the deployment marker <Case>' -TestCases @(
@{ Case = 'is there (logs kept after enrollment)'; Options = @{ Marker = '2026-10-01T08:00:00Z' } }
@{ Case = 'is missing but the agent task exists'; Options = @{ TaskPresent = $true } }
) {
$s = New-Scenario
Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log'
$before = Get-Tree $s.ProgramData -Content
$run = Invoke-Scenario $s -Script $script:loaderPath -Options $Options
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData -Content | Should -Be $before
}
It 'keeps its log when the installer fails; a later run removes it, a run within the hour leaves it' {
$s = New-Scenario
Remove-Item -LiteralPath (Join-Path $s.Server 'version.json')
$failed = Invoke-Scenario $s -Script $script:loaderPath
$failed.ExitCode | Should -Be 0
@(Get-StdErrLines $failed).Count | Should -Be 1
$failed.StdErr | Should -Match '^AutopilotMonitor bootstrap failed: No SHA-256 hash available'
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
'AutopilotMonitor-Loader-<id>\'
'AutopilotMonitor-Loader-<id>\bootstrap-loader.log'
'AutopilotMonitor\'
'AutopilotMonitor\Logs\'
'AutopilotMonitor\Logs\bootstrap-loader.log'
'AutopilotMonitor\Logs\bootstrap-script.log'
))
Set-Content -LiteralPath (Join-Path $s.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
$kept = (Get-ChildItem -LiteralPath $s.ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*').FullName
# Minutes later the folder may still belong to a running loader: it stays.
$soon = Invoke-Scenario $s -Script $script:loaderPath
$soon.ExitCode | Should -Be 0
$soon.StdErr | Should -BeNullOrEmpty
Test-Path -LiteralPath $kept | Should -BeTrue
Get-Tree $s.Base | Should -Be (Join-Tree ((@($script:installedTree) | ForEach-Object { $_ -replace '^AutopilotMonitor\\', '' } | Where-Object { $_ }) + 'Logs\bootstrap-loader.log' + 'Logs\bootstrap-script.log'))
[System.IO.Directory]::SetCreationTimeUtc($kept, [System.DateTime]::UtcNow.AddHours(-2))
$later = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Install = $false }
$later.ExitCode | Should -Be 0
Test-Path -LiteralPath $kept | Should -BeFalse
}
It 'runs nothing it cannot verify: <Case>' -TestCases @(
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Edit = $null }
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Edit = $null }
@{ Case = 'an installer below 2.8'; Options = @{}; Edit = @('$ScriptVersion = "2.9"', '$ScriptVersion = "2.7"') }
@{ Case = 'not the installer'; Options = @{}; Edit = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "AutopilotMonitor-Other"') }
) {
$s = New-Scenario
if ($Edit) {
$served = Join-Path $s.Server 'Install-AutopilotMonitor.ps1'
$text = [System.IO.File]::ReadAllText($served)
$text.Contains($Edit[0]) | Should -BeTrue
[System.IO.File]::WriteAllText($served, $text.Replace($Edit[0], $Edit[1]))
}
$run = Invoke-Scenario $s -Script $script:loaderPath -Options $Options
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Install-AutopilotMonitor.ps1' }).Count | Should -Be 3
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor-Loader-<id>\', 'AutopilotMonitor-Loader-<id>\bootstrap-loader.log'))
}
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
$s = New-Scenario
$run = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Elevated = $false }
$run.ExitCode | Should -Be 0
@(Get-StdErrLines $run) | Should -Be 'AutopilotMonitor loader: not running as SYSTEM or administrator; nothing was downloaded or changed.'
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
}
}
Describe 'installer assigned directly' {
AfterEach { Assert-PromiseOfLastRun }
BeforeEach {
$s = New-Scenario
$imeCopy = Join-Path $s.Ime '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.ps1'
Copy-Item -LiteralPath $script:installerPath -Destination $imeCopy
}
It 'installs, and the copy IME runs stays where IME put it' {
$run = Invoke-Scenario $s -Script $imeCopy
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-script.log'))
Test-Path -LiteralPath $imeCopy | Should -BeTrue
Get-Tree $s.Temp | Should -BeNullOrEmpty
}
It 'skips and leaves nothing behind' {
Add-Leftovers $s
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $false }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
}
# An agent that ran owns its folder, even after a failed --install; one that never started
# leaves only payload, which a later run would take for an agent.
It 'fails with exit code 1 and exactly one line on stderr: <Case>' -TestCases @(
@{ Case = 'the download fails'; Missing = 'AutopilotMonitor-Agent.zip'; Options = @{}; Message = 'AutopilotMonitor bootstrap failed: The remote server returned an error: (404) Not Found.*'; AgentFolder = 'gone' }
@{ Case = 'the payload fails its catalog'; Missing = $null; Options = @{ CatalogValid = $false }; Message = 'AutopilotMonitor bootstrap failed: Payload verification FAILED*'; AgentFolder = 'gone' }
@{ Case = 'the agent install fails'; Missing = $null; Options = @{ InstallExitCode = 7 }; Message = 'AutopilotMonitor bootstrap failed: Agent install failed with exit code 7'; AgentFolder = 'stays' }
@{ Case = 'the agent does not start'; Missing = $null; Options = @{ AgentStartFails = $true }; Message = "AutopilotMonitor bootstrap failed: Program 'AutopilotMonitor.Agent.exe' failed to run*"; AgentFolder = 'gone' }
) {
if ($Missing) { Remove-Item -LiteralPath (Join-Path $s.Server $Missing) }
$run = Invoke-Scenario $s -Script $imeCopy -Options $Options
$run.ExitCode | Should -Be 1
@(Get-StdErrLines $run).Count | Should -Be 1
@(Get-StdErrLines $run)[0] | Should -BeLike $Message
Get-Tree $s.ProgramData | Should -Not -Match 'Agent-Install'
Test-Path -LiteralPath (Join-Path $s.Base 'Agent') | Should -Be ($AgentFolder -eq 'stays')
Test-Path -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-script.log') | Should -BeTrue
}
It 'refuses an agent package with an entry outside the payload folder and writes nothing outside' {
Set-EscapingPackage $s
$run = Invoke-Scenario $s -Script $imeCopy
$run.ExitCode | Should -Be 1
@(Get-StdErrLines $run).Count | Should -Be 1
@(Get-StdErrLines $run)[0] | Should -BeLike 'AutopilotMonitor bootstrap failed: The agent package has an entry that would be written outside the payload folder*'
Get-Tree $s.Root | Should -Not -Match 'outside\.txt'
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\bootstrap-script.log'))
}
It 'refuses to run without SYSTEM or administrator rights and changes nothing' {
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
$before = Get-Tree $s.ProgramData -Content
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Elevated = $false }
$run.ExitCode | Should -Be 1
@(Get-StdErrLines $run) | Should -Be 'AutopilotMonitor bootstrap: not running as SYSTEM or administrator; nothing was changed.'
Get-Tree $s.ProgramData -Content | Should -Be $before
}
It 'does not install into a folder that holds another account''s entries, and leaves them' {
Add-File (Join-Path $s.Base 'foreign-planted.dll') 'planted'
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
$run = Invoke-Scenario $s -Script $imeCopy
$run.ExitCode | Should -Be 1
@(Get-StdErrLines $run).Count | Should -Be 1
$run.StdErr | Should -Match 'holds entries another account created'
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\foreign-planted.dll'))
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
}
It 'leaves another account''s entries and their folder when it skips' {
Add-File (Join-Path $s.Base 'foreign-planted.dll') 'planted'
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $false }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\foreign-planted.dll'))
}
It 'never follows an agent folder that is a link: <Case>' -TestCases @(
@{ Case = 'install'; Install = $true; ExitCode = 1 }
@{ Case = 'skip'; Install = $false; ExitCode = 0 }
) {
Add-File (Join-Path $s.Outside 'target\sentinel.txt') 'must survive'
New-Junction -Link $s.Base -Target (Join-Path $s.Outside 'target')
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $Install }
$run.ExitCode | Should -Be $ExitCode
Get-Tree $s.ProgramData | Should -Be 'AutopilotMonitor -> link'
Get-Tree (Join-Path $s.Outside 'target') | Should -Be 'sentinel.txt'
}
}
Describe 'loader 1.2, frozen' {
AfterEach { Assert-PromiseOfLastRun }
It 'installs with the current installer, which removes its copy and keeps the loader start line' {
$s = New-Scenario
$run = Invoke-Scenario $s -Script $script:loader12Path
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
$log = @(Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-loader.log'))
$log[0] | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v1\.2 started \(carried over by the bootstrapper\) ===$'
}
It 'leaves the folder of an agent as it is, but not the copy the old loader saved there' {
$s = New-Scenario
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
$run = Invoke-Scenario $s -Script $script:loader12Path
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
'AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\agent.log', 'AutopilotMonitor\Logs\bootstrap-loader.log'
))
Get-Content -LiteralPath (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') | Should -Be 'running agent'
}
It 'skips with the current installer and leaves nothing behind' {
$s = New-Scenario
Add-Leftovers $s
$run = Invoke-Scenario $s -Script $script:loader12Path -Options @{ Install = $false }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
}
}
Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
AfterEach { Assert-PromiseOfLastRun }
It '<Name> skips and leaves <Left>' -TestCases @(
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
@{ Name = 'MSI 1.1 (loader 1.2)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader12'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
@{ Name = 'MSI 1.0 (own runner)'; File = 'Invoke-BootstrapDownload.ps1'; Source = 'msiRunner'; Arguments = @(); Left = 'only the log the runner holds open'; Tree = @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\bootstrap-msi.log') }
) {
$s = New-Scenario
$msi = if ($Location -eq 'program-files') { Join-Path $s.Root 'ProgramFiles\AutopilotMonitor\Bootstrap' } else { Join-Path $s.Base 'Msi' }
New-Item -ItemType Directory -Path $msi -Force | Out-Null
$sourcePath = @{ loader = $script:loaderPath; loader12 = $script:loader12Path; msiRunner = $script:msiRunnerPath }[$Source]
Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $msi $File)
$run = Invoke-Scenario $s -Script (Join-Path $msi $File) -Arguments $Arguments -WorkingDirectory $msi -Options @{ Install = $false }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree $Tree)
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
}
# MSI 1.3 installed its loader under Program Files, where nothing of ours belongs; the
# installer takes it away in every outcome, also when an agent is present.
It 'MSI 1.3 leaves nothing under Program Files when an agent is present' {
$s = New-Scenario
$msi = Join-Path $s.ProgramFiles 'AutopilotMonitor\Bootstrap'
New-Item -ItemType Directory -Path $msi -Force | Out-Null
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log'
$before = Get-Tree $s.ProgramData -Content
$run = Invoke-Scenario $s -Script (Join-Path $msi 'Start-AutopilotMonitor.ps1') -Arguments @('-LogFileName', 'bootstrap-msi.log') -WorkingDirectory $msi -Options @{ Marker = '2026-10-01T08:00:00Z' }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData -Content | Should -Be $before
Test-Path -LiteralPath (Join-Path $s.ProgramFiles 'AutopilotMonitor') | Should -BeFalse
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .* is left as it is\.'
$run.StdOut | Should -Match 'Bootstrap removed, and with it the empty'
}
It '<Name> installs' -TestCases @(
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
@{ Name = 'MSI 1.1 (loader 1.2)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader12'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
@{ Name = 'MSI 1.0 (own runner)'; File = 'Invoke-BootstrapDownload.ps1'; Source = 'msiRunner'; Arguments = @() }
) {
$s = New-Scenario
$msi = if ($Location -eq 'program-files') { Join-Path $s.Root 'ProgramFiles\AutopilotMonitor\Bootstrap' } else { Join-Path $s.Base 'Msi' }
New-Item -ItemType Directory -Path $msi -Force | Out-Null
$sourcePath = @{ loader = $script:loaderPath; loader12 = $script:loader12Path; msiRunner = $script:msiRunnerPath }[$Source]
Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $msi $File)
$run = Invoke-Scenario $s -Script (Join-Path $msi $File) -Arguments $Arguments -WorkingDirectory $msi
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
}
}
# MSI 1.4 installs the loader copy under <base>\Msi and runs this command as SYSTEM from
# System32: read the copy once, check our signature on those bytes, run those bytes. The
# command is taken from Package.wxs and formatted as Windows Installer formats it (measured
# on the built MSI, 2026-10-23); the wrapper runs it where the stubs apply.
Describe 'bootstrap MSI 1.4: the action checks the loader copy and runs what it read' {
AfterEach { Assert-PromiseOfLastRun }
BeforeAll {
$wxs = [xml][System.IO.File]::ReadAllText((Join-Path $script:repoRoot 'src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs'))
$action = @($wxs.Wix.Package.CustomAction) | Where-Object { $_.Id -eq 'RunBootstrap' }
if ($action.ExeCommand -notmatch ' -Command "(.*)"$') { throw 'Package.wxs: the action has no -Command argument.' }
$script:actionCommand = $Matches[1]
# What the build bakes in: the hash of the loader the package embeds, here the one served.
$script:loaderSha256 = (Get-FileHash -LiteralPath $script:loaderPath -Algorithm SHA256).Hash.ToLowerInvariant()
function Format-MsiAction {
param([string]$Text, [string]$InstallFolder)
return $Text.Replace('$(var.LoaderSha256)', $script:loaderSha256).Replace('[\[]', '[').Replace('[\]]', ']').Replace('[\{]', '{').Replace('[\}]', '}').Replace('[INSTALLFOLDER]', $InstallFolder)
}
function Add-MsiCopy {
param($Scenario)
$msi = Join-Path $Scenario.Base 'Msi'
New-Item -ItemType Directory -Path $msi -Force | Out-Null
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
}
function Invoke-MsiAction {
param($Scenario, [hashtable]$Options = @{})
$command = Format-MsiAction -Text $script:actionCommand -InstallFolder ((Join-Path $Scenario.Base 'Msi') + '\')
return Invoke-Scenario $Scenario -WorkingDirectory "$env:SystemRoot\System32" -Options ($Options + @{ CommandText = $command })
}
$script:copyOnlyTree = @('AutopilotMonitor\', 'AutopilotMonitor\Msi\', 'AutopilotMonitor\Msi\Start-AutopilotMonitor.ps1')
}
It 'is PowerShell that parses, with every bracket and brace escaped for Windows Installer' {
# Without the [\x] escapes, the only bracket left is the one property reference.
($script:actionCommand -replace '\[\\.\]', '').Replace('[INSTALLFOLDER]', '') | Should -Not -Match '[{}\[\]]'
@([regex]::Matches($script:actionCommand, '\[[A-Za-z0-9_.]+\]') | ForEach-Object { $_.Value } | Sort-Object -Unique) | Should -Be @('[INSTALLFOLDER]')
$errors = $null
[System.Management.Automation.Language.Parser]::ParseInput((Format-MsiAction -Text $script:actionCommand -InstallFolder 'C:\ProgramData\AutopilotMonitor\Msi\'), [ref]$null, [ref]$errors) | Out-Null
@($errors) | Should -BeNullOrEmpty
}
It 'installs, and the copy goes with the emptied agent folder' {
$s = New-Scenario
Add-MsiCopy $s
$run = Invoke-MsiAction $s
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 1
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-msi.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.4 started'
}
It 'skips and leaves nothing behind' {
$s = New-Scenario
Add-MsiCopy $s
$run = Invoke-MsiAction $s -Options @{ Install = $false }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
$run.StdOut | Should -Match 'No agent on this device: .* removed\.'
}
It 'leaves nothing behind when only the deployment marker is there' {
$s = New-Scenario
Add-MsiCopy $s
$run = Invoke-MsiAction $s -Options @{ Marker = '2026-10-01T08:00:00Z' }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .*\\Msi removed, and with it the empty'
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0
}
It 'removes the copy and leaves the folder of an agent as it is' {
$s = New-Scenario
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
Add-MsiCopy $s
$run = Invoke-MsiAction $s
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\agent.log'))
$run.StdOut | Should -Match 'Agent present \(agent executable\): .*\\Msi removed\.'
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0
}
It 'runs nothing it cannot verify: <Case>' -TestCases @(
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Append = $null }
@{ Case = 'changed after signing'; Options = @{ SignatureStatus = 'HashMismatch' }; Append = $null }
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Append = $null }
@{ Case = 'not the loader the package embeds, however validly signed'; Options = @{}; Append = "`r`n# one more line" }
) {
$s = New-Scenario
Add-MsiCopy $s
if ($Append) { [System.IO.File]::AppendAllText((Join-Path $s.Base 'Msi\Start-AutopilotMonitor.ps1'), $Append) }
$run = Invoke-MsiAction $s -Options $Options
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree $script:copyOnlyTree)
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0
}
}
Describe 'OOBE script, as the backend generates it' {
AfterEach { Assert-PromiseOfLastRun }
It 'installs the agent with the bootstrap token and leaves only the agent folder' {
$s = New-Scenario
Add-Leftovers $s
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\oobe_bootstrap.log'))
Get-Tree $s.Temp | Should -BeNullOrEmpty
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
$run.Trace | Should -Contain ('Invoke-AgentInstallMode|' + $s.Base + '\Agent\AutopilotMonitor.Agent.exe --install --no-auth --bootstrap-token 22222222-2222-2222-2222-222222222222 --tenant-id 11111111-1111-1111-1111-111111111111 exists=True')
@($run.Trace | Where-Object { $_ -like 'Test-FileCatalog|*' }).Count | Should -Be 1
}
It 'only runs --install again when the agent is there' {
$s = New-Scenario
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
Add-File (Join-Path $s.Base 'State\session.id') 'session'
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
$run.ExitCode | Should -Be 0
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
'AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\oobe_bootstrap.log', 'AutopilotMonitor\State\', 'AutopilotMonitor\State\session.id'
))
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 1
}
It 'refuses an agent package with an entry outside the payload folder and writes nothing outside' {
$s = New-Scenario
Set-EscapingPackage $s
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
$run.ExitCode | Should -Be 1
$run.StdOut | Should -Match 'The agent package has an entry that would be written outside the payload folder'
Get-Tree $s.Root | Should -Not -Match 'outside\.txt'
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\oobe_bootstrap.log'))
}
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
$s = New-Scenario
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true; Elevated = $false }
$run.ExitCode | Should -Be 1
$run.StdOut | Should -Match 'Run this script from PowerShell as SYSTEM or administrator\. Nothing was changed\.'
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
}
}
Describe 'publish pipeline and dev channel' {
AfterEach { Assert-PromiseOfLastRun }
BeforeAll {
$script:rendered = Join-Path $TestDrive 'rendered'
$script:pwsh = (Get-Command pwsh -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
$script:publish = Join-Path $script:repoRoot '.github\scripts\Publish-BootstrapScripts.ps1'
# Output read as text from a process of its own: a stderr line must not become an error
# record, which ends the test under the 'Stop' preference CI runs with.
function Invoke-Publish {
param([string[]]$Arguments)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = $script:pwsh
$psi.Arguments = ('-NoProfile -File "{0}" {1}' -f $script:publish, ($Arguments -join ' ')).Trim()
$psi.UseShellExecute = $false
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$process = [System.Diagnostics.Process]::Start($psi)
$stdout = $process.StandardOutput.ReadToEndAsync()
$stderr = $process.StandardError.ReadToEndAsync()
$process.WaitForExit()
return [pscustomobject]@{ ExitCode = $process.ExitCode; Output = @(($stdout.Result + "`n" + $stderr.Result) -split "`r?`n") }
}
$dryRun = Invoke-Publish @('-DryRun', '-SignedOutputDir', ('"' + $script:rendered + '"'))
if ($dryRun.ExitCode -ne 0) { throw "Publish dry run failed: $($dryRun.Output -join [Environment]::NewLine)" }
$script:publishOutput = $dryRun.Output
}
# A loader that went live first would refuse the installer still being served.
It 'puts both installers before both loaders' {
$order = @($script:publishOutput | Where-Object { $_ -match '\[dry-run\] (\S+\.ps1) ' } | ForEach-Object { $Matches[1] })
$order[0..3] | Should -Be @('Install-AutopilotMonitor.ps1', 'Install-AutopilotMonitor-Dev.ps1', 'Start-AutopilotMonitor.ps1', 'Start-AutopilotMonitor-Dev.ps1')
@($script:publishOutput | Where-Object { $_ -like '*Installer accepted: AutopilotMonitor-Installer v*' }).Count | Should -Be 2
}
It 'refuses to publish an installer the loader would refuse: <Case>' -TestCases @(
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.9"'; New = '$ScriptVersion = "2.7"' }
@{ Case = 'without its role line'; Old = '$ScriptRole = "AutopilotMonitor-Installer"'; New = '# no role' }
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.9"'; New = "`$ScriptVersion = `"2.9`"`n`$ScriptVersion = `"3.0`"" }
) {
$root = Join-Path $TestDrive ('repo' + [guid]::NewGuid().ToString('N').Substring(0, 6))
New-Item -ItemType Directory -Path (Join-Path $root 'scripts\Bootstrap') -Force | Out-Null
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $root 'scripts\Bootstrap')
$text = [System.IO.File]::ReadAllText($script:installerPath)
$text.Contains($Old) | Should -BeTrue
[System.IO.File]::WriteAllText((Join-Path $root 'scripts\Bootstrap\Install-AutopilotMonitor.ps1'), $text.Replace($Old, $New))
$refused = Invoke-Publish @('-DryRun', '-Channel', 'dev', '-RepoRoot', ('"' + $root + '"'))
$refused.ExitCode | Should -Not -Be 0
($refused.Output -join "`n") | Should -Match 'The loader would refuse Install-AutopilotMonitor-Dev\.ps1'
($refused.Output -join "`n") | Should -Not -Match '\[dry-run\]'
}
It 'the dev loader fetches the dev installer, which installs the dev agent' {
$s = New-Scenario
Copy-Item -LiteralPath (Join-Path $script:rendered 'Install-AutopilotMonitor-Dev.ps1') -Destination $s.Server
Copy-Item -LiteralPath $script:agentZip -Destination (Join-Path $s.Server 'AutopilotMonitor-Agent-dev.zip')
Set-Content -LiteralPath (Join-Path $s.Server 'version-dev.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
Remove-Item -LiteralPath (Join-Path $s.Server 'Install-AutopilotMonitor.ps1'), (Join-Path $s.Server 'AutopilotMonitor-Agent.zip'), (Join-Path $s.Server 'version.json')
$devLoader = Join-Path $s.Ime 'Start-AutopilotMonitor-Dev.ps1'
Copy-Item -LiteralPath (Join-Path $script:rendered 'Start-AutopilotMonitor-Dev.ps1') -Destination $devLoader
$run = Invoke-Scenario $s -Script $devLoader
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
@($run.Trace | Where-Object { $_ -like 'Invoke-*' }) | Should -Be @(
'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor-Dev.ps1'
'Invoke-RestMethod|https://download.autopilotmonitor.com/agent/version-dev.json'
'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip'
('Invoke-AgentInstallMode|' + $s.Base + '\Agent\AutopilotMonitor.Agent.exe --install exists=True')
)
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
}
It 'the stable loader in its published form runs the stable installer in its published form' {
$s = New-Scenario
Copy-Item -LiteralPath (Join-Path $script:rendered 'Install-AutopilotMonitor.ps1') -Destination $s.Server -Force
$loader = Join-Path $s.Ime 'Start-AutopilotMonitor.ps1'
Copy-Item -LiteralPath (Join-Path $script:rendered 'Start-AutopilotMonitor.ps1') -Destination $loader
[System.IO.File]::ReadAllText($loader).Contains("`r`n") | Should -BeTrue
$run = Invoke-Scenario $s -Script $loader
$run.ExitCode | Should -Be 0
$run.StdErr | Should -BeNullOrEmpty
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.9'
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@
# The script under test is dot-sourced; its entry guard prevents the loader from
# executing, so loading it here is side-effect free. Network and signature checks
# are mocked -- what is verified is the DECISION: what does the loader accept, what
# does it refuse, and how often does it retry.
# does it refuse, how often does it retry, and what does it leave behind.
#
# Run under Windows PowerShell 5.1 to match the IME runtime:
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
@@ -20,6 +20,20 @@ BeforeAll {
} else { $null }
return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert }
}
# Arguments as PowerShell hands them to a script, names marked as names; an array of
# plain strings would bind by position instead.
function ConvertTo-ScriptArguments { return , $args }
function Get-ScriptAst {
param([string]$Path)
$tokens = $null; $errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$tokens, [ref]$errors)
return [pscustomobject]@{ Ast = $ast; Tokens = $tokens; Errors = $errors }
}
$script:loaderPath = Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1'
$script:installerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1'
}
Describe 'Test-BootstrapSignature' {
@@ -69,22 +83,110 @@ Describe 'Test-BootstrapSignature' {
}
}
Describe 'Get-InstallerIdentity' {
BeforeAll {
$script:roleLine = '$ScriptRole = "AutopilotMonitor-Installer"'
}
It 'reads the role and the version of LF text' {
$id = Get-InstallerIdentity -Text ("param()`n`$ScriptVersion = `"2.7`"`n" + $script:roleLine + "`n")
$id.Role | Should -BeExactly 'AutopilotMonitor-Installer'
$id.Version | Should -Be ([version]'2.7')
}
It 'reads CRLF text that ends in an Authenticode signature block' {
$text = (@('$ScriptVersion = "2.7"', $script:roleLine, '# SIG # Begin signature block', '# MIIFxwYJKoZIhvcNAQcCoIIFuDCCBbQCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB', '# SIG # End signature block') -join "`r`n") + "`r`n"
(Get-InstallerIdentity -Text $text).Version | Should -Be ([version]'2.7')
}
It 'compares versions as numbers, so 2.10 is above 2.7' {
$id = Get-InstallerIdentity -Text ("`$ScriptVersion = `"2.10`"`n" + $script:roleLine + "`n")
$id.Version -gt [version]'2.7' | Should -BeTrue
}
It 'returns nothing for <Case>' -TestCases @(
@{ Case = 'a missing role line'; Lines = @('$ScriptVersion = "2.7"') }
@{ Case = 'a missing version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"') }
@{ Case = 'a role line in a comment'; Lines = @('# $ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"') }
@{ Case = 'an indented version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', ' $ScriptVersion = "2.7"') }
@{ Case = 'a second version line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"', '$ScriptVersion = "9.9"') }
@{ Case = 'a second role line'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "Other"', '$ScriptVersion = "2.7"') }
@{ Case = 'a pre-release version'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7-beta"') }
@{ Case = 'a version line with more code'; Lines = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptVersion = "2.7"; $x = 1') }
) {
Get-InstallerIdentity -Text (($Lines -join "`n") + "`n") | Should -BeNullOrEmpty
}
# The pair the loader and the installer in this repository must keep: the published
# installer has to pass the loader that ships with it.
It 'accepts the installer in this repository' {
$id = Get-InstallerIdentity -Text ([System.IO.File]::ReadAllText($script:installerPath))
$id.Role | Should -BeExactly $ExpectedInstallerRole
$id.Version -ge $MinimumInstallerVersion | Should -BeTrue
}
}
Describe 'Test-InstallerIdentity' {
BeforeEach {
Mock Write-Log { }
$script:candidate = Join-Path $TestDrive ("installer-" + [guid]::NewGuid().ToString('N') + '.ps1')
}
It 'accepts version <Version>' -TestCases @(@{ Version = '2.8' }, @{ Version = '2.10' }, @{ Version = '3.0.1' }) {
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"$Version`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n")
Test-InstallerIdentity -Path $script:candidate | Should -BeTrue
}
# Every installer the publisher ever signed keeps a valid signature; the minimum keeps the
# older ones from running.
It 'refuses an installer below the minimum version' {
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.7`"`n`$ScriptRole = `"AutopilotMonitor-Installer`"`n")
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
Should -Invoke Write-Log -ParameterFilter { $Message -like '*below the minimum 2.8*' }
}
# The publisher signs other scripts too.
It 'refuses another role' {
[System.IO.File]::WriteAllText($script:candidate, "`$ScriptVersion = `"2.8`"`n`$ScriptRole = `"AutopilotMonitor-Uninstaller`"`n")
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
}
It 'refuses a script without the identity lines' {
[System.IO.File]::WriteAllText($script:candidate, "Write-Host 'signed, but not the installer'`n")
Test-InstallerIdentity -Path $script:candidate | Should -BeFalse
}
It 'refuses a file it cannot read' {
Test-InstallerIdentity -Path (Join-Path $TestDrive 'missing.ps1') | Should -BeFalse
}
}
Describe 'Get-VerifiedBootstrapScript' {
BeforeEach {
Mock Write-Log { }
Mock Start-Sleep { }
Mock Invoke-WebRequest { }
Mock Invoke-WebRequest { [System.IO.File]::WriteAllText($OutFile, 'downloaded') }
Mock Test-InstallerIdentity { $true }
# The test account owns the folder; the loader asks for SYSTEM or Administrators.
Mock Test-IsOwnedByUs { $true }
$script:run = Join-Path $TestDrive ('run-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Path $script:run | Out-Null
$script:dest = Join-Path $script:run 'Install-AutopilotMonitor.ps1'
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
}
AfterEach {
if ($RunState.Installer) { $RunState.Installer.Dispose() }
}
It 'returns after the first attempt when the download verifies' {
Mock Test-BootstrapSignature { $true }
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
Should -Invoke Invoke-WebRequest -Times 1 -Exactly
}
It 'gives up after three attempts when verification keeps failing' {
Mock Test-BootstrapSignature { $false }
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
}
@@ -93,27 +195,247 @@ Describe 'Get-VerifiedBootstrapScript' {
It 'retries a failed verification and accepts a later attempt' {
$script:calls = 0
Mock Test-BootstrapSignature { $script:calls++; return ($script:calls -ge 2) }
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
}
It 'retries a transport failure' {
$script:downloads = 0
Mock Invoke-WebRequest { $script:downloads++; if ($script:downloads -eq 1) { throw 'connection reset' } }
Mock Invoke-WebRequest { $script:downloads++; if ($script:downloads -eq 1) { throw 'connection reset' }; [System.IO.File]::WriteAllText($OutFile, 'downloaded') }
Mock Test-BootstrapSignature { $true }
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
Should -Invoke Invoke-WebRequest -Times 2 -Exactly
}
It 'never executes anything when no attempt verifies' {
It 'refuses a validly signed script that is not the current installer' {
Mock Test-BootstrapSignature { $true }
Mock Test-InstallerIdentity { $false }
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
Should -Invoke Invoke-WebRequest -Times 3 -Exactly
}
# The identity lines count only once the signature proves they are the publisher's.
It 'reads the identity only after the signature passed' {
Mock Test-BootstrapSignature { $false }
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
Should -Invoke Test-InstallerIdentity -Times 0 -Exactly
}
It 'stops without downloading when the run folder is no longer a folder of ours' {
Mock Test-IsOwnedByUs { $false }
Mock Test-BootstrapSignature { $true }
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
Should -Invoke Invoke-WebRequest -Times 0 -Exactly
}
# The file that was checked is the file that runs: nobody can replace it in between.
It 'keeps the verified file open, so it cannot be replaced or deleted' {
Mock Test-BootstrapSignature { $true }
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeTrue
$RunState.Installer | Should -Not -BeNullOrEmpty
{ [System.IO.File]::WriteAllText($script:dest, 'replaced') } | Should -Throw
{ [System.IO.File]::Delete($script:dest) } | Should -Throw
[System.IO.File]::ReadAllText($script:dest) | Should -Be 'downloaded'
}
It 'holds nothing when no attempt verifies' {
Mock Test-BootstrapSignature { $false }
Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination $script:dest -RunFolder $script:run | Should -BeFalse
$RunState.Installer | Should -BeNullOrEmpty
{ [System.IO.File]::Delete($script:dest) } | Should -Not -Throw
}
}
Describe 'Get-DownloadSecurityProtocol' {
It 'keeps the system default, which includes TLS 1.3' {
Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]::SystemDefault) | Should -Be ([Net.SecurityProtocolType]::SystemDefault)
}
It 'adds TLS 1.2 to an explicit list' {
Get-DownloadSecurityProtocol -Current ([Net.SecurityProtocolType]'Ssl3, Tls') | Should -Be ([Net.SecurityProtocolType]'Ssl3, Tls, Tls12')
}
}
Describe 'Remove-LoaderRunFiles' {
BeforeEach {
Mock Write-Host { }
$run = Join-Path $TestDrive ("run-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $run | Out-Null
$installer = Join-Path $run 'Install-AutopilotMonitor.ps1'
$log = Join-Path $run 'bootstrap-loader.log'
Set-Content -Path $installer -Value 'downloaded'
Set-Content -Path $log -Value 'log'
$RunState = [pscustomobject]@{ LogFile = $log; Installer = $null }
}
It 'removes the download, the log and the folder' {
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer
Test-Path -LiteralPath $run | Should -BeFalse
$RunState.LogFile | Should -BeNullOrEmpty
}
It 'releases the held download before deleting it' {
$RunState.Installer = New-Object System.IO.FileStream($installer, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer
Test-Path -LiteralPath $run | Should -BeFalse
$RunState.Installer | Should -BeNullOrEmpty
}
It 'keeps the log and the folder with -KeepLog, but never the download' {
Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer -KeepLog
@(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
}
It 'leaves a folder that holds anything else, without throwing' {
Set-Content -Path (Join-Path $run 'other.txt') -Value 'other'
{ Remove-LoaderRunFiles -RunFolder $run -InstallerPath $installer } | Should -Not -Throw
@(Get-ChildItem -LiteralPath $run -Force | ForEach-Object Name) | Should -Be @('other.txt')
Should -Invoke Write-Host -ParameterFilter { "$Object" -like 'INFO: run folder*' }
}
}
Describe 'Start-BootstrapLoader' {
BeforeEach {
$script:savedProgramData = $env:ProgramData
$env:ProgramData = Join-Path $TestDrive ("pd-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $env:ProgramData | Out-Null
$script:runFolder = Join-Path $env:ProgramData 'AutopilotMonitor-Loader-0a1b2c3d'
$script:marker = Join-Path $TestDrive ("ran-" + [guid]::NewGuid().ToString('N') + '.txt')
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
$InstallerArguments = @()
Mock Write-Host { }
Mock Start-Sleep { }
Mock Test-IsElevated { $true }
# A standard user cannot make Administrators the owner, so the run folder is a plain
# one here; New-RunFolder itself is tested with the installer, which shares it.
Mock New-RunFolder { [System.IO.Directory]::CreateDirectory($script:runFolder) | Out-Null; return $script:runFolder }
}
AfterEach {
$env:ProgramData = $script:savedProgramData
}
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
Mock Test-IsElevated { $false }
$stderr = New-Object System.IO.StringWriter
$previous = [Console]::Error
[Console]::SetError($stderr)
try { Start-BootstrapLoader } finally { [Console]::SetError($previous) }
$stderr.ToString() | Should -Match 'not running as SYSTEM or administrator'
Should -Invoke New-RunFolder -Times 0 -Exactly
@(Get-ChildItem -LiteralPath $env:ProgramData -Force).Count | Should -Be 0
}
It 'runs nothing and keeps only its log when no download verifies' {
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Set-Content -LiteralPath '$($script:marker)' -Value ran"); $false }
Start-BootstrapLoader
Test-Path -LiteralPath $script:marker | Should -BeFalse
@(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Nothing was executed'
}
It 'runs the verified installer with the passed arguments by name and removes its folder after exit 0' {
$agentLog = Join-Path $env:ProgramData 'AutopilotMonitor\Logs\agent.log'
New-Item -ItemType Directory -Path (Split-Path $agentLog) -Force | Out-Null
Set-Content -Path $agentLog -Value 'agent'
Mock Get-VerifiedBootstrapScript {
[System.IO.File]::WriteAllText($Destination, "param([string]`$AgentDownloadUrl = 'default', [int]`$MaxBootstrapWindowHours = 12)`nSet-Content -LiteralPath '$($script:marker)' -Value `"`$AgentDownloadUrl `$MaxBootstrapWindowHours`"`nexit 0`n")
$true
}
$InstallerArguments = ConvertTo-ScriptArguments -MaxBootstrapWindowHours 24
Start-BootstrapLoader
Get-Content -LiteralPath $script:marker | Should -Be 'default 24'
Test-Path -LiteralPath $script:runFolder | Should -BeFalse
Get-Content -LiteralPath $agentLog | Should -Be 'agent'
}
It 'keeps its log but not the installer when the installer fails' {
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "exit 1`n"); $true }
Start-BootstrapLoader
@(Get-ChildItem -LiteralPath $script:runFolder -Force | ForEach-Object Name) | Should -Be @('bootstrap-loader.log')
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer finished \(exit code 1\)'
}
It 'keeps its log when the installer throws' {
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "throw 'installer broke'`n"); $true }
Start-BootstrapLoader
Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') -Raw | Should -Match 'Installer threw: installer broke'
}
# An installer that ends without an exit code must not inherit a 0 from earlier work.
It 'does not take an earlier exit code for the installer''s' {
Mock Get-VerifiedBootstrapScript { [System.IO.File]::WriteAllText($Destination, "Write-Output 'no exit code'`n"); $true }
$global:LASTEXITCODE = 0
Start-BootstrapLoader
Test-Path -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log') | Should -BeTrue
}
It 'names its version first in its log, in the form the installer carries over' {
Mock Get-VerifiedBootstrapScript { $false }
Start-BootstrapLoader
$first = (Get-Content -LiteralPath (Join-Path $script:runFolder 'bootstrap-loader.log'))[0]
$first | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v\d+(\.\d+){0,3} started'
}
}
Describe 'loader contract' {
BeforeAll {
$script:loader = Get-ScriptAst $script:loaderPath
}
It 'parses without errors' {
$script:loader.Errors.Count | Should -Be 0
}
# Unbound arguments reach the installer only while the script is not an advanced one.
It 'stays a plain script, so unbound arguments are kept for the installer' {
$script:loader.Ast.ParamBlock.Attributes.Count | Should -Be 0
@($script:loader.Ast.ParamBlock.Parameters.Attributes | Where-Object { $_.TypeName.Name -eq 'Parameter' }) | Should -BeNullOrEmpty
}
# The six functions also run in the installer and, embedded, in the OOBE script.
It 'carries the shared functions word for word as the installer has them' {
$installer = Get-ScriptAst $script:installerPath
foreach ($name in @('Test-IsElevated', 'New-ProtectedDirectorySecurity', 'Test-IsReparsePoint', 'Test-IsOwnedByUs', 'New-RunFolderName', 'New-RunFolder')) {
$mine = $script:loader.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true)
$theirs = $installer.Ast.Find({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $a.Name -eq $name }, $true)
$mine | Should -Not -BeNullOrEmpty -Because $name
$mine.Extent.Text | Should -BeExactly $theirs.Extent.Text -Because $name
}
}
It 'calls no command beyond the few it needs' {
$own = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true) | ForEach-Object { $_.Name })
$allowed = $own + @('Add-Content', 'ForEach-Object', 'Get-AuthenticodeSignature', 'Get-Date', 'Get-Item', 'Invoke-WebRequest', 'Join-Path', 'New-Object', 'Out-Null', 'Start-Sleep', 'Test-Path', 'Write-Host')
$used = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.CommandAst] }, $true) | ForEach-Object { $_.GetCommandName() } | Where-Object { $_ } | Sort-Object -Unique)
@($used | Where-Object { $allowed -notcontains $_ }) | Should -BeNullOrEmpty
}
It 'deletes only the download, its log and its own empty folder' {
$deletes = @($script:loader.Ast.FindAll({ param($a) $a -is [System.Management.Automation.Language.InvokeMemberExpressionAst] -and $a.Member.Extent.Text -eq 'Delete' }, $true))
$deletes.Count | Should -Be 3
foreach ($delete in $deletes) {
if ($delete.Expression.Extent.Text -eq '[System.IO.Directory]') {
$delete.Arguments[1].Extent.Text | Should -Be '$false'
} else {
$delete.Expression.Extent.Text | Should -Be '[System.IO.File]'
}
}
}
It 'never names the agent folder' {
$strings = @($script:loader.Tokens | Where-Object { $_ -is [System.Management.Automation.Language.StringToken] } | ForEach-Object { $_.Value })
@($strings | Where-Object { $_ -match '(^|\\)AutopilotMonitor($|\\)' }) | Should -BeNullOrEmpty
}
}
Describe 'publish contract' {
BeforeAll {
$script:loaderText = Get-Content (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1') -Raw
$script:loaderText = Get-Content $script:loaderPath -Raw
}
# Publish-BootstrapScripts.ps1 renders the -Dev loader by substituting this exact
@@ -127,7 +449,7 @@ Describe 'publish contract' {
# The publisher parses this to write version.json.loaderVersion and to run the
# version-bump guard.
It 'declares a parsable ScriptVersion' {
$script:loaderText -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"' | Should -BeTrue
$script:loaderText -match '(?m)^\$ScriptVersion = "\d+(\.\d+){1,3}"\r?$' | Should -BeTrue
}
It 'pins the expected publisher by subject, not by thumbprint' {
@@ -144,4 +466,16 @@ Describe 'publish contract' {
$wxs.Contains('[INSTALLFOLDER]Start-AutopilotMonitor.ps1') | Should -BeTrue
$wxs.Contains('-LogFileName bootstrap-msi.log') | Should -BeTrue
}
# Everything of ours lives under ProgramData, so the copy the MSI installs does too, where
# the installer removes it. A standard user could have created that folder first, so the
# action never runs the file as such: it checks the signature on the bytes it read and runs
# those bytes, from System32.
It 'is installed by the bootstrap MSI under ProgramData and run only after the check' {
$wxs = Get-Content (Join-Path $PSScriptRoot '..\..\src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs') -Raw
$wxs | Should -Match '<StandardDirectory Id="CommonAppDataFolder">'
$wxs | Should -Not -Match 'ProgramFiles'
$wxs.Contains('Get-AuthenticodeSignature -Content $b -SourcePathOrExtension ''.ps1''') | Should -BeTrue
$wxs.Contains('Directory="System64Folder"') | Should -BeTrue
}
}
+220 -111
View File
@@ -1,107 +1,174 @@
<#
.SYNOPSIS
Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it.
Downloads the current Autopilot Monitor installer and runs it.
.DESCRIPTION
This is the file an administrator assigns in Intune, and the same file the bootstrap MSI
installs and runs -- one implementation for both delivery channels; the MSI is only the
packaging. It carries no enrollment logic at all: it downloads the published
Install-AutopilotMonitor.ps1, proves that it was signed by Autopilot Monitor, and runs it
in this process.
Assign this script in Intune once; the bootstrap MSI runs the same script. It downloads
Install-AutopilotMonitor.ps1 from download.autopilotmonitor.com and runs it only when the
file carries a valid signature from glueckkanja AG and is installer version 2.7 or later.
Because the installer is fetched on every run, this script does not need replacing when
the installer changes.
Why two stages: the installer holds every guard, relax rule and download path, and those
change. Whoever assigns the installer directly has to replace their Intune copy each
time; whoever assigns this loader never does, because the part that changes lives on the
server. Assigning the installer directly stays supported and unchanged.
The download and the log go into %ProgramData%\AutopilotMonitor-Loader-<random>, which only
SYSTEM and administrators can change. The loader keeps the downloaded file open from the
check to the end of the run, so what it checked is what runs. After a successful run the
folder is removed; after a failure it keeps the log for diagnosis, and a later installer
run removes it.
This file is deliberately boring. It is the one piece that cannot be fixed remotely, so
it does the least it can: download, verify, invoke, log.
The exit code is always 0, so the loader never fails enrollment. Without SYSTEM or
administrator rights it stops with an error and changes nothing.
Verification is by Authenticode publisher, not by hash. A hash oracle would have to be
fetched from the same host as the script itself, so it could not survive a compromise of
that host, while the expected publisher below travels with this file. It pins the
certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a
loader that is already deployed on devices.
Fail-soft: the loader always exits 0 and never throws into the caller. A device that
cannot reach the download endpoint is a device without monitoring, never a failed
enrollment step. Verification failures are the same: nothing runs, and the reason is in
the log. Refusing to execute unverified code as SYSTEM is the point of this file.
This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
Any other arguments are passed to the installer, for example -MaxBootstrapWindowHours 24.
.PARAMETER BootstrapUrl
Installer URL. Defaults to the published stable installer; the -Dev variant of this
loader is rendered from that default at publish time.
Installer to download. Default: the published installer.
.PARAMETER LogFileName
Log file inside the agent log directory. The MSI channel passes bootstrap-msi.log so the
delivery channel stays visible in a diagnostics package; the diag tooling keys on these
names, so do not rename them.
.PARAMETER BootstrapArguments
Everything else is passed through to the installer unchanged, so any parameter the
installer accepts can be set here without this file knowing about it.
.EXAMPLE
.\Start-AutopilotMonitor.ps1
.EXAMPLE
.\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24
Name of the log file. Default: bootstrap-loader.log.
.NOTES
Author : Oliver Kieselbach (autopilotmonitor.com)
Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment
.CHANGELOG
2026-09-08 v1.2 Log the signer CN instead of the full EV subject; the full subject is
logged only when the publisher is why a script was rejected.
2026-09-08 v1.1 Also used by the bootstrap MSI, which replaced its own copy of this
logic; -LogFileName keeps the channel visible in diagnostics.
2026-09-08 v1.0 Initial version: two-stage bootstrap, publisher-pinned verification.
Requires Windows PowerShell 5.1.
Changes: https://docs.autopilotmonitor.com/changelog/platform-changelog
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1",
[Parameter(Mandatory = $false)]
[ValidatePattern('^[A-Za-z0-9._-]+$')]
[string]$LogFileName = "bootstrap-loader.log",
[Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)]
[string[]]$BootstrapArguments
[string]$LogFileName = "bootstrap-loader.log"
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "1.2"
# Unbound arguments reach the installer by name. [CmdletBinding()] or a [Parameter()]
# attribute would reject them instead.
$InstallerArguments = $args
# The publisher every downloaded stage-2 script must carry. A -like pattern on the subject,
# NOT a thumbprint: certificates are renewed, deployed loaders are not.
$ScriptVersion = "1.4"
# A subject pattern, not a thumbprint, so a certificate renewal keeps deployed loaders working.
$ExpectedPublisher = "*O=glueckkanja AG*"
$LoaderBasePath = "$env:ProgramData\AutopilotMonitor"
$LoaderLogPath = "$LoaderBasePath\Logs"
$LogFile = "$LoaderLogPath\$LogFileName"
$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1"
$ExpectedInstallerRole = "AutopilotMonitor-Installer"
$MinimumInstallerVersion = [version]"2.8"
$RunFolderPrefix = "AutopilotMonitor-Loader-"
$DownloadAttempts = 3
$RetryDelaySeconds = 10
# Write-Host as well as the file: the Intune script output is what an administrator sees in
# the portal, the file is what a support case reads.
# The log file, and the handle that keeps the verified installer unchanged until the run ends.
$RunState = [pscustomobject]@{ LogFile = $null; Installer = $null }
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Host $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
if ($RunState.LogFile) {
Add-Content -Path $RunState.LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
}
# True only when the file is signed, the signature validates against the machine's trust
# chain, and the signer is us. Any error is a false -- an unreadable signature is an
# unverified script.
### Shared with Install-AutopilotMonitor.ps1, word for word.
function Test-IsElevated {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
return (New-Object System.Security.Principal.WindowsPrincipal($identity)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
}
function New-ProtectedDirectorySecurity {
$security = New-Object System.Security.AccessControl.DirectorySecurity
$security.SetAccessRuleProtection($true, $false)
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
$propagation = [System.Security.AccessControl.PropagationFlags]::None
$system = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::LocalSystemSid, $null)
$admins = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid, $null)
$users = New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::BuiltinUsersSid, $null)
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($system, 'FullControl', $inherit, $propagation, 'Allow')))
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($admins, 'FullControl', $inherit, $propagation, 'Allow')))
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($users, 'ReadAndExecute', $inherit, $propagation, 'Allow')))
$security.SetOwner($admins)
return $security
}
function Test-IsReparsePoint {
param([string]$Path)
return (([System.IO.File]::GetAttributes($Path) -band [System.IO.FileAttributes]::ReparsePoint) -ne 0)
}
function Test-IsOwnedByUs {
param([string]$Path)
try {
$owner = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop).GetAccessControl([System.Security.AccessControl.AccessControlSections]::Owner).GetOwner([System.Security.Principal.SecurityIdentifier])
return ($owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::LocalSystemSid) -or
$owner.IsWellKnown([System.Security.Principal.WellKnownSidType]::BuiltinAdministratorsSid))
} catch {
return $false
}
}
function New-RunFolderName {
$bytes = New-Object byte[] 4
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
try { $generator.GetBytes($bytes) } finally { $generator.Dispose() }
return -join ($bytes | ForEach-Object { $_.ToString('x2') })
}
function New-RunFolder {
param([string]$Parent, [string]$Prefix)
for ($attempt = 1; $attempt -le 3; $attempt++) {
$path = Join-Path $Parent ($Prefix + (New-RunFolderName))
if (Test-Path -LiteralPath $path) { continue }
[System.IO.Directory]::CreateDirectory($path, (New-ProtectedDirectorySecurity)) | Out-Null
if (-not (Test-IsReparsePoint $path) -and (Test-IsOwnedByUs $path)) { return $path }
}
throw "Could not create a run folder under $Parent."
}
### End of the shared functions.
# Keeps the system default (0, which includes TLS 1.3); otherwise adds TLS 1.2.
function Get-DownloadSecurityProtocol {
param([Net.SecurityProtocolType]$Current)
if ([int]$Current -eq 0) { return $Current }
return ($Current -bor [Net.SecurityProtocolType]::Tls12)
}
# Exactly one role line and one version line, each at the start of a line; $null otherwise.
function Get-InstallerIdentity {
param([string]$Text)
$roles = [regex]::Matches($Text, '(?m)^\$ScriptRole[ \t]*=[ \t]*"([A-Za-z0-9.-]+)"[ \t]*\r?$')
$versions = [regex]::Matches($Text, '(?m)^\$ScriptVersion[ \t]*=[ \t]*"(\d+(?:\.\d+){1,3})"[ \t]*\r?$')
if ($roles.Count -ne 1 -or $versions.Count -ne 1) { return $null }
return [pscustomobject]@{ Role = $roles[0].Groups[1].Value; Version = [version]$versions[0].Groups[1].Value }
}
# Runs after the signature check, so the lines it reads are signed.
function Test-InstallerIdentity {
param([string]$Path)
try {
$identity = Get-InstallerIdentity -Text ([System.IO.File]::ReadAllText($Path))
}
catch {
Write-Log "Identity check failed to run: $($_.Exception.Message)"
return $false
}
if ($null -eq $identity) {
Write-Log "REJECTED: the script does not declare exactly one role line and one version line."
return $false
}
if ($identity.Role -ne $ExpectedInstallerRole) {
Write-Log "REJECTED: role '$($identity.Role)' is not '$ExpectedInstallerRole'."
return $false
}
if ($identity.Version -lt $MinimumInstallerVersion) {
Write-Log "REJECTED: installer version $($identity.Version) is below the minimum $MinimumInstallerVersion."
return $false
}
Write-Log "Installer accepted: $($identity.Role) v$($identity.Version)."
return $true
}
# A valid signature from the expected publisher; any error counts as unverified.
function Test-BootstrapSignature {
param([string]$Path)
@@ -118,9 +185,7 @@ function Test-BootstrapSignature {
return $false
}
# The full subject of an EV certificate is a line of street address and registration
# numbers. The CN answers the only question the log has to answer; the full subject is
# logged when it is the reason for a rejection.
# The CN is enough for the log; the full subject is logged when it causes a rejection.
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "<unsigned>" }
$signer = if ($subject -match 'CN=([^,]+)') { $Matches[1] } else { $subject }
Write-Log "Signature status: $($sig.Status). Signer: $signer"
@@ -138,20 +203,28 @@ function Test-BootstrapSignature {
return $true
}
# Downloads the installer and returns $true once a downloaded copy passes verification.
# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body
# fails the signature check, and that is worth another attempt.
# A download that fails verification, such as a truncated one, is retried too. The run folder
# is checked before every attempt: one that is no longer a real folder of ours stops the run.
# The downloaded file is opened for reading before it is checked and stays open until the run
# ends, so no one can change or replace what was checked.
function Get-VerifiedBootstrapScript {
param([string]$Url, [string]$Destination)
param([string]$Url, [string]$Destination, [string]$RunFolder)
for ($attempt = 1; $attempt -le $DownloadAttempts; $attempt++) {
if ((Test-IsReparsePoint $RunFolder) -or -not (Test-IsOwnedByUs $RunFolder)) {
Write-Log "REJECTED: the run folder $RunFolder is no longer a folder of ours. Nothing was downloaded."
return $false
}
try {
Write-Log "Download attempt $attempt/$DownloadAttempts from $Url"
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
if (Test-BootstrapSignature -Path $Destination) {
$held = New-Object System.IO.FileStream($Destination, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read)
if ((Test-BootstrapSignature -Path $Destination) -and (Test-InstallerIdentity -Path $Destination)) {
$RunState.Installer = $held
return $true
}
$held.Dispose()
}
catch {
Write-Log "Download attempt $attempt failed: $($_.Exception.Message)"
@@ -165,51 +238,87 @@ function Get-VerifiedBootstrapScript {
return $false
}
# Main flow. Only invoked when the script is run directly (see entry guard at the bottom),
# so the Pester suite can dot-source this file without bootstrapping anything.
function Start-BootstrapLoader {
New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
# PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process.
# The download always goes. After an installer exit 0 the log and the folder go too;
# otherwise they stay for diagnosis.
function Remove-LoaderRunFiles {
param([string]$RunFolder, [string]$InstallerPath, [switch]$KeepLog)
if ($RunState.Installer) {
$RunState.Installer.Dispose()
$RunState.Installer = $null
}
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
if ([System.IO.File]::Exists($InstallerPath)) { [System.IO.File]::Delete($InstallerPath) }
if ($KeepLog) { return }
$logFile = $RunState.LogFile
$RunState.LogFile = $null
if ($logFile -and [System.IO.File]::Exists($logFile)) { [System.IO.File]::Delete($logFile) }
[System.IO.Directory]::Delete($RunFolder, $false)
}
catch {
Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)"
}
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) {
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
return
}
Write-Log "Executing $BootstrapPath"
# Same process, so the execution policy this loader runs under applies to stage 2 as
# well and no second policy decision can strand the installer. Splatting an array of
# remaining arguments keeps this file free of any knowledge about the installer's
# parameters.
$arguments = @()
if ($BootstrapArguments) { $arguments = $BootstrapArguments }
try {
& $BootstrapPath @arguments
Write-Log "Installer finished (exit code $LASTEXITCODE)."
}
catch {
Write-Log "Installer threw: $($_.Exception.Message)"
Write-Host "INFO: run folder $RunFolder was not fully removed: $($_.Exception.Message)"
}
}
# Entry guard: dot-sourcing (Pester) loads the functions without running the loader.
function Start-BootstrapLoader {
if (-not (Test-IsElevated)) {
$message = "AutopilotMonitor loader: not running as SYSTEM or administrator; nothing was downloaded or changed."
Write-Host $message
[Console]::Error.WriteLine($message)
return
}
try {
[Net.ServicePointManager]::SecurityProtocol = Get-DownloadSecurityProtocol -Current ([Net.ServicePointManager]::SecurityProtocol)
}
catch {
Write-Host "INFO: could not adjust the TLS protocols: $($_.Exception.Message)"
}
try {
$runFolder = New-RunFolder -Parent $env:ProgramData -Prefix $RunFolderPrefix
}
catch {
Write-Host "AutopilotMonitor loader: no run folder ($($_.Exception.Message)). Nothing was downloaded or executed."
return
}
$RunState.LogFile = Join-Path $runFolder $LogFileName
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
Write-Log ("PowerShell {0}, {1}, language mode {2}, run folder {3}" -f $PSVersionTable.PSVersion, $(if ([Environment]::Is64BitProcess) { '64-bit' } else { '32-bit' }), $ExecutionContext.SessionState.LanguageMode, $runFolder)
$installerPath = Join-Path $runFolder 'Install-AutopilotMonitor.ps1'
$succeeded = $false
try {
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $installerPath -RunFolder $runFolder)) {
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
return
}
Write-Log "Executing $installerPath"
# Same process, so the execution policy that admitted this script covers the installer.
# The exit code is reset so that an installer that throws cannot report an older one.
$global:LASTEXITCODE = $null
try {
& $installerPath @InstallerArguments
Write-Log "Installer finished (exit code $LASTEXITCODE)."
$succeeded = ($LASTEXITCODE -eq 0)
}
catch {
Write-Log "Installer threw: $($_.Exception.Message)"
}
}
finally {
Remove-LoaderRunFiles -RunFolder $runFolder -InstallerPath $installerPath -KeepLog:(-not $succeeded)
}
}
# Dot-sourcing (tests) loads the functions without running the loader.
if ($MyInvocation.InvocationName -ne '.') {
try {
Start-BootstrapLoader
}
catch {
# Nothing above should throw; if it does, it must not become a failed Intune script.
Write-Log "Loader failed: $($_.Exception.Message)"
}
exit 0
+44 -11
View File
@@ -1,9 +1,9 @@
# Autopilot Monitor Bootstrap Guard Test
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
# Mirrors Install-AutopilotMonitor.ps1 (v2.3) incl. the relax exemption for
# Mirrors Install-AutopilotMonitor.ps1 (v2.7) incl. the relax exemption for
# guards 2+3, triggered by "Windows Backup for Organizations" (OOBE InProgress)
# or a positively identified Windows 365 Cloud PC, and the guard 4 (uptime)
# exemption while the Cloud PC relax is active.
# or a positively identified Windows 365 Cloud PC, the guard 4 (uptime)
# exemption while the Cloud PC relax is active, and the agent check.
param(
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
@@ -29,20 +29,47 @@ function Write-Step {
Write-Host "$prefix $Message"
}
# HKLM through the 64-bit view, as the installer reads it: a 32-bit host would read WOW6432Node.
function Get-RegistryValueSafe {
param(
[string]$Path,
[string]$SubKey,
[string]$Name
)
try {
return (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $hive.OpenSubKey($SubKey)
if ($null -eq $key) { return $null }
try { return $key.GetValue($Name) } finally { $key.Close() }
} finally {
$hive.Close()
}
}
catch {
return $null
}
}
function Test-RegistryKeySafe {
param([string]$SubKey)
try {
$hive = [Microsoft.Win32.RegistryKey]::OpenBaseKey([Microsoft.Win32.RegistryHive]::LocalMachine, [Microsoft.Win32.RegistryView]::Registry64)
try {
$key = $hive.OpenSubKey($SubKey)
if ($null -eq $key) { return $false }
$key.Close()
return $true
} finally {
$hive.Close()
}
}
catch {
return $false
}
}
# Positive OOBE check via WinRT Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Requires Windows 10, version 1809 (10.0.17763.0). Older builds / errors -> $false (SKIP-safe).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
@@ -58,7 +85,7 @@ function Test-OobeInProgress {
# Positive Windows 365 Cloud PC detection: two local markers, BOTH required (AND),
# because each alone has look-alikes. Mirrors Test-IsCloudPc in the bootstrapper.
$cloudPcRegMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
$cloudPcRegMarker = Test-RegistryKeySafe -SubKey 'SOFTWARE\Microsoft\Windows365'
$cloudPcSvcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
$isCloudPc = ($cloudPcRegMarker -and $cloudPcSvcMarker)
@@ -73,7 +100,7 @@ Write-Host '=== Autopilot Monitor Bootstrap Guard Test (Dry Run) ==='
Write-Host ''
# Guard 1: Agent was already deployed on this device (survives self-destruct)
$deployed = Get-RegistryValueSafe -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed'
$deployed = Get-RegistryValueSafe -SubKey 'SOFTWARE\AutopilotMonitor' -Name 'Deployed'
if ($deployed) {
Write-Step -Status 'SKIP' -Message "Guard 1: Agent was previously deployed at '$deployed'."
$wouldInstall = $false
@@ -146,7 +173,7 @@ if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
}
# Guard 3: LastLoggedOnUser (unless OOBE-relax holds)
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
$lastLoggedOnUser = Get-RegistryValueSafe -SubKey 'SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser'
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
Write-Step -Status 'SKIP' -Message "Guard 3: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
$wouldInstall = $false
@@ -183,13 +210,19 @@ catch {
Write-Step -Status 'WARN' -Message 'Guard 4: Could not determine LastBootUpTime / uptime.'
}
# Guard 5: Agent binary already present
# Agent check: an installed agent (its executable or its task; the marker is guard 1) is
# left as it is.
$agentTask = Get-ScheduledTask -TaskName 'AutopilotMonitor-Agent' -ErrorAction SilentlyContinue
if (Test-Path $AgentExePath) {
Write-Step -Status 'SKIP' -Message "Guard 5: Agent already installed at '$AgentExePath'."
Write-Step -Status 'SKIP' -Message "Agent check: Agent already installed at '$AgentExePath'."
$wouldInstall = $false
$reasons.Add("Agent binary already present: $AgentExePath")
} elseif ($agentTask) {
Write-Step -Status 'SKIP' -Message "Agent check: Agent task 'AutopilotMonitor-Agent' exists."
$wouldInstall = $false
$reasons.Add("Agent task present: AutopilotMonitor-Agent")
} else {
Write-Step -Status 'PASS' -Message "Guard 5: Agent binary not present at '$AgentExePath'."
Write-Step -Status 'PASS' -Message "Agent check: No agent binary at '$AgentExePath' and no agent task."
}
Write-Host ''
@@ -0,0 +1,55 @@
# Invoke-BootstrapDownload.ps1 - thin runner embedded in the Bootstrap MSI.
#
# The MSI is only a delivery vehicle (MDM LOB channel installs earlier than the IME
# script/app batches during Autopilot Device Preparation). This runner downloads the
# CURRENT Install-AutopilotMonitor.ps1 from the download endpoint and executes it, so
# every piece of real bootstrap logic (guards, relax rules, agent download) stays
# server-updatable without re-shipping the MSI.
#
# Fail-soft by design: the runner always exits 0 so a transient network failure never
# marks the LOB install as failed; diagnostics go to bootstrap-msi.log.
# ASCII only - no special characters in this file.
$ErrorActionPreference = 'Stop'
$baseDir = Join-Path $env:ProgramData 'AutopilotMonitor'
$logDir = Join-Path $baseDir 'Logs'
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$log = Join-Path $logDir 'bootstrap-msi.log'
function Write-Log([string]$message) {
Add-Content -Path $log -Value ("[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $message)
}
try {
Write-Log "MSI bootstrap runner started (user=$env:USERNAME, pid=$PID)."
if (Test-Path 'HKLM:\SOFTWARE\AutopilotMonitor\Deployed') {
Write-Log 'Deployed marker present - skipping, another channel already installed the agent.'
exit 0
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$url = 'https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1'
$dest = Join-Path $baseDir 'Install-AutopilotMonitor.ps1'
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $dest
break
} catch {
Write-Log "Download attempt $attempt failed: $_"
if ($attempt -eq 3) { throw }
Start-Sleep -Seconds 10
}
}
Write-Log "Bootstrap script downloaded to $dest - executing."
# PS 5.1 file redirection (*>>) writes UTF-16LE, which garbles a log otherwise
# written via Add-Content (ANSI). Route through Add-Content for one encoding.
& $dest *>&1 | ForEach-Object { $_.ToString() } | Add-Content -Path $log
Write-Log "Bootstrap script finished (exit code $LASTEXITCODE)."
exit 0
} catch {
Write-Log ("MSI bootstrap runner failed: " + ($_ | Out-String))
exit 0
}
@@ -0,0 +1,216 @@
<#
.SYNOPSIS
Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it.
.DESCRIPTION
This is the file an administrator assigns in Intune, and the same file the bootstrap MSI
installs and runs -- one implementation for both delivery channels; the MSI is only the
packaging. It carries no enrollment logic at all: it downloads the published
Install-AutopilotMonitor.ps1, proves that it was signed by Autopilot Monitor, and runs it
in this process.
Why two stages: the installer holds every guard, relax rule and download path, and those
change. Whoever assigns the installer directly has to replace their Intune copy each
time; whoever assigns this loader never does, because the part that changes lives on the
server. Assigning the installer directly stays supported and unchanged.
This file is deliberately boring. It is the one piece that cannot be fixed remotely, so
it does the least it can: download, verify, invoke, log.
Verification is by Authenticode publisher, not by hash. A hash oracle would have to be
fetched from the same host as the script itself, so it could not survive a compromise of
that host, while the expected publisher below travels with this file. It pins the
certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a
loader that is already deployed on devices.
Fail-soft: the loader always exits 0 and never throws into the caller. A device that
cannot reach the download endpoint is a device without monitoring, never a failed
enrollment step. Verification failures are the same: nothing runs, and the reason is in
the log. Refusing to execute unverified code as SYSTEM is the point of this file.
This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
.PARAMETER BootstrapUrl
Installer URL. Defaults to the published stable installer; the -Dev variant of this
loader is rendered from that default at publish time.
.PARAMETER LogFileName
Log file inside the agent log directory. The MSI channel passes bootstrap-msi.log so the
delivery channel stays visible in a diagnostics package; the diag tooling keys on these
names, so do not rename them.
.PARAMETER BootstrapArguments
Everything else is passed through to the installer unchanged, so any parameter the
installer accepts can be set here without this file knowing about it.
.EXAMPLE
.\Start-AutopilotMonitor.ps1
.EXAMPLE
.\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24
.NOTES
Author : Oliver Kieselbach (autopilotmonitor.com)
Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment
.CHANGELOG
2026-09-08 v1.2 Log the signer CN instead of the full EV subject; the full subject is
logged only when the publisher is why a script was rejected.
2026-09-08 v1.1 Also used by the bootstrap MSI, which replaced its own copy of this
logic; -LogFileName keeps the channel visible in diagnostics.
2026-09-08 v1.0 Initial version: two-stage bootstrap, publisher-pinned verification.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1",
[Parameter(Mandatory = $false)]
[ValidatePattern('^[A-Za-z0-9._-]+$')]
[string]$LogFileName = "bootstrap-loader.log",
[Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)]
[string[]]$BootstrapArguments
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "1.2"
# The publisher every downloaded stage-2 script must carry. A -like pattern on the subject,
# NOT a thumbprint: certificates are renewed, deployed loaders are not.
$ExpectedPublisher = "*O=glueckkanja AG*"
$LoaderBasePath = "$env:ProgramData\AutopilotMonitor"
$LoaderLogPath = "$LoaderBasePath\Logs"
$LogFile = "$LoaderLogPath\$LogFileName"
$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1"
$DownloadAttempts = 3
$RetryDelaySeconds = 10
# Write-Host as well as the file: the Intune script output is what an administrator sees in
# the portal, the file is what a support case reads.
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Host $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
# True only when the file is signed, the signature validates against the machine's trust
# chain, and the signer is us. Any error is a false -- an unreadable signature is an
# unverified script.
function Test-BootstrapSignature {
param([string]$Path)
try {
$sig = Get-AuthenticodeSignature -FilePath $Path -ErrorAction Stop
}
catch {
Write-Log "Signature check failed to run: $($_.Exception.Message)"
return $false
}
if ($null -eq $sig) {
Write-Log "Signature check returned nothing."
return $false
}
# The full subject of an EV certificate is a line of street address and registration
# numbers. The CN answers the only question the log has to answer; the full subject is
# logged when it is the reason for a rejection.
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "<unsigned>" }
$signer = if ($subject -match 'CN=([^,]+)') { $Matches[1] } else { $subject }
Write-Log "Signature status: $($sig.Status). Signer: $signer"
if ($sig.Status -ne 'Valid') {
Write-Log "REJECTED: expected signature status 'Valid'."
return $false
}
if ($subject -notlike $ExpectedPublisher) {
Write-Log "REJECTED: signer does not match the expected publisher. Full subject: $subject"
return $false
}
Write-Log "Signature accepted (thumbprint $($sig.SignerCertificate.Thumbprint))."
return $true
}
# Downloads the installer and returns $true once a downloaded copy passes verification.
# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body
# fails the signature check, and that is worth another attempt.
function Get-VerifiedBootstrapScript {
param([string]$Url, [string]$Destination)
for ($attempt = 1; $attempt -le $DownloadAttempts; $attempt++) {
try {
Write-Log "Download attempt $attempt/$DownloadAttempts from $Url"
Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop
if (Test-BootstrapSignature -Path $Destination) {
return $true
}
}
catch {
Write-Log "Download attempt $attempt failed: $($_.Exception.Message)"
}
if ($attempt -lt $DownloadAttempts) {
Start-Sleep -Seconds $RetryDelaySeconds
}
}
return $false
}
# Main flow. Only invoked when the script is run directly (see entry guard at the bottom),
# so the Pester suite can dot-source this file without bootstrapping anything.
function Start-BootstrapLoader {
New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null
Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ==="
# PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process.
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
}
catch {
Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)"
}
if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) {
Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed."
return
}
Write-Log "Executing $BootstrapPath"
# Same process, so the execution policy this loader runs under applies to stage 2 as
# well and no second policy decision can strand the installer. Splatting an array of
# remaining arguments keeps this file free of any knowledge about the installer's
# parameters.
$arguments = @()
if ($BootstrapArguments) { $arguments = $BootstrapArguments }
try {
& $BootstrapPath @arguments
Write-Log "Installer finished (exit code $LASTEXITCODE)."
}
catch {
Write-Log "Installer threw: $($_.Exception.Message)"
}
}
# Entry guard: dot-sourcing (Pester) loads the functions without running the loader.
if ($MyInvocation.InvocationName -ne '.') {
try {
Start-BootstrapLoader
}
catch {
# Nothing above should throw; if it does, it must not become a failed Intune script.
Write-Log "Loader failed: $($_.Exception.Message)"
}
exit 0
}
@@ -6,6 +6,7 @@
This script removes all traces of the Autopilot Monitor agent:
- Stops and removes the Scheduled Task
- Deletes C:\ProgramData\AutopilotMonitor folder
- Deletes C:\ProgramData\AutopilotMonitor-Loader-* folders that a failed loader run kept
Normally the agent self-destructs after enrollment completion.
Use this script only for manual cleanup or troubleshooting.
@@ -16,7 +17,8 @@
.NOTES
- Requires Administrator privileges
- Safe to run multiple times
- No Registry cleanup needed (agent doesn't use Registry)
- Keeps HKLM\SOFTWARE\AutopilotMonitor\Deployed: the bootstrap reads it and never
installs the agent on this device again
#>
[CmdletBinding()]
@@ -67,6 +69,17 @@ catch {
Write-Host " [INFO] You may need to manually delete: $AgentBasePath" -ForegroundColor Yellow
}
# Delete loader run folders kept by failed runs
foreach ($runFolder in @(Get-ChildItem -Path $env:ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*' -ErrorAction SilentlyContinue)) {
try {
Remove-Item -LiteralPath $runFolder.FullName -Recurse -Force -ErrorAction Stop
Write-Host " [OK] Loader run folder deleted: $($runFolder.FullName)" -ForegroundColor Green
}
catch {
Write-Host " [FAIL] Error deleting $($runFolder.FullName): $($_.Exception.Message)" -ForegroundColor Red
}
}
Write-Host ""
Write-Host "===== Cleanup Completed =====" -ForegroundColor Cyan
Write-Host "Autopilot Monitor agent has been removed from this device." -ForegroundColor Green
@@ -0,0 +1,171 @@
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using System.Xml.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Shared;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Bootstrap
{
/// <summary>
/// What the bootstrap scripts and the agent must agree on: the names by which the installer
/// recognizes an installed agent, the folders and log files, and the log lines the agent reads
/// back as its provenance. Every script value is read from the script's own text, so a rename
/// on either side fails here. The bootstrap script gates run this class on every change to
/// the scripts.
/// </summary>
[Trait("Category", "BootstrapContract")]
public sealed class BootstrapContractTests : IDisposable
{
private static readonly string BootstrapDir = Path.Combine(RepoPaths.Root(), "scripts", "Bootstrap");
private static readonly string Installer = File.ReadAllText(Path.Combine(BootstrapDir, "Install-AutopilotMonitor.ps1"));
private static readonly string Loader = File.ReadAllText(Path.Combine(BootstrapDir, "Start-AutopilotMonitor.ps1"));
private static readonly string OobeScript = File.ReadAllText(Path.Combine(
RepoPaths.Root(), "src", "Backend", "AutopilotMonitor.Functions.Tests", "TestData", "oobe-bootstrap-golden.ps1"));
private static readonly string Wxs = File.ReadAllText(Path.Combine(
RepoPaths.Root(), "src", "Agent", "AutopilotMonitor.BootstrapMsi", "Package.wxs"));
private readonly string _logs = Path.Combine(Path.GetTempPath(), "apm-bootstrap-contract-" + Guid.NewGuid().ToString("N"));
public BootstrapContractTests() => Directory.CreateDirectory(_logs);
public void Dispose()
{
try { Directory.Delete(_logs, recursive: true); } catch { /* best effort */ }
}
/// <summary>The value of the one top-level <c>$Name = "value"</c> line of a script.</summary>
private static string Assignment(string script, string name)
{
var matches = Regex.Matches(script, @"(?m)^\$" + name + @"[ \t]*=[ \t]*""([^""]*)""[ \t]*\r?$");
Assert.True(matches.Count == 1, $"expected exactly one top-level ${name} line, found {matches.Count}");
return matches[0].Groups[1].Value;
}
private static string ScriptPath(string agentPath) => agentPath.Replace("%ProgramData%", "$env:ProgramData");
private static string AgentSubdirectory() =>
(string)typeof(AutopilotMonitor.Agent.V2.Program)
.GetField("DefaultAgentSubdirectory", BindingFlags.NonPublic | BindingFlags.Static)
.GetRawConstantValue();
private void WriteLog(string name, string line) =>
File.WriteAllText(Path.Combine(_logs, name), line + "\r\n", Encoding.ASCII);
[Fact]
public void The_installer_recognizes_an_agent_by_the_names_the_agent_uses()
{
Assert.Equal(DeploymentMarker.RegistryKeyPath, Assignment(Installer, "DeploymentMarkerKey"));
Assert.Equal(DeploymentMarker.RegistryValueName, Assignment(Installer, "DeploymentMarkerValue"));
Assert.Equal(Constants.ScheduledTaskName, Assignment(Installer, "AgentTaskName"));
Assert.Equal(typeof(AutopilotMonitor.Agent.V2.Program).Assembly.GetName().Name + ".exe", Assignment(Installer, "AgentExeName"));
}
[Fact]
public void The_installer_works_in_the_folders_the_agent_uses()
{
var basePath = Assignment(Installer, "AgentBasePath");
Assert.Equal(ScriptPath(Constants.AgentDataDirectory), basePath);
Assert.Equal(ScriptPath(Constants.LogDirectory), Assignment(Installer, "AgentLogPath").Replace("$AgentBasePath", basePath));
Assert.Equal(basePath + "\\" + AgentSubdirectory(), Assignment(Installer, "AgentBinPath").Replace("$AgentBasePath", basePath));
}
[Fact]
public void Every_bootstrap_logs_under_the_name_the_agent_reads()
{
Assert.Equal("$AgentLogPath\\" + BootstrapProvenanceReader.InstallerLogName, Assignment(Installer, "LogFile"));
Assert.Matches(@"\[string\]\$LogFileName = """ + Regex.Escape(BootstrapProvenanceReader.LoaderLogName) + @"""", Loader);
Assert.Contains("$LogFile = \"$AgentLogPath\\" + BootstrapProvenanceReader.OobeLogName + "\"", OobeScript);
Assert.Contains("-LogFileName " + BootstrapProvenanceReader.MsiLoaderLogName, Wxs);
}
// The bootstrap MSI installs its loader copy where the installer removes it, starts
// PowerShell in System32, and runs only bytes it has checked against the publisher pin
// the loader applies to the installer. Windows Installer formats the command, so every
// bracket and brace is escaped and the only property references are the two paths.
[Fact]
public void The_msi_puts_the_loader_copy_where_the_installer_removes_it_and_runs_only_what_it_checked()
{
XNamespace ns = "http://wixtoolset.org/schemas/v4/wxs";
var package = XDocument.Parse(Wxs).Root.Element(ns + "Package");
Assert.Equal("1fbe5515-d346-445c-b8b8-14ee79f97522", (string)package.Attribute("UpgradeCode"));
var root = package.Elements(ns + "StandardDirectory").Single();
Assert.Equal("CommonAppDataFolder", (string)root.Attribute("Id"));
var chain = root.Descendants(ns + "Directory").Select(d => (string)d.Attribute("Name")).ToArray();
Assert.Equal("INSTALLFOLDER", (string)root.Descendants(ns + "Directory").Last().Attribute("Id"));
var transport = Assignment(Installer, "MsiTransportPath").Replace("$AgentBasePath", Assignment(Installer, "AgentBasePath"));
Assert.Equal("$env:ProgramData\\" + string.Join("\\", chain), transport);
var action = package.Elements(ns + "CustomAction").Single(a => (string)a.Attribute("Id") == "RunBootstrap");
Assert.Equal("System64Folder", (string)action.Attribute("Directory"));
Assert.Equal("deferred", (string)action.Attribute("Execute"));
Assert.Equal("no", (string)action.Attribute("Impersonate"));
Assert.Equal("ignore", (string)action.Attribute("Return"));
var exeCommand = (string)action.Attribute("ExeCommand");
var command = exeCommand.Substring(exeCommand.IndexOf(" -Command ", StringComparison.Ordinal));
Assert.Contains("-NonInteractive", exeCommand);
Assert.Contains("[\\[]System.IO.File[\\]]::ReadAllBytes('[INSTALLFOLDER]Start-AutopilotMonitor.ps1')", command);
Assert.Contains("Get-AuthenticodeSignature -Content $b -SourcePathOrExtension '.ps1'", command);
Assert.Contains("$h -eq '$(var.LoaderSha256)' -and $s.Status -eq 'Valid' -and $s.SignerCertificate.Subject -like '" + Assignment(Loader, "ExpectedPublisher") + "'", command);
// The hash is baked in by the build from the loader it embeds; a package built without it must not exist.
Assert.Contains("<?ifndef var.LoaderSha256 ?>", Wxs);
var workflow = File.ReadAllText(Path.Combine(RepoPaths.Root(), ".github", "workflows", "build-msi.yml"));
Assert.Contains("wix build Package.wxs -arch x64 -d LoaderSha256=$loaderSha256", workflow);
Assert.Contains("Get-FileHash -LiteralPath $env:RUNNER_SCRIPT -Algorithm SHA256", workflow);
Assert.Contains("[\\[]scriptblock[\\]]::Create([\\[]System.Text.Encoding[\\]]::UTF8.GetString($b))", command);
Assert.DoesNotContain("-File", command);
// Without the [\x] escapes, the only bracket left is the one property reference.
var unescaped = Regex.Replace(command, @"\[\\.\]", "");
Assert.DoesNotMatch(@"[{}\[\]]", unescaped.Replace("[INSTALLFOLDER]", ""));
Assert.Equal(new[] { "[INSTALLFOLDER]" }, Regex.Matches(command, @"\[[A-Za-z0-9_.]+\]").Cast<Match>().Select(m => m.Value).Distinct().ToArray());
var custom = package.Element(ns + "InstallExecuteSequence").Elements(ns + "Custom").Single();
Assert.Equal("RunBootstrap", (string)custom.Attribute("Action"));
Assert.Equal("NOT Installed", (string)custom.Attribute("Condition"));
}
[Fact]
public void The_agent_reads_the_versions_the_current_scripts_write()
{
var installerVersion = Assignment(Installer, "ScriptVersion");
var loaderVersion = Assignment(Loader, "ScriptVersion");
Assert.Contains("Write-Log \"Bootstrap script version: v$ScriptVersion\"", Installer);
Assert.Contains("Write-Log \"=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ===\"", Loader);
WriteLog(BootstrapProvenanceReader.InstallerLogName, $"[2026-10-22 10:00:01] Bootstrap script version: v{installerVersion}");
WriteLog(BootstrapProvenanceReader.LoaderLogName, $"[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v{loaderVersion} started (user=SYSTEM, pid=4242) ===");
var provenance = BootstrapProvenanceReader.Read(_logs);
Assert.Equal(installerVersion, provenance.ScriptVersion);
Assert.Equal("loader", provenance.LoaderLog);
Assert.Equal(loaderVersion, provenance.LoaderVersion);
}
// The installer carries only the start line over from loaders before 1.3, rebuilt from a
// pattern; the agent has to read that line as the loader's version.
[Fact]
public void The_agent_reads_the_start_line_the_installer_carries_over_from_an_older_loader()
{
const string suffix = " (carried over by the bootstrapper) ===`r`n";
Assert.Contains("$starts[$starts.Count - 1].Value + \"" + suffix + "\"", Installer);
WriteLog(BootstrapProvenanceReader.MsiLoaderLogName, "[2026-10-22 10:00:00] === Autopilot Monitor bootstrap loader v1.2 started (carried over by the bootstrapper) ===");
var provenance = BootstrapProvenanceReader.Read(_logs);
Assert.Equal("msi", provenance.LoaderLog);
Assert.Equal("1.2", provenance.LoaderVersion);
}
}
}
@@ -0,0 +1,21 @@
using System.Security.Principal;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
{
/// <summary>
/// A fact that needs an elevated process, such as making the Administrators group the owner
/// of a folder. Skipped in a non-elevated local run; the CI runners are elevated.
/// </summary>
public sealed class ElevatedFactAttribute : FactAttribute
{
public ElevatedFactAttribute()
{
using (var identity = WindowsIdentity.GetCurrent())
{
if (!new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator))
Skip = "needs an elevated process (assigning Administrators as the folder owner)";
}
}
}
}
@@ -17,6 +17,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
public DistressErrorType ErrorType { get; set; }
public string Message { get; set; } = default!;
public int? HttpStatusCode { get; set; }
public string? TpmVersion { get; set; }
public DistressDeviceContext? Context { get; set; }
}
public List<Call> Calls { get; } = new List<Call>();
@@ -32,9 +34,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
{
}
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null)
public override Task TrySendAsync(DistressErrorType errorType, string message, int? httpStatusCode = null, DistressDetail? detail = null)
{
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode });
Calls.Add(new Call { ErrorType = errorType, Message = message, HttpStatusCode = httpStatusCode, TpmVersion = detail?.TpmVersion, Context = detail?.Context });
return Send?.Invoke() ?? Task.CompletedTask;
}
}
@@ -0,0 +1,82 @@
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
{
/// <summary>
/// The ESP phase a report names, derived from the three ESP registry values. Both value shapes
/// Windows writes are covered: flat status texts (older builds) and nested subcategory objects.
/// </summary>
public sealed class EspPhaseSnapshotTests
{
private const string DoneFlag = "{\"categorySucceeded\":true}";
private const string FailedFlag = "{\"categorySucceeded\":false}";
private const string NestedRunning =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\",\"subcategoryStatusText\":\"Complete\"}," +
"\"AppsSubcategory\":{\"subcategoryState\":\"inProgress\",\"subcategoryStatusText\":\"Installing (1 of 3)\"}}";
private const string NestedAllDoneNoFlag =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\"},\"AppsSubcategory\":{\"subcategoryState\":\"notRequired\"}}";
private const string NestedFailedNoFlag =
"{\"SecurityPoliciesSubcategory\":{\"subcategoryState\":\"succeeded\"},\"AppsSubcategory\":{\"subcategoryState\":\"failed\"}}";
private const string FlatRunning = "{\"CertificatesSubcategory\":\"Certificates (0 of 1 applied)\",\"AppsSubcategory\":\"Identifying\"}";
private static string Derive(string? preparation, string? deviceSetup, string? accountSetup)
=> EspPhaseSnapshot.Derive(new List<string> { preparation!, deviceSetup!, accountSetup! });
[Fact]
public void No_value_is_none()
{
Assert.Equal("none", Derive(null, null, null));
}
[Fact]
public void Running_category_names_its_phase()
{
Assert.Equal("device_preparation", Derive(NestedRunning, null, null));
Assert.Equal("device_setup", Derive(DoneFlag, NestedRunning, null));
Assert.Equal("account_setup", Derive(DoneFlag, DoneFlag, NestedRunning));
}
[Fact]
public void Flat_status_texts_are_read_too()
{
Assert.Equal("device_setup", Derive(DoneFlag, FlatRunning, null));
}
[Fact]
public void Done_categories_name_the_last_one_reached()
{
Assert.Equal("device_preparation_done", Derive(DoneFlag, null, null));
Assert.Equal("device_setup_done", Derive(DoneFlag, DoneFlag, null));
Assert.Equal("esp_done", Derive(DoneFlag, DoneFlag, DoneFlag));
}
[Fact]
public void All_subcategories_done_counts_as_done_without_the_flag()
{
// Some builds never set categorySucceeded although every subcategory finished.
Assert.Equal("esp_done", Derive(DoneFlag, NestedAllDoneNoFlag, NestedAllDoneNoFlag));
}
[Fact]
public void A_failure_wins_over_everything_after_it()
{
Assert.Equal("device_setup_failed", Derive(DoneFlag, FailedFlag, NestedRunning));
Assert.Equal("account_setup_failed", Derive(DoneFlag, DoneFlag, NestedFailedNoFlag));
}
[Fact]
public void Unparseable_value_counts_as_running()
{
Assert.Equal("device_setup", Derive(DoneFlag, "{not json", null));
}
[Fact]
public void Read_on_this_machine_returns_a_known_token()
{
var phase = EspPhaseSnapshot.Read();
Assert.Matches("^[a-z_]+$", phase);
}
}
}
@@ -12,7 +12,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
/// The bootstrap provenance in <c>agent_started</c>. Fixture lines are the scripts' real
/// formats: installer <c>[ts] Bootstrap script version: vX.Y</c>
/// (Install-AutopilotMonitor.ps1), loader <c>[ts] === Autopilot Monitor bootstrap loader vX.Y
/// started (user=..., pid=...) ===</c> (Start-AutopilotMonitor.ps1).
/// started (user=..., pid=...) ===</c> (Start-AutopilotMonitor.ps1), the MSI's downloader before
/// the loader <c>[ts] MSI bootstrap runner started (user=..., pid=...).</c>.
/// </summary>
public sealed class BootstrapProvenanceReaderTests : IDisposable
{
@@ -79,6 +80,39 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Assert.Null(p.ScriptVersion);
}
[Fact]
public void An_installer_from_before_the_log_rename_is_read_under_its_old_name()
{
Write(BootstrapProvenanceReader.LegacyInstallerLogName, InstallerRun("2.3"));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.True(p.ScriptLogPresent);
Assert.Equal("2.3", p.ScriptVersion);
}
[Fact]
public void With_both_installer_logs_the_most_recently_written_one_counts()
{
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.6"));
Write(BootstrapProvenanceReader.LegacyInstallerLogName, InstallerRun("2.3"));
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.InstallerLogName), DateTime.UtcNow.AddDays(-3));
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.LegacyInstallerLogName), DateTime.UtcNow.AddMinutes(-1));
Assert.Equal("2.3", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
}
[Fact]
public void The_msi_downloader_from_before_the_loader_counts_as_msi_without_version()
{
Write(BootstrapProvenanceReader.LegacyMsiLoaderLogName, "[2026-08-01 09:00:00] MSI bootstrap runner started (user=SECRETUSER, pid=4242).");
var p = BootstrapProvenanceReader.Read(_dir);
Assert.Equal("msi", p.LoaderLog);
Assert.Null(p.LoaderVersion);
}
[Fact]
public void The_msi_loader_log_is_told_apart_from_the_intune_loader()
{
@@ -0,0 +1,175 @@
#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.Persistence;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>The V1 confidence score of <see cref="ConfigMgrClientSnapshot"/>.</summary>
public sealed class ConfigMgrClientScoreTests
{
[Fact]
public void Nothing_found_scores_zero_and_is_not_detected()
{
Assert.Equal(0, ConfigMgrClientSnapshot.Empty.ConfidenceScore);
Assert.False(ConfigMgrClientSnapshot.Empty.Detected);
}
[Fact]
public void A_registered_running_client_scores_100()
{
var snapshot = new ConfigMgrClientSnapshot(true, true, "Running", "5.00.9999.1000", "ABC");
Assert.Equal(100, snapshot.ConfidenceScore);
}
[Fact]
public void The_folder_alone_is_a_weak_remnant_below_the_portal_threshold()
{
var snapshot = new ConfigMgrClientSnapshot(false, true, ConfigMgrClientProbe.ServiceNotFound, null, null);
Assert.True(snapshot.Detected);
Assert.Equal(10, snapshot.ConfidenceScore);
}
[Fact]
public void A_stopped_service_counts_without_the_running_bonus()
{
var snapshot = new ConfigMgrClientSnapshot(true, true, "Stopped", "5.00.9999.1000", "ABC");
Assert.Equal(80, snapshot.ConfidenceScore);
}
}
/// <summary>
/// Emission surface of <see cref="ConfigMgrClientDetector"/> via the real
/// <see cref="InformationalEventPost"/>. Static probe override → serial collection.
/// </summary>
[Collection("SerialThreading")]
public sealed class ConfigMgrClientDetectorEmissionTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 10, 23, 8, 0, 0, DateTimeKind.Utc);
private readonly TempDirectory _tmp = new TempDirectory();
public void Dispose() => _tmp.Dispose();
private (ConfigMgrClientDetector sut, FakeSignalIngressSink sink) BuildDetector(StartupEventGate? gate = null)
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var sink = new FakeSignalIngressSink();
var clock = new VirtualClock(At);
var sut = new ConfigMgrClientDetector("S1", "T1", new InformationalEventPost(sink, clock), logger, clock, gate);
return (sut, sink);
}
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> Detected(FakeSignalIngressSink sink) =>
sink.Posted.Where(p =>
p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == "configmgr_client_detected").ToList();
private static ConfigMgrClientSnapshot Client(string serviceState, string? siteCode = "ABC") =>
new ConfigMgrClientSnapshot(true, true, serviceState, "5.00.9999.1000", siteCode);
[Fact]
public void No_client_never_emits()
{
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => ConfigMgrClientSnapshot.Empty);
var (sut, sink) = BuildDetector();
sut.Check();
sut.Check();
Assert.Empty(Detected(sink));
}
[Fact]
public void First_detection_emits_the_v1_payload()
{
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client("Running"));
var (sut, sink) = BuildDetector();
sut.Check();
var evt = Assert.Single(Detected(sink));
Assert.Equal(DecisionSignalKind.InformationalEvent, evt.Kind);
Assert.Equal("Info", evt.Payload![SignalPayloadKeys.Severity]);
Assert.Equal(ConfigMgrClientDetector.SourceLabel, evt.Payload[SignalPayloadKeys.Source]);
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
Assert.Equal(true, data["ccmRegistryFound"]);
Assert.Equal(true, data["ccmDirectoryFound"]);
Assert.Equal("Running", data["ccmServiceState"]);
Assert.Equal("5.00.9999.1000", data["ccmVersion"]);
Assert.Equal("ABC", data["siteCode"]);
Assert.Equal(100, data["confidenceScore"]);
Assert.Equal("startup", data["detectedAt"]);
}
[Fact]
public void An_unchanged_state_emits_once_a_changed_state_again()
{
var current = Client("Stopped", siteCode: null);
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
var (sut, sink) = BuildDetector();
sut.Check();
sut.Check();
Assert.Single(Detected(sink));
current = Client("Running");
sut.Check();
var events = Detected(sink);
Assert.Equal(2, events.Count);
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[0].TypedPayload);
Assert.Equal("unknown", data["siteCode"]); // missing values keep the V1 placeholder
var latest = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(events[1].TypedPayload);
Assert.Equal("Running", latest["ccmServiceState"]);
Assert.Equal("poll", latest["detectedAt"]);
}
[Fact]
public void A_restart_into_the_same_state_stays_silent()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var current = Client("Running");
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => current);
var (run1, sink1) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
run1.Check();
Assert.Single(Detected(sink1));
var (run2, sink2) = BuildDetector(new StartupEventGate(_tmp.Path, logger));
run2.Check();
Assert.Empty(Detected(sink2));
current = Client("Stopped");
run2.Check();
Assert.Single(Detected(sink2));
}
[Fact]
public void Emissions_are_capped_per_run()
{
var running = true;
using var _probe = new ConfigMgrClientProbe.ScopedOverride(() => Client(running ? "Running" : "Stopped"));
var (sut, sink) = BuildDetector();
for (var i = 0; i < ConfigMgrClientDetector.MaxEmissionsPerRun * 2; i++)
{
sut.Check();
running = !running;
}
Assert.Equal(ConfigMgrClientDetector.MaxEmissionsPerRun, Detected(sink).Count);
}
}
}
@@ -185,6 +185,46 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
Assert.False(EspPolicyProviderSnapshot.Empty.SidecarRegistered);
}
// ------------------------------------------------- phase gate for DevicePreparation providers
[Theory]
[InlineData("device_preparation", true)]
[InlineData("device_preparation_failed", true)] // a failed device preparation keeps its culprit
[InlineData("none", true)] // unknown phase → keep observing
[InlineData("unavailable", true)]
[InlineData("", true)]
[InlineData("device_preparation_done", false)]
[InlineData("device_setup", false)]
[InlineData("device_setup_failed", false)]
[InlineData("account_setup", false)]
[InlineData("esp_done", false)]
public void Device_preparation_counts_until_it_is_done(string phase, bool expected)
=> Assert.Equal(expected, EspPhaseSnapshot.DevicePreparationOpen(phase));
[Fact]
public void DevicePreparation_provider_past_its_phase_drops_out_while_setupApps_still_counts()
{
// Field shape (client installed as a Win32 app): the client registers its
// DevicePreparation provider after its own install with InstallationState=1, long
// after device preparation ended. Nothing ever completes it.
var snapshot = Snapshot(
DevicePrep("ConfigMgr", installState: 1),
SetupApps("Contoso", tracking: 0));
var seen = new Dictionary<string, DateTime>
{
["devicePreparation|device|ConfigMgr"] = T0,
["setupApps|device|Contoso"] = T0,
};
var eval = EspPolicyProviderStallDetector.EvaluateStallTransition(
snapshot, seen, new HashSet<string>(), null, false, T0.AddMinutes(30), Dwell,
devicePreparationOpen: false);
Assert.False(eval.UpdatedFirstSeenUtc.ContainsKey("devicePreparation|device|ConfigMgr")); // reset
var stalled = Assert.Single(eval.NewlyStalled);
Assert.Equal("Contoso", stalled.Provider.Name);
}
// ------------------------------------------------- arm 2: sidecar-missing transition
[Fact]
@@ -265,17 +305,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
public void Dispose() => _tmp.Dispose();
// The phase defaults to "none" so the tests never read the test machine's own ESP state.
private (EspPolicyProviderStallDetector sut, FakeSignalIngressSink sink, VirtualClock clock)
BuildDetector(StartupEventGate? gate = null)
BuildDetector(StartupEventGate? gate = null, Func<string>? phase = null)
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var sink = new FakeSignalIngressSink();
var clock = new VirtualClock(At);
var post = new InformationalEventPost(sink, clock);
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate);
var sut = new EspPolicyProviderStallDetector("S1", "T1", post, logger, clock, gate,
espPhaseReader: phase ?? (() => EspPhaseSnapshot.None));
return (sut, sink, clock);
}
private static readonly TimeSpan DwellSpan = TimeSpan.FromMinutes(EspPolicyProviderStallDetector.DwellMinutes);
private static PolicyProviderState SidecarDone() =>
new PolicyProviderState("Sidecar", "device", EspPolicyProviderProbe.KindSetupApps, 1, null);
private static PolicyProviderState ConfigMgrDevicePrep(int? installationState) =>
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindDevicePreparation, null, installationState);
private static EspPolicyProviderSnapshot ConfigMgrOnly() => new EspPolicyProviderSnapshot(new[]
{
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps,
@@ -435,6 +485,104 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
}
}
[Fact]
public void Provider_registered_after_device_preparation_never_emits()
{
// The ConfigMgr client as a Win32 app: its DevicePreparation provider shows up during
// device setup with InstallationState=1 and stays there.
var snapshot = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
var (sut, sink, clock) = BuildDetector(phase: () => "device_setup");
sut.Tick();
clock.Advance(TimeSpan.FromHours(2));
sut.Tick();
Assert.Empty(StalledEvents(sink));
}
[Fact]
public void A_real_device_preparation_hang_still_emits()
{
// Field shape: only ConfigMgr providers registered, the ESP never left device
// preparation and failed with 0x800705b4.
var snapshot = new EspPolicyProviderSnapshot(new[]
{
new PolicyProviderState("ConfigMgr", "device", EspPolicyProviderProbe.KindSetupApps, null, null),
ConfigMgrDevicePrep(1),
});
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
var (sut, sink, clock) = BuildDetector(phase: () => EspPhaseSnapshot.DevicePreparation);
sut.Tick();
clock.Advance(DwellSpan);
sut.Tick();
var evt = Assert.Single(StalledEvents(sink));
var data = Assert.IsAssignableFrom<IReadOnlyDictionary<string, object>>(evt.TypedPayload);
var stalled = Assert.IsAssignableFrom<IReadOnlyList<Dictionary<string, object>>>(data["stalledProviders"]);
Assert.Contains(stalled, s => (string)s["kind"] == EspPolicyProviderProbe.KindDevicePreparation);
}
[Fact]
public void Device_preparation_stall_message_names_the_provider_timeout()
{
var snapshot = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
using var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => snapshot);
var (sut, sink, clock) = BuildDetector(phase: () => EspPhaseSnapshot.DevicePreparation);
sut.Tick();
clock.Advance(DwellSpan);
sut.Tick();
var message = Assert.Single(StalledEvents(sink)).Payload![SignalPayloadKeys.Message];
Assert.Contains("until their timeout", message);
Assert.DoesNotContain("without any timeout", message);
}
[Fact]
public void Fingerprint_skips_device_preparation_providers_past_their_phase()
{
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
var phase = EspPhaseSnapshot.DevicePreparation;
var devicePrepOnly = new EspPolicyProviderSnapshot(new[] { SidecarDone(), ConfigMgrDevicePrep(1) });
var withContoso = new EspPolicyProviderSnapshot(new[]
{
SidecarDone(),
ConfigMgrDevicePrep(1),
new PolicyProviderState("Contoso", "device", EspPolicyProviderProbe.KindSetupApps, 0, null),
});
var current = devicePrepOnly;
// Run 1: the DevicePreparation provider stalls during device preparation; later, in
// device setup, a Setup/Apps provider stalls as well.
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => current))
{
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger), () => phase);
sut.Tick();
clock.Advance(DwellSpan);
sut.Tick();
phase = "device_setup";
current = withContoso;
sut.Tick();
clock.Advance(DwellSpan);
sut.Tick();
Assert.Equal(2, StalledEvents(sink).Count);
}
// Run 2 (restart in device setup): the same Contoso stall lands on the same
// fingerprint only if the stale DevicePreparation provider stayed out of it.
using (var _probe = new EspPolicyProviderProbe.ScopedOverride(_ => withContoso))
{
var (sut, sink, clock) = BuildDetector(new StartupEventGate(_tmp.Path, logger), () => "device_setup");
sut.Tick();
clock.Advance(DwellSpan);
sut.Tick();
Assert.Empty(StalledEvents(sink));
}
}
[Fact]
public void Healthy_or_absent_tracking_never_emits()
{
@@ -40,7 +40,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
/// enabled by default) and system-boundary fakes only: temp state dir, fake ingress,
/// virtual clock, no network metrics, no telemetry spool.
/// </summary>
private CollectorSurfaces CreateSurfaces()
private CollectorSurfaces CreateSurfaces(AnalyzerConfiguration? analyzers = null)
{
var factory = new DefaultComponentFactory(
agentConfig: new AgentConfiguration
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
remoteConfig: new AgentConfigResponse
{
Collectors = CollectorConfiguration.CreateDefault(),
Analyzers = analyzers ?? new AnalyzerConfiguration(),
},
networkMetrics: null,
agentVersion: "test",
@@ -142,6 +143,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
}
}
[Theory]
[InlineData(false, 0)]
[InlineData(true, 1)]
public void ConfigMgr_client_host_exists_only_when_the_tenant_enables_the_watcher(bool enabled, int expected)
{
// Opt-in (D-382): with the watcher off the check must not run at all, not merely stay quiet.
var hosts = CreateSurfaces(new AnalyzerConfiguration { EnableConfigMgrWatcher = enabled }).Hosts;
try
{
Assert.Equal(expected, hosts.Count(h => h is ConfigMgrClientHost));
}
finally
{
foreach (var host in hosts)
{
try { host.Dispose(); } catch { /* construction-only test — best-effort cleanup */ }
}
}
}
[Fact]
public void Ime_app_state_callback_is_chained_into_the_user_apps_settled_recheck()
{
@@ -164,6 +164,70 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Null(persistence.LoadRegistrationFailure());
}
[Fact]
public void Success_after_a_failed_start_tells_reboot_certificate_and_where_the_failure_began()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var at = new DateTime(2026, 10, 21, 7, 0, 0, DateTimeKind.Utc);
var failedBoot = at.AddMinutes(-16);
BackendSessionRegistration.RecordFailedRun(
persistence, SessionRegistrationResult.Failed("An error occurred while sending the request."),
at, at.AddSeconds(90), true, true, "Succeeded", "fp-a", NewLogger(tmp.Path),
new AutopilotMonitor.Agent.V2.Core.Monitoring.Transport.DistressDeviceContext("completed", "account_setup", failedBoot),
certThumbprint: "6B98620E8BE952FEA7CD9200B899A9A8CCADD1E5");
// The record carries phase and OOBE flag; boot and thumbprint stay beside it.
var summary = persistence.LoadRegistrationFailure()!;
Assert.Equal("account_setup", summary.EspPhase);
Assert.Equal("completed", summary.OobeState);
var prior = BackendSessionRegistration.ClearAfterSuccess(
persistence, () => "fp-a", NewLogger(tmp.Path),
currentBootUtc: at.AddHours(1), currentCertThumbprint: "6b98620e8be952fea7cd9200b899a9a8ccadd1e5");
Assert.NotNull(prior);
Assert.Equal("account_setup", prior!.LastEspPhase);
Assert.Equal("completed", prior.LastOobeState);
Assert.True(prior.RebootedSince);
Assert.False(prior.CertChanged);
Assert.False(prior.NetworkChanged);
}
[Fact]
public void Same_boot_and_unknown_sides_are_reported_as_such()
{
var boot = new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc);
Assert.False(BackendSessionRegistration.RebootedSince(boot, boot.AddSeconds(30)));
Assert.True(BackendSessionRegistration.RebootedSince(boot, boot.AddMinutes(5)));
Assert.Null(BackendSessionRegistration.RebootedSince(null, boot));
Assert.Null(BackendSessionRegistration.RebootedSince(boot, null));
Assert.True(BackendSessionRegistration.CertificateChanged("AA", "BB"));
Assert.Null(BackendSessionRegistration.CertificateChanged(null, "BB"));
Assert.Null(BackendSessionRegistration.CertificateChanged("AA", ""));
}
[Fact]
public void Record_from_an_older_agent_leaves_the_new_comparisons_unknown()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var at = new DateTime(2026, 10, 21, 7, 0, 0, DateTimeKind.Utc);
// Written without context, boot or thumbprint — the shape agents up to 2.0.1487 leave.
BackendSessionRegistration.RecordFailedRun(
persistence, SessionRegistrationResult.Failed("x"), at, at.AddSeconds(5), true, true, null, null, NewLogger(tmp.Path));
var prior = BackendSessionRegistration.ClearAfterSuccess(
persistence, () => null, NewLogger(tmp.Path), at.AddHours(1), "AA");
Assert.NotNull(prior);
Assert.Null(prior!.LastEspPhase);
Assert.Null(prior.RebootedSince);
Assert.Null(prior.CertChanged);
}
[Fact]
public void Recording_without_persistence_is_a_no_op()
{
@@ -3,6 +3,8 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
@@ -16,14 +18,16 @@ using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Regression gate for the "soft admin-action" semantics change: backend
/// <c>AdminAction=Succeeded</c> is purely informational (timeline entry only, agent keeps
/// running its own path) while <c>AdminAction=Failed</c> drives the agent's own soft-shutdown
/// pipeline via the injected <c>onAdminFailed</c> callback — without forcing self-destruct.
/// Previously both were routed through the full <c>ServerActionDispatcher</c> +
/// <c>terminationHandler.Handle</c> path, which (a) hijacked the agent's own completion flow
/// for Succeeded and (b) crashed with <c>InvalidOperationException: SignalIngress has been
/// stopped</c> when a terminal-drain response arrived after the ingress was torn down.
/// Regression gate for the soft admin-action semantics: backend <c>AdminAction=Succeeded</c>
/// and <c>AdminAction=Failed</c> (portal Mark-Succeeded / Mark-Failed) both drive the agent's
/// own soft-shutdown pipeline via the injected <c>onAdminTerminal</c> callback with that
/// outcome — without forcing self-destruct — and only the first AdminAction of a process is
/// acted on. The backend repeats AdminAction on every telemetry response; acting on each echo
/// re-emitted <c>admin_marked_session</c> with an immediate upload whose response carried the
/// next echo. Earlier, both outcomes were routed through the full
/// <c>ServerActionDispatcher</c> path, which crashed with
/// <c>InvalidOperationException: SignalIngress has been stopped</c> when a terminal-drain
/// response arrived after the ingress was torn down.
/// </summary>
public sealed class HandleAdminActionTests
{
@@ -34,13 +38,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
ApiBaseUrl = "http://localhost",
};
private static global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.AdminActionLatch NewLatch() =>
new global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.AdminActionLatch();
private sealed class Rig : IDisposable
{
public TempDirectory Tmp { get; } = new TempDirectory();
public AgentLogger Logger { get; }
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
public InformationalEventPost Post { get; }
public List<EnrollmentTerminatedEventArgs> OnAdminFailedCalls { get; } = new List<EnrollmentTerminatedEventArgs>();
public List<EnrollmentTerminatedEventArgs> OnAdminTerminalCalls { get; } = new List<EnrollmentTerminatedEventArgs>();
public Rig()
{
@@ -48,7 +55,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Post = new InformationalEventPost(Ingress, new VirtualClock(new DateTime(2026, 4, 23, 10, 0, 0, DateTimeKind.Utc)));
}
public Action<EnrollmentTerminatedEventArgs> OnAdminFailed => args => OnAdminFailedCalls.Add(args);
public Action<EnrollmentTerminatedEventArgs> OnAdminTerminal => args => OnAdminTerminalCalls.Add(args);
public IEnumerable<FakeSignalIngressSink.PostedSignal> EventsOfType(string eventType) =>
Ingress.Posted.Where(p =>
@@ -61,15 +68,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
}
[Fact]
public void Succeeded_emits_admin_marked_session_as_Info_and_does_not_invoke_onAdminFailed()
public void Succeeded_emits_admin_marked_session_as_Info_and_terminates_with_Succeeded()
{
using var rig = new Rig();
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Succeeded",
stageNameAccessor: () => "Completed",
latch: NewLatch(),
stageNameAccessor: () => "EspAccountSetup",
post: rig.Post,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
@@ -78,24 +86,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
Assert.Equal(EventSeverity.Info.ToString(), sev);
// Informational only — agent keeps running its own path.
Assert.Empty(rig.OnAdminFailedCalls);
Assert.Single(rig.OnAdminTerminalCalls);
var args = rig.OnAdminTerminalCalls[0];
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, args.Outcome);
Assert.Equal(EnrollmentTerminationReason.DecisionTerminalStage, args.Reason);
Assert.Equal("EspAccountSetup", args.StageName);
Assert.Contains("Admin marked session as Succeeded", args.Details);
// No server_action_* noise — Succeeded isn't a ServerAction.
// No server_action_* noise — an admin mark isn't a ServerAction.
Assert.Empty(rig.EventsOfType("server_action_received"));
Assert.Empty(rig.EventsOfType("server_action_executed"));
}
[Fact]
public void Failed_emits_admin_marked_session_as_Warning_and_invokes_onAdminFailed_once()
public void Failed_emits_admin_marked_session_as_Warning_and_terminates_with_Failed()
{
using var rig = new Rig();
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Failed",
latch: NewLatch(),
stageNameAccessor: () => "DeviceSetup",
post: rig.Post,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
@@ -104,8 +117,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
Assert.Equal(EventSeverity.Warning.ToString(), sev);
Assert.Single(rig.OnAdminFailedCalls);
var args = rig.OnAdminFailedCalls[0];
Assert.Single(rig.OnAdminTerminalCalls);
var args = rig.OnAdminTerminalCalls[0];
Assert.Equal(EnrollmentTerminationOutcome.Failed, args.Outcome);
Assert.Equal(EnrollmentTerminationReason.DecisionTerminalStage, args.Reason);
Assert.Equal("DeviceSetup", args.StageName);
@@ -119,13 +132,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "succeeded",
latch: NewLatch(),
stageNameAccessor: () => null,
post: rig.Post,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
Assert.Empty(rig.OnAdminFailedCalls);
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
}
[Fact]
@@ -137,28 +152,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Quarantined",
latch: NewLatch(),
stageNameAccessor: () => null,
post: rig.Post,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
Assert.Single(rig.OnAdminFailedCalls);
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminFailedCalls[0].Outcome);
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminTerminalCalls[0].Outcome);
}
[Fact]
public void Failed_with_null_onAdminFailed_does_not_throw()
public void Null_onAdminTerminal_does_not_throw()
{
// Defensive path: onAdminFailed can be null if the terminate handler wasn't wired yet.
// Defensive path: onAdminTerminal can be null if the terminate handler wasn't wired yet.
using var rig = new Rig();
var ex = Record.Exception(() =>
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Failed",
latch: NewLatch(),
stageNameAccessor: () => null,
post: rig.Post,
onAdminFailed: null!,
onAdminTerminal: null!,
agentConfig: Config(),
logger: rig.Logger));
@@ -176,14 +193,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Failed",
latch: NewLatch(),
stageNameAccessor: () => throw new InvalidOperationException("not started"),
post: rig.Post,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
Assert.Single(rig.OnAdminFailedCalls);
Assert.Null(rig.OnAdminFailedCalls[0].StageName);
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Null(rig.OnAdminTerminalCalls[0].StageName);
}
[Fact]
@@ -202,18 +220,22 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
var ex = Record.Exception(() =>
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Succeeded",
latch: NewLatch(),
stageNameAccessor: () => null,
post: stoppedPost,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger));
Assert.Null(ex);
Assert.Empty(rig.OnAdminFailedCalls);
// A lost timeline entry never cancels the admin's decision.
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
}
[Fact]
public void Failed_emit_race_still_invokes_onAdminFailed()
public void Failed_emit_race_still_terminates()
{
// If the timeline emit fails due to a stopped ingress, the agent's own soft-shutdown
// must still run — the admin's decision to Mark-Failed outranks a lost timeline entry.
@@ -225,14 +247,85 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: "Failed",
latch: NewLatch(),
stageNameAccessor: () => "DeviceSetup",
post: stoppedPost,
onAdminFailed: rig.OnAdminFailed,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
Assert.Single(rig.OnAdminFailedCalls);
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminFailedCalls[0].Outcome);
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Equal(EnrollmentTerminationOutcome.Failed, rig.OnAdminTerminalCalls[0].Outcome);
}
[Theory]
[InlineData("Succeeded")]
[InlineData("Failed")]
public void Repeated_echo_is_handled_once(string adminAction)
{
// The backend repeats AdminAction on every telemetry response. Each echo handled anew
// emitted another immediate-upload timeline entry whose response carried the next echo.
using var rig = new Rig();
var latch = NewLatch();
for (var i = 0; i < 3; i++)
{
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: adminAction,
latch: latch,
stageNameAccessor: () => "EspAccountSetup",
post: rig.Post,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
}
Assert.Single(rig.EventsOfType("admin_marked_session"));
Assert.Single(rig.OnAdminTerminalCalls);
}
[Fact]
public void First_admin_action_of_the_process_wins()
{
// The first mark already started the shutdown with its outcome; a later change of
// mind in the portal only updates the session row on the backend.
using var rig = new Rig();
var latch = NewLatch();
foreach (var adminAction in new[] { "Succeeded", "Failed" })
{
global::AutopilotMonitor.Agent.V2.Runtime.ServerControlPlane.HandleAdminAction(
adminAction: adminAction,
latch: latch,
stageNameAccessor: () => null,
post: rig.Post,
onAdminTerminal: rig.OnAdminTerminal,
agentConfig: Config(),
logger: rig.Logger);
}
var emitted = rig.EventsOfType("admin_marked_session").ToList();
Assert.Single(emitted);
Assert.True(emitted[0].Payload!.TryGetValue(SignalPayloadKeys.Severity, out var sev));
Assert.Equal(EventSeverity.Info.ToString(), sev);
Assert.Single(rig.OnAdminTerminalCalls);
Assert.Equal(EnrollmentTerminationOutcome.Succeeded, rig.OnAdminTerminalCalls[0].Outcome);
}
[Fact]
public void Latch_admits_exactly_one_of_concurrent_callers()
{
// Responses are raised on whichever thread ran the drain, so two can overlap.
var latch = NewLatch();
var admitted = 0;
Parallel.For(0, 64, _ =>
{
if (latch.TryEnter()) Interlocked.Increment(ref admitted);
});
Assert.Equal(1, admitted);
}
private sealed class ThrowingSink : ISignalIngressSink
@@ -0,0 +1,200 @@
using System;
using System.ComponentModel;
using System.IO;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Runtime;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// The TPM-PSS report after a terminal SecureChannelFailure: it leaves before the run exits,
/// carries the handshake code, the Schannel events and the TPM firmware, and a slow or failing
/// context read never holds it back.
/// </summary>
public sealed class TpmPssReportTests
{
private static AgentLogger NewLogger(TempDirectory tmp)
=> new AgentLogger(Path.Combine(tmp.Path, "logs"), AgentLogLevel.Debug);
private static TpmPssProbeResult Probe(bool pssWorks = false, string provider = "Microsoft Platform Crypto Provider") =>
new TpmPssProbeResult(
pkcs1Sha256Works: true, pssSha256Works: pssWorks, pssSha384Works: pssWorks,
providerName: provider, keySizeBits: 2048,
pssSha256Error: pssWorks ? null : "salt", pssSha384Error: pssWorks ? null : "not supported",
pssSha256ErrorCode: pssWorks ? (int?)null : unchecked((int)0x80290423),
pssSha384ErrorCode: pssWorks ? (int?)null : unchecked((int)0x80090029));
private static Exception TlsFailure() =>
new WebException("Could not create SSL/TLS secure channel.",
new Win32Exception(unchecked((int)0x80090326)), WebExceptionStatus.SecureChannelFailure, null);
private static Func<Task<(string TpmVersion, string SchannelEvents, string TlsProbe)>> Context(
string tpm, string schannel, string tlsProbe = null!) =>
() => Task.FromResult((tpm, schannel, tlsProbe));
[Fact]
public async Task Broken_tpm_report_completes_only_after_the_send()
{
using var tmp = new TempDirectory();
var sendGate = new TaskCompletionSource<bool>();
var reporter = new FakeDistressReporter { Send = () => sendGate.Task };
var report = BackendSessionRegistration.ReportProbeResultAsync(
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "36888:40/1205", "tls12,-,cc403"));
var call = Assert.Single(reporter.Calls);
Assert.False(report.IsCompleted);
Assert.Equal(DistressErrorType.TpmPssUnsupported, call.ErrorType);
Assert.Equal("IFX 7.63.3353.0", call.TpmVersion);
Assert.Equal(
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 tlsp=tls12,-,cc403 sch=36888:40/1205 prov=PCP key=2048",
call.Message);
sendGate.SetResult(true);
await report;
}
[Fact]
public async Task Failing_context_read_still_sends()
{
using var tmp = new TempDirectory();
var reporter = new FakeDistressReporter();
await BackendSessionRegistration.ReportProbeResultAsync(
Probe(), TlsFailure(), reporter, NewLogger(tmp),
() => throw new InvalidOperationException("WMI gone"));
var call = Assert.Single(reporter.Calls);
Assert.Null(call.TpmVersion);
Assert.Contains("sch=unreadable", call.Message);
}
[Fact]
public async Task Working_pss_sends_nothing()
{
using var tmp = new TempDirectory();
var reporter = new FakeDistressReporter();
await BackendSessionRegistration.ReportProbeResultAsync(
Probe(pssWorks: true), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.85", null!));
Assert.Empty(reporter.Calls);
}
[Fact]
public async Task Software_key_with_broken_pss_sends_nothing()
{
using var tmp = new TempDirectory();
var reporter = new FakeDistressReporter();
await BackendSessionRegistration.ReportProbeResultAsync(
Probe(provider: "Microsoft Software Key Storage Provider"), TlsFailure(), reporter, NewLogger(tmp), Context(null!, null!));
Assert.Empty(reporter.Calls);
}
[Fact]
public async Task Context_reads_return_what_finished_within_the_bound()
{
using var tmp = new TempDirectory();
using var slowRelease = new ManualResetEventSlim(false);
using var fastReturned = new ManualResetEventSlim(false);
try
{
// The bound ends only after the fast read returned (plus a grace for its task to
// complete) — independent of how busy the thread pool is.
var bound = Task.Run(async () =>
{
fastReturned.Wait(TimeSpan.FromSeconds(30));
await Task.Delay(500);
});
var (tpm, schannel, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => { fastReturned.Set(); return "IFX 7.63.3353.0"; },
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "late"; },
() => { slowRelease.Wait(TimeSpan.FromSeconds(30)); return "tls12,-,cc403"; },
bound,
NewLogger(tmp));
Assert.Equal("IFX 7.63.3353.0", tpm);
Assert.Equal(BackendSessionRegistration.SchannelReadTimeout, schannel);
Assert.Equal(BackendSessionRegistration.SchannelReadTimeout, tlsProbe);
}
finally
{
slowRelease.Set();
}
}
[Fact]
public async Task Context_read_that_throws_counts_as_missing()
{
using var tmp = new TempDirectory();
var (tpm, schannel, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => throw new InvalidOperationException("no WMI"),
() => "36887:116",
() => "tls13,-,cc403",
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Null(tpm);
Assert.Equal("36887:116", schannel);
Assert.Equal("tls13,-,cc403", tlsProbe);
}
[Fact]
public async Task Schannel_read_that_throws_is_reported_as_unreadable_not_as_empty()
{
using var tmp = new TempDirectory();
var (_, schannel, _) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => "IFX 7.63.3353.0",
() => throw new UnauthorizedAccessException("System log"),
() => "tls12,-,cc403",
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Equal(SchannelEventReader.Unreadable, schannel);
}
[Fact]
public async Task Tls_probe_that_throws_is_reported_as_error_not_left_out()
{
using var tmp = new TempDirectory();
var (_, _, tlsProbe) = await BackendSessionRegistration.ReadDiagnosticContextAsync(
() => "IFX 7.63.3353.0",
() => "none",
() => throw new InvalidOperationException("socket"),
Task.Delay(TimeSpan.FromSeconds(30)),
NewLogger(tmp));
Assert.Equal("error", tlsProbe);
}
[Fact]
public async Task Report_carries_the_context_the_run_started_in()
{
using var tmp = new TempDirectory();
var reporter = new FakeDistressReporter();
var startContext = new DistressDeviceContext("in_progress", "device_setup",
new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc));
await BackendSessionRegistration.ReportProbeResultAsync(
Probe(), TlsFailure(), reporter, NewLogger(tmp), Context("IFX 7.63.3353.0", "none"), startContext);
var call = Assert.Single(reporter.Calls);
Assert.Same(startContext, call.Context);
Assert.Contains("sch=none", call.Message);
}
}
}
@@ -4,14 +4,16 @@ using System.Linq;
using System.Security.AccessControl;
using System.Security.Principal;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// The agent data directory keeps standard users from adding files where the SYSTEM agent
/// starts from. Works on a temp folder the test owns; as owner it can always hand itself the
/// rights back to clean up.
/// The agent folders keep standard users from adding files where the SYSTEM agent starts from.
/// Works on a temp folder the test owns; as owner, or as an elevated administrator, it can
/// always hand itself the rights back to clean up. Tests that assign the Administrators group
/// as owner need an elevated process and skip without one.
/// </summary>
public sealed class AgentDirectoryAclTests : IDisposable
{
@@ -21,6 +23,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
| FileSystemRights.ChangePermissions | FileSystemRights.TakeOwnership;
private static readonly SecurityIdentifier Users = new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null);
private static readonly SecurityIdentifier Administrators = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
private readonly string _root = Path.Combine(Path.GetTempPath(), "apm-acl-" + Guid.NewGuid().ToString("N"));
@@ -51,8 +54,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
.Where(r => r.AccessControlType == AccessControlType.Allow && Users.Equals(r.IdentityReference))
.Aggregate((FileSystemRights)0, (all, r) => all | r.FileSystemRights);
private static SecurityIdentifier OwnerOf(string path) =>
(SecurityIdentifier)new DirectoryInfo(path).GetAccessControl(AccessControlSections.Owner).GetOwner(typeof(SecurityIdentifier));
[Fact]
public void The_protected_security_inherits_nothing_and_gives_users_read_only()
public void The_protected_security_inherits_nothing_gives_users_read_only_and_names_administrators_as_owner()
{
var security = AgentDirectoryAcl.BuildProtectedSecurity();
@@ -62,9 +68,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
// An allow rule always carries Synchronize; .NET adds it on construction.
Assert.Equal(FileSystemRights.ReadAndExecute, users.FileSystemRights & ~FileSystemRights.Synchronize);
Assert.Equal((FileSystemRights)0, users.FileSystemRights & WriteRights);
Assert.Equal(Administrators, security.GetOwner(typeof(SecurityIdentifier)));
}
[Fact]
[ElevatedFact]
public void A_folder_is_protected_once_and_then_left_alone()
{
var first = AgentDirectoryAcl.EnsureProtected(_root);
@@ -72,11 +79,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(AgentDirectoryAclOutcome.Applied, first.Outcome);
Assert.Equal(AgentDirectoryAclOutcome.AlreadyProtected, second.Outcome);
Assert.True(AgentDirectoryAcl.IsProtectedAsExpected(new DirectoryInfo(_root).GetAccessControl(AccessControlSections.Access)));
Assert.True(AgentDirectoryAcl.IsProtectedAsExpected(new DirectoryInfo(_root).GetAccessControl(AccessControlSections.Access | AccessControlSections.Owner)));
Assert.Equal((FileSystemRights)0, UsersRights(_root) & WriteRights);
Assert.Equal(Administrators, OwnerOf(_root));
}
[Fact]
// The owner can always hand itself the rights back, so a folder another account created
// first is theirs until the owner changes, whatever its DACL says.
[ElevatedFact]
public void A_folder_owned_by_another_account_gets_administrators_as_owner()
{
var directory = new DirectoryInfo(_root);
var mine = directory.GetAccessControl(AccessControlSections.Owner);
mine.SetOwner(WindowsIdentity.GetCurrent().User!);
directory.SetAccessControl(mine);
Assert.NotEqual(Administrators, OwnerOf(_root));
var result = AgentDirectoryAcl.EnsureProtected(_root);
Assert.Equal(AgentDirectoryAclOutcome.Applied, result.Outcome);
Assert.Equal(Administrators, OwnerOf(_root));
}
[ElevatedFact]
public void Existing_subfolders_take_over_the_protection()
{
// Install mode protects the base after Agent, Spool and Logs already exist.
@@ -101,6 +126,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(widened));
}
[Fact]
public void A_dacl_under_another_owner_is_not_taken_as_protected()
{
var mine = AgentDirectoryAcl.BuildProtectedSecurity();
mine.SetOwner(WindowsIdentity.GetCurrent().User!);
Assert.False(AgentDirectoryAcl.IsProtectedAsExpected(mine));
}
[Fact]
public void A_junction_is_refused_and_its_target_left_alone()
{
var target = Directory.CreateDirectory(Path.Combine(_root, "target")).FullName;
var link = Path.Combine(_root, "link");
NtfsLinks.CreateJunction(link, target);
try
{
var result = AgentDirectoryAcl.EnsureProtected(link);
Assert.Equal(AgentDirectoryAclOutcome.Failed, result.Outcome);
Assert.Contains("junction", result.Error);
Assert.False(new DirectoryInfo(target).GetAccessControl().AreAccessRulesProtected);
}
finally
{
NtfsLinks.RemoveLink(link);
}
}
[Fact]
public void A_missing_folder_is_reported_not_created()
{
@@ -0,0 +1,74 @@
using System;
using System.Diagnostics.Eventing.Reader;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
public sealed class SchannelEventReaderTests
{
[Fact]
public void Token_is_event_id_with_numeric_properties()
{
Assert.Equal("36888:40/1205", SchannelEventReader.FormatToken(36888, new[] { "40", "1205" }));
}
[Fact]
public void Token_drops_text_properties()
{
// Newer builds add target name and certificate subjects; they stay on the device.
Assert.Equal("36887:116", SchannelEventReader.FormatToken(
36887, new[] { "116", "autopilotmonitor-api.example.test", "CN=device", "" }));
}
[Fact]
public void Token_without_numbers_is_the_event_id()
{
Assert.Equal("36871", SchannelEventReader.FormatToken(36871, new[] { "TLS 1.2" }));
Assert.Equal("36871", SchannelEventReader.FormatToken(36871, null!));
}
[Fact]
public void Token_keeps_at_most_three_numbers()
{
Assert.Equal("36888:1/2/3", SchannelEventReader.FormatToken(36888, new[] { "1", "2", "3", "4" }));
}
[Fact]
public void Token_rejects_non_ascii_digits()
{
Assert.Equal("36888", SchannelEventReader.FormatToken(36888, new[] { "٤٠" }));
}
[Fact]
public void Query_is_accepted_by_the_event_log_service()
{
// A malformed XPath would only surface as "unreadable" through ReadBetween (it never
// throws), so the query itself is run here.
using (var reader = new EventLogReader(new EventLogQuery(
"System", PathType.LogName, SchannelEventReader.BuildXPath(DateTime.UtcNow.AddDays(-1), DateTime.UtcNow)) { ReverseDirection = true }))
{
using (reader.ReadEvent()) { }
}
}
[Fact]
public void No_events_after_now()
{
Assert.Equal(SchannelEventReader.NoEvents,
SchannelEventReader.ReadBetween(DateTime.UtcNow.AddHours(1), DateTime.UtcNow.AddHours(2)));
}
[Fact]
public void Window_ends_where_the_diagnostics_begin()
{
var xpath = SchannelEventReader.BuildXPath(
new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc),
new DateTime(2026, 10, 21, 6, 55, 30, 250, DateTimeKind.Utc));
Assert.Contains(
"TimeCreated[@SystemTime >= '2026-10-21T06:40:00.000Z' and @SystemTime <= '2026-10-21T06:55:30.250Z']",
xpath);
}
}
}
@@ -332,6 +332,74 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Contains("transient attempt 6", result.ErrorMessage);
}
[Fact]
public async Task Terminal_callback_gets_the_last_attempts_exception()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
Exception? seen = null;
var apiClient = new FakeApiClient(attempt =>
throw new InvalidOperationException($"transient attempt {attempt}"));
await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => Task.CompletedTask,
onTerminalTransportFailure: ex => { seen = ex; return Task.CompletedTask; });
Assert.Equal("transient attempt 6", seen?.Message);
}
[Fact]
public async Task Terminal_callback_skipped_when_the_last_attempt_answered()
{
// Five transport failures, then the backend answers without success: the run did not
// end in a transport failure, so nothing may classify the old exception.
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var callbackRan = false;
var apiClient = new FakeApiClient(attempt =>
{
if (attempt < 6) throw new InvalidOperationException($"transient attempt {attempt}");
return new RegisterSessionResponse { Success = false, Message = "backend 500" };
});
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => Task.CompletedTask,
onTerminalTransportFailure: _ => { callbackRan = true; return Task.CompletedTask; });
Assert.Equal(SessionRegistrationOutcome.Failed, result.Outcome);
Assert.Equal("backend 500", result.ErrorMessage);
Assert.False(callbackRan);
}
[Fact]
public async Task Failure_message_carries_the_native_code_of_the_tls_failure()
{
using var tmp = new TempDirectory();
var logger = NewLogger(tmp);
var config = NewConfig();
var apiClient = new FakeApiClient(_ =>
throw new System.Net.Http.HttpRequestException(
"An error occurred while sending the request.",
new System.Net.WebException(
"Could not create SSL/TLS secure channel.",
new System.ComponentModel.Win32Exception(unchecked((int)0x80090326)),
System.Net.WebExceptionStatus.SecureChannelFailure,
null)));
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: logger,
backoffDelay: _ => Task.CompletedTask);
Assert.Equal("An error occurred while sending the request. (win32 0x80090326)", result.ErrorMessage);
}
[Fact]
public async Task Waits_for_network_link_before_first_attempt()
{
@@ -0,0 +1,151 @@
using System.Security.Authentication;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// The TLS probe's request and the reading of its answer: only an echo of the probe's own
/// correlation id counts as the backend, the platform's "Client Certificate Required" page says
/// the certificate did not arrive, and a device value can never split the request.
/// </summary>
public sealed class TlsHandshakeProbeTests
{
private const string SentId = "0123456789abcdef0123456789abcdef";
private static TlsProbeTarget Target(string serial = "SN-1") => new TlsProbeTarget
{
BaseUrl = "https://api.example.test",
TenantId = "00000000-0000-0000-0000-000000000001",
Manufacturer = "Contoso",
Model = "Book 1",
SerialNumber = serial,
AgentVersion = "2.0.1500",
};
private static byte[] Raw(string statusLine, string headers, string body) =>
Encoding.UTF8.GetBytes(statusLine + "\r\n" + headers + "\r\n" + body);
[Fact]
public void Request_asks_for_the_agent_config_with_the_agent_headers()
{
var request = TlsHandshakeProbe.BuildRequest(Target(), "api.example.test", SentId);
Assert.StartsWith(
"GET /api/agent/config?tenantId=00000000-0000-0000-0000-000000000001 HTTP/1.1\r\nHost: api.example.test\r\n",
request);
Assert.Contains("\r\nX-Correlation-ID: " + SentId + "\r\n", request);
Assert.Contains("\r\nX-Device-Manufacturer: Contoso\r\n", request);
Assert.Contains("\r\nX-Device-Model: Book 1\r\n", request);
Assert.Contains("\r\nX-Device-SerialNumber: SN-1\r\n", request);
Assert.Contains("\r\nX-Agent-Version: 2.0.1500\r\n", request);
Assert.EndsWith("\r\nConnection: close\r\n\r\n", request);
}
[Fact]
public void Device_value_with_a_line_break_cannot_add_a_header()
{
var request = TlsHandshakeProbe.BuildRequest(Target("SN\r\nX-Injected: 1"), "api.example.test", SentId);
Assert.DoesNotContain("\r\nX-Injected", request);
Assert.Contains("\r\nX-Device-SerialNumber: SN??X-Injected: 1\r\n", request);
}
[Fact]
public void Echoed_correlation_id_means_the_backend_answered()
{
var raw = Raw("HTTP/1.1 401 Unauthorized", "X-Correlation-ID: " + SentId + "\r\nContent-Type: application/json\r\n", "{}");
Assert.Equal("b401", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Backend_success_is_read_as_backend()
{
var raw = Raw("HTTP/1.1 200 OK", "x-correlation-id: " + SentId + "\r\n", "{\"ok\":true}");
Assert.Equal("b200", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Platform_certificate_required_page_means_the_certificate_did_not_arrive()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "Content-Type: text/html\r\n", InterceptionFixtures.CertificateRequiredPage);
Assert.Equal("cc403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Certificate_required_page_with_an_echo_is_still_the_backend()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "X-Correlation-ID: " + SentId + "\r\n", InterceptionFixtures.CertificateRequiredPage);
Assert.Equal("b403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Echo_of_another_id_is_someone_else()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "X-Correlation-ID: ffffffffffffffffffffffffffffffff\r\n", "<html>Access denied</html>");
Assert.Equal("x403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Fact]
public void Stopped_app_page_is_reported_as_stopped()
{
var raw = Raw("HTTP/1.1 403 Forbidden", "Content-Type: text/html\r\n", InterceptionFixtures.StoppedAppPage);
Assert.Equal("st403", TlsHandshakeProbe.ClassifyAnswer(raw, SentId));
}
[Theory]
[InlineData("")]
[InlineData("garbage")]
[InlineData("HTTP/1.1 abc\r\n\r\n")]
public void No_parsable_answer_is_reported_as_no_response(string text)
{
Assert.Equal("io(noresponse)", TlsHandshakeProbe.ClassifyAnswer(Encoding.ASCII.GetBytes(text), SentId));
}
[Fact]
public void Microsoft_issued_server_certificate_adds_nothing()
{
using var cert = ServerCertificateObserverTests.SelfSigned(
"CN=Microsoft Azure RSA TLS Issuing CA 03, O=Microsoft Corporation, C=US");
Assert.Null(TlsHandshakeProbe.ForeignIssuer(cert));
Assert.Null(TlsHandshakeProbe.ForeignIssuer(null!));
}
[Fact]
public void Inspection_issuer_is_named_in_a_compact_token()
{
using var cert = ServerCertificateObserverTests.SelfSigned("CN=Contoso Inspection CA (2026), O=Contoso Ltd");
Assert.Equal("Contoso_Inspection_CA__2026_", TlsHandshakeProbe.ForeignIssuer(cert));
}
[Fact]
public void Long_issuer_is_capped()
{
using var cert = ServerCertificateObserverTests.SelfSigned("CN=" + new string('A', 80) + ", O=Fabrikam");
Assert.Equal(new string('A', TlsHandshakeProbe.MaxIssuerLength), TlsHandshakeProbe.ForeignIssuer(cert));
}
[Theory]
[InlineData(SslProtocols.Tls12, "tls12")]
[InlineData((SslProtocols)12288, "tls13")]
[InlineData(SslProtocols.Tls11, "tls11")]
[InlineData(SslProtocols.Tls, "tls10")]
[InlineData(SslProtocols.None, "p0")]
public void Protocol_token_names_the_negotiated_version(SslProtocols protocol, string expected)
{
Assert.Equal(expected, TlsHandshakeProbe.ProtocolToken(protocol));
}
}
}
@@ -0,0 +1,94 @@
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// The TPM-PSS distress message: codes instead of localized error texts, so reports from
/// German, Czech and English devices read the same and the 256-char budget holds the
/// handshake code and the Schannel events.
/// </summary>
public sealed class TpmPssProbeResultTests
{
private const int SaltSizeMismatch = unchecked((int)0x80290423);
private const int NotSupported = unchecked((int)0x80090029);
private static TpmPssProbeResult BrokenPcp(string provider = "Microsoft Platform Crypto Provider") =>
new TpmPssProbeResult(
pkcs1Sha256Works: true,
pssSha256Works: false,
pssSha384Works: false,
providerName: provider,
keySizeBits: 2048,
pssSha256Error: "Die angeforderte Salt-Größe für die Signierung mit RSAPSS entspricht nicht dem, was TPM verwendet.",
pssSha384Error: "Der angeforderte Vorgang wird nicht unterstützt.",
pssSha256ErrorCode: SaltSizeMismatch,
pssSha384ErrorCode: NotSupported);
[Fact]
public void Message_carries_codes_handshake_and_schannel_events()
{
var message = BrokenPcp().ToDistressMessage("0x80090326", "36888:40/1205,36887:116", "tls12,-,cc403");
Assert.Equal(
"pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) tls=0x80090326 tlsp=tls12,-,cc403 sch=36888:40/1205,36887:116 prov=PCP key=2048",
message);
}
[Fact]
public void Message_never_carries_the_localized_error_text()
{
var message = BrokenPcp().ToDistressMessage();
Assert.DoesNotContain("Salt", message);
Assert.Equal("pkcs1=ok pss256=fail(0x80290423) pss384=fail(0x80090029) prov=PCP key=2048", message);
}
[Fact]
public void Working_signatures_and_failures_without_a_code()
{
var result = new TpmPssProbeResult(
pkcs1Sha256Works: true, pssSha256Works: true, pssSha384Works: false,
providerName: "Microsoft Software Key Storage Provider", keySizeBits: 0,
pssSha256Error: null, pssSha384Error: "empty signature");
Assert.Equal("pkcs1=ok pss256=ok pss384=fail prov=KSP", result.ToDistressMessage());
}
[Fact]
public void Message_stays_within_the_distress_cap()
{
var longEvents = string.Join(",", System.Linq.Enumerable.Repeat("36888:4294967295/4294967295/4294967295", 10));
var message = BrokenPcp(new string('P', 200)).ToDistressMessage("0x80090326", longEvents);
Assert.Equal(TpmPssProbeResult.MaxDistressMessageLength, message.Length);
}
[Theory]
[InlineData("Microsoft Platform Crypto Provider", "PCP")]
[InlineData("microsoft platform crypto provider", "PCP")]
[InlineData("Microsoft Software Key Storage Provider", "KSP")]
[InlineData("Contoso Smart Card Provider", "Contoso Smart Card Provider")]
[InlineData("", "(unknown)")]
public void Provider_names_are_shortened(string provider, string expected)
{
Assert.Equal(expected, TpmPssProbeResult.ShortProviderName(provider));
}
[Fact]
public void Long_provider_name_is_cut_to_32_chars()
{
Assert.Equal(32, TpmPssProbeResult.ShortProviderName(new string('x', 40)).Length);
}
[Fact]
public void Tpm_backed_broken_key_is_the_reportable_footprint()
{
var result = BrokenPcp();
Assert.True(result.IsTpmPssBroken);
Assert.True(result.IsTpmBacked);
}
}
}
@@ -0,0 +1,20 @@
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo
{
public sealed class TpmInfoLabelTests
{
[Theory]
[InlineData("IFX", "7.63.3353.0", "IFX 7.63.3353.0")]
[InlineData("STM ", "73.4.17568.4452", "STM 73.4.17568.4452")] // ManufacturerIdTxt is space-padded
[InlineData("INTC", null, "INTC")]
[InlineData(null, "403.1.0.0", "403.1.0.0")]
[InlineData(" ", " ", null)]
[InlineData(null, null, null)]
public void Label_is_trimmed_manufacturer_and_firmware(string? name, string? version, string? expected)
{
Assert.Equal(expected, TpmInfo.FormatLabel(name!, version!));
}
}
}
@@ -0,0 +1,124 @@
using System;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
/// <summary>
/// What a distress report carries and that it fits the backend's body cap: the endpoint drops
/// a larger body with 200, so an oversized report would be lost without a trace.
/// </summary>
public sealed class DistressReporterPayloadTests
{
private static DistressReporter NewReporter(string field = "x", string agentVersion = "2.0.1500")
=> new DistressReporter(
baseUrl: "https://example.test",
tenantId: "11111111-2222-3333-4444-555555555555",
manufacturer: field,
model: field,
serialNumber: field,
agentVersion: agentVersion,
logger: null!);
[Fact]
public void Without_a_context_the_report_samples_the_device_when_sent()
{
using (var reporter = NewReporter())
{
var report = reporter.BuildReport(DistressErrorType.AuthCertificateRejected, "m", 401, detail: null);
var osBuild = DeviceInfoProvider.GetOsBuild();
Assert.Equal(string.IsNullOrEmpty(osBuild) ? null : osBuild, report.OsBuild);
Assert.Equal(OobeStateReader.Read(), report.OobeState);
Assert.Equal(EspPhaseSnapshot.Read(), report.EspPhase);
Assert.NotNull(report.DeviceBootUtc);
Assert.True(report.DeviceBootUtc <= DateTime.UtcNow);
Assert.Null(report.TpmVersion);
}
}
[Fact]
public void A_given_context_wins_over_the_send_time_sample()
{
// A failed registration samples the device before its first attempt; the report must
// carry that, not where the device stands minutes later.
var boot = new DateTime(2026, 10, 21, 6, 40, 0, DateTimeKind.Utc);
using (var reporter = NewReporter())
{
var report = reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail
{
Context = new DistressDeviceContext("in_progress", "device_setup", boot),
});
Assert.Equal("in_progress", report.OobeState);
Assert.Equal("device_setup", report.EspPhase);
Assert.Equal(boot, report.DeviceBootUtc);
}
}
[Fact]
public void Tpm_version_passes_through_capped_at_48()
{
using (var reporter = NewReporter())
{
Assert.Equal("IFX 7.63.3353.0",
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail { TpmVersion = "IFX 7.63.3353.0" }).TpmVersion);
Assert.Equal(48,
reporter.BuildReport(DistressErrorType.TpmPssUnsupported, "m", null, new DistressDetail { TpmVersion = new string('v', 60) }).TpmVersion!.Length);
}
}
[Fact]
public void Largest_ascii_report_fits_the_cap_untouched()
{
// Every field at the agent's own caps; the agent version as built (not cut to 32).
using (var reporter = NewReporter(new string('A', 64), "2.0.1486+d95640c597cb90849803dd65afda19e2d7a39444"))
{
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('m', 256), 403,
new DistressDetail { TpmVersion = new string('t', 48) });
report.CertThumbprint = new string('F', 40);
report.CertSubject = new string('S', 96);
report.CertIssuer = new string('I', 96);
report.CertNotBefore = DateTime.UtcNow;
report.CertNotAfter = DateTime.UtcNow;
report.OsBuild = "26300.97111";
report.OobeState = "unknown_2147483647".Substring(0, 16);
report.EspPhase = "device_preparation_failed";
report.DeviceBootUtc = DateTime.UtcNow;
var json = DistressReporter.SerializeWithinCap(report);
Assert.Equal(256, report.Message!.Length);
Assert.True(Encoding.UTF8.GetByteCount(json) <= Constants.DistressReportMaxBytes,
$"{Encoding.UTF8.GetByteCount(json)} bytes");
}
}
[Fact]
public void Oversized_report_gives_up_message_characters_to_fit()
{
// Three-byte characters everywhere — far past the cap before trimming.
using (var reporter = NewReporter(new string('€', 64)))
{
var report = reporter.BuildReport(DistressErrorType.ClientCertificateNotReceived, new string('€', 256), 403, null);
report.CertSubject = new string('€', 96);
report.CertIssuer = new string('€', 96);
Assert.True(Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(report)) > Constants.DistressReportMaxBytes);
var json = DistressReporter.SerializeWithinCap(report);
Assert.True(Encoding.UTF8.GetByteCount(json) <= Constants.DistressReportMaxBytes,
$"{Encoding.UTF8.GetByteCount(json)} bytes");
Assert.True(report.Message!.Length < 256);
Assert.Equal(report.Message, JsonConvert.DeserializeObject<DistressReport>(json)!.Message);
}
}
}
}
@@ -0,0 +1,73 @@
using System;
using System.ComponentModel;
using System.Net;
using System.Net.Http;
using System.Security.Authentication;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
public sealed class TransportErrorCodesTests
{
private const int IllegalMessage = unchecked((int)0x80090326);
// The .NET Framework shape of a failed TLS handshake through HttpClient.
private static Exception TlsFailureChain(params Exception[] innermost) =>
new HttpRequestException(
"An error occurred while sending the request.",
new WebException(
"The request was aborted: Could not create SSL/TLS secure channel.",
new AuthenticationException("A call to SSPI failed, see inner exception.", innermost.Length > 0 ? innermost[0] : null),
WebExceptionStatus.SecureChannelFailure,
null));
[Fact]
public void Hex_is_eight_digits_with_prefix()
{
Assert.Equal("0x80090326", TransportErrorCodes.Hex(IllegalMessage));
Assert.Equal("0x00002746", TransportErrorCodes.Hex(10054));
}
[Fact]
public void Innermost_win32_code_of_a_tls_failure()
{
var chain = TlsFailureChain(new Win32Exception(IllegalMessage));
Assert.Equal("0x80090326", TransportErrorCodes.InnermostNativeCode(chain));
}
[Fact]
public void Innermost_wins_over_an_outer_win32_exception()
{
// The outer code is whatever the last Win32 error was; only the innermost counts.
var chain = new Win32Exception("outer", new Win32Exception(IllegalMessage));
Assert.Equal("0x80090326", TransportErrorCodes.InnermostNativeCode(chain));
}
[Fact]
public void No_win32_exception_no_code()
{
Assert.Null(TransportErrorCodes.InnermostNativeCode(TlsFailureChain()));
Assert.Null(TransportErrorCodes.InnermostNativeCode(null!));
}
[Fact]
public void Message_gets_the_code_appended()
{
var chain = TlsFailureChain(new Win32Exception(IllegalMessage));
Assert.Equal(
"An error occurred while sending the request. (win32 0x80090326)",
TransportErrorCodes.MessageWithNativeCode(chain));
}
[Fact]
public void Message_without_a_code_stays_as_is()
{
Assert.Equal("boom", TransportErrorCodes.MessageWithNativeCode(new InvalidOperationException("boom")));
Assert.Null(TransportErrorCodes.MessageWithNativeCode(null!));
}
}
}
@@ -0,0 +1,105 @@
#nullable enable
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Persistence;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// The V1 ConfigMgr client check, run on every poll: emits <c>configmgr_client_detected</c>
/// with the V1 payload when the client is first seen, and again when what it reports changes
/// (the service starts, a site code is assigned) — the client often installs after the agent
/// started. A <see cref="StartupEventGate"/> fingerprint keeps a restart into the same state
/// silent. Observational only: emission goes through <see cref="InformationalEventPost"/>.
/// </summary>
internal sealed class ConfigMgrClientDetector
{
internal const string SourceLabel = "ConfigMgrClientDetector";
/// <summary>Emissions per agent run; a flapping service must not flood the timeline.</summary>
internal const int MaxEmissionsPerRun = 10;
private readonly string _sessionId;
private readonly string _tenantId;
private readonly InformationalEventPost _post;
private readonly AgentLogger _logger;
private readonly IClock _clock;
private readonly StartupEventGate? _startupGate;
private string? _lastFingerprint;
private int _emissions;
private bool _checkedOnce;
public ConfigMgrClientDetector(
string sessionId,
string tenantId,
InformationalEventPost post,
AgentLogger logger,
IClock clock,
StartupEventGate? startupGate = null)
{
_sessionId = sessionId;
_tenantId = tenantId;
_post = post ?? throw new ArgumentNullException(nameof(post));
_logger = logger;
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_startupGate = startupGate;
}
/// <summary>Host timer callback and test seam — one probe round.</summary>
internal void Check()
{
var trigger = _checkedOnce ? "poll" : "startup";
_checkedOnce = true;
var snapshot = ConfigMgrClientProbe.Read(_logger);
if (!snapshot.Detected || snapshot.Fingerprint == _lastFingerprint || _emissions >= MaxEmissionsPerRun)
return;
_lastFingerprint = snapshot.Fingerprint;
if (_startupGate != null && !_startupGate.ShouldEmit(
Constants.EventTypes.ConfigMgrClientDetected, StartupEventGate.HashString(snapshot.Fingerprint)))
{
_logger?.Debug("ConfigMgrClientDetector: state already reported by a previous agent run — suppressed.");
return;
}
_emissions++;
var score = snapshot.ConfidenceScore;
var versionSuffix = snapshot.Version != null ? $", v{snapshot.Version}" : "";
var siteSuffix = snapshot.SiteCode != null ? $", site {snapshot.SiteCode}" : "";
_post.Emit(new EnrollmentEvent
{
SessionId = _sessionId,
TenantId = _tenantId,
Timestamp = _clock.UtcNow,
EventType = Constants.EventTypes.ConfigMgrClientDetected,
Severity = EventSeverity.Info,
Source = SourceLabel,
Phase = EnrollmentPhase.Unknown,
Message = $"ConfigMgr client detected (confidence {score}/100): CcmExec {snapshot.ServiceState}{versionSuffix}{siteSuffix}",
Data = new Dictionary<string, object>
{
["ccmRegistryFound"] = snapshot.RegistryFound,
["ccmDirectoryFound"] = snapshot.DirectoryFound,
["ccmServiceState"] = snapshot.ServiceState,
["ccmVersion"] = snapshot.Version ?? "unknown",
["siteCode"] = snapshot.SiteCode ?? "unknown",
["confidenceScore"] = score,
["detectedAt"] = trigger,
}
});
_startupGate?.MarkEmitted(Constants.EventTypes.ConfigMgrClientDetected);
_logger?.Info(
$"ConfigMgrClientDetector: client detected (trigger={trigger}, confidence={score}, " +
$"service={snapshot.ServiceState}, version={snapshot.Version ?? "?"}, site={snapshot.SiteCode ?? "?"}).");
}
}
}
@@ -0,0 +1,146 @@
#nullable enable
using System;
using System.IO;
using System.ServiceProcess;
using AutopilotMonitor.Agent.V2.Core.Logging;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Reads the Configuration Manager client's footprint the way the V1 agent did: the
/// <c>CCM</c> key, the version from <c>CCMSetup</c>, the assigned site code, the
/// <c>%windir%\CCM</c> folder and the <c>CcmExec</c> service. Fail-soft: any failure yields
/// <see cref="ConfigMgrClientSnapshot.Empty"/>. The service is queried only when its key
/// exists, so devices without the client never throw on every poll.
/// </summary>
internal static class ConfigMgrClientProbe
{
internal const string CcmKeyPath = @"SOFTWARE\Microsoft\CCM";
internal const string CcmSetupKeyPath = @"SOFTWARE\Microsoft\CCMSetup";
internal const string MobileClientKeyPath = @"SOFTWARE\Microsoft\SMS\Mobile Client";
internal const string CcmExecServiceKeyPath = @"SYSTEM\CurrentControlSet\Services\CcmExec";
internal const string CcmExecServiceName = "CcmExec";
/// <summary>Service state when <c>CcmExec</c> is not installed (the V1 payload value).</summary>
internal const string ServiceNotFound = "not_found";
/// <summary>Test seam; use <see cref="ScopedOverride"/> to guarantee cleanup.</summary>
internal static Func<ConfigMgrClientSnapshot>? TestOverride;
public static ConfigMgrClientSnapshot Read(AgentLogger? logger = null)
{
var probe = TestOverride;
if (probe != null) return probe();
try
{
// The agent is AnyCPU; the client writes the 64-bit view.
using var hklm = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64);
bool registryFound;
using (var ccm = hklm.OpenSubKey(CcmKeyPath))
registryFound = ccm != null;
string? version;
using (var setup = hklm.OpenSubKey(CcmSetupKeyPath))
version = NonEmpty(setup?.GetValue("LastValidVersion")?.ToString());
string? siteCode;
using (var mobileClient = hklm.OpenSubKey(MobileClientKeyPath))
siteCode = NonEmpty(mobileClient?.GetValue("AssignedSiteCode")?.ToString());
bool serviceInstalled;
using (var service = hklm.OpenSubKey(CcmExecServiceKeyPath))
serviceInstalled = service != null;
var directoryFound = Directory.Exists(
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "CCM"));
return new ConfigMgrClientSnapshot(
registryFound, directoryFound, serviceInstalled ? ReadServiceState() : ServiceNotFound, version, siteCode);
}
catch (Exception ex)
{
logger?.Debug($"ConfigMgrClientProbe: read failed: {ex.Message}");
return ConfigMgrClientSnapshot.Empty;
}
}
private static string ReadServiceState()
{
try
{
using (var controller = new ServiceController(CcmExecServiceName))
return controller.Status.ToString();
}
catch (InvalidOperationException)
{
return ServiceNotFound; // removed between the key read and the query
}
}
private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value!.Trim();
/// <summary>Sets <see cref="TestOverride"/> for the scope and restores the previous value on Dispose.</summary>
internal sealed class ScopedOverride : IDisposable
{
private readonly Func<ConfigMgrClientSnapshot>? _previous;
private int _disposed;
public ScopedOverride(Func<ConfigMgrClientSnapshot> probe)
{
_previous = TestOverride;
TestOverride = probe;
}
public void Dispose()
{
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
TestOverride = _previous;
}
}
}
/// <summary>One read of the client's footprint, with the V1 confidence score.</summary>
internal sealed class ConfigMgrClientSnapshot
{
public static readonly ConfigMgrClientSnapshot Empty =
new ConfigMgrClientSnapshot(false, false, ConfigMgrClientProbe.ServiceNotFound, null, null);
public ConfigMgrClientSnapshot(bool registryFound, bool directoryFound, string serviceState, string? version, string? siteCode)
{
RegistryFound = registryFound;
DirectoryFound = directoryFound;
ServiceState = serviceState;
Version = version;
SiteCode = siteCode;
}
public bool RegistryFound { get; }
public bool DirectoryFound { get; }
/// <summary><see cref="System.ServiceProcess.ServiceControllerStatus"/> name, or <see cref="ConfigMgrClientProbe.ServiceNotFound"/>.</summary>
public string ServiceState { get; }
public string? Version { get; }
public string? SiteCode { get; }
/// <summary>
/// The V1 weights: the folder alone is too weak (it can be a remnant); the portal shows the
/// detection from 50 on.
/// </summary>
public int ConfidenceScore =>
(DirectoryFound ? 10 : 0)
+ (RegistryFound ? 20 : 0)
+ (Version != null ? 15 : 0)
+ (SiteCode != null ? 15 : 0)
+ (ServiceState != ConfigMgrClientProbe.ServiceNotFound ? 20 : 0)
+ (ServiceState == nameof(ServiceControllerStatus.Running) ? 20 : 0);
public bool Detected => ConfidenceScore > 0;
/// <summary>Everything the event reports; a change re-emits.</summary>
public string Fingerprint => $"{RegistryFound}|{DirectoryFound}|{ServiceState}|{Version}|{SiteCode}";
}
}
@@ -0,0 +1,121 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// One-shot read of the Autopilot ESP phase from the values <see cref="ProvisioningStatusTracker"/>
/// watches (<c>HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings</c>), for reports taken
/// without the tracker — a failed registration has no session and no tracker yet.
/// <para>
/// <b>Contract:</b> observational only, never decision-engine input. Unlike the tracker it has
/// no settle or fallback timers: a category whose <c>categorySucceeded</c> Windows never sets
/// counts as done once all its subcategories read succeeded or notRequired.
/// </para>
/// </summary>
internal static class EspPhaseSnapshot
{
internal const string None = "none";
internal const string Unavailable = "unavailable";
internal const string DevicePreparation = "device_preparation";
private const string FailedSuffix = "_failed";
internal enum CategoryState { Absent, Running, Done, Failed }
// ESP order: device preparation, device setup, account setup.
private static readonly (string ValueName, string Phase)[] Categories =
{
("DevicePreparationCategory.Status", DevicePreparation),
("DeviceSetupCategory.Status", "device_setup"),
("AccountSetupCategory.Status", "account_setup"),
};
/// <summary>
/// <c>device_preparation</c> / <c>device_setup</c> / <c>account_setup</c> while that
/// category runs, <c>&lt;category&gt;_failed</c>, <c>device_preparation_done</c> /
/// <c>device_setup_done</c> when the next category has not appeared (yet, or skipped),
/// <c>esp_done</c>, <c>none</c> without any ESP value, <c>unavailable</c> when the
/// registry cannot be read. Never throws.
/// </summary>
public static string Read()
{
try
{
using (var key = Registry.LocalMachine.OpenSubKey(ProvisioningStatusTracker.ProvisioningStatusRegistryPath, writable: false))
{
if (key == null) return None;
return Derive(Categories.Select(c => key.GetValue(c.ValueName) as string).ToList());
}
}
catch
{
return Unavailable;
}
}
/// <param name="statusJson">The three category values in ESP order; null where absent.</param>
internal static string Derive(IReadOnlyList<string> statusJson)
{
var states = Categories.Select((_, i) => Classify(i < statusJson.Count ? statusJson[i] : null)).ToList();
for (var i = 0; i < Categories.Length; i++)
if (states[i] == CategoryState.Failed) return Categories[i].Phase + FailedSuffix;
for (var i = 0; i < Categories.Length; i++)
if (states[i] == CategoryState.Running) return Categories[i].Phase;
var lastDone = states.FindLastIndex(s => s == CategoryState.Done);
if (lastDone < 0) return None;
return lastDone == Categories.Length - 1 ? "esp_done" : Categories[lastDone].Phase + "_done";
}
/// <summary>
/// Whether device preparation can still hold the ESP in a <see cref="Read"/> result: it
/// runs or failed, or the phase is unknown (<see cref="None"/>, <see cref="Unavailable"/>).
/// False once device preparation is done, whichever later phase the ESP is in.
/// </summary>
internal static bool DevicePreparationOpen(string phase) =>
string.IsNullOrEmpty(phase)
|| phase == None
|| phase == Unavailable
|| phase == DevicePreparation
|| phase == DevicePreparation + FailedSuffix;
/// <summary>
/// One category value: done (<c>categorySucceeded=true</c>, or every subcategory succeeded
/// or notRequired), failed (<c>false</c>, or a failed subcategory), running (anything
/// else that is present, also unparseable JSON), absent (no value).
/// </summary>
internal static CategoryState Classify(string json)
{
if (string.IsNullOrWhiteSpace(json)) return CategoryState.Absent;
try
{
using (var doc = JsonDocument.Parse(json))
{
var root = doc.RootElement;
if (root.ValueKind != JsonValueKind.Object) return CategoryState.Running;
var succeeded = ProvisioningStatusTracker.SafeGetBool(root, "categorySucceeded");
if (succeeded == true) return CategoryState.Done;
if (succeeded == false) return CategoryState.Failed;
var subcategories = ProvisioningStatusTracker.ParseSubcategories(root);
if (subcategories.Any(s => string.Equals(s.State, "failed", StringComparison.OrdinalIgnoreCase)))
return CategoryState.Failed;
if (subcategories.Count > 0 && subcategories.All(s =>
string.Equals(s.State, "succeeded", StringComparison.OrdinalIgnoreCase)
|| string.Equals(s.State, "notRequired", StringComparison.OrdinalIgnoreCase)))
return CategoryState.Done;
return CategoryState.Running;
}
}
catch (JsonException)
{
return CategoryState.Running;
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More