Compare commits

...

141 Commits

Author SHA1 Message Date
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
Oliver Kieselbach d95640c597 Agent: protected DACL on the agent data directory (D-355)
%ProgramData%\AutopilotMonitor inherited ProgramData's "Users may create files and
folders" entry, so a standard user could add files to the folder the SYSTEM agent and its
boot task start from. The agent outlives the device phase of OOBE (user phase, desktop,
reboots until the enrollment ends), and the payload catalog is checked at install time
only.

- AgentDirectoryAcl.EnsureProtected: protected DACL, SYSTEM and Administrators full
  control, Users read and execute, inherited by every subfolder; idempotent, never blocks
  the agent.
- Install mode applies it after the agent directories exist; every runtime start
  re-checks it, which also reaches agents that arrived by self-update.
- Tests: protected shape, apply once then leave alone, existing subfolders take it over,
  a widened or unprotected DACL is not taken as protected, a missing folder is not created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:26:44 +02:00
Oliver Kieselbach b5e9f45a46 bump pointer: internal (payload catalog lab VM result)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:57:58 +02:00
Oliver Kieselbach b4dabcce94 bump pointer: internal (payload catalog dev rehearsal)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:32:25 +02:00
Oliver Kieselbach 6bbdcc0313 bump pointer: internal (agent payload catalog plan status)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:09:51 +02:00
Oliver Kieselbach 283c094986 Agent payload: signed catalog, vendor check, dev channel in CI (D-354, phase A)
- build-agent.yml: input channel (release | dev; dev writes the lab blobs only and
  refuses stable/release). Every third-party PE needs its vendor's embedded Authenticode
  signature (signtool verify /pa, read-only). AutopilotMonitor.Agent.cat, a signed
  SHA-256 catalog over every payload file, is created after Authenticode and before the
  ZIP; the device check runs under Windows PowerShell 5.1 on the final ZIP.
- Agent: SelfUpdater step 5b verifies the staged payload against the catalog before any
  file is swapped (PayloadCatalogVerifier, Test-FileCatalog in-process; a rejection means
  no update, skip reason catalog_rejected). agent_started reports which bootstrap
  installed the agent, read from the bootstrap logs (BootstrapProvenanceReader).
- Payload: System.Management.Automation.dll (never loaded, the GAC copy binds first) and
  AutopilotMonitor.Shared.xml no longer ship; PDBs stay.
- Shared: CodeSigningPublisherPattern, AgentPayloadCatalogFileName;
  CodeSigningParityTests pins them against installer, loader and workflow.
- Submodules: infra (build.ps1 local only), internal (D-354, plan, docs), commands.

Installer v2.6 and the OOBE script follow once a catalog ZIP is on stable: a merged
installer change is live on the next loader run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 17:48:29 +02:00
Oliver Kieselbach 47728fbcd4 bump pointer: internal (external identities released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:44:18 +02:00
Oliver Kieselbach d5f885c4b4 bump pointer: internal (external identities never claim a tenant, D-353)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:21:38 +02:00
Oliver Kieselbach dd4975b73e Auth: an external identity never claims a tenant (D-353)
A caller whose token shows it is not a member of the token's tenant (acct = 1, a #EXT# user name,
or an idp naming another tenant, a personal Microsoft account or an external sign-in provider) no
longer seeds DomainName/OnboardedBy or queues the activation, is never the first-user Tenant Admin,
and cannot set the activation-page address in the roleless signup window. Positive evidence only:
a token without these signals stays a member's, and tid/idp are read under their inbound-mapped
names as well. Activation promotes OnboardedBy only; the UpdatedBy fallback is gone and a stored
#EXT# UPN is refused. Explicit grants (TenantAdmins row, app role) still apply.

The once-per-interval log gate of the client-app binding becomes Helpers/LogThrottle and also
throttles the new warning, which the polling activation page would otherwise repeat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 01:19:56 +02:00
Oliver Kieselbach 18310b05aa bump pointer: internal (MCP input bounds released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:29:24 +02:00
Oliver Kieselbach 40894ed5e7 bump pointer: internal (MCP input bounds, D-352)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:10:02 +02:00
Oliver Kieselbach 6894e71668 MCP: bound caller-sized search input; scoring cost no longer grows with the keyword count
search_events, search_knowledge and search_docs take a query of at most 1,000
characters; search_events takes at most 20 keywords of 100 characters and
scores at most 32 distinct keywords, caller keywords first, reporting the rest
as keywordsDropped with a note. The error-code fallback takes at most 10 codes,
search_docs.section at most 100 characters.

The event scorer compiles the query once (compileConcepts): each event field is
split into words once and its words are looked up by the key of their first
three characters, so scoring an event costs the same whatever the number of
keywords. The matching rule is unchanged; a seeded differential test pins it
against the per-keyword rule. scoreEvents yields to the event loop every 256
events and stops once the caller has gone.

Every paginated tool's continuation goes through one ContinuationSchema
(at most 4,096 characters), and buildQuery appends instead of set: both
URLSearchParams.set paths were quadratic in caller-sized input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:07:28 +02:00
Oliver Kieselbach a28fdc874f bump pointer: internal (watch at the seal + app badge released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:45:24 +02:00
Oliver Kieselbach 7480a2e218 Push receiver: an app badge counts unread alerts; an open history reads them
Every stored history entry starts unread. After a new entry the worker sets the
app badge to the unread count through applyBadge, fire and forget: inside
Safari's declarative push event the setAppBadge promise never settles, so it is
never awaited and never handed to waitUntil (pushSw.test.ts pins it). The
history page marks everything read on every visible load, clears the badge,
closes the notifications whose entries the history holds and shows "New" on
what arrived since the last visit. Unpair clears the badge; the wipe push
leaves it to the next open. The payload repeats mutable inside notification,
where Safari 18.4-26.1 reads it, and carries no app_badge.

Bumps internal (plan, D-350, D-351, concept doc).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:22:56 +02:00
Oliver Kieselbach 96fc88462d Push: the session watch fires at the WhiteGlove seal
"Notify me when done" now also fires on the first genuine pre-provisioning
seal ("Pre-provisioning finished") on the gate of the Pre-Provisioning
Completed alert, and the watch is consumed there; watching the user phase takes
a new click. A watch placed on a sealed session lives 30 days instead of 48
hours. EventIngestProcessor.WatchOutcomeFor is the one gate, the watch text
comes from PushSessionWatchService.WatchMessage. The portal button re-reads the
watch on every status change and names what it waits for. The Success hint
says it covers a finished pre-provisioning; the receiver labels the
preprovisioning_* alerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 22:22:44 +02:00
Oliver Kieselbach 6b86549fd0 bump pointers: internal, .claude/commands (client-cert interception released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 18:30:15 +02:00
Oliver Kieselbach e8e350bf14 bump pointer: internal (B-k87 done)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:55:27 +02:00
Oliver Kieselbach 4e519fa4b7 Backend: the correlation-id gate anchors with \z
.NET's $ also matches before a final newline, so "7c9e6679\n" passed the allow-list that guards
the inbound X-Correlation-ID against CRLF and log forgery and was echoed. Such a value does not
arrive over HTTP, but the gate now holds what its comment promises; a new test case pins it
(red with $).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:55:26 +02:00
Oliver Kieselbach 52f6a177f9 bump pointers: internal, .claude/commands (client-cert interception, D-349)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:45:02 +02:00
Oliver Kieselbach caafaeafd4 Tests: the push recording handler survives the parallel fan-out
PushDeliveryService sends with Parallel.ForEachAsync, and RecordingHandler appended to plain
lists without a lock: two concurrent sends could lose a request (Delivered 2, recorded 1), so
Tenant_alert_also_reaches_platform_devices_whose_owners_home_tenant_it_is failed now and then
(once in six full runs while it was traced). A request and its body are now recorded together
under the lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:43:52 +02:00
Oliver Kieselbach bfb6c12384 Agent: an intercepted connection is no longer an auth failure
A security client that inspects TLS strips the device certificate on the way to the backend;
the platform then answers 403 "Client Certificate Required" itself, or a proxy answers with
its own page. The agent counted these like backend rejections: five in a row shut it down,
the scheduled task restarted it at every boot, and only the 48-hour emergency break removed it.

- Origin of every 401/403: only a response that echoes the request's X-Correlation-ID is the
  backend's verdict and counts toward the auth shutdown; the stopped-app page still counts;
  anything else is foreign (client_certificate_required or foreign_response) and never counts.
- Telemetry: a foreign answer keeps the batch and pauses uploads with a backoff from 30 s up to
  15 min, without a timer; collectors and the decision engine keep running, the terminal phase
  gets one upload attempt, and the agent still leaves the device when the enrollment ends.
- Distress type ClientCertificateNotReceived (9), once per process, with operation, status,
  page title and the issuer of the server certificate; both handlers observe it through a
  validation callback that returns exactly sslPolicyErrors == None.
- Registration: an intercepted start retries on at most InterceptedRegistrationRetryBoots
  further boots (config channel, default 1, agent clamp 0..5, ConfigVersion 41), then ends
  monitoring like the emergency break. The break, this give-up and the config kill signal now
  share Program.EndMonitoringAtStart.
- Evidence: telemetry_upload_blocked carries reason, operation, statusCode, pageTitle,
  serverCertIssuer and firstBlockedAtUtc; agent_started carries priorRegistration* after failed
  starts (a local network fingerprint; only whether it changed leaves the device);
  agent_metrics_snapshot counts 2xx responses without the echo.
- Backend: distress type 9, the buffer default, InterceptedRuns on the registration-failure
  summary; tests pin the correlation echo on 401/403 and the middleware's place in the pipeline.
- Web: distress labels for TPM Incompatible and Cert Not Received; config report row.
- MCP: session coverage names intercepted uploads and the failed registrations before the start.

D-349

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:42:59 +02:00
Oliver Kieselbach cd4199b1d1 bump pointer: internal (about page in the evidence-loop plan)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:53:02 +02:00
Oliver Kieselbach d203ceee32 About: the evidence loop and AI investigation
- What is Autopilot Monitor?: the evidence of every enrollment is recorded while it runs;
  analyze rules flag the patterns they know, and for the rest the AI assistant over the
  built-in MCP server investigates the session (clients that work with files down to the
  raw IME logs of an uploaded diagnostics package) and drafts the rule for the next one
- Key Features: "AI Investigation & Rule Authoring", full width as the seventh card
- Technology & Platform → Integrations: the MCP server (read-only)

The claims carry the same qualifiers as the landing and the docs (D-348).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:52:03 +02:00
Oliver Kieselbach 59c012b42e chore 2026-10-12 00:49:40 +02:00
Oliver Kieselbach 86a122f31f bump pointer: internal (evidence loop released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:46:13 +02:00
Oliver Kieselbach 14af0a05cd bump pointers: internal, .claude/commands (evidence loop, D-348)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:37:03 +02:00
Oliver Kieselbach ee41ea80cf Landing, /ai and portal: the evidence loop
- Landing: section evidence_loop right after the stats ("Never debug the same failure
  twice." — capture, detect, investigate, learn), hero sub-line, Compare row "Unknown
  failures", capabilities tile. The terminal demo continues with the rule step: validate_rule
  and both test_analyze_rule verdicts are the real results of a dry-run against the demo's
  two sessions, anonymized like the rest; the window scrolls with the newest line.
- /ai: "Facts first. Raw logs when it matters." with the way back to the raw line; new
  section rules ("From finding to rule") with the anonymized trace of the same dry-run;
  the step card is shared with Connect.
- Portal: a failed session without any rule finding offers the AI investigation in Analysis
  and Guided Diagnosis (the debug-session prompt for clients with MCP prompts, a copyable
  full prompt for every other client). A failed session without a diagnostics package on a
  tenant without a usable upload links to the diagnostics settings. The analyze-rule editor
  tests a draft against a session on the existing dry-run route; create, save and test now
  share one rule body.
- llms.txt (evidence loop, two docs links without a redirect), about FAQ, marketing guard
  sections, three tracking events; jscpd baseline -1 for the removed payload clone.

D-348

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:37:03 +02:00
Oliver Kieselbach 20ae3d8c09 MCP: list the rule dry-run tools only for roles that can run them
GET /api/auth/mcp reports the caller's client-capped member role in the token's tenant
(tenantRole; reported, never a grant). The MCP server registers test_analyze_rule and
test_log_pattern only for a platform role or a Tenant Admin: their routes are
TenantAdminOrGlobalReader, so Operators and Viewers only ever got a 403. validate_rule,
author-rule and the authoring guide hand a draft to a Tenant Admin otherwise, and
investigate-failed-session and debug-session end by drafting a rule for a cause that can
recur. Catalog ratchet lowered for tenant users (51,000) and delegated callers (55,500).

D-348

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:37:03 +02:00
Oliver Kieselbach d0f62a6aaf web(geo): sign-in before the loading card on Geographic Performance
Both geo pages returned their CalculatingCard before rendering their own
ProtectedRoute. MSAL keeps the account in sessionStorage, so a new tab,
bookmark or typed URL starts signed out: the tenant scope never settled,
the fetch never started, and ProtectedRoute never mounted to fire the
login redirect. The page spun on "Calculating geographic metrics… 0s
elapsed" forever with no API call.

ProtectedRoute now wraps each page's default export, so the content only
mounts once signed in. Verified in a signed-out headless Edge: production
stays on the card, the fix redirects to login.microsoftonline.com.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 00:28:08 +02:00
Oliver Kieselbach 683142dd27 bump pointer: internal (docs meta descriptions live)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:24:56 +02:00
Oliver Kieselbach e3f9ad5704 bump pointer: internal (CI endpoint registry released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:56:41 +02:00
Oliver Kieselbach 0f98d9ca17 CodeQL: ignore action-pins.yml like ci.yml does
The workflow gates file was missing from both paths-ignore lists, so every gate
change re-ran the full CodeQL analysis for nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:56:40 +02:00
Oliver Kieselbach bfe345ca93 bump pointer: internal (CI endpoint registry: redirect URIs)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:39:50 +02:00
Oliver Kieselbach 587ed74d96 deploy-web: Entra redirect URIs from the endpoint registry
Both were repository variables holding the www origin; lib/msalConfig.ts uses them
only as the static-render fallback and window.location.origin in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:39:49 +02:00
Oliver Kieselbach d5222fcb93 bump pointer: infra, internal (CI endpoint registry: D-347)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:32:38 +02:00
Oliver Kieselbach 8d24c5680b CI: one endpoint registry for every host the workflows use
.github/scripts/Get-Endpoints.ps1 defines the storage account (blob and table
derived from its name), the containers, the AdminConfiguration row, the legacy
mirror, the download alias, API, MCP, portal, www, docs and the timestamp
server. Workflows load it once per job; the script publisher and the local
release build call it; the dev-render anchors are built from the alias entry.

deploy-web takes NEXT_PUBLIC_API_BASE_URL and NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL
from the registry instead of repository variables and refuses an empty value.
A new Workflow gates job fails on any host spelled out in a workflow or CI
helper; the enforced list is derived from the registry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:32:36 +02:00
Oliver Kieselbach 818f5f1091 bump pointer: internal (CI credential zones verified)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:56:56 +02:00
Oliver Kieselbach 485bda3658 bump pointer: internal (CI credential zones: 2b on 2026-10-19, B-2s7 done)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:44:56 +02:00
Oliver Kieselbach 0b82faa7bd bump pointer: internal (CI credential zones: D-346, deploy identity on environments)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:21:58 +02:00
Oliver Kieselbach c26542776e CI: credential-free build jobs, trust jobs that never build
Every job that runs third-party code (restore, build, tests, npm ci, next build,
wix) now holds only a read-only token and hands its output on as one archive.
Signing, publishing and deploying happen in separate jobs on a fresh runner that
run only this repository's scripts and pinned actions, unpack artifacts outside
the checkout and take no build-job output into a script.

- build-agent: reserve -> build -> release
- build-msi: sign-loader -> build -> release
- deploy-backend: reserve -> build -> deploy (publish output as one tar.gz)
- deploy-web: build -> deploy (SWA token only, no id-token) -> record
- combine-rules: validate (read-only, PRs too) -> publish (push to main only)
- deploy-mcp: same shape; documents docker build as its boundary

Deploy-only jobs run in the production environment. The jobs that sign stay
without an environment for now: a job carries one OIDC subject, and the signing
identity still trusts the ref. oidc-login-test.yml is removed; combine-rules no
longer triggers on the dev branch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:21:04 +02:00
Oliver Kieselbach 80cd6c52f4 bump pointer: internal (B-vky)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:46:09 +02:00
Oliver Kieselbach e40471e61a Agent release: run all agent test suites before the release build
build-agent.yml tested only DecisionCore (648 of 4,154 agent tests). It now runs
the DecisionCore, Agent.V2.Core and SummaryDialog suites before building the
release binaries, with the reserved build number.

The order matters: Agent.V2.Core.Tests references the agent exe, so its build
compiles the agent. Run after the release build or without BUILD_NUMBER, it
rewrites the exe with another version (a fresh checkout stamped 2.0.1 in a local
simulation). Run first with the number, it stamps the release version and the
Build Agent step stays an up-to-date no-op (same FileVersion, same write time).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:39:01 +02:00
Oliver Kieselbach 7908d54999 bump pointer: internal (dead-code and duplicate gates released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:17:04 +02:00
dependabot[bot] 51aaa2e80a chore(deps): bump the mcp group (#183)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@modelcontextprotocol/node](https://github.com/modelcontextprotocol/typescript-sdk) | `2.1.0` | `2.1.1` |
| [@modelcontextprotocol/server](https://github.com/modelcontextprotocol/typescript-sdk) | `2.2.0` | `2.3.0` |
| [@modelcontextprotocol/client](https://github.com/modelcontextprotocol/typescript-sdk) | `2.2.0` | `2.3.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.20.4` | `22.20.5` |
| [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip) | `6.37.0` | `6.39.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.65.0` | `8.71.0` |


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

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

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

Updates `@types/node` from 22.20.4 to 22.20.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

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

Updates `typescript-eslint` from 8.65.0 to 8.71.0
- [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.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/node"
  dependency-version: 2.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: "@modelcontextprotocol/server"
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: "@modelcontextprotocol/client"
  dependency-version: 2.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: "@types/node"
  dependency-version: 22.20.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
- dependency-name: knip
  dependency-version: 6.39.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: typescript-eslint
  dependency-version: 8.71.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  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-10 18:59:41 +02:00
dependabot[bot] 0a6779cd26 chore(deps-dev): bump the web group (#182)
Bumps the web group in /src/Web/autopilot-monitor-web with 2 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [knip](https://github.com/webpro-nl/knip/tree/HEAD/packages/knip).


Updates `@types/node` from 22.20.4 to 22.20.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

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

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.20.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: knip
  dependency-version: 6.39.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-10 18:59:27 +02:00
dependabot[bot] 1a1c2b413e chore(deps-dev): bump jscpd (#181)
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.3.0 to 5.4.0
- [Release notes](https://github.com/kucherenko/jscpd/releases)
- [Commits](https://github.com/kucherenko/jscpd/commits/v5.4.0/rust/jscpd)

---
updated-dependencies:
- dependency-name: jscpd
  dependency-version: 5.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  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-10 18:59:15 +02:00
Oliver Kieselbach 5e763375b4 bump pointer: internal, .claude/commands (dead-code and duplicate gates built: D-343..D-345)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:33:20 +02:00
Oliver Kieselbach 9f4f456dc5 CLAUDE.md: search for an existing helper by behaviour; run the dead-code and duplicate gates
Names the helper homes (web lib/, backend Functions/Helpers/) and asks for a
search by what a helper does before writing a new one, which no tool can judge.
Lists the local commands for the new gates, since CI only reports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:10:32 +02:00
Oliver Kieselbach af2f2c7abe CI: duplicate-code check over src/ with a baseline that only shrinks
tools/quality runs jscpd (5.3.0, pinned with a lockfile like rules/scripts)
over the C# and TypeScript sources, tests and generated files excluded, at 100
tokens. The 95 existing clones are frozen in jscpd-baseline.json. A clone that
is not in the baseline fails, and so does a baseline entry whose clone is gone:
jscpd only reports missing fingerprints, and a stale one would let the same
copy come back unnoticed.

jscpd scans an LF-normalized copy of the tracked and new files under src/,
because with CRLF in a Windows checkout some JSX clones hash differently than
on the Linux runner; local runs and CI now see the same 95 clones.

At 70 tokens a sixth of all clones were identical using blocks, hence 100.
Gate proven both ways with an untracked probe file: a third copy of an existing
helper fails with its location, and a baseline entry without its clone fails.

New CI job "Duplicate code"; Dependabot watches tools/quality with the usual
7-day cooldown. Locally: npm run dup (check), npm run dup:baseline (shrink).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:10:07 +02:00
Oliver Kieselbach 98d87a8579 Build: unused private members, unread fields and unused parameters fail the .NET build
The root .editorconfig raises IDE0051, IDE0052 and IDE0060 (non-public
methods) to warning, and Directory.Build.props sets EnforceCodeStyleInBuild,
so the warning freeze turns them into build errors in every project, the net48
agent included.

Cleanup to start at zero, each finding checked for leftover vs. missing
behaviour (none was a feature that silently never ran):
- 38 injected dependencies nobody read: loggers, configuration, and services
  whose last read had moved to a live replacement (the IME reseed writes
  through the rule repository, the offboarding poison OpsEvent comes from the
  handler, the vulnerability gate sits in the correlate handler). Removed with
  their constructor parameters and the argument at every construction site.
- unused private members: SelfUpdater.IsNewerVersion, a GatherRuleExecutor
  wrapper, test constants and an unused test overload.
  RjPhaseCompletedFirstDeployment now replaces the two literal 110s it names.
- unused parameters removed together with their arguments; test fakes bound as
  method groups use discards.
- the commented-out backend-URL connectivity check and its plumbing are gone;
  the reason the backend stays out remains as a comment.
- DiagnosticsPackageService names the blob in its upload-failure warnings, as
  its doc already said.
- stale docs fixed on the way: OnboardedAt no longer claims a maintenance
  backfill (shared manifest and wire types regenerated), the vulnerability
  functions' policy comments match the catalog, a misplaced summary moved to
  its method.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 18:03:00 +02:00
Oliver Kieselbach 0709ce2f76 Web, MCP: knip gates unused files, exports and dependencies
knip (6.37.0) runs in both TypeScript projects as its own CI step
(npm run knip) next to ESLint. It builds the module graph, so "nothing
imports this" is checked against every reference instead of a search.
Generated files stay out of scope (lib/generated, src/generated); the
MCP's Dockerfile-run precompute and its manual scripts are entries, and so
is the web's compile-time manifest check.

Cleanup to start at zero:
- web: four orphaned components (AdminPageSections, SettingsMenu, UserMenu
  (the navbar renders its own menu) and MarkFailedModal), 17 unused sidebar
  icons, four unused config constants (the agent-download host allowlist lost
  its only reader with the /go route in July), the push device-status union
  that existed twice and was used nowhere, an uncalled push error helper,
  unused barrel re-exports, default exports duplicating named ones, four
  superseded DTO interfaces, and a JSDoc type import of an undeclared package.
- mcp: two test helpers nothing calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:56:23 +02:00
Oliver Kieselbach 69d876b0a6 MCP: lint and type-check tests and scripts like the web app
The MCP server had no ESLint, and since its tests left the tsconfig (to keep
them out of dist/) nothing type-checked them; scripts/ never was. tsconfig.json
now type-checks sources, tests and scripts (noEmit); tsconfig.build.json emits
the same dist/ as before (byte-identical) for npm run build and the Docker
image. ESLint (the web's versions: eslint 9.39.5, typescript-eslint 8.65.0)
runs the recommended rules plus the typed promise rules, and CI's mcp job runs
it with --max-warnings 0.

Findings fixed: 16 type errors in tests (a fixture missing three wire fields,
unknown catch results, untyped mocks); gracefulShutdown was async without
awaiting anything, so the signal handlers returned a promise; two dead imports;
a Function cast replaced by the exact pipeline() signature; an unused test
variable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:42:13 +02:00
Oliver Kieselbach 224eb668a1 Web: helpers live in lib/, generated files in lib/generated/; utils/ is gone
The web app kept helpers in two root folders (lib/ and utils/) and named
route-local helper folders either utils/ or lib/. Shared helpers now live in
lib/, route-only helpers in a lib/ next to their route (app/dashboard/lib,
app/sessions/lib). Generated wire types, manifests, guardrails, error codes and
the lastmod file move to lib/generated/, like the MCP server's src/generated/.
The session-search parity cases sit next to sessionSearchQuery.ts.

Pure move, no behaviour change. Imports, the generators (combine.js,
generate-shared-manifest-types.js, sync-error-codes.js, generate-lastmod.js),
the CI drift paths, the two backend tests and the MCP freshness test that read
these files, and the guard tests' registry path and scan list follow the new
locations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:29:57 +02:00
Oliver Kieselbach a6980a93f6 bump pointer: internal (notification channel health released)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:06:37 +02:00
Oliver Kieselbach eb57980b53 bump pointer: internal, .claude/commands (channel health orphan sweep)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:49:28 +02:00
Oliver Kieselbach c4c2415de8 Notification channel health: the maintenance run deletes the rows of deleted channels (D-342)
A health row goes once its channel is no longer in the stored channel list of its scope and it
saw no send for 30 days. The channel lists are read strictly, so a failed read skips the scope
instead of reading as "no channels"; the delete is ETag-conditional, so a row a send just
rewrote survives. Runs on the 2-hourly maintenance timer next to the push retention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:49:27 +02:00
Oliver Kieselbach 9331366025 bump pointer: internal, .claude/commands (notification channel health: D-342, marker)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:23:44 +02:00
Oliver Kieselbach 85a0cb3c31 Notification channels: per-channel delivery status and a bell when a channel keeps failing (D-342)
Every non-Push send, real or test, is recorded in a NotificationChannelHealth row per channel
(own table, server-side status rule, destination fingerprint resets the history). The settings
show a status dot per channel: Operating normally, Failure rate x%, Error with the cause, or No
deliveries yet. Two failed deliveries in a row raise one bell for the tenant admins (or the GA
bell for ops channels) per failing episode; a failing channel keeps receiving deliveries.

Each transport now has one send path that reports its outcome (WebhookTestResult is
NotificationSendResult, the dispatcher's test entry is SendTestAsync); failures are logged with
tenant and channel id. New routes GET config/{tenantId}/notification-channel-health
(TenantAdminOrGA) and GET global/config/ops-channel-health (GlobalAdminOnly).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:23:43 +02:00
Oliver Kieselbach 5efa7d078c bump pointer: internal (global fan-out released, first measurements)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:18:18 +02:00
Oliver Kieselbach 35d1445ebb bump pointer: internal, .claude/commands (global fan-out built: D-340/D-341, health markers)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:01:30 +02:00
Oliver Kieselbach 23cd9050ea Global fan-out: session-derived cross-tenant reads run over enrolled tenants, GA follow-up pages fetch 100 (D-340)
- TableStorageService.GetEnrolledTenantIdsCachedAsync: configured tenants with a PlatformStats counter row (RowKey 'current', TotalEnrollments > 0), 60 s single-flight; fail-open to the configured list with a Warning when the counter scan fails
- Session list pages, cross-tenant stats drain, geo window and app-summary snapshot fan out over that subset (353 -> 127 partition queries per page live); fresh-install fallbacks key on the configured list, a configured set nobody enrolled in is an empty page; audit logs, QuerySessionsByDateRangeAsync and maintenance keep the full list
- Web: cross-tenant follow-up pages fetch max(pageSize, 100) (resolveSessionsFetchPageSize); first page and own-tenant list unchanged
- Tests: CrossTenantFanOutTests (enrolled subset, counter 0 / no row, fail-open, unconfigured counter row, empty enrolled set never the legacy scan, bound after the filter, geo + apps), DelegatedBoundedAggregateTests harness, sessionsPageSize.test.ts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:00:24 +02:00
Oliver Kieselbach 8615fb458f bump pointer: internal (global fan-out audit for B-gdd)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 13:20:03 +02:00
Oliver Kieselbach 645f747191 bump pointer: internal (orphan sweep released, first measurements)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 00:42:32 +02:00
981 changed files with 42035 additions and 7370 deletions
+2
View File
@@ -38,6 +38,8 @@
- Simplicity first: minimal, targeted changes. No temporary fixes — find the root cause.
- Where architecture is flawed, state is duplicated, or patterns are inconsistent, propose and implement the structural fix. Ask what a perfectionist reviewer would reject, and fix that too.
- On non-trivial changes, ask whether there is a more elegant way before presenting. Skip it for simple fixes — don't over-engineer.
- Reuse before you write: search for an existing helper by what it does (its key call, literal or regex), not by a guessed name. Web helpers live in `lib/` (route-only ones in a `lib/` beside the route), backend helpers in `Functions/Helpers/`.
- Dead and copied code is gated: the .NET build fails on unused private members and parameters (`.editorconfig`), `npm run knip` covers web and MCP, and `npm run dup` in `tools/quality` fails on new clones. CI only reports, so run them before calling a change done.
- English for code comments and everything that carries knowledge.
Private half of these instructions (process, working files, technical docs, operations): @../internal/CLAUDE.md
+11
View File
@@ -0,0 +1,11 @@
# Analyzer severities for the .NET build. Directory.Build.props runs the IDE rules in
# `dotnet build` (EnforceCodeStyleInBuild), and the warning freeze makes every warning an error.
root = true
[*.cs]
# Unused private members, private members that are written but never read, and unused parameters
# of non-public methods. An injected dependency nobody reads is a feature that silently never runs.
dotnet_diagnostic.IDE0051.severity = warning
dotnet_diagnostic.IDE0052.severity = warning
dotnet_diagnostic.IDE0060.severity = warning
dotnet_code_quality_unused_parameters = non_public
+17
View File
@@ -81,6 +81,23 @@ updates:
applies-to: security-updates
patterns: ['*']
# Duplicate-check tooling (jscpd for tools/quality/check-duplicates.mjs), installed by the
# CI duplicates job from this lockfile. A jscpd update can change the clone fingerprints:
# if its PR fails the job, regenerate the baseline (npm run dup:baseline) in that PR.
- package-ecosystem: npm
directory: /tools/quality
schedule:
interval: weekly
cooldown:
default-days: 7
open-pull-requests-limit: 5
groups:
quality-tooling:
patterns: ['*']
quality-tooling-security:
applies-to: security-updates
patterns: ['*']
# Every action is referenced by commit SHA with the version in a trailing comment;
# action-pins.yml fails any workflow change that reintroduces a mutable tag. Dependabot
# is what keeps those pins current: it rewrites the SHA and the comment together, so the
@@ -0,0 +1,84 @@
<#
.SYNOPSIS
Fails when a workflow or a CI helper spells out a host instead of reading Get-Endpoints.ps1.
.DESCRIPTION
The CI counterpart of the URL-registry guard tests (HardcodedUrlGuardTests,
hardcodedUrls.guard.test.ts, hardcoded-urls.guard.test.ts). Enforced: every host the registry
defines -- derived from it, so the list cannot fall behind -- plus any Azure storage, App Service
or autopilotmonitor.com host, so a new or misspelled account is caught as well.
Scans .github/workflows/*.yml and .github/scripts/*.ps1 except the registry itself. Comment lines
and PowerShell block comments do not count: comments may cite URLs.
.PARAMETER Root
Repository root to scan. Defaults to the parent of .github/scripts.
#>
[CmdletBinding()]
param(
[string]$Root
)
$ErrorActionPreference = 'Stop'
if (-not $Root) {
$Root = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
}
$endpoints = & (Join-Path $PSScriptRoot 'Get-Endpoints.ps1')
$registryHosts = $endpoints.Values | ForEach-Object { ([uri]$_).Host } | Sort-Object -Unique
$patterns = @($registryHosts | ForEach-Object { [regex]::Escape($_) }) + @(
'[a-z0-9-]+\.(blob|table|queue|file)\.core\.windows\.net'
'[a-z0-9-]+\.azurewebsites\.net'
'([a-z0-9-]+\.)*autopilotmonitor\.com'
)
$hostPattern = [regex]::new('(' + ($patterns -join '|') + ')', 'IgnoreCase')
$files = @(
Get-ChildItem -Path (Join-Path $Root '.github/workflows') -File -Filter '*.yml'
Get-ChildItem -Path (Join-Path $Root '.github/workflows') -File -Filter '*.yaml'
Get-ChildItem -Path (Join-Path $Root '.github/scripts') -File -Filter '*.ps1' |
Where-Object Name -ne 'Get-Endpoints.ps1'
)
$findings = 0
foreach ($file in $files) {
$relative = [System.IO.Path]::GetRelativePath($Root, $file.FullName) -replace '\\', '/'
$isPowerShell = $file.Extension -eq '.ps1'
$inBlock = $false
$lineNumber = 0
foreach ($line in [System.IO.File]::ReadAllLines($file.FullName)) {
$lineNumber++
$code = $line
if ($isPowerShell) {
if ($inBlock) {
$end = $code.IndexOf('#>')
if ($end -lt 0) { continue }
$code = $code.Substring($end + 2)
$inBlock = $false
}
$start = $code.IndexOf('<#')
if ($start -ge 0) {
$end = $code.IndexOf('#>', $start + 2)
if ($end -lt 0) {
$code = $code.Substring(0, $start)
$inBlock = $true
} else {
$code = $code.Remove($start, $end + 2 - $start)
}
}
}
if ($code.TrimStart().StartsWith('#')) { continue }
$match = $hostPattern.Match($code)
if ($match.Success) {
Write-Host "::error file=$relative,line=$lineNumber::$($match.Value) is spelled out here -- take it from .github/scripts/Get-Endpoints.ps1."
$findings++
}
}
}
if ($findings -gt 0) {
throw "$findings host literal(s) outside the endpoint registry."
}
Write-Host "No host literal outside .github/scripts/Get-Endpoints.ps1 ($($files.Count) files scanned)."
+72
View File
@@ -0,0 +1,72 @@
<#
.SYNOPSIS
The one place the workflows and the release scripts take their hosts from.
.DESCRIPTION
CI's member of the URL registry (Shared Constants.cs, web lib/config.ts, MCP src/config.ts):
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 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
read them as environment variables ($env:AGENT_CONTAINER_URL, ${{ env.AGENT_CONTAINER_URL }}).
.OUTPUTS
[ordered] hashtable, keyed by the names the environment variables carry.
.EXAMPLE
$endpoints = & "$PSScriptRoot/Get-Endpoints.ps1"
Invoke-WebRequest -Uri "$($endpoints.AGENT_CONTAINER_URL)/version.json"
#>
[CmdletBinding()]
param(
[switch]$ExportToGitHubEnv
)
$ErrorActionPreference = 'Stop'
$storageAccount = 'autopilotmonitoreu'
# Pre-migration account in another tenant; only the fail-soft legacy mirror still writes there.
$legacyStorageAccount = 'autopilotmonitor'
$blob = "https://$storageAccount.blob.core.windows.net"
$table = "https://$storageAccount.table.core.windows.net"
$endpoints = [ordered]@{
STORAGE_BLOB_URL = $blob
STORAGE_TABLE_URL = $table
AGENT_CONTAINER_URL = "$blob/agent"
VERSIONS_CONTAINER_URL = "$blob/versions"
STATS_CONTAINER_URL = "$blob/stats"
# Platform settings row; CI writes only its agent and bootstrap version columns.
ADMIN_CONFIG_URL = "$table/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')"
LEGACY_AGENT_CONTAINER_URL = "https://$legacyStorageAccount.blob.core.windows.net/agent"
# Front Door in front of the agent container: what customer scripts download from.
DOWNLOAD_ALIAS_URL = 'https://download.autopilotmonitor.com/agent'
API_BASE_URL = 'https://autopilotmonitor-api-eu.azurewebsites.net'
MCP_BASE_URL = 'https://mcp.autopilotmonitor.com'
PORTAL_BASE_URL = 'https://portal.autopilotmonitor.com'
WWW_BASE_URL = 'https://www.autopilotmonitor.com'
DOCS_BASE_URL = 'https://docs.autopilotmonitor.com'
CODE_SIGN_TIMESTAMP_URL = 'http://timestamp.acs.microsoft.com'
}
if ($ExportToGitHubEnv) {
if ([string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) {
throw '-ExportToGitHubEnv only works inside a GitHub Actions step.'
}
$endpoints.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" } |
Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
$endpoints.GetEnumerator() | ForEach-Object { Write-Host "$($_.Key) = $($_.Value)" }
return
}
return $endpoints
+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
+14 -10
View File
@@ -81,7 +81,6 @@ param(
[string]$SigningToken,
[string]$KeyVaultUrl,
[string]$CertificateName,
[string]$TimestampUrl = 'http://timestamp.acs.microsoft.com',
[string]$ExpectedPublisher,
[string]$SignedOutputDir,
[switch]$DryRun
@@ -93,10 +92,13 @@ if (-not $RepoRoot) {
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
}
$ContainerUrl = 'https://autopilotmonitoreu.blob.core.windows.net/agent'
$LegacyContainerUrl = 'https://autopilotmonitor.blob.core.windows.net/agent'
$AliasUrl = 'https://download.autopilotmonitor.com/agent'
$TableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')"
# Every host, the timestamp server included, from the one endpoint registry.
$Endpoints = & (Join-Path $PSScriptRoot 'Get-Endpoints.ps1')
$ContainerUrl = $Endpoints.AGENT_CONTAINER_URL
$LegacyContainerUrl = $Endpoints.LEGACY_AGENT_CONTAINER_URL
$AliasUrl = $Endpoints.DOWNLOAD_ALIAS_URL
$TableUrl = $Endpoints.ADMIN_CONFIG_URL
$TimestampUrl = $Endpoints.CODE_SIGN_TIMESTAMP_URL
$BootstrapSource = Join-Path $RepoRoot 'scripts/Bootstrap/Install-AutopilotMonitor.ps1'
$BootstrapBlob = 'Install-AutopilotMonitor.ps1'
@@ -210,7 +212,7 @@ function Set-PublishSetSignature {
--timestamp-rfc3161 $TimestampUrl `
--file-digest sha256 `
--description 'Autopilot Monitor bootstrap' `
--description-url 'https://www.autopilotmonitor.com' `
--description-url $Endpoints.WWW_BASE_URL `
$paths
# Native tool: PowerShell does not throw on a non-zero exit, and an unnoticed signing
@@ -327,8 +329,10 @@ $loaderVersion = Get-BootstrapScriptVersion -Content $loaderContent -Origin $Loa
Write-Host "Loader script version: $loaderVersion"
# ------------------------------------------------------------------ 2. dev render
$stableUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
$devUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
# The anchors are the customer scripts' own text, built from the alias in the registry. Should
# the alias ever move, the scripts have to move with it -- until they do, the render stops here.
$stableUrlLiteral = '$AgentDownloadUrl = "' + $AliasUrl + '/AutopilotMonitor-Agent.zip"'
$devUrlLiteral = '$AgentDownloadUrl = "' + $AliasUrl + '/AutopilotMonitor-Agent-dev.zip"'
$stableManLiteral = '$VersionJsonName = "version.json"'
$devManLiteral = '$VersionJsonName = "version-dev.json"'
@@ -339,8 +343,8 @@ $devContent = $sourceContent.Replace($stableUrlLiteral, $devUrlLiteral).Replace(
Write-Host "Rendered $DevBlob (dev agent URL + version-dev.json)"
# The dev loader must fetch the dev bootstrap, or a dev device would run the stable chain.
$stableLoaderLiteral = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1"'
$devLoaderLiteral = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor-Dev.ps1"'
$stableLoaderLiteral = '$BootstrapUrl = "' + $AliasUrl + '/Install-AutopilotMonitor.ps1"'
$devLoaderLiteral = '$BootstrapUrl = "' + $AliasUrl + '/Install-AutopilotMonitor-Dev.ps1"'
if ($loaderContent.IndexOf($stableLoaderLiteral) -lt 0) {
throw "Loader dev-render: anchor literal missing in $LoaderSource"
+20 -3
View File
@@ -1,12 +1,15 @@
name: Workflow gates
# Two checks over the workflow files themselves:
# Three checks over the workflow files and their helper scripts:
# * verify-pins -- every third-party action is referenced by a full commit SHA plus a
# version comment (stricter than zizmor's unpinned-uses audit).
# * zizmor -- static analysis of every workflow (permissions, credential
# persistence, template injection, ...). Configuration and the
# reasoned ignores live in zizmor.yml at the repository root; the
# same run is expected locally before a workflow change is committed.
# * hosts -- no workflow or .github/scripts helper spells out a host; every one
# comes from .github/scripts/Get-Endpoints.ps1, CI's member of the URL
# registry (the C#, web and MCP registries have guard tests of their own).
#
# Every third-party action must be referenced by a full commit SHA, never by a tag.
# Tags are mutable: whoever controls an action repository can repoint v4 at new code,
@@ -34,10 +37,10 @@ name: Workflow gates
on:
push:
branches: [main]
paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml']
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
pull_request:
branches: [main]
paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml']
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
workflow_dispatch:
permissions:
@@ -106,3 +109,17 @@ jobs:
with:
version: 1.30.1
min-severity: low
hosts:
name: Hosts only from the endpoint registry
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The enforced host list is derived from the registry itself, plus every Azure storage,
# App Service and autopilotmonitor.com host, so it cannot fall behind a new entry.
- name: No host literal outside .github/scripts/Get-Endpoints.ps1
shell: pwsh
run: ./.github/scripts/Assert-HostsFromRegistry.ps1
+378 -68
View File
@@ -7,6 +7,14 @@ on:
description: 'Build number override (leave empty to auto-increment from buildcounter.txt)'
required: false
type: string
channel:
description: 'release = versioned line (+ stable on cutover). dev = lab channel only (Agent-v2-dev.zip, version-v2-dev.json, Agent-dev.zip, version-dev.json); needs publish_as_stable=false and create_release=false'
required: true
type: choice
options:
- release
- dev
default: release
upload_blob:
description: 'Upload to versioned namespace (Agent-v2.zip + version-v2.json)'
required: true
@@ -32,10 +40,11 @@ on:
required: false
type: string
# Every job states its own permissions. This default is what the build job runs with: it
# executes third-party code (MSBuild targets, analyzers, test frameworks) and must never hold
# more than a read-only token.
permissions:
contents: write
id-token: write # keyless Sigstore signing (artifact attestation)
attestations: write # store the provenance attestation
contents: read
# Shared with publish-scripts.yml: both write version.json (this workflow the agent fields,
# the script publisher bootstrapVersion under If-Match). Serialising them keeps a script push
@@ -48,7 +57,9 @@ env:
LINE_MAJOR: 2
AGENT_PROJECT: src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
DIALOG_PROJECT: src/Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj
TEST_PROJECT: src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj
DECISIONCORE_TEST_PROJECT: src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj
CORE_TEST_PROJECT: src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj
DIALOG_TEST_PROJECT: src/Agent/AutopilotMonitor.SummaryDialog.Tests/AutopilotMonitor.SummaryDialog.Tests.csproj
CSPROJ_PATH: src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
BOOTSTRAP_SOURCE: scripts/Bootstrap/Install-AutopilotMonitor.ps1
AGENT_OUTPUT: src/Agent/AutopilotMonitor.Agent.V2/bin/Release/net48
@@ -57,18 +68,37 @@ env:
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
STABLE_ZIP: AutopilotMonitor-Agent.zip
STABLE_MANIFEST: version.json
# Authenticode via Azure Key Vault; identity and vault come from repository variables.
# Lab channel (channel=dev): the names the dev renders of the bootstrap scripts read.
CHANNEL: ${{ inputs.channel }}
DEV_VERSIONED_ZIP: AutopilotMonitor-Agent-v2-dev.zip
DEV_MANIFEST: version-v2-dev.json
DEV_STABLE_ZIP: AutopilotMonitor-Agent-dev.zip
DEV_STABLE_MANIFEST: version-dev.json
# Signed catalog over every other file of the payload. Must equal
# Constants.AgentPayloadCatalogFileName; CodeSigningParityTests keeps them in step.
PAYLOAD_CATALOG: AutopilotMonitor.Agent.cat
# Authenticode via Azure Key Vault; identity and vault come from repository variables, the
# timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here.
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify step.
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
jobs:
build:
runs-on: windows-latest
name: Build Agent
# Runs only this repository's scripts. The deploy identity trusts the `production`
# environment, which admits main only.
reserve:
name: Reserve build number
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write # OIDC federated login for the counter write
outputs:
build_number: ${{ steps.version.outputs.build_number }}
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
steps:
- name: Checkout repository
@@ -76,6 +106,11 @@ jobs:
with:
persist-credentials: false
# Every host in this workflow comes from the one endpoint registry.
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
# Every released build is signed; a missing variable stops the run before a build
# number is burnt.
- name: Check code-signing configuration
@@ -88,33 +123,22 @@ jobs:
}
Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')"
# Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az
# account, so the storage login below has to be the last one. Both federated credentials
# are bound to the ref, so this job must never be given an `environment:` -- that would
# change the OIDC subject claim and break both logins.
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Acquire Key Vault token for signing
# The dev channel is a lab channel: it never touches stable, AdminConfiguration, the
# bootstrap scripts or a GitHub Release. Refused here, before a build number is burnt.
- name: Check channel inputs
shell: pwsh
env:
PUBLISH_AS_STABLE: ${{ inputs.publish_as_stable }}
CREATE_RELEASE: ${{ inputs.create_release }}
run: |
$ErrorActionPreference = 'Stop'
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
throw "Could not acquire a Key Vault token for the code-signing identity."
if ($env:CHANNEL -notin @('release', 'dev')) {
throw "Unknown channel '$($env:CHANNEL)'."
}
Write-Host "::add-mask::$token"
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
if ($env:CHANNEL -eq 'dev' -and ($env:PUBLISH_AS_STABLE -eq 'true' -or $env:CREATE_RELEASE -eq 'true')) {
throw "channel=dev publishes the lab channel only: run it with publish_as_stable=false and create_release=false."
}
Write-Host "Channel: $env:CHANNEL"
# Every storage write below (counter blob, agent ZIP, manifests, AdminConfiguration)
# is authorized by this federated identity -- no SAS secret. One login covers the whole
# job: the federated credential is bound to the ref, and a release run takes ~2 minutes,
# well inside the token lifetime. Only the legacy mirror still needs a SAS: that account
# lives in the pre-migration tenant, where this principal has no identity.
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
@@ -122,18 +146,6 @@ jobs:
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
# Version pinned; roll-forward because the tool targets an older runtime than the SDK above.
- name: Install AzureSignTool
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
# Build numbers come from the shared counter blob -- one counter and one
# implementation for CI and the local build (.github/scripts/Request-BuildNumber.ps1).
# Reserve-before-build with ETag-CAS: concurrent builds can never mint the same
@@ -155,7 +167,7 @@ jobs:
$override = [int]$env:BUILD_NUMBER_OVERRIDE
}
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
$counterUrl = "$env:AGENT_CONTAINER_URL/buildcounter-v$($env:LINE_MAJOR).txt"
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -Override $override
$csproj = Get-Content $env:CSPROJ_PATH -Raw
@@ -172,22 +184,169 @@ jobs:
"version=$version" >> $env:GITHUB_OUTPUT
"tag=v$version" >> $env:GITHUB_OUTPUT
# Build zone: no login, no secret, no id-token. Its only output is the artifact; the
# release job treats that as data and never builds.
build:
name: Build and test
needs: reserve
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: |
dotnet restore ${{ env.AGENT_PROJECT }}
dotnet restore ${{ env.DIALOG_PROJECT }}
dotnet restore ${{ env.TEST_PROJECT }}
dotnet restore ${{ env.DECISIONCORE_TEST_PROJECT }}
dotnet restore ${{ env.CORE_TEST_PROJECT }}
dotnet restore ${{ env.DIALOG_TEST_PROJECT }}
# Every agent suite runs before the release build. ORDER AND BUILD_NUMBER ARE LOAD-BEARING:
# Agent.V2.Core.Tests references the agent exe, so the test build compiles it. With the
# reserved number it stamps the release version and the Build Agent step is up to date; run
# after that step or without the number, it would rewrite the binaries with another version.
- name: Run agent tests (all suites)
shell: pwsh
env:
BUILD_NUMBER: ${{ needs.reserve.outputs.build_number }}
run: |
foreach ($project in @($env:DECISIONCORE_TEST_PROJECT, $env:CORE_TEST_PROJECT, $env:DIALOG_TEST_PROJECT)) {
dotnet test $project --configuration Release --no-restore --verbosity quiet
if ($LASTEXITCODE -ne 0) { throw "Tests failed: $project" }
}
- name: Build Agent
run: dotnet build ${{ env.AGENT_PROJECT }} --configuration Release --no-restore
env:
BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
BUILD_NUMBER: ${{ needs.reserve.outputs.build_number }}
- name: Build SummaryDialog
run: dotnet build ${{ env.DIALOG_PROJECT }} --configuration Release --no-restore
- name: Run Agent unit tests
run: dotnet test ${{ env.TEST_PROJECT }} --configuration Release --no-restore --verbosity quiet
# One archive per output folder, unsigned. ZipFile rather than Compress-Archive, which
# skips hidden files.
- name: Package build output
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$outDir = Join-Path $env:RUNNER_TEMP 'agent-unsigned'
New-Item -Path $outDir -ItemType Directory -Force | Out-Null
[System.IO.Compression.ZipFile]::CreateFromDirectory((Join-Path $env:GITHUB_WORKSPACE $env:AGENT_OUTPUT), (Join-Path $outDir 'agent.zip'))
[System.IO.Compression.ZipFile]::CreateFromDirectory((Join-Path $env:GITHUB_WORKSPACE $env:DIALOG_OUTPUT), (Join-Path $outDir 'dialog.zip'))
Get-ChildItem $outDir | ForEach-Object { Write-Host "$($_.Name): $([math]::Round($_.Length / 1MB, 2)) MB" }
- name: Upload unsigned build output
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: agent-unsigned
path: ${{ runner.temp }}/agent-unsigned
if-no-files-found: error
retention-days: 1
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool
# 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.
#
# 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
attestations: write # store the provenance attestation
env:
AGENT_VERSION: ${{ needs.reserve.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# Storage, legacy mirror, docs link, timestamp server: all from the endpoint registry.
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
- name: Download unsigned build output
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: agent-unsigned
path: ${{ runner.temp }}/agent-unsigned
# Only the two expected archives, each into its own fresh output folder; the .NET
# extractor refuses entries that would land outside it. Never unpack into the checkout
# root: an archive could otherwise replace the scripts this job runs below.
- name: Unpack build output
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression.FileSystem
foreach ($pair in @(@('agent.zip', $env:AGENT_OUTPUT), @('dialog.zip', $env:DIALOG_OUTPUT))) {
$archive = Join-Path $env:RUNNER_TEMP "agent-unsigned/$($pair[0])"
$target = Join-Path $env:GITHUB_WORKSPACE $pair[1]
if (Test-Path $target) { throw "$($pair[1]) already exists -- refusing to unpack over it." }
[System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $target)
Write-Host "Unpacked $($pair[0]) into $($pair[1])"
}
# Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az
# account, so the storage login below has to be the last one.
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Acquire Key Vault token for signing
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
throw "Could not acquire a Key Vault token for the code-signing identity."
}
Write-Host "::add-mask::$token"
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# Every storage write below (agent ZIP, manifests, AdminConfiguration) is authorized by
# this federated identity -- no SAS secret. Only the legacy mirror still needs a SAS:
# that account lives in the pre-migration tenant, where this principal has no identity.
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Runtime for AzureSignTool only; nothing is built in this job.
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
# Version pinned; roll-forward because the tool targets an older runtime than the SDK above.
- name: Install AzureSignTool
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
# Authenticode over everything we author; third-party assemblies keep their own signature.
#
@@ -223,18 +382,17 @@ jobs:
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description "Autopilot Monitor Agent" `
--description-url "https://www.autopilotmonitor.com" `
--description-url "$env:WWW_BASE_URL" `
($targets | ForEach-Object { $_.FullName })
if ($LASTEXITCODE -ne 0) {
throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published."
}
- name: Create release artifact
id: artifact
- name: Stage release payload
shell: pwsh
run: |
$version = "${{ steps.version.outputs.version }}"
$version = $env:AGENT_VERSION
$stagingDir = "staging/agent"
if (-not (Test-Path "$env:AGENT_OUTPUT/AutopilotMonitor.Agent.exe")) {
@@ -276,6 +434,98 @@ jobs:
}
Write-Host "Version check passed: EXE = manifest = reserved = $version"
# Third-party code enters the payload only with its vendor's own signature: every PE that
# is not ours needs a valid EMBEDDED Authenticode signature from an allowed vendor. Embedded
# only (signtool verify /pa, no /a): Get-AuthenticodeSignature prefers the runner's Windows
# catalogs and reports "Microsoft Windows" for files Windows happens to ship as well. An
# unsigned file or a new vendor stops the release until a person decides. This guards the
# bytes we did not write; a build that alters our own code is D-346's remaining risk.
- name: Check third-party signatures
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$allowedVendors = @('Microsoft Corporation', 'Json.NET (.NET Foundation)')
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue |
Where-Object { $_.Directory.Parent.Name -match '^\d+(\.\d+){1,3}$' } |
Sort-Object { [version]$_.Directory.Parent.Name } -Descending |
Select-Object -First 1
if (-not $signtool) { throw "signtool.exe not found in the runner image's Windows SDK." }
Write-Host "Using $($signtool.FullName)"
$failed = $false
$checked = 0
foreach ($file in Get-ChildItem -Path 'staging/agent' -Recurse -File) {
# A PE by content (MZ header), not by extension.
$head = [byte[]]::new(2)
$stream = [System.IO.File]::OpenRead($file.FullName)
try { $read = $stream.Read($head, 0, 2) } finally { $stream.Dispose() }
if ($read -lt 2 -or $head[0] -ne 0x4D -or $head[1] -ne 0x5A) { continue }
if ($file.Name -like 'AutopilotMonitor.*') { continue } # ours: the verify step pins them
$checked++
$output = & $signtool.FullName verify /pa $file.FullName 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "::error file=$($file.Name)::No valid embedded vendor signature. $(($output | Out-String).Trim())"
$failed = $true
continue
}
$subject = [System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($file.FullName).Subject
$vendor = if ($subject -match '(?:^|,\s*)O=("[^"]*"|[^,]*)') { $Matches[1].Trim('"') } else { '' }
if ($vendor -notin $allowedVendors) {
Write-Host "::error file=$($file.Name)::Signed by '$subject' -- vendor '$vendor' is not on the allowlist."
$failed = $true
continue
}
Write-Host "$($file.Name): embedded signature valid, vendor $vendor"
}
if ($failed) { throw "Third-party signature check failed. Nothing was published." }
Write-Host "All $checked third-party PE files carry a valid signature from an allowed vendor."
# The payload contract: one catalog, signed like our binaries, holding the SHA-256 of every
# other file in the ZIP. Installer, OOBE script and self-updater accept a payload only when
# this catalog is ours and matches the extracted files exactly. Created AFTER signing and
# BEFORE the ZIP -- the same load-bearing position as the signing step above.
- name: Create and sign payload catalog
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$ErrorActionPreference = 'Stop'
$stagingDir = 'staging/agent'
$catalog = Join-Path $env:RUNNER_TEMP $env:PAYLOAD_CATALOG
if (Test-Path (Join-Path $stagingDir $env:PAYLOAD_CATALOG)) {
throw "$stagingDir already contains $env:PAYLOAD_CATALOG."
}
New-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null
& azuresigntool.exe sign --verbose `
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description "Autopilot Monitor Agent" `
--description-url "$env:WWW_BASE_URL" `
$catalog
if ($LASTEXITCODE -ne 0) {
throw "azuresigntool failed on the payload catalog with exit code $LASTEXITCODE -- aborting before anything is published."
}
Copy-Item -Path $catalog -Destination $stagingDir
$count = (Test-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed).CatalogItems.Count
Write-Host "Payload catalog signed: $count files"
- name: Create release artifact
id: artifact
shell: pwsh
run: |
$version = $env:AGENT_VERSION
$stagingDir = "staging/agent"
$manifest = Get-Content "$env:AGENT_OUTPUT/$env:MANIFEST_NAME" -Raw | ConvertFrom-Json
$versionedZip = "AutopilotMonitor-Agent-v$version.zip"
Compress-Archive -Path "$stagingDir/*" -DestinationPath $versionedZip
Write-Host "Created GitHub-Release artifact: $versionedZip"
@@ -365,17 +615,66 @@ jobs:
}
Write-Host "All $($files.Count) binaries signed with certificate $($thumbprints.Keys -join ', ')"
# 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 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:
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
run: |
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.IO.Compression.FileSystem
$payload = Join-Path $env:RUNNER_TEMP 'verify-payload'
if (Test-Path $payload) { throw "$payload already exists." }
[System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $env:RELEASE_ZIP).Path, $payload)
. (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'."
}
# 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)
# 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'
}
$exeSigner = (Get-AuthenticodeSignature (Join-Path $payload 'AutopilotMonitor.Agent.exe')).SignerCertificate
if ($signature.SignerCertificate -and $exeSigner -and $signature.SignerCertificate.Thumbprint -ne $exeSigner.Thumbprint) {
$problems += 'catalog and agent exe carry different signing certificates'
}
if ($problems.Count -gt 0) {
$problems | ForEach-Object { Write-Host "::error::$_" }
throw "Payload catalog verification failed. Nothing was published."
}
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
shell: pwsh
env:
ZIP_SHA256: ${{ steps.artifact.outputs.sha256 }}
EXE_SHA256: ${{ steps.artifact.outputs.exe_sha256 }}
run: |
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
$containerUrl = $env:AGENT_CONTAINER_URL
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
# Legacy mirror (transition until customers moved to the download.-alias):
# blobs are ALSO pushed to the old account so the bootstrap URL baked into
# customers' Intune scripts keeps serving current bits. Fail-soft -- a
# legacy hiccup must never fail a release. Counter lives ONLY on eu.
$legacyContainerUrl = "https://autopilotmonitor.blob.core.windows.net/agent"
$legacyContainerUrl = $env:LEGACY_AGENT_CONTAINER_URL
$legacySasToken = "${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}".TrimStart('?')
function Send-Blob {
@@ -405,6 +704,18 @@ jobs:
}
}
# Dev channel: lab blobs only, under the names the dev renders of the bootstrap scripts
# read. Nothing else is touched -- no versioned or stable line, no AdminConfiguration, no
# scripts; the channel check in `reserve` already refused publish_as_stable/create_release.
if ($env:CHANNEL -eq 'dev') {
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_VERSIONED_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_MANIFEST -ContentType "application/json"
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_STABLE_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_STABLE_MANIFEST -ContentType "application/json"
Write-Host "DEV CHANNEL: lab blobs written, nothing else touched."
exit 0
}
# Versioned namespace (always)
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:VERSIONED_ZIP -ContentType "application/zip"
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:MANIFEST_NAME -ContentType "application/json"
@@ -426,7 +737,6 @@ jobs:
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
-CertificateName $env:CODE_SIGN_CERTIFICATE_NAME `
-TimestampUrl $env:CODE_SIGN_TIMESTAMP_URL `
-ExpectedPublisher $env:CODE_SIGN_EXPECTED_SUBJECT
}
@@ -437,10 +747,10 @@ jobs:
if ('${{ inputs.publish_as_stable }}' -ne 'true') {
Write-Host "SKIPPED: AdminConfiguration update (versioned-only run -- stable namespace unchanged)"
} else {
$sha256 = "${{ steps.artifact.outputs.sha256 }}"
$exeSha256 = "${{ steps.artifact.outputs.exe_sha256 }}"
$agentVersion = "${{ steps.version.outputs.version }}"
$tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')"
$sha256 = $env:ZIP_SHA256
$exeSha256 = $env:EXE_SHA256
$agentVersion = $env:AGENT_VERSION
$tableUrl = $env:ADMIN_CONFIG_URL
$tableBody = @{
LatestAgentV2Version = $agentVersion
LatestAgentV2Sha256 = $sha256
@@ -474,7 +784,7 @@ jobs:
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}
name: AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}
path: ${{ steps.artifact.outputs.versioned_zip_release }}
- name: Upload version metadata
@@ -497,30 +807,30 @@ jobs:
shell: pwsh
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.version.outputs.tag }}
RELEASE_NAME: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
RELEASE_TAG: ${{ needs.reserve.outputs.tag }}
RELEASE_NAME: Autopilot Monitor Agent ${{ needs.reserve.outputs.version }}
RELEASE_PRERELEASE: ${{ inputs.prerelease }}
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
RELEASE_NOTES: ${{ inputs.release_notes }}
RELEASE_BODY_TAIL: |
### Version Info
- **Version:** ${{ steps.version.outputs.version }}
- **Build Number:** ${{ steps.version.outputs.build_number }}
- **Version:** ${{ needs.reserve.outputs.version }}
- **Build Number:** ${{ needs.reserve.outputs.build_number }}
- **SHA-256:** `${{ steps.artifact.outputs.sha256 }}`
- **Built from:** ${{ github.sha }}
- **Published as stable:** ${{ inputs.publish_as_stable }}
### Provenance
The executables are Authenticode-signed and timestamped. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
The executables are Authenticode-signed and timestamped. Every file in the ZIP is listed in `AutopilotMonitor.Agent.cat`, a catalog signed with the same certificate, and third-party libraries carry their vendor's signature. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
```
gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
gh attestation verify AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
```
run: |
$ErrorActionPreference = 'Stop'
$notes = "$env:RELEASE_NOTES".Trim()
if ($notes -eq '') {
$notes = "Minor enhancements and bug fixes. See the [Agent Changelog](https://docs.autopilotmonitor.com/changelog/agent-changelog) for details.`n<!-- release-notes: none -->"
$notes = "Minor enhancements and bug fixes. See the [Agent Changelog]($env:DOCS_BASE_URL/changelog/agent-changelog) for details.`n<!-- release-notes: none -->"
}
# Previous release for the compare link. Fail-soft: the first release has none,
+192 -70
View File
@@ -11,32 +11,42 @@ on:
workflow_dispatch:
inputs:
upload_blob:
description: 'Upload to blob (customer-facing via download.autopilotmonitor.com/agent/)'
description: 'Upload to blob (customer-facing through the download alias)'
required: true
type: boolean
default: true
# Every job states its own permissions. This default is what the build job runs with: wix is
# third-party code and must never hold more than a read-only token.
permissions:
contents: read
id-token: write # keyless Sigstore signing (artifact attestation)
attestations: write # store the provenance attestation
env:
MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi
RUNNER_SCRIPT: scripts/Bootstrap/Start-AutopilotMonitor.ps1
STABLE_MSI: AutopilotMonitor-Bootstrap.msi
# Authenticode via Azure Key Vault; identity and vault come from repository variables.
# Authenticode via Azure Key Vault; identity and vault come from repository variables, the
# timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here.
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify steps.
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
jobs:
build:
# Signs the loader BEFORE the build: wix embeds the file as it finds it, so a loader signed
# afterwards would leave the copy inside the MSI unsigned. Devices under an AllSigned policy
# 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. The `release` environment is the only subject the code-signing identity trusts.
sign-loader:
name: Sign bootstrap loader
runs-on: windows-latest
name: Build Bootstrap MSI
environment: release
permissions:
contents: read
id-token: write # code-signing login
steps:
- name: Checkout repository
@@ -44,6 +54,11 @@ jobs:
with:
persist-credentials: false
# Every host in this workflow comes from the one endpoint registry.
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
# The loader executes as SYSTEM on every enrolling device — a parse error or
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
# bootstrap-script-gates.yml runs the same checks plus Pester on pushes; this job
@@ -60,6 +75,152 @@ jobs:
}
Write-Host "Runner script parse + ASCII check passed"
# Every published MSI is signed; a missing variable stops the run before the build.
- name: Check code-signing configuration
shell: pwsh
run: |
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Repository variable $name is not set. Every published MSI must be Authenticode-signed."
}
}
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
allow-no-subscriptions: true
- name: Acquire Key Vault token for signing
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
throw "Could not acquire a Key Vault token for the code-signing identity."
}
Write-Host "::add-mask::$token"
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
# Version pinned; roll-forward because the tool targets an older runtime than the SDK.
- name: Install AzureSignTool
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
# A missing timestamp fails the run: without it the signature dies with the certificate.
- name: Sign bootstrap loader (Authenticode)
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$ErrorActionPreference = 'Stop'
& azuresigntool.exe sign --verbose `
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description 'Autopilot Monitor bootstrap runner' `
--description-url $env:WWW_BASE_URL `
$env:RUNNER_SCRIPT
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
$sig = Get-AuthenticodeSignature -FilePath $env:RUNNER_SCRIPT
if ($sig.Status -ne 'Valid') { throw "Signature on $env:RUNNER_SCRIPT is '$($sig.Status)', expected 'Valid'." }
if (-not $sig.TimeStamperCertificate) { throw "Signature on $env:RUNNER_SCRIPT carries no timestamp." }
if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
throw "$env:RUNNER_SCRIPT was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
}
Write-Host "$env:RUNNER_SCRIPT : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
- name: Upload signed loader
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bootstrap-loader-signed
path: ${{ env.RUNNER_SCRIPT }}
if-no-files-found: error
retention-days: 1
# Build zone: no login, no secret, no id-token. Its only output is the artifact; the
# release job treats that as data and never builds.
build:
name: Build MSI
needs: sign-loader
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download signed loader
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bootstrap-loader-signed
path: ${{ runner.temp }}/loader
# The signed copy replaces the checkout's unsigned one before wix embeds it.
- name: Put the signed loader in place
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
Copy-Item -Path (Join-Path $env:RUNNER_TEMP 'loader/Start-AutopilotMonitor.ps1') -Destination $env:RUNNER_SCRIPT -Force
$sig = Get-AuthenticodeSignature -FilePath $env:RUNNER_SCRIPT
if ($sig.Status -ne 'Valid') { throw "Loader to embed is '$($sig.Status)', expected 'Valid'." }
Write-Host "Embedding the signed loader ($((Get-Item $env:RUNNER_SCRIPT).Length) bytes)"
# Exact version, not a "5.*" range: wix writes the bytes of the MSI that customers
# install, so a compromised or simply broken point release must not flow into a build
# unnoticed. Bumping is a reviewed commit.
- name: Install WiX toolset
run: dotnet tool install --global wix --version 5.0.2
- name: Build MSI
shell: pwsh
run: |
# 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
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)"
- name: Upload unsigned MSI
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: bootstrap-msi-unsigned
path: ${{ env.STABLE_MSI }}
if-no-files-found: error
retention-days: 1
# 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
runs-on: windows-latest
environment: release
permissions:
contents: read
id-token: write # code-signing and storage logins, keyless Sigstore signing
attestations: write # store the provenance attestation
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
# Read from this job's own checkout, never handed over by the build job.
- name: Read MSI version from Package.wxs
id: version
shell: pwsh
@@ -73,20 +234,19 @@ jobs:
Write-Host "MSI version (from Package.wxs): $version"
"version=$version" >> $env:GITHUB_OUTPUT
# Every published MSI is signed; a missing variable stops the run before the build.
- name: Check code-signing configuration
- name: Download unsigned MSI
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bootstrap-msi-unsigned
path: ${{ runner.temp }}/msi
# Only the expected file leaves the download folder.
- name: Take the MSI from the build
shell: pwsh
run: |
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
throw "Repository variable $name is not set. Every published MSI must be Authenticode-signed."
}
}
run: Copy-Item -Path (Join-Path $env:RUNNER_TEMP "msi/$env:STABLE_MSI") -Destination $env:STABLE_MSI -ErrorAction Stop
# Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az
# account, so the storage login below has to be the last one. Both federated credentials
# are bound to the ref, so this job must never be given an `environment:` -- that would
# change the OIDC subject claim and break both logins.
# account, so the storage login below has to be the last one.
- name: Login to Azure (code-signing identity)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
@@ -113,50 +273,12 @@ jobs:
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Exact version, not a "5.*" range: wix writes the bytes of the MSI that customers
# install, so a compromised or simply broken point release must not flow into a build
# unnoticed. Bumping is a reviewed commit.
- name: Install WiX toolset
run: dotnet tool install --global wix --version 5.0.2
# Version pinned; roll-forward because the tool targets an older runtime than the SDK.
- name: Install AzureSignTool
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
# BEFORE the build: wix embeds the file as it finds it, so a loader signed afterwards
# would leave the copy inside the MSI unsigned. Devices under an AllSigned policy need
# the embedded loader to carry a signature of its own.
- name: Sign bootstrap loader (Authenticode)
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$ErrorActionPreference = 'Stop'
& azuresigntool.exe sign --verbose `
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description 'Autopilot Monitor bootstrap runner' `
--description-url 'https://www.autopilotmonitor.com' `
$env:RUNNER_SCRIPT
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
- name: Build MSI
shell: pwsh
run: |
# 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
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)"
# Signing rewrites the MSI, so it runs before the hash and the upload. A missing
# timestamp fails the run: without it the signature dies with the certificate.
- name: Sign MSI (Authenticode)
@@ -172,30 +294,30 @@ jobs:
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
--file-digest sha256 `
--description 'Autopilot Monitor Bootstrap' `
--description-url 'https://www.autopilotmonitor.com' `
--description-url $env:WWW_BASE_URL `
$env:STABLE_MSI
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
# The MSI and the loader it carries -- verified here, before anything is published.
foreach ($file in @($env:STABLE_MSI, $env:RUNNER_SCRIPT)) {
$sig = Get-AuthenticodeSignature -FilePath $file
if ($sig.Status -ne 'Valid') { throw "Signature on $file is '$($sig.Status)', expected 'Valid'." }
if (-not $sig.TimeStamperCertificate) { throw "Signature on $file carries no timestamp." }
if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
throw "$file was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
}
Write-Host "$file : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
# The loader was verified in sign-loader; here the MSI, before anything is published.
$sig = Get-AuthenticodeSignature -FilePath $env:STABLE_MSI
if ($sig.Status -ne 'Valid') { throw "Signature on $env:STABLE_MSI is '$($sig.Status)', expected 'Valid'." }
if (-not $sig.TimeStamperCertificate) { throw "Signature on $env:STABLE_MSI carries no timestamp." }
if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
throw "$env:STABLE_MSI was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
}
Write-Host "$env:STABLE_MSI : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
Write-Host "SHA-256: $((Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant())"
- name: Upload to Azure Blob Storage
if: inputs.upload_blob
shell: pwsh
env:
MSI_VERSION: ${{ steps.version.outputs.version }}
run: |
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
$containerUrl = $env:AGENT_CONTAINER_URL
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
$version = "${{ steps.version.outputs.version }}"
$version = $env:MSI_VERSION
# Same Send-Blob contract as build-agent.yml: no-cache on every blob because the
# stable name rotates in place behind the download alias. No legacy mirror — the
@@ -226,14 +348,14 @@ jobs:
shell: pwsh
run: |
$localSize = (Get-Item $env:STABLE_MSI).Length
$resp = Invoke-WebRequest -Uri "https://download.autopilotmonitor.com/agent/$env:STABLE_MSI" -Method Head -UseBasicParsing
$resp = Invoke-WebRequest -Uri "$env:DOWNLOAD_ALIAS_URL/$env:STABLE_MSI" -Method Head -UseBasicParsing
if ($resp.StatusCode -ne 200) { Write-Error "Alias HEAD returned $($resp.StatusCode)"; exit 1 }
$remoteSize = [long]$resp.Headers['Content-Length'][0]
if ($remoteSize -ne $localSize) {
Write-Error "Size mismatch: alias serves $remoteSize bytes, local MSI is $localSize bytes"
exit 1
}
Write-Host "Alias verified: https://download.autopilotmonitor.com/agent/$env:STABLE_MSI ($remoteSize bytes)"
Write-Host "Alias verified: $env:DOWNLOAD_ALIAS_URL/$env:STABLE_MSI ($remoteSize bytes)"
# Sigstore keyless provenance, same as the agent ZIP; covers the signed bytes. Skipped
# on runs that publish nothing -- no log entry for bytes nobody receives. Verify:
+44 -5
View File
@@ -1,8 +1,8 @@
name: CI
# Tests, typechecks and drift-checks every push to main and every pull request:
# the .NET suites, the web and MCP suites, and a guard that the generated rule
# files still match rules/guardrails.json.
# the .NET suites, the web and MCP suites, a guard that the generated rule
# files still match rules/guardrails.json, and the duplicate-code check.
#
# The deploy workflows build but never test, so this is the only place the suites
# actually run. It does not gate anything — main is deliberately unprotected, so a
@@ -46,7 +46,6 @@ on:
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
pull_request:
@@ -80,7 +79,6 @@ on:
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
workflow_dispatch:
@@ -160,6 +158,12 @@ jobs:
- name: Lint
run: npm run lint -- --max-warnings 0
# Unused files, exports and dependencies (knip.json). Its own script on
# purpose: chained into `lint`, the `-- --max-warnings 0` above would be
# handed to knip and ESLint would lose its cap.
- name: Unused code
run: npm run knip
- name: Tests
run: npm test
@@ -182,9 +186,19 @@ jobs:
- run: npm ci
# tsconfig.json covers sources, tests and scripts; the build emits via
# tsconfig.build.json.
- name: Typecheck
run: npx tsc --noEmit
# Same rule as the web job: any warning fails — fix new findings, never
# raise the cap.
- name: Lint
run: npm run lint -- --max-warnings 0
- name: Unused code
run: npm run knip
# The vector-search tests tokenize with the real all-MiniLM-L6-v2 model
# (~23 MB tokenizer + ONNX weights). transformers.js caches under
# node_modules by default, which npm ci recreates — so every CI run
@@ -247,8 +261,33 @@ jobs:
- name: Fail if the generated files drifted
run: |
if ! git diff --quiet -- src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts src/Web/autopilot-monitor-web/utils/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts; then
if ! git diff --quiet -- src/Web/autopilot-monitor-web/lib/generated/shared-manifests.generated.ts src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts; then
echo "::error::Generated wire types are out of date. Run 'npm run generate:manifests' in src/Web/autopilot-monitor-web and commit the result."
git diff --stat
exit 1
fi
duplicates:
name: Duplicate code
runs-on: ubuntu-latest
defaults:
run:
working-directory: tools/quality
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
cache: npm
cache-dependency-path: tools/quality/package-lock.json
- run: npm ci --ignore-scripts
# jscpd over the C# and TypeScript sources (.jscpd.json). A clone missing from
# jscpd-baseline.json fails, and so does a baseline entry whose clone is gone,
# so the baseline only shrinks. Locally: npm run dup in tools/quality.
- name: Duplicate check
run: npm run dup
+2 -2
View File
@@ -28,6 +28,7 @@ on:
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
- '.github/workflows/action-pins.yml'
- '.github/workflows/bootstrap-script-gates.yml'
- '.github/workflows/build-agent.yml'
- '.github/workflows/build-msi.yml'
@@ -37,7 +38,6 @@ on:
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
pull_request:
@@ -60,6 +60,7 @@ on:
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
- '.github/workflows/action-pins.yml'
- '.github/workflows/bootstrap-script-gates.yml'
- '.github/workflows/build-agent.yml'
- '.github/workflows/build-msi.yml'
@@ -69,7 +70,6 @@ on:
- '.github/workflows/deploy-mcp.yml'
- '.github/workflows/deploy-web.yml'
- '.github/workflows/indexnow-notify.yml'
- '.github/workflows/oidc-login-test.yml'
- '.github/workflows/pester.yml'
- '.github/workflows/publish-scripts.yml'
schedule:
+36 -14
View File
@@ -1,7 +1,7 @@
name: Validate & Combine Rules
on:
push:
branches: [main, dev]
branches: [main]
paths:
- 'rules/gather/**'
- 'rules/analyze/**'
@@ -9,6 +9,7 @@ on:
- 'rules/schema/**'
- 'rules/scripts/**'
- 'rules/guardrails.json'
- '.github/workflows/combine-rules.yml'
pull_request:
paths:
- 'rules/gather/**'
@@ -17,24 +18,27 @@ on:
- 'rules/schema/**'
- 'rules/scripts/**'
- 'rules/guardrails.json'
- '.github/workflows/combine-rules.yml'
permissions:
contents: read
jobs:
validate-and-combine:
# Pull requests and pushes alike; read-only, because the validator is third-party code.
validate:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
# Lockfile and no lifecycle scripts: this job holds contents:write and pushes to
# main, so an install-time script from a hijacked package would run with a token that
# can rewrite the repository. rules/scripts/package-lock.json pins ajv, ajv-formats
# and every transitive package (Dependabot keeps it current); ajv is pure JS and
# needs no install hooks.
# Lockfile and no lifecycle scripts: rules/scripts/package-lock.json pins ajv,
# ajv-formats and every transitive package (Dependabot keeps it current); ajv is pure
# JS and needs no install hooks.
- name: Install the rules validator from the lockfile
working-directory: rules/scripts
run: npm ci --ignore-scripts
@@ -48,19 +52,37 @@ jobs:
- name: Combine rules into dist/
run: node rules/scripts/combine.js
# Plain git instead of a third-party action: this step runs with contents:write on
# main, so no code we do not control gets to push. Checkout leaves the job's
# GITHUB_TOKEN in the remote credential helper, which is all `git push` needs.
# The one job that can push to main, and it installs nothing: combine.js needs only node's
# built-ins, so no package code ever runs next to the write token. Keep it that way -- a
# dependency in combine.js would need its own job.
publish:
needs: validate
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# Checkout leaves the job's GITHUB_TOKEN in the remote credential helper, which is all
# `git push` needs.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Combine rules into dist/
run: node rules/scripts/combine.js
# Plain git instead of a third-party action, so no code we do not control gets to push.
# NOTE: combine.js writes THREE outputs; committing only dist/ would silently discard
# the two generated TS modules and leave the guardrails-in-sync CI check red.
- name: Commit generated outputs
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -- rules/dist/ \
src/Web/autopilot-monitor-web/utils/guardrails.generated.ts \
src/Web/autopilot-monitor-web/lib/generated/guardrails.generated.ts \
src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts
if git diff --cached --quiet; then
echo "Generated outputs unchanged - nothing to commit"
+104 -19
View File
@@ -3,26 +3,41 @@ name: Deploy Backend (Azure Functions)
on:
workflow_dispatch:
# Every job states its own permissions. This default is what the build job runs with: restore
# and build execute third-party code (NuGet MSBuild targets, the Functions worker SDK) and must
# never hold more than a read-only token.
permissions:
id-token: write # OIDC federated login (secret-less)
contents: read
# Hosts (API, versions container) come from .github/scripts/Get-Endpoints.ps1.
env:
FUNCTION_APP: autopilotmonitor-api-eu
HEALTH_URL: https://autopilotmonitor-api-eu.azurewebsites.net/api/health
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
CSPROJ_PATH: src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
jobs:
build-and-deploy:
# Runs only this repository's scripts. The deploy identity trusts the `production`
# environment, which admits main only.
reserve:
name: Reserve build number
runs-on: ubuntu-latest
name: Build and Deploy Backend
environment: production
permissions:
contents: read
id-token: write # OIDC federated login (secret-less)
outputs:
build_number: ${{ steps.version.outputs.build_number }}
version: ${{ steps.version.outputs.version }}
build_utc: ${{ steps.version.outputs.build_utc }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
@@ -30,11 +45,6 @@ jobs:
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup .NET 10
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
# Same counter mechanism as the agent line: reserve-before-build with ETag-CAS
# against a per-component counter blob. BuildTimestampUtc is pinned here rather
# than left to MSBuild's default so the published manifest and the value baked
@@ -43,7 +53,7 @@ jobs:
id: version
shell: pwsh
run: |
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt"
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "$env:VERSIONS_CONTAINER_URL/backend.txt"
$csproj = Get-Content ${{ env.CSPROJ_PATH }} -Raw
if ($csproj -notmatch '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
@@ -57,20 +67,90 @@ jobs:
"version=$version" >> $env:GITHUB_OUTPUT
"build_utc=$buildUtc" >> $env:GITHUB_OUTPUT
# Build zone: no login, no secret, no id-token. Its only output is the artifact; the
# deploy job treats that as data and never builds.
build:
name: Build
needs: reserve
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup .NET 10
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore ${{ env.CSPROJ_PATH }}
- name: Build
run: dotnet build ${{ env.CSPROJ_PATH }} --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
run: dotnet build ${{ env.CSPROJ_PATH }} --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ needs.reserve.outputs.build_number }} -p:BuildTimestampUtc=${{ needs.reserve.outputs.build_utc }}
- name: Publish
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ needs.reserve.outputs.build_number }} -p:BuildTimestampUtc=${{ needs.reserve.outputs.build_utc }}
# One archive, not a folder upload: upload-artifact skips hidden files, and the
# isolated worker cannot start without the publish output's .azurefunctions/ folder.
- name: Package publish output
run: tar -czf "$RUNNER_TEMP/backend-publish.tgz" -C publish .
- name: Upload publish output
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: backend-publish
path: ${{ runner.temp }}/backend-publish.tgz
if-no-files-found: error
retention-days: 1
# Trust zone: only this repository's scripts and pinned actions run here, never a restore
# or build. The package is unpacked outside the checkout, so nothing in it can replace the
# scripts this job runs.
deploy:
name: Deploy and verify
needs: [reserve, build]
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write # OIDC federated login (secret-less)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
- name: Download publish output
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: backend-publish
path: ${{ runner.temp }}/backend-artifact
# GNU tar refuses members with '..' and strips leading '/'.
- name: Unpack publish output
run: |
mkdir "$RUNNER_TEMP/publish"
tar -xzf "$RUNNER_TEMP/backend-artifact/backend-publish.tgz" -C "$RUNNER_TEMP/publish"
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to Azure Functions
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1.5.7
with:
app-name: ${{ env.FUNCTION_APP }}
package: ./publish
package: ${{ runner.temp }}/publish
# A green deploy step only means the package was accepted. This gate makes the
# workflow assert what is actually being served, so the manifest published below
@@ -78,16 +158,18 @@ jobs:
# /api/health is IP rate-limited at 30/min; one poll per 10s stays well under.
- name: Verify deployed build is live
shell: pwsh
env:
EXPECTED_VERSION: ${{ needs.reserve.outputs.version }}
run: |
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
$expectedVersion = '${{ steps.version.outputs.version }}'
$expectedVersion = $env:EXPECTED_VERSION
$deadline = [DateTime]::UtcNow.AddMinutes(3)
$attempt = 0
while ([DateTime]::UtcNow -lt $deadline) {
$attempt++
try {
$health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 30
$health = Invoke-RestMethod -Uri "$env:API_BASE_URL/api/health" -Method Get -TimeoutSec 30
if ($health.commitHash -eq $expectedCommit -and $health.version -eq $expectedVersion) {
Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commitHash), built $($health.buildUtc)"
exit 0
@@ -107,18 +189,21 @@ jobs:
# (rollback, stale instance) rather than a half-finished pipeline.
- name: Publish version manifest
shell: pwsh
env:
BACKEND_VERSION: ${{ needs.reserve.outputs.version }}
BACKEND_BUILD_UTC: ${{ needs.reserve.outputs.build_utc }}
run: |
$manifest = [ordered]@{
component = 'backend'
version = '${{ steps.version.outputs.version }}'
version = $env:BACKEND_VERSION
commit = '${{ github.sha }}'.Substring(0, 7)
buildUtc = '${{ steps.version.outputs.build_utc }}'
buildUtc = $env:BACKEND_BUILD_UTC
deployedUtc = [DateTime]::UtcNow.ToString('o')
runId = '${{ github.run_id }}'
} | ConvertTo-Json
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
$url = "${{ env.VERSIONS_CONTAINER }}/backend.json"
$url = "$env:VERSIONS_CONTAINER_URL/backend.json"
# no-cache: rotating pointer, must never be served stale by any fronting cache.
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/backend.json"
+14 -5
View File
@@ -16,20 +16,29 @@ env:
# reference — with a sha-only tag `az containerapp update` saw the same image
# and silently created no revision (2026-08-28).
IMAGE_TAG: ${{ github.sha }}-${{ github.run_number }}
HEALTH_URL: https://mcp.autopilotmonitor.com/health
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json
# Hosts (MCP, versions container) come from .github/scripts/Get-Endpoints.ps1.
jobs:
# One job on purpose, unlike the other deploys: all third-party code (npm ci, the build, the
# embedding precompute) runs inside `docker build`, whose containers see neither the az
# login nor the registry credentials nor this job's OIDC request token. That container is
# the boundary -- never move npm onto the runner here, or the build gets the job's
# credentials. The deploy identity trusts the `production` environment, which admits main only.
build-and-deploy:
runs-on: ubuntu-latest
name: Build and Deploy MCP Server
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
# The published customer documentation lives in its own PUBLIC repo and is
# baked into the image as the search_docs corpus (see the Dockerfile). No
# token is needed. Checking it out here rather than vendoring it keeps the
@@ -62,7 +71,7 @@ jobs:
id: version
shell: pwsh
run: |
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt"
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "$env:VERSIONS_CONTAINER_URL/mcp.txt"
$pkgVersion = (Get-Content ${{ env.PACKAGE_JSON }} -Raw | ConvertFrom-Json).version
$prefix = ($pkgVersion -split '\.')[0..1] -join '.'
@@ -117,7 +126,7 @@ jobs:
while ([DateTime]::UtcNow -lt $deadline) {
$attempt++
try {
$health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 60
$health = Invoke-RestMethod -Uri "$env:MCP_BASE_URL/health" -Method Get -TimeoutSec 60
if ($health.commit -eq $expectedCommit -and $health.version -eq $expectedVersion) {
Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commit), docs $($health.docs.commit) ($($health.docs.chunks) chunks)"
exit 0
@@ -148,7 +157,7 @@ jobs:
} | ConvertTo-Json
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
$url = "${{ env.VERSIONS_CONTAINER }}/mcp.json"
$url = "$env:VERSIONS_CONTAINER_URL/mcp.json"
# no-cache: rotating pointer, must never be served stale by any fronting cache.
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/mcp.json"
+117 -31
View File
@@ -5,6 +5,11 @@ name: Deploy Web (Static Web App)
# path ci.yml tests (setup-node 22 + npm ci + next build) instead of Oryx guessing inside the
# action's container, and the exported site is attested like the agent ZIP and the MSI.
#
# Three jobs, each holding only what it needs: build runs all third-party code without any
# credential, deploy holds the SWA token alone, record attests, verifies and writes the
# manifest. A failed record job is re-run on its own; it writes only once the live site
# serves this commit.
#
# PR preview environments were dropped with the action: their per-PR URLs are never
# registered as redirect URIs, so nobody could log in on them anyway.
@@ -17,23 +22,29 @@ on:
- '.github/workflows/deploy-web.yml'
workflow_dispatch:
# Every job states its own permissions. This default is what the build job runs with: npm ci
# (install scripts included) and next build execute third-party code and must never hold more
# than a read-only token.
permissions:
contents: read
id-token: write # keyless Sigstore signing (artifact attestation)
attestations: write # store the provenance attestation
# Hosts (API, portal, storage) come from .github/scripts/Get-Endpoints.ps1, never from a
# repository variable.
env:
PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
WEB_DIR: src/Web/autopilot-monitor-web
# Exact version: `npx --package` resolves from the registry at run time, so the pin is the
# only thing that keeps the deploy step's code fixed. Bump deliberately.
SWA_CLI_VERSION: 2.0.10
jobs:
build_and_deploy_job:
# Build zone: no login, no id-token. The one secret here is the Application Insights
# connection string, which ships inside the public bundle anyway.
build:
name: Build
runs-on: ubuntu-latest
name: Build and Deploy Job
outputs:
docs_sha: ${{ steps.docs.outputs.sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -41,14 +52,9 @@ jobs:
lfs: false
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
# Only the version manifest at the very end needs Azure; logging in here rather than
# next to that step keeps a credential problem from surfacing after a live deploy.
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
# The customer docs repo is the single source of the "What's new" panel: the prebuild
# step (scripts/whats-new/build-whats-new.js) turns changelog/*.md into
@@ -105,12 +111,6 @@ jobs:
# and the default app for browsers without a stored selection (legacy|primary).
NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID }}
NEXT_PUBLIC_ENTRA_DEFAULT_APP: ${{ vars.NEXT_PUBLIC_ENTRA_DEFAULT_APP }}
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
# Browsers load the live stats through it, and the build reads it once to bake the
# numbers of this moment into the landing page's static HTML (lib/platformStats.ts).
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
# the verify step below polls the deployed file for exactly this commit.
@@ -120,6 +120,18 @@ jobs:
# Star counter from the step above; empty when the lookup failed.
NEXT_PUBLIC_GITHUB_STARS: ${{ steps.stars.outputs.count }}
run: |
# All from the endpoint registry (Load endpoints). Empty, the portal would silently call
# localhost:7071. The stats manifest is what browsers load the live numbers from, and the
# build reads it once to bake the numbers of this moment into the landing page's static
# HTML (lib/platformStats.ts). The redirect URIs are only the fallback for the static
# render; in the browser lib/msalConfig.ts uses window.location.origin.
export NEXT_PUBLIC_API_BASE_URL="$API_BASE_URL"
export NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL="$STATS_CONTAINER_URL"
export NEXT_PUBLIC_ENTRA_REDIRECT_URI="$WWW_BASE_URL"
export NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI="$WWW_BASE_URL"
test -n "$NEXT_PUBLIC_API_BASE_URL" || { echo "::error::API_BASE_URL is empty - the portal would call localhost"; exit 1; }
test -n "$NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL" || { echo "::error::STATS_CONTAINER_URL is empty"; exit 1; }
test -n "$NEXT_PUBLIC_ENTRA_REDIRECT_URI" || { echo "::error::WWW_BASE_URL is empty"; exit 1; }
npm run build
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
@@ -136,19 +148,37 @@ jobs:
echo "archive=$archive" >> "$GITHUB_OUTPUT"
sha256sum "$archive"
# Sigstore keyless provenance: binds the export digest to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log.
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
- name: Attest build provenance (portal export)
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: ${{ steps.package.outputs.archive }}
- name: Upload build artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: portal-${{ github.sha }}
path: ${{ steps.package.outputs.archive }}
if-no-files-found: error
# Holds the SWA deployment token and nothing else -- deliberately no id-token: the SWA CLI
# is third-party npm code whose dependencies resolve at run time.
deploy:
name: Deploy to Static Web App
needs: build
runs-on: ubuntu-latest
environment: production
steps:
# For staticwebapp.config.json only.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: portal-${{ github.sha }}
path: ${{ runner.temp }}/portal
- name: Unpack export
run: |
mkdir "$WEB_DIR/out"
tar -xzf "$RUNNER_TEMP/portal/portal-${GITHUB_SHA::7}.tar.gz" -C "$WEB_DIR/out"
# Upload only -- out/ is already built. --swa-config-location points at the app root,
# where staticwebapp.config.json lives (next export does not copy it into out/).
@@ -163,6 +193,48 @@ jobs:
--env production \
--no-use-keychain
# Trust zone: only this repository's scripts and pinned actions run here. The deploy
# identity trusts the `production` environment, which admits main only.
record:
name: Attest, verify and record
needs: [build, deploy]
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write # keyless Sigstore signing, OIDC login for the manifest
attestations: write # store the provenance attestation
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
- name: Download build artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: portal-${{ github.sha }}
path: ${{ runner.temp }}/portal
- name: Locate the export archive
id: archive
run: |
path="$RUNNER_TEMP/portal/portal-${GITHUB_SHA::7}.tar.gz"
test -f "$path" || { echo "::error::Export archive for this commit is missing"; exit 1; }
echo "path=$path" >> "$GITHUB_OUTPUT"
# Sigstore keyless provenance: binds the export digest to this workflow run (repo, ref,
# commit, workflow file) in a public transparency log.
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
- name: Attest build provenance (portal export)
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: ${{ steps.archive.outputs.path }}
# The CLI reporting success proves the upload, not that the production host serves the
# new build -- verify against the live /version.json (Cache-Control: no-store via
# staticwebapp.config.json) before publishing the version manifest.
@@ -176,7 +248,7 @@ jobs:
while ([DateTime]::UtcNow -lt $deadline) {
$attempt++
try {
$v = Invoke-RestMethod -Uri '${{ env.PORTAL_VERSION_URL }}' -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' }
$v = Invoke-RestMethod -Uri "$env:PORTAL_BASE_URL/version.json" -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' }
if ($v.commit -eq $expectedCommit) {
Write-Host "Live after $attempt attempt(s): web @ $($v.commit), built $($v.buildUtc)"
exit 0
@@ -190,22 +262,36 @@ jobs:
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Cross-component deploy record, written last and only on a verified deploy --
# same contract as versions/backend.json and versions/mcp.json. docsCommit says which
# docs revision the live What's new panel was generated from (as for the MCP corpus).
# The docs commit comes from the build job, so it arrives as data and is checked
# against the shape of a commit id before it is used.
- name: Publish version manifest
shell: pwsh
env:
DOCS_SHA: ${{ needs.build.outputs.docs_sha }}
run: |
if ($env:DOCS_SHA -notmatch '^[0-9a-f]{40}$') {
throw "The build job handed over no valid docs commit -- version manifest NOT published."
}
$manifest = [ordered]@{
component = 'web'
commit = '${{ github.sha }}'.Substring(0, 7)
deployedUtc = [DateTime]::UtcNow.ToString('o')
runId = '${{ github.run_id }}'
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
docsCommit = $env:DOCS_SHA.Substring(0, 7)
} | ConvertTo-Json
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
$url = "${{ env.VERSIONS_CONTAINER }}/web.json"
$url = "$env:VERSIONS_CONTAINER_URL/web.json"
# no-cache: rotating pointer, must never be served stale by any fronting cache.
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
Write-Host "Published versions/web.json"
+15 -5
View File
@@ -3,24 +3,34 @@ name: IndexNow Notify (Bing/Yandex)
on:
workflow_dispatch:
# The job only talks to public endpoints; it never needs a repository token.
permissions: {}
# Read-only, for the checkout of the endpoint registry; beyond that the job only talks to
# public endpoints.
permissions:
contents: read
jobs:
notify:
runs-on: ubuntu-latest
name: Submit URLs to IndexNow
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Load endpoints
shell: pwsh
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
- name: Notify IndexNow
run: |
URLS=$(curl -s https://www.autopilotmonitor.com/sitemap.xml | grep -oP '<loc>\K[^<]+')
URLS=$(curl -s "$WWW_BASE_URL/sitemap.xml" | grep -oP '<loc>\K[^<]+')
URL_JSON=$(echo "$URLS" | jq -R -s 'split("\n") | map(select(length > 0))')
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "https://api.indexnow.org/indexnow" \
-H "Content-Type: application/json" \
-d "{
\"host\": \"www.autopilotmonitor.com\",
\"host\": \"${WWW_BASE_URL#https://}\",
\"key\": \"8f1710b5-f14b-477e-938b-6c01ef851661\",
\"keyLocation\": \"https://www.autopilotmonitor.com/IndexNow.txt\",
\"keyLocation\": \"$WWW_BASE_URL/IndexNow.txt\",
\"urlList\": $URL_JSON
}")
echo "IndexNow response: HTTP $RESPONSE"
-28
View File
@@ -1,28 +0,0 @@
# TEMPORARY: proves the secret-less OIDC federated login works end-to-end.
# Delete after the cross-tenant migration is verified (or keep as a cheap smoke test).
name: OIDC Login Test
on:
workflow_dispatch:
permissions:
id-token: write
contents: read
jobs:
login-test:
runs-on: ubuntu-latest
name: Federated login + read check
steps:
- name: Login to Azure (OIDC)
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Prove identity and RBAC scope
run: |
az account show --query "{subscription:name, subscriptionId:id, tenantId:tenantId, user:user.name}" -o json
echo "--- function app visible with Contributor scope? ---"
az functionapp list --query "[].name" -o json
+5 -6
View File
@@ -36,13 +36,13 @@ concurrency:
# Authenticode for the customer scripts. Same identity and same repository variables as
# build-agent.yml -- the loader on every device pins this publisher, so the two must not
# drift apart.
# drift apart. The publisher takes every host, the timestamp server included, from
# .github/scripts/Get-Endpoints.ps1.
env:
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
jobs:
# $/ is the self-repository form: this repository at the running commit, never a
@@ -54,6 +54,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 +76,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:
@@ -124,7 +124,6 @@ jobs:
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
-CertificateName $env:CODE_SIGN_CERTIFICATE_NAME `
-TimestampUrl $env:CODE_SIGN_TIMESTAMP_URL `
-ExpectedPublisher $env:CODE_SIGN_EXPECTED_SUBJECT `
-SignedOutputDir "$env:RUNNER_TEMP/signed-scripts" `
-DryRun:$dryRun
+3
View File
@@ -8,5 +8,8 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
<!-- Run the IDE code-style analyzers in the build too, so the rules .editorconfig raises
to warning fail it like any compiler warning. -->
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>
+1 -1
Submodule infra updated: f93e475b78...0085deab45
+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.",
+2 -2
View File
@@ -86,12 +86,12 @@ for (const config of configs) {
// ── Guardrails generation ───────────────────────────────────────────────────
const guardrailsPath = path.join(rulesRoot, 'guardrails.json');
// NOTE: consumers import from "@/utils/guardrails.generated" (see guardValidation.ts
// NOTE: consumers import from "@/lib/generated/guardrails.generated" (see guardValidation.ts
// and SectionGatherRules.tsx). The web re-org (commit a3e8e044) moved this file to
// utils/ but left this output path pointing at the now-orphaned lib/ copy, which
// silently froze the live allowlist. Keep this in sync with the import path.
const guardrailsOutput = path.resolve(
rulesRoot, '..', 'src', 'Web', 'autopilot-monitor-web', 'utils', 'guardrails.generated.ts'
rulesRoot, '..', 'src', 'Web', 'autopilot-monitor-web', 'lib', 'generated', 'guardrails.generated.ts'
);
if (fs.existsSync(guardrailsPath)) {
@@ -30,6 +30,17 @@ BeforeAll {
return $dir
}
# What Test-FileCatalog -Detailed returns, reduced to the fields the check reads.
function New-FakeCatalogResult {
param(
[string]$Status = 'Valid',
[object]$Signature,
[hashtable]$CatalogItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' },
[hashtable]$PathItems = @{ 'AutopilotMonitor.Agent.exe' = 'AA' }
)
return [pscustomobject]@{ Status = $Status; Signature = $Signature; CatalogItems = $CatalogItems; PathItems = $PathItems }
}
function New-FakeProfile {
param([string]$Name, [double]$AgeMinutes)
$p = Join-Path $TestDrive $Name
@@ -322,86 +333,163 @@ Describe 'logging purity' {
}
}
Describe 'Get-UnverifiedAgentBinaries' {
# The publisher gate on the extracted payload. What matters is the DECISION: which
# payload is accepted, which is refused, and which files are looked at at all.
Describe 'Get-AgentPayloadRejections' {
# The payload catalog check, shared verbatim with the OOBE go-script. What matters is the
# DECISION: which payload is accepted and which is refused. Test-FileCatalog is mocked for
# the branches; one test runs the real cmdlet over a real (unsigned) catalog.
BeforeEach {
Mock Write-Log { }
$script:ours = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
$script:dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat')
}
It 'accepts a payload where every binary of ours is validly signed by us' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
It 'accepts a payload whose catalog is signed by us and matches every file' {
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) }
Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Should -BeNullOrEmpty
}
It 'checks the binaries we author and leaves third-party assemblies alone' {
$dir = New-FakePayload -Files @(
'AutopilotMonitor.Agent.exe',
'AutopilotMonitor.Agent.exe.config',
'AutopilotMonitor.Shared.dll',
'Newtonsoft.Json.dll',
'System.Management.Automation.dll'
)
$script:checked = @()
Mock Get-AuthenticodeSignature {
$script:checked += (Split-Path $FilePath -Leaf)
New-FakeSignature -Status 'Valid' -Subject $script:ours
It 'asks for the detailed result and skips the catalog itself' {
Mock Test-FileCatalog {
$script:call = @{ Skip = $FilesToSkip; Detailed = [bool]$Detailed; Catalog = $CatalogFilePath; Path = $Path }
New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours)
}
Get-UnverifiedAgentBinaries -Path $dir | Should -BeNullOrEmpty
$script:checked | Should -Be @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher | Out-Null
$script:call.Skip | Should -Be @('AutopilotMonitor.Agent.cat')
$script:call.Detailed | Should -BeTrue
$script:call.Catalog | Should -Be (Join-Path $script:dir 'AutopilotMonitor.Agent.cat')
$script:call.Path | Should -Be $script:dir
}
It 'refuses an unsigned agent executable' {
It 'refuses a payload without catalog and does not run the check at all' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
Mock Test-FileCatalog { throw 'must not run' }
$r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*AutopilotMonitor.Agent.cat missing*'
Should -Invoke Test-FileCatalog -Times 0
}
It 'refuses a tampered binary (hash mismatch)' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject $script:ours }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
It 'refuses matching files under an unsigned catalog (Status alone is not the verdict)' {
Mock Test-FileCatalog { New-FakeCatalogResult -Status 'Valid' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*signature status NotSigned*'
}
It 'refuses a signature whose chain does not validate' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject $script:ours }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
It 'refuses a catalog whose signature does not validate' {
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'HashMismatch' -Subject $script:ours) }
(Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 1
}
It 'refuses a validly signed binary from a different publisher' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
It 'refuses a validly signed catalog from another publisher' {
Mock Test-FileCatalog { New-FakeCatalogResult -Signature (New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US') }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*does not match*'
}
It 'refuses the whole payload when a single DLL of ours is swapped' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Shared.dll')
Mock Get-AuthenticodeSignature {
if ((Split-Path $FilePath -Leaf) -eq 'AutopilotMonitor.Shared.dll') {
New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US'
} else {
New-FakeSignature -Status 'Valid' -Subject $script:ours
}
It 'names the missing, extra and changed files of a payload that differs' {
Mock Test-FileCatalog {
New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'Valid' -Subject $script:ours) `
-CatalogItems @{ 'a.dll' = 'AA'; 'b.dll' = 'BB'; 'c.config' = 'CC' } `
-PathItems @{ 'a.dll' = 'AA'; 'b.dll' = 'XX'; 'evil.dll' = 'EE' }
}
$rejected = Get-UnverifiedAgentBinaries -Path $dir
$rejected.Count | Should -Be 1
$rejected[0] | Should -BeLike 'AutopilotMonitor.Shared.dll*'
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*missing: c.config;*'
$r[0] | Should -BeLike '*extra: evil.dll;*'
$r[0] | Should -BeLike '*changed: b.dll)*'
}
It 'refuses when the signature check itself throws' {
$dir = New-FakePayload -Files @('AutopilotMonitor.Agent.exe')
Mock Get-AuthenticodeSignature { throw 'access denied' }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
It 'reports a bad signature and a differing payload together' {
Mock Test-FileCatalog {
New-FakeCatalogResult -Status 'ValidationFailed' -Signature (New-FakeSignature -Status 'NotSigned' -Subject $null) `
-PathItems @{ 'AutopilotMonitor.Agent.exe' = 'AA'; 'extra.dll' = 'EE' }
}
(Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher).Count | Should -Be 2
}
It 'refuses an empty payload instead of passing it' {
$dir = New-FakePayload -Files @('Newtonsoft.Json.dll')
Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject $script:ours }
(Get-UnverifiedAgentBinaries -Path $dir).Count | Should -Be 1
It 'refuses when the catalog check itself throws' {
Mock Test-FileCatalog { throw 'access denied' }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r.Count | Should -Be 1
$r[0] | Should -BeLike '*failed to run*access denied*'
}
It 'refuses when the catalog check returns nothing' {
Mock Test-FileCatalog { }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
$r[0] | Should -BeLike '*returned nothing*'
}
It 'returns a single rejection as an array' {
Mock Test-FileCatalog { throw 'boom' }
$r = Get-AgentPayloadRejections -Path $script:dir -ExpectedPublisher $ExpectedPublisher
($r -is [array]) | Should -BeTrue
}
It 'catches an extra file with the real cmdlet over a real catalog' {
# Real New-FileCatalog/Test-FileCatalog over text files: no PE, no process, no mock.
$dir = New-FakePayload -Files @('a.txt', 'b.txt')
$catalog = Join-Path $TestDrive "real-$([guid]::NewGuid()).cat"
New-FileCatalog -Path $dir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null
Copy-Item $catalog (Join-Path $dir 'AutopilotMonitor.Agent.cat')
Set-Content -Path (Join-Path $dir 'extra.txt') -Value 'planted'
$r = Get-AgentPayloadRejections -Path $dir -ExpectedPublisher $ExpectedPublisher
($r -join ' | ') | Should -BeLike '*extra: extra.txt;*'
($r -join ' | ') | Should -BeLike '*signature status NotSigned*'
}
}
Describe 'Install-VerifiedAgentPayload' {
BeforeEach {
Mock Write-Log { }
$src = New-FakePayload -Files @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', 'Newtonsoft.Json.dll')
$script:zip = Join-Path $TestDrive "payload-$([guid]::NewGuid()).zip"
Compress-Archive -Path (Join-Path $src '*') -DestinationPath $script:zip
$script:staging = Join-Path $TestDrive "staging-$([guid]::NewGuid())"
$script:target = Join-Path $TestDrive "agent-$([guid]::NewGuid())"
}
It 'moves an accepted payload into the agent folder and removes the staging folder' {
Mock Get-AgentPayloadRejections { , @() }
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
@(Get-ChildItem $script:target | ForEach-Object Name | Sort-Object) |
Should -Be @('AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'Newtonsoft.Json.dll')
Test-Path $script:staging | Should -BeFalse
}
It 'verifies the staging folder, never the agent folder' {
Mock Get-AgentPayloadRejections { $script:checked = $Path; , @() }
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
$script:checked | Should -Be $script:staging
}
It 'leaves the agent folder untouched when the payload is refused, and cleans up' {
Mock Get-AgentPayloadRejections { , @('payload differs from catalog (missing: none; extra: evil.dll; changed: none)') }
{ Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher } |
Should -Throw '*Payload verification FAILED*evil.dll*'
Test-Path $script:target | Should -BeFalse
Test-Path $script:staging | Should -BeFalse
}
It 'removes leftovers from the agent folder before the payload moves in' {
New-Item -ItemType Directory -Path $script:target | Out-Null
Set-Content -Path (Join-Path $script:target 'stale.dll') -Value 'x'
Mock Get-AgentPayloadRejections { , @() }
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
Test-Path (Join-Path $script:target 'stale.dll') | Should -BeFalse
Test-Path (Join-Path $script:target 'AutopilotMonitor.Agent.exe') | Should -BeTrue
}
It 'starts from an empty staging folder even when a previous run left one behind' {
New-Item -ItemType Directory -Path $script:staging | Out-Null
Set-Content -Path (Join-Path $script:staging 'leftover.dll') -Value 'x'
Mock Get-AgentPayloadRejections { $script:seen = @(Get-ChildItem $Path | ForEach-Object Name); , @() }
Install-VerifiedAgentPayload -ZipPath $script:zip -StagingPath $script:staging -TargetPath $script:target -ExpectedPublisher $ExpectedPublisher
$script:seen | Should -Not -Contain 'leftover.dll'
}
}
@@ -410,4 +498,8 @@ Describe 'publisher pin' {
$ExpectedPublisher | Should -Be '*O=glueckkanja AG*'
$ExpectedPublisher | Should -Not -Match '^[0-9A-Fa-f]{40}$'
}
It 'names the payload catalog the release pipeline signs' {
$AgentCatalogName | Should -Be 'AutopilotMonitor.Agent.cat'
}
}
+200 -146
View File
@@ -11,10 +11,12 @@
freshly provisioned Windows 365 Cloud PC.
2. Downloads the monitoring agent ZIP from Azure Blob Storage
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
5. Verifies the Authenticode signature and publisher of every binary we author
6. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
7. Verifies the runtime process actually launched
4. Extracts the agent into a fresh staging folder
5. Verifies the payload against its signed catalog (AutopilotMonitor.Agent.cat):
signed by the expected publisher, every file present and unchanged, nothing extra
6. Moves the verified payload into %ProgramData%\AutopilotMonitor\Agent
7. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
8. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
@@ -77,6 +79,14 @@
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-10-13 v2.6 Payload catalog: the agent ZIP carries AutopilotMonitor.Agent.cat,
a catalog of every payload file signed by the expected publisher.
The ZIP is extracted into a staging folder and moved into the
agent folder only when Test-FileCatalog finds the catalog signed
by the expected publisher and every file present, unchanged and
nothing extra. A rejected payload is deleted, so the next run
starts clean. Replaces the v2.5 gate, which covered only the
AutopilotMonitor binaries.
2026-09-08 v2.5 Publisher gate: every AutopilotMonitor binary in the extracted
payload must carry a valid Authenticode signature from the
expected publisher before --install runs. The SHA-256 manifest
@@ -134,16 +144,20 @@ param(
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.5"
$ScriptVersion = "2.6"
# The publisher every binary we author must carry. A -like pattern on the subject, NOT a
# The publisher the payload catalog must carry. A -like pattern on the subject, NOT a
# thumbprint: certificates are renewed, deployed copies of this script are not. Same value
# the loader pins for this script.
$ExpectedPublisher = "*O=glueckkanja AG*"
# Signed catalog at the root of every agent ZIP: the SHA-256 of every other payload file.
$AgentCatalogName = "AutopilotMonitor.Agent.cat"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentStagingPath = "$AgentBasePath\Agent-Install"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap-script.log"
@@ -340,56 +354,107 @@ function Get-BootstrapDecision {
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
}
# Returns the list of reasons why the extracted payload is not ours; an empty result means
# every binary we author carries a valid Authenticode signature from the expected publisher.
# Third-party assemblies keep their own publisher's signature and are not checked here.
# Any error is a rejection: an unreadable signature is an unverified binary.
function Get-UnverifiedAgentBinaries {
param([string]$Path)
$rejected = @()
$ourBinaries = @(
Get-ChildItem -Path $Path -Filter 'AutopilotMonitor.*' -File -ErrorAction SilentlyContinue |
Where-Object { $_.Extension -in @('.exe', '.dll') }
### BEGIN shared payload verification -- embedded verbatim into the OOBE go-script by
### OobeBootstrapScriptGenerator (D-354). Keep it self-contained: parameters only, no
### script-scope variables, no Write-Log.
# Returns the reasons why the extracted payload in $Path must not run; an empty result means
# it matches its signed catalog exactly. Test-FileCatalog's Status covers the hashes only (an
# unsigned catalog over matching files reports Valid), so the signature and its publisher are
# judged on their own. Any error is a rejection: a payload that cannot be verified is an
# unverified payload. The comma keeps a single rejection an ARRAY: PowerShell unrolls a
# one-element result to a bare string, and the caller reads .Count.
function Get-AgentPayloadRejections {
param(
[string]$Path,
[string]$ExpectedPublisher,
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
)
# An empty set is a rejection, not a pass: it means the extraction produced nothing we
# recognise, and the alternative would be a gate that silently approves an empty folder.
# The comma keeps a single rejection an ARRAY: PowerShell unrolls a one-element result to
# a bare string, and the caller reads .Count and indexes into it.
if ($ourBinaries.Count -eq 0) {
return , @("no AutopilotMonitor binaries found in $Path")
$catalog = Join-Path $Path $CatalogName
if (-not (Test-Path -LiteralPath $catalog -PathType Leaf)) {
return , @("catalog $CatalogName missing")
}
foreach ($binary in $ourBinaries) {
try {
$sig = Get-AuthenticodeSignature -FilePath $binary.FullName -ErrorAction Stop
}
catch {
$rejected += "$($binary.Name): signature check failed to run ($($_.Exception.Message))"
continue
}
try {
$result = Test-FileCatalog -Path $Path -CatalogFilePath $catalog -FilesToSkip $CatalogName -Detailed -ErrorAction Stop
}
catch {
return , @("catalog check failed to run ($($_.Exception.Message))")
}
if ($null -eq $result) {
return , @('catalog check returned nothing')
}
if ($null -eq $sig) {
$rejected += "$($binary.Name): signature check returned nothing"
continue
}
$rejected = @()
$signature = $result.Signature
if ($null -eq $signature -or "$($signature.Status)" -ne 'Valid') {
$status = if ($null -eq $signature) { 'missing' } else { "$($signature.Status)" }
$rejected += "catalog signature status $status"
}
elseif ($null -eq $signature.SignerCertificate -or $signature.SignerCertificate.Subject -notlike $ExpectedPublisher) {
$subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject }
$rejected += "catalog publisher '$subject' does not match $ExpectedPublisher"
}
$subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { '<unsigned>' }
if ($sig.Status -ne 'Valid') {
$rejected += "$($binary.Name): signature status $($sig.Status)"
continue
}
if ($subject -notlike $ExpectedPublisher) {
$rejected += "$($binary.Name): unexpected publisher $subject"
continue
if ("$($result.Status)" -ne 'Valid') {
$expected = if ($null -eq $result.CatalogItems) { @{} } else { $result.CatalogItems }
$actual = if ($null -eq $result.PathItems) { @{} } else { $result.PathItems }
$names = {
param($list)
if ($list.Count -eq 0) { return 'none' }
$shown = ($list | Select-Object -First 5) -join ', '
if ($list.Count -gt 5) { $shown += " (+$($list.Count - 5) more)" }
return $shown
}
$missing = @($expected.Keys | Where-Object { -not $actual.ContainsKey($_) } | Sort-Object)
$extra = @($actual.Keys | Where-Object { -not $expected.ContainsKey($_) } | Sort-Object)
$changed = @($expected.Keys | Where-Object { $actual.ContainsKey($_) -and $actual[$_] -ne $expected[$_] } | Sort-Object)
$rejected += "payload differs from catalog (missing: $(& $names $missing); extra: $(& $names $extra); changed: $(& $names $changed))"
}
return , $rejected
}
# Extracts the agent ZIP into a fresh staging folder, verifies it there and moves it into the
# agent folder only when it matches its signed catalog. A rejected payload is deleted with the
# staging folder: nothing unverified lands where SYSTEM runs the agent, and the next run starts
# clean instead of finding a half-installed agent. The caller has made sure no agent is
# installed, so anything already in the agent folder is a leftover and goes first.
function Install-VerifiedAgentPayload {
param(
[string]$ZipPath,
[string]$StagingPath,
[string]$TargetPath,
[string]$ExpectedPublisher,
[string]$CatalogName = 'AutopilotMonitor.Agent.cat'
)
if (Test-Path -LiteralPath $StagingPath) {
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction Stop
}
New-Item -Path $StagingPath -ItemType Directory -Force | Out-Null
try {
Expand-Archive -Path $ZipPath -DestinationPath $StagingPath -Force -ErrorAction Stop
$rejections = Get-AgentPayloadRejections -Path $StagingPath -ExpectedPublisher $ExpectedPublisher -CatalogName $CatalogName
if ($rejections.Count -gt 0) {
throw "Payload verification FAILED ($($rejections -join '; ')). Refusing to install an agent that is not the published one."
}
if (Test-Path -LiteralPath $TargetPath) {
Get-ChildItem -LiteralPath $TargetPath -Force | Remove-Item -Recurse -Force -ErrorAction Stop
}
else {
New-Item -Path $TargetPath -ItemType Directory -Force | Out-Null
}
Get-ChildItem -LiteralPath $StagingPath -Force | Move-Item -Destination $TargetPath -Force -ErrorAction Stop
}
finally {
Remove-Item -LiteralPath $StagingPath -Recurse -Force -ErrorAction SilentlyContinue
}
}
### END shared payload verification
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
# at the bottom); never on dot-source.
function Invoke-Bootstrap {
@@ -408,117 +473,106 @@ function Invoke-Bootstrap {
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
# Download and extract agent binaries
# Download, verify and install the agent binaries. Guard 5 has already returned when an
# agent exe exists, so this always starts without an installed agent.
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
Write-Log "Downloading agent from $AgentDownloadUrl..."
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
else {
Write-Log "Downloading agent from $AgentDownloadUrl..."
try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
}
}
# Publisher gate. The SHA-256 above proves the ZIP is the one the manifest names, but
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
# of the download host. The expected publisher travels inside THIS script, which the
# loader verified before running it, so the chain from the file an administrator
# assigns to the process that starts here stays publisher-anchored end to end.
# Fail-closed, exactly like the hash check: running unverified code as SYSTEM is the
# risk being removed.
Write-Log "Verifying Authenticode signatures of the agent binaries..."
$unverified = Get-UnverifiedAgentBinaries -Path $AgentBinPath
if ($unverified.Count -gt 0) {
throw "Authenticode verification FAILED ($($unverified -join '; ')). Refusing to run an agent that is not signed by the expected publisher."
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
# Payload catalog. The SHA-256 above proves the ZIP is the one the manifest names, but
# that manifest lives in the same container as the ZIP and cannot outlive a compromise
# of the download host. The catalog is signed by the expected publisher, whose pin
# travels inside THIS script -- verified by the loader before it ran -- so the chain
# from the file an administrator assigns to every file the agent runs from stays
# publisher-anchored end to end. Fail-closed like the hash check: running unverified
# code as SYSTEM is the risk being removed.
Write-Log "Verifying the payload against its signed catalog ($AgentCatalogName, publisher pin: $ExpectedPublisher)..."
Install-VerifiedAgentPayload -ZipPath $zipPath -StagingPath $AgentStagingPath -TargetPath $AgentBinPath -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
Write-Log "Payload verified against its signed catalog and installed to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/verifying agent: $($_.Exception.Message)"
throw
}
Write-Log "Authenticode verification passed (publisher pin: $ExpectedPublisher)"
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
@@ -50,6 +50,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
nameof(AgentConfigResponse.MaxBatchSize),
nameof(AgentConfigResponse.MaxAuthFailures),
nameof(AgentConfigResponse.AuthFailureTimeoutMinutes),
nameof(AgentConfigResponse.InterceptedRegistrationRetryBoots),
// Merge reads two Collectors knobs (AgentMaxLifetimeMinutes, HelloWaitTimeoutSeconds);
// the remaining CollectorConfiguration flags flow untouched to DefaultComponentFactory.
nameof(AgentConfigResponse.Collectors),
@@ -55,6 +55,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
UnrestrictedMode = true,
MaxAuthFailures = 7,
AuthFailureTimeoutMinutes = 15,
InterceptedRegistrationRetryBoots = 2,
EnableImeMatchLog = true,
Collectors = new CollectorConfiguration
{
@@ -98,6 +99,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(45, agent.HelloWaitTimeoutSeconds);
Assert.Equal(7, agent.MaxAuthFailures);
Assert.Equal(15, agent.AuthFailureTimeoutMinutes);
Assert.Equal(2, agent.InterceptedRegistrationRetryBoots);
Assert.NotNull(result);
Assert.True(result.UnrestrictedModeChanged);
@@ -468,6 +470,50 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(0, agent.AgentMaxLifetimeMinutes);
}
// ============================================================= Intercepted-registration buffer
[Theory]
[InlineData(0)] // clean up at the first intercepted registration
[InlineData(3)]
public void Merge_copies_intercepted_registration_retry_boots(int remoteValue)
{
var agent = NewAgentConfig();
var remote = FullRemote();
remote.InterceptedRegistrationRetryBoots = remoteValue;
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(remoteValue, agent.InterceptedRegistrationRetryBoots);
}
[Fact]
public void Merge_keeps_the_agent_default_when_the_backend_sends_no_intercepted_registration_retry_boots()
{
var agent = NewAgentConfig();
var remote = FullRemote();
remote.InterceptedRegistrationRetryBoots = null; // backend that predates the field
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(1, agent.InterceptedRegistrationRetryBoots);
}
[Theory]
[InlineData(-1, 0)]
[InlineData(int.MinValue, 0)]
[InlineData(6, 5)]
[InlineData(int.MaxValue, 5)]
public void Merge_clamps_intercepted_registration_retry_boots_to_zero_through_five(int remoteValue, int expected)
{
var agent = NewAgentConfig();
var remote = FullRemote();
remote.InterceptedRegistrationRetryBoots = remoteValue;
RemoteConfigMerger.Merge(agent, remote);
Assert.Equal(expected, agent.InterceptedRegistrationRetryBoots);
}
// ============================================================= Guard
[Fact]
@@ -214,6 +214,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Null(loaded.LatestAgentSha256);
}
[Theory]
[InlineData(0)]
[InlineData(3)]
[InlineData(null)] // backend that predates the field — the agent keeps its default
public void RoundTrip_preserves_intercepted_registration_retry_boots(int? value)
{
// An intercepted start gets no live config, so the registration buffer it decides on
// is the cached value: it must survive the write + read strip untouched.
var probe = NewProbe();
probe.DoCache(new AgentConfigResponse { InterceptedRegistrationRetryBoots = value });
var loaded = probe.DoLoad();
Assert.NotNull(loaded);
Assert.Equal(value, loaded.InterceptedRegistrationRetryBoots);
}
// ── Helpers ─────────────────────────────────────────────────────────
private CacheProbe NewProbe()
@@ -7,6 +7,7 @@ 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.Core.Tests.Transport;
using AutopilotMonitor.Shared.Models;
using Xunit;
@@ -101,6 +102,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.Equal(401, svc.LastFetchAuthStatusCode);
}
[Fact]
public async Task FetchConfig_InterceptedAnswer_FallsBack_WithoutCounting_AndReportsOnce()
{
// A 403 something on the network path answered (TLS inspection): same fallback as an
// auth failure, but the tracker does not count it — it sends ClientCertificateNotReceived.
var distress = new FakeDistressReporter();
var tracker = new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0,
AutopilotMonitor.DecisionCore.Engine.SystemClock.Instance, _logger, distress);
var svc = NewService(BackendBehaviour.AlwaysIntercepted403, tracker);
var result = await svc.FetchConfigAsync(retryOnTransientErrors: true);
Assert.NotNull(result);
Assert.Equal(RemoteConfigFetchOutcome.UsedDefaults, svc.LastFetchOutcome);
Assert.Equal(1, svc.LastFetchAttempts);
Assert.Equal(403, svc.LastFetchAuthStatusCode);
Assert.Equal(0, tracker.ConsecutiveFailures);
var call = Assert.Single(distress.Calls);
Assert.Equal(DistressErrorType.ClientCertificateNotReceived, call.ErrorType);
Assert.StartsWith("agent/config http 403 client_certificate_required", call.Message);
}
[Fact]
public async Task FetchConfig_RetryDisabled_OneAttemptOnly()
{
@@ -252,10 +275,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
/// <summary>All 4 initial attempts fail; the NEXT call (the recovery fetch) succeeds.</summary>
FailAllThenSucceed,
AlwaysAuthFailure401,
AlwaysIntercepted403,
AlwaysReturnNull,
}
private FakeRemoteConfigService NewService(BackendBehaviour behaviour)
private FakeRemoteConfigService NewService(BackendBehaviour behaviour, AuthFailureTracker? tracker = null)
{
// Pass a throwaway BackendApiClient — the fake overrides CallBackendAsync
// before it ever hits the wire.
@@ -269,7 +293,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
bootstrapToken: null,
agentVersion: "0.0.0",
logger: _logger);
return new FakeRemoteConfigService(apiClient, _logger, behaviour);
return new FakeRemoteConfigService(apiClient, _logger, behaviour, tracker);
}
private sealed class FakeRemoteConfigService : RemoteConfigService
@@ -277,8 +301,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
private readonly BackendBehaviour _behaviour;
private int _calls;
public FakeRemoteConfigService(BackendApiClient apiClient, AgentLogger logger, BackendBehaviour behaviour)
: base(apiClient, "00000000-0000-0000-0000-000000000001", logger)
public FakeRemoteConfigService(BackendApiClient apiClient, AgentLogger logger, BackendBehaviour behaviour, AuthFailureTracker? tracker)
: base(apiClient, "00000000-0000-0000-0000-000000000001", logger, authFailureTracker: tracker)
{
_behaviour = behaviour;
}
@@ -305,6 +329,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
throw new HttpRequestException("simulated network failure");
case BackendBehaviour.AlwaysAuthFailure401:
throw new BackendAuthException("Unauthorized", 401);
case BackendBehaviour.AlwaysIntercepted403:
throw new BackendAuthException("The device certificate did not reach the backend.", 403,
classification: InterceptionFixtures.CertificateRequired());
case BackendBehaviour.AlwaysReturnNull:
return Task.FromResult<AgentConfigResponse>(null!);
default:
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
{
/// <summary>
/// Records every distress dispatch instead of sending it. The real anti-flood rules of
/// <see cref="DistressReporter"/> are bypassed on purpose: tests assert what the caller dispatched.
/// </summary>
internal sealed class FakeDistressReporter : DistressReporter
{
public sealed class Call
{
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>();
/// <summary>
/// The send each dispatch returns; null = already completed. Lets a test keep a send in
/// flight to prove a caller waits for it (or stops waiting at its bound).
/// </summary>
public Func<Task>? Send { get; set; }
public FakeDistressReporter()
: base(baseUrl: "https://example.test", tenantId: "t", manufacturer: "m", model: "d", serialNumber: "s", agentVersion: "0.0.0", logger: 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, 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);
}
}
}
@@ -0,0 +1,85 @@
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Periodic;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
{
/// <summary>
/// The local network fingerprint a failed registration is stored with: SHA-256 over the
/// connection type and gateway <see cref="NetworkChangeDetector"/> captures, cut to 16 hex
/// characters; unknown (null) whenever either input is missing. Gateways are documentation
/// addresses (RFC 5737).
/// </summary>
public sealed class NetworkFingerprintTests
{
private static NetworkChangeDetector.NetworkStateSnapshot State(string? connectionType, string? gateway, bool hasNetwork = true) =>
new NetworkChangeDetector.NetworkStateSnapshot
{
HasNetwork = hasNetwork,
ConnectionType = connectionType,
Gateway = gateway,
};
[Fact]
public void Same_connection_type_and_gateway_give_the_same_short_hex_fingerprint()
{
var first = NetworkFingerprint.Of(State("WiFi", "192.0.2.1"));
var second = NetworkFingerprint.Of(State("WiFi", "192.0.2.1"));
Assert.Equal(first, second);
Assert.Equal(NetworkFingerprint.Length, first!.Length);
Assert.True(first.All(c => (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')), first);
}
[Theory]
[InlineData("WiFi", "198.51.100.1")] // another gateway
[InlineData("Ethernet", "192.0.2.1")] // another connection type
public void Another_gateway_or_connection_type_is_another_network(string connectionType, string gateway)
{
Assert.NotEqual(
NetworkFingerprint.Of(State("WiFi", "192.0.2.1")),
NetworkFingerprint.Of(State(connectionType, gateway)));
}
[Fact]
public void Unknown_network_has_no_fingerprint()
{
Assert.Null(NetworkFingerprint.Of(null));
Assert.Null(NetworkFingerprint.Of(State("WiFi", "192.0.2.1", hasNetwork: false)));
Assert.Null(NetworkFingerprint.Of(State("WiFi", null)));
Assert.Null(NetworkFingerprint.Of(State(null, "192.0.2.1")));
}
[Theory]
[InlineData("aaaa", "aaaa", false)]
[InlineData("aaaa", "bbbb", true)]
public void Differs_compares_known_fingerprints(string before, string after, bool expected)
{
Assert.Equal(expected, NetworkFingerprint.Differs(before, after));
}
[Theory]
[InlineData(null, "aaaa")]
[InlineData("aaaa", null)]
[InlineData(null, null)]
public void Differs_is_unknown_when_either_side_is(string? before, string? after)
{
Assert.Null(NetworkFingerprint.Differs(before, after));
}
[Fact]
public void TryCapture_reads_this_machine_without_throwing()
{
// Machine-dependent: either no active interface (null) or a well-formed fingerprint
// that matches the detector's own capture.
var captured = NetworkFingerprint.TryCapture();
if (captured != null)
{
Assert.Equal(NetworkFingerprint.Length, captured.Length);
Assert.Equal(NetworkFingerprint.Of(NetworkChangeDetector.CaptureInterfaceState()), captured);
}
}
}
}
@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
/// <summary>
/// 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), the MSI's downloader before
/// the loader <c>[ts] MSI bootstrap runner started (user=..., pid=...).</c>.
/// </summary>
public sealed class BootstrapProvenanceReaderTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "apm-provenance-" + Guid.NewGuid().ToString("N"));
public BootstrapProvenanceReaderTests() => Directory.CreateDirectory(_dir);
public void Dispose()
{
try { Directory.Delete(_dir, recursive: true); } catch { /* best effort */ }
}
private void Write(string name, params string[] lines) =>
File.WriteAllText(Path.Combine(_dir, name), string.Join("\r\n", lines) + "\r\n", Encoding.ASCII);
private static string InstallerRun(string version) => $"[2026-10-13 09:00:00] Bootstrap script version: v{version}";
private static string LoaderRun(string version) =>
$"[2026-10-13 08:59:58] === Autopilot Monitor bootstrap loader v{version} started (user=SECRETUSER, pid=4242) ===";
[Fact]
public void Loader_path_reports_installer_and_loader_versions()
{
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2"), "[2026-10-13 08:59:59] Installer signature verified");
Write(BootstrapProvenanceReader.InstallerLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor Bootstrap Started =====", InstallerRun("2.5"));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.True(p.ScriptLogPresent);
Assert.Equal("2.5", p.ScriptVersion);
Assert.Equal("loader", p.LoaderLog);
Assert.Equal("1.2", p.LoaderVersion);
Assert.False(p.OobeLogPresent);
}
[Fact]
public void The_last_run_wins_when_the_log_grew_over_several_runs()
{
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.3"), "x", InstallerRun("2.4"), "y", InstallerRun("2.5"), "z");
Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
}
[Fact]
public void A_directly_assigned_installer_has_no_loader_log()
{
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.4"));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.Equal("2.4", p.ScriptVersion);
Assert.Null(p.LoaderLog);
Assert.Null(p.LoaderVersion);
}
[Fact]
public void An_installer_from_before_the_version_line_is_present_but_without_version()
{
Write(BootstrapProvenanceReader.InstallerLogName, "[2026-03-01 09:00:00] ===== Autopilot Monitor Bootstrap Started =====");
var p = BootstrapProvenanceReader.Read(_dir);
Assert.True(p.ScriptLogPresent);
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()
{
Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.1"));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.Equal("msi", p.LoaderLog);
Assert.Equal("1.1", p.LoaderVersion);
}
[Fact]
public void With_both_loader_logs_the_most_recently_written_one_counts()
{
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.0"));
Write(BootstrapProvenanceReader.MsiLoaderLogName, LoaderRun("1.2"));
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.LoaderLogName), DateTime.UtcNow.AddDays(-3));
File.SetLastWriteTimeUtc(Path.Combine(_dir, BootstrapProvenanceReader.MsiLoaderLogName), DateTime.UtcNow.AddMinutes(-1));
var p = BootstrapProvenanceReader.Read(_dir);
Assert.Equal("msi", p.LoaderLog);
Assert.Equal("1.2", p.LoaderVersion);
}
[Fact]
public void The_oobe_go_script_is_recognised_by_its_log()
{
Write(BootstrapProvenanceReader.OobeLogName, "[2026-10-13 09:00:00] ===== Autopilot Monitor OOBE Bootstrap =====");
Assert.True(BootstrapProvenanceReader.Read(_dir).OobeLogPresent);
}
[Fact]
public void Only_the_tail_of_a_large_log_is_read()
{
var filler = Enumerable.Repeat(new string('.', 200), 600).ToArray(); // ~120 KB
var lines = new List<string> { InstallerRun("2.1") };
lines.AddRange(filler);
lines.Add(InstallerRun("2.5"));
Write(BootstrapProvenanceReader.InstallerLogName, lines.ToArray());
Assert.Equal("2.5", BootstrapProvenanceReader.Read(_dir).ScriptVersion);
}
[Fact]
public void A_missing_directory_reads_as_unknown_and_never_throws()
{
var p = BootstrapProvenanceReader.Read(Path.Combine(_dir, "does-not-exist"));
Assert.False(p.ScriptLogPresent);
Assert.Null(p.ScriptVersion);
Assert.Null(p.LoaderLog);
Assert.False(p.OobeLogPresent);
}
[Fact]
public void The_event_keys_carry_versions_only_never_the_account_name()
{
Write(BootstrapProvenanceReader.LoaderLogName, LoaderRun("1.2"));
Write(BootstrapProvenanceReader.InstallerLogName, InstallerRun("2.5"));
var data = new Dictionary<string, object>();
BootstrapProvenanceReader.Read(_dir).AddTo(data);
Assert.Equal(true, data["bootstrapScriptLogPresent"]);
Assert.Equal("2.5", data["bootstrapScriptVersion"]);
Assert.Equal("loader", data["bootstrapLoaderLog"]);
Assert.Equal("1.2", data["bootstrapLoaderVersion"]);
Assert.Equal(false, data["oobeBootstrapLogPresent"]);
Assert.DoesNotContain(data.Values, v => v is string s && s.Contains("SECRETUSER"));
}
[Fact]
public void Keys_that_were_not_found_are_left_out()
{
var data = new Dictionary<string, object>();
BootstrapProvenanceReader.Read(_dir).AddTo(data);
Assert.Equal(new[] { "bootstrapScriptLogPresent", "oobeBootstrapLogPresent" }, data.Keys.OrderBy(k => k).ToArray());
}
}
}
@@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.IO;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
{
/// <summary>
/// Step 5b of the self-update: the staged payload must match its signed catalog before a
/// single file is swapped in. The verifier is replaced through the static hook; each test
/// restores it, as in <see cref="SelfUpdaterGracefulShutdownTests"/>.
/// </summary>
public sealed class SelfUpdaterPayloadCatalogTests : IDisposable
{
private const string StagingDir = @"C:\ProgramData\AutopilotMonitor\Agent-Update";
private readonly Func<string, PayloadCatalogVerdict> _savedVerifier;
private readonly List<string> _log = new List<string>();
public SelfUpdaterPayloadCatalogTests()
{
_savedVerifier = SelfUpdater.VerifyPayloadCatalog;
}
public void Dispose() => SelfUpdater.VerifyPayloadCatalog = _savedVerifier;
[Fact]
public void Production_default_is_the_real_verifier()
{
Assert.Equal(typeof(PayloadCatalogVerifier), _savedVerifier.Method.DeclaringType);
Assert.Equal(nameof(PayloadCatalogVerifier.Verify), _savedVerifier.Method.Name);
}
[Fact]
public void Accepted_payload_lets_the_update_continue()
{
string? checkedDir = null;
SelfUpdater.VerifyPayloadCatalog = dir => { checkedDir = dir; return PayloadCatalogVerdict.Accept(); };
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
Assert.True(ok);
Assert.Null(reason);
Assert.Equal(StagingDir, checkedDir);
Assert.Contains(_log, line => line.Contains("payload catalog verified"));
}
[Fact]
public void Rejected_payload_stops_the_update_and_hands_over_the_reason()
{
SelfUpdater.VerifyPayloadCatalog = _ =>
PayloadCatalogVerdict.Reject("payload differs from catalog (missing: none; extra: evil.dll; changed: none)");
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
Assert.False(ok);
Assert.Contains("extra: evil.dll", reason);
Assert.Contains(_log, line => line.Contains("REJECTED"));
}
[Fact]
public void A_throwing_verifier_is_a_rejection_not_a_pass()
{
SelfUpdater.VerifyPayloadCatalog = _ => throw new InvalidOperationException("runspace broke");
var ok = SelfUpdater.VerifyStagedPayload(StagingDir, _log.Add, out var reason);
Assert.False(ok);
Assert.Contains("runspace broke", reason);
}
[Fact]
public void The_catalog_check_runs_before_any_file_is_swapped()
{
// Source-level pin: CheckAndApplyUpdateAsync has no seam for the download, so the
// ordering that makes the check meaningful is pinned on the source itself.
var source = File.ReadAllText(Path.Combine(FindRepoRoot(),
"src", "Agent", "AutopilotMonitor.Agent.V2.Core", "Monitoring", "Runtime", "SelfUpdater.cs"));
var check = source.IndexOf("if (!VerifyStagedPayload(stagingDir, log, out var catalogRejection))", StringComparison.Ordinal);
var swap = source.IndexOf("var swapOk = SwapFiles(agentDir, stagingDir, log);", StringComparison.Ordinal);
Assert.True(check > 0, "step 5b not found in SelfUpdater.cs");
Assert.True(swap > check, "SwapFiles must come after the catalog check");
Assert.Contains("WriteSkipMarker(\"catalog_rejected\"", source);
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return dir.FullName;
}
}
}
@@ -64,7 +64,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
private void Buffer(string? rebootUri, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null, string? description = null)
{
_tracker.ProcessEvent(
eventId: MdmRebootPolicyTracker.EventId_PolicyRebootRequired,
recordId: recordId ?? _nextRecordId++,
timeCreatedUtc: timeCreatedUtc ?? At,
rebootUri: rebootUri,
@@ -226,13 +225,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
// The coalesced reboot restarts the agent; the post-reboot backfill re-reads the same
// records — a FLUSHED record must be skipped by the next instance.
var first = MakeTracker(stateDirectory: _tmp.Path);
first.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
first.ProcessEvent(recordId: 500,
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: false);
first.FlushPending();
var second = MakeTracker(stateDirectory: _tmp.Path);
second.LoadWatermark();
second.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
second.ProcessEvent(recordId: 500,
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: true);
second.FlushPending();
@@ -245,13 +244,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
// Process killed by the very reboot the records announce: nothing was flushed, so the
// next instance must re-read and emit the records.
var first = MakeTracker(stateDirectory: _tmp.Path);
first.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
first.ProcessEvent(recordId: 500,
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: false);
// no FlushPending — simulated reboot kill
var second = MakeTracker(stateDirectory: _tmp.Path);
second.LoadWatermark();
second.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
second.ProcessEvent(recordId: 500,
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: true);
second.FlushPending();
@@ -0,0 +1,132 @@
#nullable enable
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport.Telemetry;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
{
/// <summary>
/// The orchestrator side of an intercepted upload: one <c>telemetry_upload_blocked</c> with the
/// evidence (queued behind the intercepted batch, never uploaded while paused), and the one
/// terminal attempt — requested by <c>DrainSpool</c> or made by <see cref="EnrollmentOrchestrator.Stop"/>.
/// </summary>
[Collection("SerialThreading")]
public sealed class EnrollmentOrchestratorInterceptionTests
{
private const int WaitMs = 10_000;
private static TelemetryItemDraft Draft() =>
new TelemetryItemDraft(
kind: TelemetryItemKind.Event,
partitionKey: "T1_S1",
rowKey: "0000000001",
payloadJson: "{}",
isSessionScoped: true,
requiresImmediateFlush: false);
// The signal and the decision transition of the same post mention the event type too.
private static bool IsBlockedEvent(TelemetryItem item) =>
item.Kind == TelemetryItemKind.Event
&& item.PayloadJson.Contains(AutopilotMonitor.Shared.Constants.EventTypes.TelemetryUploadBlocked);
private static TelemetryItem? FindBlockedEvent(EnrollmentOrchestrator sut) =>
sut.SpoolForTests!.Peek(1000).FirstOrDefault(IsBlockedEvent);
// Dates stay strings: the wire format of firstBlockedAtUtc is what is under test.
private static JObject Parse(string json)
{
using var reader = new JsonTextReader(new StringReader(json)) { DateParseHandling = DateParseHandling.None };
return JObject.Load(reader);
}
/// <summary>Starts the orchestrator and drives the first upload into an interception.</summary>
private static async Task<EnrollmentOrchestrator> StartInterceptedAsync(EnrollmentOrchestratorRig rig)
{
rig.Uploader.QueueIntercepted(5);
var sut = rig.Build();
sut.Start();
sut.SpoolForTests!.Enqueue(Draft());
await sut.DrainAsync();
Assert.True(sut.Transport.IsInterceptionPaused);
return sut;
}
[Fact]
public async Task Interception_queues_one_telemetry_upload_blocked_with_the_evidence_and_wakeups_stay_offline()
{
using var rig = new EnrollmentOrchestratorRig();
var sut = await StartInterceptedAsync(rig);
try
{
TelemetryItem? blocked = null;
Assert.True(SpinWait.SpinUntil(() => (blocked = FindBlockedEvent(sut)) != null, WaitMs),
"telemetry_upload_blocked never reached the spool.");
var calls = rig.Uploader.CallCount;
var data = (JObject)Parse(blocked!.PayloadJson)["Data"]!;
Assert.Equal(
new[] { "firstBlockedAtUtc", "operation", "pageTitle", "reason", "serverCertIssuer", "statusCode" },
data.Properties().Select(p => p.Name).OrderBy(n => n, StringComparer.Ordinal));
Assert.Equal("client_certificate_required", (string?)data["reason"]);
Assert.Equal("agent/telemetry", (string?)data["operation"]);
Assert.Equal(JTokenType.Integer, data["statusCode"]!.Type);
Assert.Equal(403, (int)data["statusCode"]!);
Assert.Equal(InterceptionFixtures.PlatformPageTitle, (string?)data["pageTitle"]);
Assert.Equal(InterceptionFixtures.InspectionIssuer, (string?)data["serverCertIssuer"]);
Assert.Equal(
EnrollmentOrchestratorRig.DefaultClockStart.ToString("o", CultureInfo.InvariantCulture),
(string?)data["firstBlockedAtUtc"]);
// The marker was an immediate-upload item; neither its wakeup nor a further drain
// touches the network while the pause holds.
var drain = await sut.DrainAsync();
Assert.Contains("upload intercepted", drain.LastErrorReason);
Assert.Equal(calls, rig.Uploader.CallCount);
Assert.Single(sut.SpoolForTests!.Peek(1000), IsBlockedEvent);
}
finally
{
sut.Stop();
}
}
[Fact]
public async Task Terminal_attempt_requested_by_the_termination_runs_once_and_Stop_adds_none()
{
using var rig = new EnrollmentOrchestratorRig();
var sut = await StartInterceptedAsync(rig);
Assert.True(SpinWait.SpinUntil(() => FindBlockedEvent(sut) != null, WaitMs));
var calls = rig.Uploader.CallCount;
sut.RequestTerminalUploadAttempt();
Assert.True(SpinWait.SpinUntil(() => rig.Uploader.CallCount == calls + 1, WaitMs),
"the woken drain loop did not make the terminal attempt.");
sut.Stop();
Assert.Equal(calls + 1, rig.Uploader.CallCount);
}
[Fact]
public async Task Stop_makes_the_one_terminal_attempt_when_the_termination_did_not()
{
using var rig = new EnrollmentOrchestratorRig();
var sut = await StartInterceptedAsync(rig);
var calls = rig.Uploader.CallCount;
sut.Stop();
Assert.Equal(calls + 1, rig.Uploader.CallCount);
}
}
}
@@ -2,6 +2,7 @@ using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Reflection;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
@@ -10,6 +11,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
@@ -96,6 +98,35 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
Assert.Equal(TimeSpan.FromSeconds(5), client.Timeout);
}
[Fact]
public void CreateHandler_without_observer_keeps_the_default_validation()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
using var cert = CreateSelfSignedCert();
using var handler = MtlsHttpClientFactory.CreateHandler(new FakeCertificateResolver(cert), logger);
Assert.Null(handler.ServerCertificateCustomValidationCallback);
}
[Fact]
public void CreateHandler_with_observer_installs_an_observing_callback_that_never_weakens_validation()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
using var cert = CreateSelfSignedCert();
var observer = new ServerCertificateObserver();
using var handler = MtlsHttpClientFactory.CreateHandler(new FakeCertificateResolver(cert), logger, observer);
var callback = handler.ServerCertificateCustomValidationCallback;
Assert.NotNull(callback);
Assert.False(callback!(null!, cert, null!, SslPolicyErrors.RemoteCertificateChainErrors));
Assert.True(callback(null!, cert, null!, SslPolicyErrors.None));
Assert.Equal(cert.Issuer, observer.LastIssuer);
}
[Fact]
public void CreateHandler_throws_when_resolver_returns_null()
{
@@ -161,7 +192,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
/// we scan the type hierarchy for the first <see cref="HttpMessageHandler"/>-typed
/// instance field to stay version-tolerant.
/// </summary>
private static HttpMessageHandler GetPipelineHead(HttpClient client)
internal static HttpMessageHandler GetPipelineHead(HttpClient client)
{
const BindingFlags flags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
for (var t = (Type?)client.GetType(); t != null; t = t.BaseType)
@@ -77,7 +77,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
using var ing = BuildIngress(clock);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
ing.Start();
host.Start();
@@ -98,7 +98,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
using var ing = BuildIngress(clock);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
ing.Start();
host.Start();
@@ -127,7 +127,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var progress = false;
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller,
espTimeoutProvider: () => null, accountSetupProgressProbe: () => Volatile.Read(ref progress));
ing.Start();
@@ -156,7 +156,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var progress = false;
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller,
espTimeoutProvider: () => null, accountSetupProgressProbe: () => Volatile.Read(ref progress));
ing.Start();
@@ -186,7 +186,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
using var ing = BuildIngress(clock);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller,
espTimeoutProvider: () => null, accountSetupProgressProbe: () => true);
ing.Start();
@@ -212,7 +212,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
using var ing = BuildIngress(clock);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller,
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller,
espTimeoutProvider: () => null, accountSetupProgressProbe: () => true);
ing.Start();
@@ -236,7 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
using var ing = BuildIngress(clock);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
ing.Start();
host.Start();
@@ -259,7 +259,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
using var ing = BuildIngress(clock);
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = new UserEspKeepAwakeHost("S1", "T1", ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
var host = new UserEspKeepAwakeHost(ing, clock, NewLogger(), controller: controller, espTimeoutProvider: () => null);
ing.Start();
host.Start();
@@ -282,7 +282,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
var api = new FakeKeepAwakeApi();
var controller = new KeepAwakeController(NewLogger(), api);
var host = new UserEspKeepAwakeHost(
"S1", "T1", ing, clock, NewLogger(),
ing, clock, NewLogger(),
controller: controller,
safetyCapOverride: TimeSpan.FromMilliseconds(75),
espTimeoutProvider: () => null);
@@ -44,7 +44,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
// from a real backend). We exercise the bundle-shape contract here.
var rcs = new AutopilotMonitor.Agent.V2.Core.Configuration.RemoteConfigService(
auth.BackendApiClient, "tenant-x", logger,
auth.EmergencyReporter, auth.DistressReporter, auth.AuthFailureTracker);
auth.EmergencyReporter, auth.AuthFailureTracker);
var rc = new AgentConfigResponse();
var merge = new RemoteConfigMergeResult();
@@ -1,7 +1,13 @@
using System.IO;
using System.Net.Http;
using System.Net.Security;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
using AutopilotMonitor.Agent.V2.Core.Transport;
using AutopilotMonitor.Agent.V2.Runtime;
using Xunit;
@@ -68,6 +74,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.False(bundle.HasClientCertificate);
}
[Fact]
public void BuildAuthClients_carries_the_server_certificate_observer_for_both_stacks()
{
using var tmp = new TempDirectory();
var bundle = BackendClientFactory.BuildAuthClients(MinimalBootstrapTokenConfig(), agentVersion: "1.2.3.4", logger: NewLogger(tmp.Path));
Assert.NotNull(bundle.ServerCertificates);
}
[Fact]
public void BuildBackendApiHttpClient_installs_the_observing_callback_on_the_inner_handler()
{
var observer = new ServerCertificateObserver();
using var cert = ServerCertificateObserverTests.SelfSigned("CN=Example Inspection CA");
using var client = BackendClientFactory.BuildBackendApiHttpClient(new NetworkMetrics(), clientCertificate: null, agentVersion: "1.2.3.4", observer);
var inner = InnerHandler(client);
var callback = inner.ServerCertificateCustomValidationCallback;
Assert.NotNull(callback);
Assert.False(callback!(null!, cert, null!, SslPolicyErrors.RemoteCertificateNameMismatch));
Assert.True(callback(null!, cert, null!, SslPolicyErrors.None));
Assert.Equal(cert.Issuer, observer.LastIssuer);
}
[Fact]
public void BuildBackendApiHttpClient_without_observer_keeps_the_default_validation()
{
using var client = BackendClientFactory.BuildBackendApiHttpClient(new NetworkMetrics(), clientCertificate: null, agentVersion: "1.2.3.4");
Assert.Null(InnerHandler(client).ServerCertificateCustomValidationCallback);
}
// Pipeline: NetworkMetricsRecordingHandler → HttpClientHandler.
private static HttpClientHandler InnerHandler(HttpClient client) =>
(HttpClientHandler)((DelegatingHandler)MtlsHttpClientFactoryTests.GetPipelineHead(client)).InnerHandler;
// =========================================================== TelemetryClientResult
[Fact]
@@ -122,20 +122,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
// ============================================================ failed-registration record
[Fact]
public void RecordRegistrationRun_records_a_failed_run_with_its_window_and_bounded_error()
public void RecordFailedRun_records_a_failed_run_with_its_window_and_bounded_error()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var started = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
BackendSessionRegistration.RecordRegistrationRun(
var returned = BackendSessionRegistration.RecordFailedRun(
persistence, SessionRegistrationResult.Failed(new string('e', 1000)),
started, started.AddSeconds(1204.46), linkUpAtStart: false, linkUpAtEnd: true,
configFetchOutcome: "UsedDefaults", logger: NewLogger(tmp.Path));
configFetchOutcome: "UsedDefaults", networkFingerprint: null, logger: NewLogger(tmp.Path));
var record = persistence.LoadRegistrationFailure();
Assert.NotNull(record);
Assert.Equal(1, record!.FailedRuns);
Assert.Equal(0, record.InterceptedRuns);
Assert.Equal("Failed", record.Outcome);
Assert.Equal(BackendSessionRegistration.MaxRecordedErrorLength, record.LastError!.Length);
Assert.False(record.NetworkLinkUpAtStart);
@@ -143,33 +144,98 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Equal(1204.5, record.AttemptWindowSeconds);
Assert.Equal("UsedDefaults", record.ConfigFetchOutcome);
Assert.Equal(started.AddSeconds(1204.46), record.LastFailedAtUtc);
// The caller decides on the record as written, including this run.
Assert.Equal(1, returned!.FailedRuns);
}
[Fact]
public void RecordRegistrationRun_records_auth_failures_and_clears_on_success()
public void RecordFailedRun_records_auth_failures_and_a_success_clears_them()
{
using var tmp = new TempDirectory();
var persistence = new SessionIdPersistence(tmp.Path);
var at = new DateTime(2026, 8, 20, 9, 0, 0, DateTimeKind.Utc);
BackendSessionRegistration.RecordRegistrationRun(
BackendSessionRegistration.RecordFailedRun(
persistence, SessionRegistrationResult.AuthFailed(401, "unauthorized"),
at, at.AddSeconds(3), true, true, "Succeeded", NewLogger(tmp.Path));
at, at.AddSeconds(3), true, true, "Succeeded", null, NewLogger(tmp.Path));
Assert.Equal("AuthFailed", persistence.LoadRegistrationFailure()!.Outcome);
BackendSessionRegistration.RecordRegistrationRun(
persistence, SessionRegistrationResult.Succeeded(new RegisterSessionResponse { Success = true }),
at, at.AddSeconds(1), true, true, "Succeeded", NewLogger(tmp.Path));
BackendSessionRegistration.ClearAfterSuccess(persistence, () => null, NewLogger(tmp.Path));
Assert.Null(persistence.LoadRegistrationFailure());
}
[Fact]
public void RecordRegistrationRun_without_persistence_is_a_no_op()
public void Success_after_a_failed_start_tells_reboot_certificate_and_where_the_failure_began()
{
using var tmp = new TempDirectory();
BackendSessionRegistration.RecordRegistrationRun(
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()
{
using var tmp = new TempDirectory();
Assert.Null(BackendSessionRegistration.RecordFailedRun(
null, SessionRegistrationResult.Failed("x"), DateTime.UtcNow, DateTime.UtcNow,
null, null, null, NewLogger(tmp.Path));
null, null, null, null, NewLogger(tmp.Path)));
Assert.Null(BackendSessionRegistration.ClearAfterSuccess(null, () => null, NewLogger(tmp.Path)));
}
// ============================================================ emergency-break message
@@ -0,0 +1,477 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Security;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
using AutopilotMonitor.Agent.V2.Core.Tests.Transport;
using AutopilotMonitor.Agent.V2.Runtime;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
using Xunit;
using ProgramAlias = AutopilotMonitor.Agent.V2.Program;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// A registration that something on the network path answered at process start (client
/// certificate interception plan, K4/K5): a buffer of further boots, then the emergency break's
/// cleanup; the pending ClientCertificateNotReceived distress leaves the machine first; a later
/// successful registration reports the failed starts on <c>agent_started</c>. Each start is
/// ended the way <see cref="BackendSessionRegistration.Register"/> ends it: record the failed
/// run, then conclude it.
/// </summary>
public sealed class InterceptedRegistrationTests : IDisposable
{
private static readonly DateTime At = new DateTime(2026, 10, 12, 9, 0, 0, DateTimeKind.Utc);
private readonly TempDirectory _tmp = new TempDirectory();
private readonly AgentLogger _logger;
private readonly SessionIdPersistence _persistence;
private readonly string _stateDir;
private int _cleanupCalls;
public InterceptedRegistrationTests()
{
_logger = new AgentLogger(Path.Combine(_tmp.Path, "logs"), AgentLogLevel.Info);
_persistence = new SessionIdPersistence(_tmp.Path);
_persistence.GetOrCreate(_logger);
_stateDir = Path.Combine(_tmp.Path, "State");
}
public void Dispose() => _tmp.Dispose();
// ============================================================ K4: buffer, then clean up
[Fact]
public void Agent_default_buffer_allows_one_further_boot_then_cleans_up()
{
var config = new AgentConfiguration();
Assert.Equal(1, config.InterceptedRegistrationRetryBoots);
Assert.Equal(6, Boot(Intercepted(), config));
Assert.False(GaveUp);
Assert.True(_persistence.SessionExists());
Assert.Equal(0, _cleanupCalls);
var record = _persistence.LoadRegistrationFailure()!;
Assert.Equal(1, record.FailedRuns);
Assert.Equal(1, record.InterceptedRuns);
Assert.Equal("AuthFailed", record.Outcome);
Assert.Equal(6, Boot(Intercepted(), config)); // the give-up keeps exit 6
AssertCleanedUpLikeTheEmergencyBreak(expectedCleanupCalls: 1);
}
[Theory]
[InlineData(0)] // clean up at the first intercepted start
[InlineData(2)]
[InlineData(5)]
public void Gives_up_on_the_first_intercepted_start_beyond_the_buffer(int buffer)
{
var config = new AgentConfiguration { InterceptedRegistrationRetryBoots = buffer };
for (var start = 1; start <= buffer; start++)
{
Assert.Equal(6, Boot(Intercepted(), config));
Assert.False(GaveUp, $"gave up on start {start} with a buffer of {buffer}");
}
Assert.Equal(6, Boot(Intercepted(), config));
AssertCleanedUpLikeTheEmergencyBreak(expectedCleanupCalls: 1);
}
[Theory]
[InlineData(true, 1)]
[InlineData(false, 0)]
public void Give_up_runs_the_cleanup_only_with_SelfDestructOnComplete_like_the_emergency_break(
bool selfDestructOnComplete, int expectedCleanupCalls)
{
var config = new AgentConfiguration
{
InterceptedRegistrationRetryBoots = 0,
SelfDestructOnComplete = selfDestructOnComplete,
};
Assert.Equal(6, Boot(Intercepted(), config));
AssertCleanedUpLikeTheEmergencyBreak(expectedCleanupCalls);
}
[Fact]
public async Task An_intercepted_start_takes_the_buffer_from_the_cached_config()
{
// The config fetch of an intercepted start is intercepted too: it falls back to the
// cache, and the registration decides on the cached buffer, not the agent default.
var config = new AgentConfiguration();
var service = new InterceptedConfigService(_logger, cached: new AgentConfigResponse
{
InterceptedRegistrationRetryBoots = 0,
SelfDestructOnComplete = true,
});
var fetched = await service.FetchConfigAsync(retryOnTransientErrors: true);
Assert.Equal(RemoteConfigFetchOutcome.FromCache, service.LastFetchOutcome);
RemoteConfigMerger.Merge(config, fetched, _logger);
Assert.Equal(0, config.InterceptedRegistrationRetryBoots);
Assert.Equal(6, Boot(Intercepted(), config));
AssertCleanedUpLikeTheEmergencyBreak(expectedCleanupCalls: 1);
}
[Fact]
public void Backend_rejection_and_network_failure_keep_their_exit_codes_and_never_give_up()
{
var config = new AgentConfiguration { InterceptedRegistrationRetryBoots = 0 };
Assert.Equal(6, Boot(BackendRejected(), config));
Assert.Equal(7, Boot(NetworkFailure(), config));
Assert.False(GaveUp);
Assert.True(_persistence.SessionExists());
Assert.Equal(0, _cleanupCalls);
var record = _persistence.LoadRegistrationFailure()!;
Assert.Equal(2, record.FailedRuns);
Assert.Equal(0, record.InterceptedRuns);
}
[Fact]
public void Only_an_intercepted_start_gives_up_even_when_the_count_already_exceeds_the_buffer()
{
var config = new AgentConfiguration { InterceptedRegistrationRetryBoots = 5 };
Boot(Intercepted(), config);
Boot(Intercepted(), config);
config.InterceptedRegistrationRetryBoots = 0; // e.g. a later cached config lowered it
Assert.Equal(6, Boot(BackendRejected(), config));
Assert.Equal(7, Boot(NetworkFailure(), config));
Assert.False(GaveUp);
Assert.Equal(0, _cleanupCalls);
var record = _persistence.LoadRegistrationFailure()!;
Assert.Equal(4, record.FailedRuns);
Assert.Equal(2, record.InterceptedRuns); // unchanged by the other failures
Assert.Equal("Failed", record.Outcome);
}
[Fact]
public void Failures_of_another_kind_between_interceptions_do_not_reset_the_count()
{
var config = new AgentConfiguration(); // buffer 1
Assert.Equal(6, Boot(Intercepted(), config));
Assert.Equal(7, Boot(NetworkFailure(), config));
Assert.Equal(6, Boot(BackendRejected(), config));
Assert.False(GaveUp);
Assert.Equal(6, Boot(Intercepted(), config)); // second interception since the last success
AssertCleanedUpLikeTheEmergencyBreak(expectedCleanupCalls: 1);
}
[Fact]
public void Only_a_successful_registration_resets_the_count()
{
var config = new AgentConfiguration(); // buffer 1
Boot(Intercepted(), config);
BackendSessionRegistration.ClearAfterSuccess(_persistence, () => "fp-office", _logger);
Assert.Equal(6, Boot(Intercepted(), config));
Assert.False(GaveUp);
Assert.Equal(1, _persistence.LoadRegistrationFailure()!.InterceptedRuns);
}
[Fact]
public void Without_a_written_record_the_buffer_cannot_be_enforced_and_the_next_boot_retries()
{
var config = new AgentConfiguration { InterceptedRegistrationRetryBoots = 0 };
var exit = BackendSessionRegistration.ConcludeFailedRun(
Intercepted(), record: null, config, authFailureTracker: null,
_persistence, _stateDir, CleanupFactory, _logger);
Assert.Equal(6, exit);
Assert.False(GaveUp);
Assert.True(_persistence.SessionExists());
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task The_registration_answer_decides_whether_the_start_counts_as_intercepted(bool foreign)
{
// Through the real client: no correlation echo + the platform's "Client Certificate
// Required" page is an interception; a JSON 403 that echoes the id is the backend's verdict.
using var handler = new RecordingHttpMessageHandler();
if (foreign)
handler.QueueForeign(HttpStatusCode.Forbidden, InterceptionFixtures.CertificateRequiredPage);
else
handler.QueueStatus(HttpStatusCode.Forbidden, "{\"error\":\"Device validation failed\"}");
using var http = new HttpClient(handler);
var apiClient = new BackendApiClient(
http, "https://backend.test", manufacturer: "m", model: "d", serialNumber: "s",
useBootstrapTokenAuth: false, bootstrapToken: null, agentVersion: "0.0.0", logger: _logger);
var distress = new FakeDistressReporter();
var config = new AgentConfiguration
{
ApiBaseUrl = "https://backend.test",
TenantId = "00000000-0000-0000-0000-000000000001",
SessionId = _persistence.GetOrCreate(_logger),
InterceptedRegistrationRetryBoots = 0,
};
var result = await SessionRegistrationHelper.RegisterWithRetryAsync(
apiClient, config, agentVersion: "0.0.0", logger: _logger,
authFailureTracker: NewTracker(distress),
deviceHardware: ("m", "d", "s"),
networkLinkWait: _ => Task.CompletedTask);
Assert.Equal(SessionRegistrationOutcome.AuthFailed, result.Outcome);
Assert.Equal(foreign, result.IsIntercepted);
Assert.Equal(
foreign ? DistressErrorType.ClientCertificateNotReceived : DistressErrorType.DeviceNotRegistered,
Assert.Single(distress.Calls).ErrorType);
Assert.Equal(6, Boot(result, config));
Assert.Equal(foreign, GaveUp);
}
// ============================================================ the distress leaves first
[Fact]
public void The_exit_waits_for_the_pending_ClientCertificateNotReceived_send()
{
var record = RecordFailed(Intercepted(), "fp-office");
var (tracker, inFlight) = DispatchSlowCertificateDistress();
var exit = BackendSessionRegistration.ConcludeFailedRun(
Intercepted(), record, new AgentConfiguration(), tracker,
_persistence, _stateDir, CleanupFactory, _logger);
Assert.Equal(6, exit);
Assert.True(inFlight.IsCompleted);
Assert.False(GaveUp);
}
[Fact]
public void The_give_up_waits_for_the_pending_send_before_it_cleans_up()
{
var record = RecordFailed(Intercepted(), "fp-office");
var (tracker, inFlight) = DispatchSlowCertificateDistress();
bool? sentBeforeCleanup = null;
var exit = BackendSessionRegistration.ConcludeFailedRun(
Intercepted(), record, new AgentConfiguration { InterceptedRegistrationRetryBoots = 0 }, tracker,
_persistence, _stateDir,
() =>
{
sentBeforeCleanup = inFlight.IsCompleted;
throw new InvalidOperationException("test-harness: cleanup factory must not spawn real PowerShell in unit tests");
},
_logger);
Assert.Equal(6, exit);
Assert.True(sentBeforeCleanup);
Assert.True(GaveUp);
}
[Fact]
public void The_wait_for_the_distress_is_bounded_by_its_own_http_timeout()
{
Assert.True(BackendSessionRegistration.PendingDistressBound <= TimeSpan.FromSeconds(5));
}
// ============================================================ K5: measurement point
[Theory]
[InlineData("fp-office", "fp-office", false)]
[InlineData("fp-office", "fp-home", true)]
public void A_success_after_failed_starts_reports_the_record_it_clears(
string failedOn, string registeredOn, bool networkChanged)
{
var config = new AgentConfiguration { InterceptedRegistrationRetryBoots = 5 };
Boot(NetworkFailure(), config, fingerprint: "fp-elsewhere");
Boot(Intercepted(), config, fingerprint: failedOn); // the latest failed start's network counts
var prior = BackendSessionRegistration.ClearAfterSuccess(_persistence, () => registeredOn, _logger)!;
Assert.Equal(2, prior.FailedRuns);
Assert.Equal(1, prior.InterceptedRuns);
Assert.Equal("AuthFailed", prior.LastOutcome);
Assert.Equal(networkChanged, prior.NetworkChanged);
Assert.Null(_persistence.LoadRegistrationFailureRecord());
var data = EmitAgentStarted(prior);
Assert.Equal(2, data["priorRegistrationFailedRuns"]);
Assert.Equal(1, data["priorRegistrationInterceptedRuns"]);
Assert.Equal("AuthFailed", data["priorRegistrationLastOutcome"]);
Assert.Equal(networkChanged, data["priorRegistrationNetworkChanged"]);
}
[Theory]
[InlineData(null, "fp-home")] // the failed start's network was unknown
[InlineData("fp-office", null)] // this start's network is unknown
public void Network_change_is_omitted_when_either_fingerprint_is_unknown(string? failedOn, string? registeredOn)
{
Boot(Intercepted(), new AgentConfiguration(), fingerprint: failedOn);
var prior = BackendSessionRegistration.ClearAfterSuccess(_persistence, () => registeredOn!, _logger)!;
Assert.Null(prior.NetworkChanged);
var data = EmitAgentStarted(prior);
Assert.Equal(1, data["priorRegistrationFailedRuns"]);
Assert.False(data.ContainsKey("priorRegistrationNetworkChanged"));
}
[Fact]
public void A_success_without_failed_starts_reports_nothing_and_never_reads_the_network()
{
var captures = 0;
var prior = BackendSessionRegistration.ClearAfterSuccess(
_persistence, () => { captures++; return "fp-office"; }, _logger);
Assert.Null(prior);
Assert.Equal(0, captures);
Assert.DoesNotContain(EmitAgentStarted(null).Keys, k => k.StartsWith("priorRegistration", StringComparison.Ordinal));
}
[Fact]
public void The_network_fingerprint_stays_on_the_device()
{
Boot(Intercepted(), new AgentConfiguration(), fingerprint: "0123456789abcdef");
// Kept locally beside the record ...
Assert.Contains("0123456789abcdef", File.ReadAllText(Path.Combine(_tmp.Path, "registration-failure.json")));
Assert.Equal("0123456789abcdef", _persistence.LoadRegistrationFailureRecord()!.NetworkFingerprint);
// ... while the summary the emergency break sends cannot carry it.
var wire = JsonConvert.SerializeObject(new AgentErrorReport
{
PriorRegistrationFailure = _persistence.LoadRegistrationFailure(),
});
Assert.DoesNotContain("0123456789abcdef", wire);
Assert.DoesNotContain("Fingerprint", wire);
Assert.Contains("\"InterceptedRuns\":1", wire);
}
// ============================================================ helpers
private static SessionRegistrationResult Intercepted() =>
SessionRegistrationResult.AuthFailed(403, "The device certificate did not reach the backend.", intercepted: true);
private static SessionRegistrationResult BackendRejected() =>
SessionRegistrationResult.AuthFailed(403, "Backend returned 403 Forbidden. The device is not authorized.");
private static SessionRegistrationResult NetworkFailure() =>
SessionRegistrationResult.Failed("No such host is known.");
private bool GaveUp => File.Exists(Path.Combine(_stateDir, "enrollment-complete.marker"));
private AuthFailureTracker NewTracker(FakeDistressReporter distress) =>
new AuthFailureTracker(maxFailures: 5, timeoutMinutes: 0, SystemClock.Instance, _logger, distress);
/// <summary>
/// What the registration helper does when the answer did not come from the backend: the
/// tracker dispatches ClientCertificateNotReceived — here a send that stays in flight for a
/// second, started right before the conclusion so nothing else can use the time up.
/// </summary>
private (AuthFailureTracker Tracker, Task InFlight) DispatchSlowCertificateDistress()
{
Task? inFlight = null;
var distress = new FakeDistressReporter { Send = () => inFlight = Task.Delay(TimeSpan.FromSeconds(1)) };
var tracker = NewTracker(distress);
tracker.RecordFailure(403, "agent/register-session", InterceptionFixtures.CertificateRequired());
Assert.Equal(DistressErrorType.ClientCertificateNotReceived, Assert.Single(distress.Calls).ErrorType);
Assert.False(inFlight!.IsCompleted);
return (tracker, inFlight);
}
private RegistrationFailureSummary? RecordFailed(SessionRegistrationResult result, string? fingerprint) =>
BackendSessionRegistration.RecordFailedRun(
_persistence, result, At, At.AddSeconds(2), linkUpAtStart: true, linkUpAtEnd: true,
configFetchOutcome: "FromCache", networkFingerprint: fingerprint, logger: _logger);
/// <summary>One start whose registration failed with <paramref name="result"/>, ended the way Register ends it.</summary>
private int Boot(SessionRegistrationResult result, AgentConfiguration config, string? fingerprint = "fp-office")
{
var record = RecordFailed(result, fingerprint);
return BackendSessionRegistration.ConcludeFailedRun(
result, record, config, authFailureTracker: null, _persistence, _stateDir, CleanupFactory, _logger);
}
private CleanupService CleanupFactory()
{
_cleanupCalls++;
// The real factory spawns a PowerShell cleanup script; throwing makes the cleanup retry
// swallow and log it, so the call is counted without a side effect (as in ProgramGuardsTests).
throw new InvalidOperationException("test-harness: cleanup factory must not spawn real PowerShell in unit tests");
}
/// <summary>The emergency break's end state (ProgramGuardsTests): marker, session files gone, cleanup per SelfDestructOnComplete.</summary>
private void AssertCleanedUpLikeTheEmergencyBreak(int expectedCleanupCalls)
{
Assert.True(GaveUp);
Assert.False(_persistence.SessionExists());
Assert.Null(_persistence.LoadRegistrationFailure());
Assert.Equal(expectedCleanupCalls, _cleanupCalls);
// The next start ends at the bootstrap guard, exactly as after the break.
Assert.True(ProgramAlias.CheckEnrollmentCompleteMarker(
_stateDir, selfDestructOnComplete: false, cleanupServiceFactory: null, _logger, consoleMode: false));
}
private Dictionary<string, object> EmitAgentStarted(PriorRegistrationFailures? prior)
{
var sink = new FakeSignalIngressSink();
LifecycleEmitters.EmitAgentStarted(
new InformationalEventPost(sink, SystemClock.Instance, _logger),
new AgentConfiguration { SessionId = "S1", TenantId = "00000000-0000-0000-0000-000000000001", CommandLineArgs = string.Empty },
new ProgramAlias.PreviousExitSummary { ExitType = "reboot_kill" },
"2.0.test",
remoteConfigService: null,
_logger,
priorRegistrationFailures: prior);
return (Dictionary<string, object>)Assert.Single(sink.Posted).TypedPayload!;
}
/// <summary>A start whose config fetch is answered by the interception: it falls back to the cache.</summary>
private sealed class InterceptedConfigService : RemoteConfigService
{
private readonly AgentConfigResponse _cached;
public InterceptedConfigService(AgentLogger logger, AgentConfigResponse cached)
: base(
new BackendApiClient(
httpClient: new HttpClient(), baseUrl: "http://localhost", manufacturer: string.Empty,
model: string.Empty, serialNumber: string.Empty, useBootstrapTokenAuth: false,
bootstrapToken: null, agentVersion: "0.0.0", logger: logger),
"00000000-0000-0000-0000-000000000001",
logger)
{
_cached = cached;
}
protected override Task<AgentConfigResponse> CallBackendAsync() =>
throw new BackendAuthException("The device certificate did not reach the backend.", 403,
classification: InterceptionFixtures.CertificateRequired());
protected override AgentConfigResponse LoadCachedConfig() => _cached;
protected override Task WaitForNetworkLinkAsync() => Task.CompletedTask;
}
}
}

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