Compare commits

...

73 Commits

Author SHA1 Message Date
Oliver Kieselbach c770bbffcf build: keep NuGet audit advisories as warnings in the warning freeze
A CVE published against an already-referenced package must not block an
unrelated hotfix deploy; advisories are handled by bumping the package.
WarningsNotAsErrors NU1901-NU1904 - everything else stays fatal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:49:27 +02:00
Oliver Kieselbach 3fbdc8ef62 build: freeze .NET builds at zero warnings (warnings-as-errors)
Repo-wide Directory.Build.props: TreatWarningsAsErrors +
MSBuildTreatWarningsAsErrors. Applies to compiler, MSBuild and NuGet
restore warnings including audit advisories (NU1901-NU1904) - a newly
published CVE fails the build until addressed (fail-closed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:44:40 +02:00
Oliver Kieselbach 35cea4fe64 build: clear all remaining .NET build warnings to zero
- CmTraceLogParser: annotate TryParseLine (string? line, [NotNullWhen(true)]
  out entry), init string properties (CS8618/CS8625/CS8604)
- Shared: internal NotNullWhen polyfill for netstandard2.0; multi-target
  netstandard2.0;net10.0 so net10 consumers use the BCL attribute and the
  polyfill cannot collide via InternalsVisibleTo (CS0433)
- AgentEndpointMigrationRules.TryNormalizeTarget: now carries [NotNullWhen(true)]
- Functions.csproj: drop DataProtection/Cryptography.Xml security pins made
  redundant by .NET 10 framework-package pruning (NU1510)
- Functions.Tests.csproj: add Microsoft.AspNetCore.App FrameworkReference so
  pruning applies there too and the pins stay unnecessary (NU1903/NU1904)
- Agent tests: nullable test-helper param, Assert.NotNull before dereference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:38:41 +02:00
Oliver Kieselbach ade6c4948f annotations: tenant switcher on the overview page + GA-lane guard on the global list
/annotations now carries the standard cross-tenant scope (GA/Reader: "All
tenants" aggregate + per-tenant drill-down via GET global/session-annotations,
delegated: managed subset), with a tenant column and tenant-qualified session
deep links. The global list endpoint now derives excludeGlobalAdminLane from
HasGlobalScope so delegated-rescue callers never receive the platform-internal
globaladmin lane (endpoint-test-pinned).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:27:31 +02:00
Oliver Kieselbach 83da522dd7 ci: freeze the web lint warning cap at zero (batch 5)
The 99-warning legacy backlog from the React 19 / eslint-plugin-react-hooks
v6 upgrade is fully burned down — CI now fails on any new warning. The
compiler rules stay demoted to "warn" so local dev output remains readable;
the eslint config comments now point to the established fix patterns
instead of the retired ratchet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:23:44 +02:00
Oliver Kieselbach c15394df87 web: refactor context/sidebar/theme effect-state sync, last lint sites to zero (batch 3)
- AuthContext: delete the SSR isLoading effect — effects never run on the
  server, the guard was unreachable dead code.
- TenantContext: mirror user.tenantId via adjust-during-render; the value
  is still retained while auth resolves and cleared on settled sign-out.
- Global/Tenant notification contexts: scope-masked empty list derived in
  the provider value instead of effect-clearing; loading flag moved into
  the canonical async wrapper.
- New shared hooks/useMediaQuery (useSyncExternalStore, configurable
  server snapshot) — consumed by GlobalSidebar (isDesktop) and
  McpTerminalDemo (prefers-reduced-motion, finished transcript derived).
- GlobalSidebar: route-mode active section derived from pathname;
  active-group and pathname auto-expansion as adjust-during-render, state
  now holds user toggles only.
- ThemeContext: theme read via useSyncExternalStore backed by
  lib/themeStore (manual preference beats OS preference in the snapshot;
  same-tab writes notify through the store's listener list).
- TipOfTheDay: random tip via useSyncExternalStore with module-memoized
  pick (build-time Math.random would freeze one tip and mismatch on
  hydration); re-rolls per mount via cleanup.
- Navbar: previewMode block removed entirely — nothing in the codebase
  consumes the flag anymore, it only persisted itself.
- activation page: ?demo flag via useSyncExternalStore, tri-state null
  sentinel dropped.

Lint is at zero react-hooks warnings repo-wide.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:17:55 +02:00
Oliver Kieselbach 810a6f1f2f web: derive instead of effect-sync across reset/clamp/seed lint sites (batch 2)
Structural fixes for the set-state-in-effect sites that were genuine
state synchronization, using the React-documented patterns:

- adjust-during-render (compare-prev) for reset-on-dep-change: dashboard
  page reset + tenant-filter clear, InstallsTab/MappedSoftwareTab page
  resets, TenantManagementSection URL seeding + page reset,
  OpsAlertRulesSection draft re-seeding, SessionReportsSection deep-link
  auto-open, ReportSessionModal result reset.
- clamp/derive at read: dashboard effectivePage (also removes the
  documented handleNextPage race), backups detail effectiveTableName,
  useBlockDevice scope-masked set, InstallProgress and
  useProgressDerivedData live timers (startedAt-tagged tick state),
  sessions timeBreakdown read guard.
- key-based reset: SessionReportsSection note editor, DeleteConfirmModal
  content remount.
- inverted state: sessions page stores collapsedPhases so new phases are
  expanded by default with no sync effect; hash deep-link expandAll moved
  into its timer callback.
- tagged result state: useScriptDisplayNames stores {key, map} and serves
  the empty map on key mismatch — same staleness guarantee, no
  effect-phase clearing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 19:17:36 +02:00
Oliver Kieselbach 019ac3959e web: clear the three preserve-manual-memoization warnings (batch 4)
- useProgressDerivedData: hoist the currentDownload computation into a
  module-level pure helper — the in-loop Map mutation and early return
  kept the compiler from memoizing the body inline.
- DeviceDetailsCard: derive bootTime from events inside the uptime memo
  (deps [events]) — the outer const is a live alias into event data that
  the compiler's alias analysis treats as maybe-mutated.
- useSessionEvents: route the queued follow-up re-invocation through a
  ref — a memoized callback that calls itself directly cannot be
  memoized. The now-traceable mount effect gets the standard wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 18:56:07 +02:00
Oliver Kieselbach 9236aba46e web: canonical inner-async wrappers for all fetch-effect lint sites (batch 1)
Convert every useEffect that directly invokes a state-setting fetch
callback to the canonical inner-async shape (const run = async () => {...};
void run();) — the react-hooks/set-state-in-effect rule traces direct
calls but accepts effect-scoped async functions. Semantic no-op: an async
body runs synchronously until its first await, so execution order is
byte-identical. Leading loading/error/data resets that are part of the
fetch flow move to the top of run in unchanged order.

52 files, ~60 sites. Lint 99 -> 36 warnings; remaining sites are
reset/derive effects and sensitive context/sidebar refactors (next
batches).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 18:55:49 +02:00
Oliver Kieselbach fda52666d6 web: clear set-state-in-effect lint warning on the annotations page
The annotations overview pushed the ESLint warning count to 100, one over
the frozen --max-warnings 99 cap, failing CI since 66d25f57's run. The
react-hooks/set-state-in-effect rule flags any direct effect call into a
state-setting useCallback, even with every setState behind an await.

Restructure instead of suppressing: loading/error begin-state moves from
fetchPage into the interaction sites (filter selects, load-more; mount is
covered by the initial state), and the effect invokes fetchPage through an
inner async wrapper so no setState is synchronously reachable. Lint is
back to exactly 99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 17:19:52 +02:00
Oliver Kieselbach dcec6a1863 web: friendly empty/error states on the annotations overview page
A 404 (backend without the route yet, deploy skew) now renders the
regular empty state instead of an error; genuine load failures show a
calm non-technical retry hint in amber instead of raw statusText in red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:42:29 +02:00
Oliver Kieselbach 041626494e ci: bump actions/cache v4 -> v6 (Node 24 runtime)
Node 20 actions are deprecated on GitHub runners; actions/cache@v4 was
the last remaining action targeting it (MCP server tests job warning).
v6 runs on node24 like the rest of the workflow actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:41:10 +02:00
Oliver Kieselbach fd32a90d57 web: label the GA annotation lane as internal, with a visibility badge
"Autopilot Monitor Team" read like a vendor-to-customer message channel.
The lane only ever renders for platform-scope users, so it now says
"Internal (Autopilot Monitor)" and carries a "never visible to the
tenant" badge on the session card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:39:25 +02:00
Oliver Kieselbach 90b8243894 annotations: move tenant list route under the /api/sessions cert-exclusion prefix
/api/session-annotations sat outside every clientCertExclusionPaths
prefix, so browsers got the client-cert picker. Rename-over-exclusion
per the diag-files precedent (the Azure list is portal-only config and
dies on app recreation). Three segments (sessions/annotations/list) on
purpose: a two-segment literal would be eaten by sessions/{sessionId}.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:35:54 +02:00
Oliver Kieselbach 66d25f57db Cloud-PC-aware autopilot_profile_missing: Info instead of Warning on W365
On Windows 365 Cloud PCs a missing Autopilot profile (ProfileAvailable=0)
is the expected state - Cloud PCs are provisioned by the Windows 365
service, not by Autopilot - so the warning shown for physical devices is
misleading there.

- Agent: emit autopilot_profile_missing as Info with a Cloud-PC-specific
  message when CloudPcDetector matches; payload now carries isCloudPc.
  Evidence collection (ZTD verdict, registry, endpoint probe) unchanged.
- Web: DeviceDetailsCard renders a neutral info note instead of the amber
  warning for Cloud PC sessions (session.isCloudPc, sticky, covers
  existing sessions; or the new event marker).
- MCP: resource-catalog documents the new isCloudPc data field.
- Docs: Cloud PC exception noted in agent/autopilot-ztd-diagnostics.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:13:34 +02:00
Oliver Kieselbach 84a0424789 annotations: tenant-scoped overview page listing judged sessions
New GET /api/session-annotations (MemberRead + QueryParam, tenant always
the middleware-validated target) backing the portal /annotations page in
the Monitoring sidebar group (deliberately no new Insights group). For
callers without global scope the repository excludes the globaladmin
lane server-side (Lane ne clause), so hidden rows never consume page
budget and an explicit lane filter self-contradicts to empty. Rows
deep-link to the session annotation section. Verdict pill styling moved
to the shared logic module.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 16:13:04 +02:00
Oliver Kieselbach 596788cc26 fix dark mode 2026-08-12 14:32:24 +02:00
Oliver Kieselbach aec2d249e1 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-12 14:23:13 +02:00
Oliver Kieselbach a63f126b88 update dark theme script executions 2026-08-12 14:23:06 +02:00
dependabot[bot] d94a91780c chore(deps): bump the web group across 1 directory with 5 updates (#129)
Bumps the web group with 5 updates in the /src/Web/autopilot-monitor-web directory:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/signalr](https://github.com/dotnet/aspnetcore) | `10.0.0` | `10.0.11` |
| [@types/leaflet](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/leaflet) | `1.9.21` | `1.9.22` |
| [next](https://github.com/vercel/next.js) | `16.2.12` | `16.3.0` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.2.12` | `16.3.0` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `16.2.12` | `16.3.0` |



Updates `@microsoft/signalr` from 10.0.0 to 10.0.11
- [Release notes](https://github.com/dotnet/aspnetcore/releases)
- [Changelog](https://github.com/dotnet/aspnetcore/blob/main/docs/ReleasePlanning.md)
- [Commits](https://github.com/dotnet/aspnetcore/commits)

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

Updates `next` from 16.2.12 to 16.3.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.12...v16.3.0)

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

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

---
updated-dependencies:
- dependency-name: "@microsoft/signalr"
  dependency-version: 10.0.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@types/leaflet"
  dependency-version: 1.9.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 16.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: next
  dependency-version: 16.3.0
  dependency-type: direct:production
  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-08-11 16:18:11 +02:00
Oliver Kieselbach 0b83fe2390 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-11 16:09:00 +02:00
Oliver Kieselbach 75c0c60717 web: use absolute destination for sign-in redirect in global error boundary
Next 16.3's new @next/next/no-location-assign-relative-destination rule
flags relative URLs assigned to window.location, pushing the lint warning
count past the CI cap of 99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:09:13 +02:00
Oliver Kieselbach 29e7aa675a web: native title tooltips explaining each annotation verdict
Same wording as the customer docs; plain browser tooltip, no cursor
change. Applied to the verdict buttons and the read-only pill.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 15:06:50 +02:00
dependabot[bot] c28d64fe27 chore(deps-dev): bump tsx (#128)
Bumps the mcp group in /src/McpServer/autopilot-monitor-mcp with 1 update: [tsx](https://github.com/privatenumber/tsx).


Updates `tsx` from 4.23.1 to 4.23.11
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.11)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.11
  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-08-11 14:45:08 +02:00
Oliver Kieselbach b2723168fd reports: snapshot session annotations into the report ZIP
SubmitReportAsync adds annotations.json (all lanes) to the package and
annotationCount to report-metadata.json. All lanes are safe: report
blobs are readable only through the GA-only session-reports routes.
Fail-soft like the tenant-context snapshot; the copy outlives the
session=s retention/delete.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:38:33 +02:00
Oliver Kieselbach d05d8ef302 web: annotations card collapsed by default with verdict summary header
The verdict comes at the end of a diagnosis, so the card must not take
space from the details/analysis work above it. Clickable chevron header
per section convention; count + verdict pills stay visible collapsed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 14:33:40 +02:00
Oliver Kieselbach 2c48968ec6 session annotations: role-separated human verdicts on sessions (flywheel labels)
New SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}, lanes
operator/tenantadmin/globaladmin) storing structured verdicts + notes with
server-stamped authorship and a fired-rule-id snapshot for join-free
rule-quality evaluation. Per-lane write matrix re-gated in-function with
own-tenant binding; the globaladmin lane is platform-internal and filtered
from tenant callers. Portal: SessionAnnotationsCard on session detail.
MCP: list_session_annotations (ga) + annotate_session (strictGa, GA lane
only) + annotations key in get_session_summary. Lifecycle: cascade steps
17-19, offboarding wipe, critical-backup member. Privacy page gains the
customer-entered-content line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 13:30:44 +02:00
Oliver Kieselbach 2f8ad82b0a gather-rules: mirror hard blocks in portal/MCP, stamp author on PUT-upsert; agent: bootstrap retry tests
Codex follow-ups to 1ebc2675:

- guardrails.json now mirrors blockedCommandPatterns + maxCommandLength and
  carries C:\Windows\System32\config in blockedFilePrefixes; portal
  (guardValidation.ts) and MCP (rule-validation.ts) enforce them in agent
  order (before the unrestricted-mode early-return), so hard-blocked commands
  no longer show "Allowed" in unrestricted tenants. GuardrailsParityTests
  pins the JSON mirror to the C# constants.
- gather-rule schema: collectorType enum gains json/xml (agent + portal
  supported both; MCP validate_rule falsely rejected them); MCP validates
  json/xml targets with file semantics and rejects activePhases +
  activeFromPhase set together (backend 400 parity); authoring guide updated.
- Author anti-spoof: tenant + global UpdateRule stamp the caller's JWT
  display name before the service call, closing the PUT-upsert hole where a
  non-existing ruleId stored the payload author; true updates still preserve
  the original. GatherRulesFunctionAuthorEndpointTests drives the real
  endpoints (first HttpRequestData-level test) proving body authors never win.
- AgentBootstrap.RetrySelfUpdateAfterEnrollment: internal + UpdateInvokerOverride
  test seam (RequestGracefulShutdown idiom), LastVersionCheckSucceeded setter
  internal; AgentBootstrapSelfUpdateRetryTests pin the guard, the relaxed
  await_enrollment timeouts, and the swallow-everything exception contract
  with save/restore of the static state.

Functions 3958, Agent-Core 2200, Web 670, MCP 543 tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 12:29:05 +02:00
Oliver Kieselbach 4b74995e79 shared: drop Private Preview wording from ContactEmail seeding comment
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 20:34:16 +02:00
Oliver Kieselbach 5d8d9e8d40 web: show tenant contact email in config report tenant status
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 17:58:47 +02:00
Oliver Kieselbach 25bd2c175e web: bump nanoid to 3.3.18 and js-yaml to 4.3.1 (Dependabot high alerts)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:00:59 +02:00
Oliver Kieselbach 830edb98e7 agent: suppress distress dispatch when the auth failure came from a dead endpoint
Follow-up to 13dd6723: a platform-403 (stopped/retired app, HTML error
page) now skips the DeviceNotRegistered/AuthCertificateRejected distress
report - it would be sent to the very endpoint that just answered with a
platform page, and its classification would be wrong. Counting and the
shutdown thresholds are unchanged, so a dead endpoint still drives the
agent to its soft shutdown instead of retrying forever.

The one-report-per-streak gate moves from "count == 1" to a streak-scoped
flag: an endpoint-unavailable failure no longer consumes the streak's
report slot, so a later genuine 403 from a live backend still dispatches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:42:43 +02:00
Oliver Kieselbach 1ebc2675b1 gather-rules: WQL property projections, portal enable-gating, author from JWT
- WMI guard accepts SELECT <property-list> FROM <class> for any class already
  allowed via SELECT * (strict data subset); same algorithm in agent, portal
  validator, and MCP validate_rule (incl. trim fix); guardrails.json unchanged
- Portal refuses to enable custom rules with not-allowed targets: create lands
  disabled, invalid edit force-disables, "Blocked on devices" badge, GitHub-issue
  hint for allowlist requests; agent stays the enforcement boundary
- Rule Author is stamped from the creator's token (display name > UPN) at create
  and preserved through every update, incl. cross-tenant GA edits
- bumpVersion keeps the segment count ("1.0.0" -> "1.1.0")

Customer feedback: SELECT BatteryStatus FROM Win32_Battery + on_change was
impossible because SELECT * re-emits on every charge-percent fluctuation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:41:14 +02:00
Oliver Kieselbach 283c2ed214 web,ci: make Grant-AutopilotMonitorAddOn.ps1 actually downloadable
The docs page and the admin UI both told customers to run
Grant-AutopilotMonitorAddOn.ps1, but the script was never published
anywhere -- it only exists in this private repo. Publish it on the
download alias next to the agent artifacts and reference it:

- build-agent.yml: upload the script on publish_as_stable runs
- admin UI: copied grant command now starts with an irm -OutFile
  download line; prose links the script directly
- utils/config.ts: new AGENT_DOWNLOAD_URL registry constant

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:33:59 +02:00
Oliver Kieselbach 13dd6723da agent: classify platform-403 as endpoint-unavailable + self-update retry after await-enrollment
Field case sits-d.cloud 2026-08-09: a pinned pre-cutover agent (2.0.1282)
hit the stopped legacy Function App. Azure answers with a platform-level
403 HTML page, which the agent reported as "device is not authorized" -
support chases a non-existent authorization problem, and the stranded
build never rescues itself because the startup self-update check runs
pre-enrollment where OOBE has no network.

Two fixes:
- BackendApiClient reads the 401/403 body: an HTML response cannot come
  from our backend, so it is classified as endpoint stopped/retired/
  proxied (BackendAuthException.EndpointUnavailable, page title in the
  message) instead of a device-authorization verdict.
- AgentBootstrap retries the self-update check once after the MDM
  certificate appears (proof of connectivity) when the startup check
  never reached the manifest, with relaxed timeouts (10s check / 60s
  download). An applied update restarts into a current binary BEFORE
  session registration hits a retired endpoint. Marker files are also
  written on this trigger so agent_version_check reflects the latest
  verdict.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:32:10 +02:00
Oliver Kieselbach cb6d80a76e agent: transliterate typographic punctuation to ASCII in log output
Agent logs are written UTF-8 without BOM; cp1252-guessing viewers
(Outlook web attachment preview) render em dashes as mojibake. Map
em/en dash, arrow, ellipsis, curly quotes and NBSP to ASCII at the
central sanitize step so every message is covered without touching
call sites. Genuine non-ASCII payload (paths, names) stays verbatim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:41:44 +02:00
Oliver Kieselbach 024c90d68f ci: cache the MiniLM embedding model so MCP tests stop 429ing on huggingface
The vector-search tests tokenize with the real Xenova/all-MiniLM-L6-v2
model. transformers.js caches it under node_modules by default, which npm ci
recreates on every run — so each CI run re-downloaded the tokenizer/weights
from huggingface.co, and a rate-limited response (429) failed the whole MCP
job (run 31252869254; unrelated to the commit under test). Persist the model
via actions/cache and point HF_CACHE_DIR (already honored by
vector-search-provider.ts) at it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:37:13 +02:00
Oliver Kieselbach 54044c5d9c esp: continue-anyway observation mode — bounded 60-min defang for device-phase failures
Classic UD fleets held at the Device-ESP timeout wall by one slow blocking
app showed ~100% Failed (tenant c9787ba2: 948/1000 esp_terminal_failure)
although the profile allows "Continue anyway" and users demonstrably reach
the desktop — the classic advisory defang requires AccountSetupEnteredUtc
and never applied there.

New operator-set tenant opt-in EnableEspContinueAnywayObservation
(ConfigVersion 36 -> 37, default off):

- DecisionCore: a Device-phase EspTerminalFailure with ContinueAnyway
  allowed + opt-in observed (EspConfigDetected payload fact — kernel stays
  config-free) + Classic/Unknown non-pre-prov scenario defangs into the
  advisory rail with a 60-min AdvisoryCompletion window. Real end via the
  DAD-validated real-user desktop + Hello gate (IME user-session gate is
  structurally unavailable without the AccountSetup anchor): eager
  completion on DesktopArrived, category recovery via the 4910a5a5 hook,
  expiry un-defangs to the original esp_terminal_failure — every observed
  session still gets a terminal verdict.
- Honest outcome: enrollment_complete under an unresolved advisory carries
  espSoftFailure/completionSource (also on the classic post-AccountSetup
  advisory path); backend stamps EspSoftFailure/CompletionSource on the
  Sessions row (+ index mirror, terminal-tuple preservation); web renders
  an amber "with issues" badge plus a session-detail banner. No new
  terminal status — Succeeded + marker.
- Agent: remote-config field merged into AgentConfiguration; stamped into
  both esp_config_detected emitters (orchestrator bootstrap via live
  Func<bool> seam, DeviceInfoCollector via host/factory pass-through).
- Admin UI: GA-only toggle in the tenant management modal (next to
  Bootstrap Token) + stored/runtime rows in the tenant config report;
  GA-gated on both write paths (PUT silently reverts for tenant admins,
  field patch rejects with 400).

Tests: 13 new ContinueAnywayObservationTests; DecisionCore 513, Agent V2
2162, Functions 3948, SummaryDialog 44, vitest 632, tsc clean.
Docs: docs/agent/continue-anyway-observation.md (OKF) + index/log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:28:45 +02:00
Oliver Kieselbach 3b3e8a7614 analyze: delay auto-analyze visibility 30s so rules stop racing the terminal flush
The enqueue fires in the very ingest batch that carries enrollment_failed /
enrollment_complete — by design the FIRST event of the agent's terminal
sequence. The worker's hot loop dequeued sub-second and evaluated rules
against a stream still missing the terminal-flush rows (stuck-promotion's
synthetic app_install_failed, app_tracking_summary, agent_shutting_down),
and the RuleResult dedup made the gap permanent. Session 53d1e9f6:
ANALYZE-ESP-004's optional app_install_failed condition never matched and
{{appName}} rendered as a literal placeholder (confidence 80, factor never
fired). A 30 s visibilityTimeout covers the flush including the agent's
late-event grace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:28:25 +02:00
Oliver Kieselbach 115b4ec8c2 bootstrap: promote Windows 365 Cloud PC relax to v2.3 production script
Install-AutopilotMonitor.ps1 takes over the field-tested v2.3-dev.3 logic
unchanged (code-only diff vs dev: ScriptVersion + log file name); comments
condensed to customer-facing descriptions, dev changelog collapsed into one
v2.3 entry. Pester suite moved to Install-AutopilotMonitor.Tests.ps1 (neutral
test user names), dev script and dev suite removed. Test-ShouldBootstrapAgent
dry-run tester mirrors the Cloud PC trigger and the guard-4 uptime exemption.

Pester 29/29 green under PS 5.1 and pwsh 7; parse + pure-ASCII checks pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 09:18:13 +02:00
Oliver Kieselbach 8ec5a5debd offboarding: close the un-offboard race — config freeze + fail-closed wipe guard
While a tenant offboarding is queued or draining, the TenantConfiguration
tombstone (Disabled=true, 'Offboarding in progress') is the cascade's only
auth gate. A GA lifting Disabled mid-cascade re-opened the tenant against a
wipe that was still coming: data written afterwards was destroyed, and
sessions registered after enumeration survived as orphans while their event
partitions were wiped.

Three layers close it:
- PUT config/{tenantId} returns 409 while the row is the offboarding
  tombstone — for all callers, GA included
- TenantConfigPatchService refuses patch AND revert against the tombstone
  (a revert restores a pre-offboarding snapshot with Disabled=false — the
  exact race)
- TenantOffboardingHandler re-reads the config before the destructive
  post-drain phases and fail-closes with 'unoffboard_race' when the
  tombstone is gone but the wipe has not run (last line of defense)

Shared predicate: TenantOffboardFunction.IsOffboardingTombstone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:25:03 +02:00
Oliver Kieselbach ff35270d5d agent: legacy bootstrap-config without TenantIdWaitSeconds must not disable the wait
A bootstrap-config.json written before the TenantIdWaitSeconds property
existed deserialized the missing field to 0, which the runtime treated as
an explicit opt-out and skipped the 600s TenantId wait entirely — exactly
the wait Hybrid-Join devices need while their Entra registration lands.

TenantIdWaitSeconds is now nullable: missing/null means 'not configured'
and falls through to the agent default (600s); an explicit 0 remains the
legacy fast-fail opt-out. The install merge carries null through instead
of freezing the default into the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:24:50 +02:00
Oliver Kieselbach 312e849573 email: welcome + farewell templates to green branding
Header gradient, CTA button, and links move from the old blue
(#2563eb/#4f46e5) to the landing-page green palette (#1e8a4c/#33b161).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:11:23 +02:00
Oliver Kieselbach 7433df528d web: mark-led OG/Twitter image + PNG apple-touch-icon
The OG image was an unreadable shrunken hero at card size; replace it
with a centered mark-first design (big green shape, large wordmark,
single tagline). Safari cannot use SVG for apple-touch-icon and fell
back to a letter tile — point the icons config at the file-convention
apple-icon.png, now the green mark on white (180x180).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:17:23 +02:00
Claude d86ae18a5e distress: carry the W365 Cloud PC marker into Devices Not Registered
First W365 field run: the rejected Cloud PC appeared in the report as an
anonymous "Microsoft Corporation / Virtual Machine" row — nothing told the
admin that the fix is the Cloud PC validator, not an Autopilot-registration
hunt. The agent's CloudPcDetector verdict (Windows365 key AND
CloudManagedDesktopExtension service) now rides the pre-auth distress channel:

- Agent: DistressReporter captures isCloudPc at construction (optional ctor
  arg, test fakes stay source-compatible); BackendClientFactory feeds it from
  CloudPcDetector; DistressReport.IsCloudPc on the wire (~18 bytes, fits the
  1536-byte payload cap without a bump — comment updated)
- Backend: DistressReportEntry.IsCloudPc, table column + read mapping
  (legacy rows -> false), structured log + AgentDistressReport AI event key;
  GetDeviceNotRegisteredFunction aggregates sticky-true per serial (same OR
  semantics as the Sessions IsCloudPc merge) and the data-quality notice
  names the new self-reported field. GA global/distress-reports serializes
  the full entry and picks it up automatically.
- Web: Cloud PC badge in the Devices Not Registered table (same sky badge as
  the session table) + conditional hint pointing at the Windows 365 Cloud PC
  Validation toggle and the W365CloudPcValidation Graph add-on.
- Tests: sticky-true + no-marker aggregation cases; MakeEntry helper gains
  the optional flag. Docs: third transport rail in
  backend/cloudpc-device-validation.md + log entry.

Unverified like every distress field — triage context only, never an auth
input. Verified: web tsc clean, eslint 0 errors (file's single warning
pre-exists). .NET build/tests run in CI (no SDK in this sandbox).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv
2026-08-07 10:52:35 +00:00
Oliver Kieselbach 1c5f69a9fe w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs (#127)
* w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs

Windows 365 Cloud PCs are never Autopilot-registered, so the serial lookup
(and the Corporate Identifier fallback) structurally reject them and Cloud PC
first-connect enrollment could not be monitored. New stage-3 fallback in the
device-validation chain: extract the Intune device id from the chain-validated
MDM client certificate's Subject CN (field-verified: CN == managedDevice id ==
cloudPC.managedDeviceId) and require a matching cloudPC object in the claimed
tenant (Graph v1.0 virtualEndpoint/cloudPCs, $filter=managedDeviceId eq).
Only service-provisioned Cloud PCs have such an object — a regular enrolled
non-Autopilot device still ends in the 403, and local W365 markers are never
consulted for auth.

- Shared: ValidatorType.CloudPc = 5, TenantConfiguration.ValidateCloudPcDevice
  (default false, tenant-admin writable), GraphAppPermissions.CloudPCReadAll,
  GraphFeatureCatalog feature W365CloudPcValidation -> CloudPC.Read.All
- Backend: CloudPcDeviceValidator (AutopilotDeviceValidator sibling contract:
  30/5 min cache, 2 attempts, transient->503; GUID gate before the OData
  filter; exact-match guard; Graph 403 = definitive "grant missing", not
  transient), SecurityValidator CN extraction + fallback stage + hard gate,
  wiring through the 5 agent endpoints, TableConfigRepository persistence
- Consent script: W365CloudPcValidation in catalog/ValidateSet/help; NEW
  GraphFeatureCatalogSyncTests pins C# catalog <-> grant script (previously
  comment-only lock-step)
- Bootstrap dev script v2.3-dev.3: guard 4 (uptime window) exempted while the
  Cloud-PC relax is active — Cloud PCs run headless for days before first
  connect; the fresh-profile window is the time anchor there (+ Pester tests)
- Web: validation toggle + context plumbing, ValidatedBy "CloudPc" label,
  privacy-page validation claim generalized (Last updated bumped)
- Docs: OKF concept doc backend/cloudpc-device-validation.md + index + log

Verified: web tsc clean, ESLint 0 errors (99 pre-existing warnings = ratchet
cap, none added). .NET build/tests and Pester run in CI — SDK download and
pwsh are unavailable in this sandbox's network policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv

* w365: agent-side IsCloudPc flag + engine expectation "no Device-ESP phase"

Companion to the CloudPc validator: the agent now classifies Windows 365
Cloud PCs locally with the bootstrap's field-verified marker AND
(HKLM\SOFTWARE\Microsoft\Windows365 key + installed
CloudManagedDesktopExtension service, probed via its Services registry key
so net48 needs no System.ServiceProcess). SKIP-safe: any error resolves
false. Never an auth input - backend admission stays exclusively the
cert-CN-bound Graph lookup (ValidatedBy=CloudPc); the two signals are
independent and may disagree.

Two transport rails:

- Session metadata: SessionRegistration.IsCloudPc -> Sessions table
  (sticky-true OR across re-registrations, IsSelfDeployingProfile pattern)
  -> SessionsIndex full mirror -> SessionSummary -> new isCloudPc search
  filter end to end (SearchSessions + QueryRawSessions + pagination
  fingerprint + all three in-memory predicates; MCP search_sessions /
  query_raw_sessions params + summary projection; web type, Cloud PC pill,
  SessionInfoCard enrollment label, CSV export appended column).
- Decision engine: isCloudPc rides the EnrollmentFactsObserved payload,
  recorded set-once for BOTH values in EnrollmentScenarioObservations.CloudPc
  and annotates the scenario profile reason
  cloud_pc_first_connect:no_device_esp_expected - the engine-recorded
  expectation that Device-ESP already ran headless at provisioning and the
  session starts at Account Setup. Deliberately reason+observation only:
  no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-
  classified (Classic), EspConfig keeps configured-FirstSync semantics.
  Positive marker surfaces as cloud_pc_marker in the audit-trail census.
  Updater idempotency: the cloudPcAnnotates gate keeps the generic v1
  reason branch from re-claiming the slot on repeat posts (would bump
  EvidenceOrdinal every post). Additive-nullable observation - no state
  schema bump (v4 convention), serializer roundtrip pinned.

Tests: CloudPcDetector resolver (marker AND, throwing probe), registration
body, facts-signal payload shape, updater annotation/idempotency/
self-deploying interplay, reducer observation both values + census fold,
serializer roundtrip, index projection superset, sticky-true both
directions. Suites: DecisionCore 500, Functions 3941, Agent V2 2154,
vitest 632 - all green; tsc web+MCP clean; ESLint 0 errors (cap 99).

Docs: cloudpc-device-validation.md follow-up section replaced with the
shipped mechanism + log entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-07 12:31:08 +02:00
Oliver Kieselbach 17affb6dfd Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-06 18:20:07 +02:00
Oliver Kieselbach acf4d0eeaa bootstrap(dev): v2.3-dev.2 - Cloud PC detection on captured evidence
First W365 field test (2026-08-06) proved the assumed WMI signature wrong:
a real Cloud PC reports the generic Manufacturer='Microsoft Corporation' +
Model='Virtual Machine'; the 'Cloud PC Enterprise ...' string exists only in
Intune/Graph inventory. Probe on the live Cloud PC captured two local
markers instead: HKLM\SOFTWARE\Microsoft\Windows365 and the
CloudManagedDesktopExtension service (W365 management agent).

Get-CloudPcModel is replaced by Test-IsCloudPc requiring BOTH markers (each
alone has look-alikes: W365-Boot physical clients / legacy managed-desktop
agents). Raw identity and both marker states are always logged. Tests
updated to the marker model (26 tests, green under PS 5.1 and pwsh 7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 18:14:58 +02:00
Oliver Kieselbach 4c1889d011 bootstrap(dev): always log raw Manufacturer/Model for Cloud PC diagnosis
First W365 field test failed: the log said only "CloudPC: False" with no way
to tell whether Win32_ComputerSystem reported unexpected values or the query
failed. Get-CloudPcModel now logs the raw identity on every run and the
error message on failure. Detection pattern itself unchanged until the
probe from the real Cloud PC delivers evidence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 17:59:49 +02:00
Oliver Kieselbach da4de79a9d web: keep Load all clear of Prev/Next on phone-width pagination (#126) 2026-08-06 17:10:15 +02:00
Oliver Kieselbach 6f3f35e1cd web: one-click reset to "All tenants (aggregated)" in tenant scope selector
While a specific tenant is selected on an aggregated-capable page, an x
button next to the dropdown jumps straight back to the aggregate view
instead of requiring a dropdown roundtrip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:55:20 +02:00
Oliver Kieselbach ca22be5cd0 notifications: dedupe duplicate Pre-Provisioning Completed webhook + traffic-light icons
The V2 agent emits whiteglove_complete twice (raw Shell-Core event 62407 +
engine timeline event, ~1s apart). The Pending status write was not
idempotent (terminal guard allowed Pending->Pending as a "transition") and
the ingest fallback additionally forced the transitioned flag, so tenants
received the Teams card twice (HSD report). Pending->Pending is now refused
in IsTerminalTransitionAllowed, and the ingest fallback distinguishes an
already-sealed session (duplicate, no notification) from a genuine write
failure.

Notification titles now use neutral traffic-light circles (green/yellow/red)
instead of rocket/checkmark/cross/siren emojis (customer feedback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:13:19 +02:00
Oliver Kieselbach c421b81751 web: fix all 16 exhaustive-deps lint warnings, ratchet cap 115 -> 99
Wrap ad-hoc fetch closures in useCallback and complete effect deps
(SectionPlatformUsage, usage-metrics, diagnosis, TenantConfigContext,
SignalRContext, dashboard, fleet-health, useProgressSignalR). Convert
sla's initialLoad to a ref so the fresh-once flag can't retrigger the
fetch effect. Document the three intentional live-ref bumps in unmount
cleanups (cancellation counters) with justified disables.

Also fixes a real stale-closure bug: TenantConfigContext's save callback
serialized webhookNotifyOnHardwareRejection without depending on it, so
toggling only that setting saved the old value.

The 3 react-compiler preserve-manual-memoization infos remain (compiler
bailouts, not defects) - cap ratcheted to the exact current count of 99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:10:04 +02:00
Oliver Kieselbach 7636a00e24 web: fix lint regression from Load-all — lift sweep state into useDashboardSessions
The Load-all button reconstructed 'sweep running' locally via a
set-state-in-effect on loadingMore, adding a 116th react-hooks warning
over the 115 cap. The hook already knows loadMore vs loadAll apart, so
expose loadingAll from useDashboardSessions and drop the local state and
effect entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:39:51 +02:00
Oliver Kieselbach 90294455e0 added "Load all" link 2026-08-06 14:26:11 +02:00
Oliver Kieselbach 4def73f193 rules: reserve the built-in rule-ID namespace against tenant custom rules
Tenant rule IDs were free-form with only a point-in-time existence check,
so a customer could occupy a gap or retired ID like ANALYZE-SEC-002; a
later built-in with that ID would then silently shadow the tenant's rule
at merge time (global wins, tenant copy skipped) and their RuleStates
would start toggling our rule.

Enforcement: RuleIdPolicy (Shared) reserves (ANALYZE|GATHER)-<CAT>-<NUM>
case-insensitively, with the CUSTOM category as the sanctioned tenant
namespace (we never ship built-ins in a category named CUSTOM). Both
rule services reject reserved IDs on create AND on the custom-branch of
update (updates upsert, so PUT was a bypass); update endpoints now map
InvalidOperationException to 409 like create. Web forms mirror the check
inline; MCP validate_rule emits a warning finding and the authoring
guide now documents the reservation (guide templates moved to
GATHER-CUSTOM-101/ANALYZE-CUSTOM-101 — the old numeric examples would be
rejected). No existing prod custom rule violates the pattern (verified
against all 13 tenant-partition rows), so nothing needs grandfathering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:15:43 +02:00
Oliver Kieselbach 6bab2d3db9 updated command 2026-08-06 13:53:56 +02:00
Oliver Kieselbach 78b8dd0f1b tests: fix ContinuationToken key-rotation flake — scope the test key to the async context
The rotation test swapped the process-wide static test signing key
mid-test; xUnit runs test classes in parallel, so any pagination test
encoding/decoding during that window failed with bad_signature instead
of its expected reject reason (seen as order-dependent one-off failures
in CI). New AsyncLocal-scoped override confines the swap to the
rotation test's own execution context; the process-wide TestSetup
default is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:51:25 +02:00
Oliver Kieselbach 14acfd5c59 rules: renumber Secure Boot rule to ANALYZE-SEC-008 — SEC-002 is a retired ID
ANALYZE-SEC-002 was the sunset AutoLogon rule; BuiltInRulesTests pins
that the ID never returns (the CI failure on main). Reusing a retired
ID would also inherit any historical tenant state keyed to it. Rule
content is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:51:16 +02:00
Oliver Kieselbach 22d4c6c677 updated commands 2026-08-06 13:30:29 +02:00
Oliver Kieselbach 9fce499f4c rules: ANALYZE-SEC-002 — warn when Secure Boot is disabled
Fires on secureboot_status.uefiSecureBootEnabled=false; legacy BIOS
devices (registry key absent, agent sets note field) are skipped via
precondition. +20 confidence on physical devices. Validated with MCP
validate_rule (0 findings) and test_analyze_rule dry-runs: fired at 90
on a physical SecureBoot:False session, silent on a SecureBoot:True
session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:27:19 +02:00
Oliver Kieselbach 8c7a6410d2 web(mcp-usage): mobile-friendly layout for admin + reporting sections
Headers stack below sm, summary cards go 2-up on phones, top endpoints
show tool name with route as secondary line, and the top-users table is
replaced by stacked rows so requests/last-active are no longer cut off
on narrow screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:01:10 +02:00
Oliver Kieselbach c158768343 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-05 22:55:50 +02:00
Oliver Kieselbach 75197a4184 Logparser pattern testing with the agent's exact .NET engine (test_log_pattern) (#125)
* updated commands

* feat(backend): logparser pattern test endpoint with agent-exact semantics

POST /api/rules/gather/test-pattern evaluates a logparser gather-rule regex
against pasted sample lines exactly the way the agent would on the device.
Motivated by a field incident: a customer verified a pattern with a PHP regex
tester and the .NET engine matched differently.

- CmTraceLogParser moves Agent -> AutopilotMonitor.Shared.Logging so agent
  (IME tracker, logparser collector, stall probe, IME matching mode) and
  backend run the SAME parsing implementation. Pure mechanical move, agent
  behavior unchanged (V2.Core suite 2147/2147 green).
- TestLogPatternFunction mirrors LogParserCollector verbatim: Regex(Compiled,
  1s timeout, NO IgnoreCase -> case-sensitive), first match per line, group
  "0" excluded / unsuccessful groups omitted, format cmtrace (default, match
  against parsed message) vs text (raw line), per-line outcomes incl.
  parse_failed/regex_timeout, and the agent's "set parameter format=text"
  hint verbatim. Pure compute, no tenant data. Caps: 200 lines, 8KB/line.
- Policy: TenantAdminOrGlobalReader (same tier as the analyze dry-run).
- Tests pin the parity showcases: case-sensitivity, envelope invisibility in
  cmtrace mode, optional-group omission, format hint, request validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): test_log_pattern tool + logparser lint and guide hardening

- test_log_pattern: wraps POST /api/rules/gather/test-pattern — tests a
  logparser regex against pasted sample lines with the agent-exact .NET
  semantics (case-sensitive, cmtrace/text mode). Description steers clients
  away from JS/PHP/Python regex testing.
- validate_rule: logparser parameter lint (missing/non-compiling pattern,
  unknown format value = silently cmtrace on the agent) + info pointing to
  test_log_pattern; nextStep for valid logparser drafts routes there too.
- rule_authoring_guide: logparser section now states the matching semantics
  (case-SENSITIVE, .NET, first match per line, cmtrace default) and two new
  pitfalls (engine mismatch, plain-text log under cmtrace default).
- Catalog snapshot + handler/validator tests extended (MCP suite 532 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(okf): test-pattern endpoint + shared CmTraceLogParser in rule authoring surface

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:48:10 +02:00
Oliver Kieselbach 179a441e64 updated commands 2026-08-05 20:21:53 +02:00
Oliver Kieselbach acc95471a6 bootstrap(dev): Pester-testable structure + guard/relax test suite + CI
Refactor Install-AutopilotMonitor-dev.ps1 (v2.3-dev) into functions
(Get-CloudPcModel, Get-RealUserProfilePaths, Get-RelaxDecision,
Get-BootstrapDecision with reason codes) with an Invoke-Bootstrap main and
a dot-source entry guard, so the Pester suite can load the logic without
executing the bootstrap. Behaviour when run directly is unchanged.

Fixes a refactoring leak found by smoke-run: Write-Log used Write-Output,
which turned function return values into arrays and swallowed console
output; now Write-Host, guarded by a dedicated logging-purity test.

Add Install-AutopilotMonitor-dev.Tests.ps1 (25 tests: Cloud PC detection,
relax scenarios incl. the W365 field case, guards 1-5, profile collection
with WMI failure path) and .github/workflows/pester.yml running the suite
under Windows PowerShell 5.1 (IME runtime) plus an ASCII guard over all
bootstrap scripts. Tests pass 25/25 under PS 5.1 and pwsh 7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:17:10 +02:00
Oliver Kieselbach 29ced81d6e docs(okf): tool-telemetry — record the real SDK 1.30 rejection shape
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:15:09 +02:00
Oliver Kieselbach 4966d23a46 fix(mcp): rejection sniffer must match the isError-result shape, not a JSON-RPC error envelope
Second live miss, root cause found via end-to-end repro against the real SDK
stack: SDK 1.30 catches every McpError around the tool handler (Zod input
validation, unknown/disabled tool) and answers with a SUCCESSFUL JSON-RPC
response whose result is a CallToolResult { isError: true, text: "MCP error
<code>: ..." } — a JSON-RPC error envelope never exists for these. The sniffer
now recognizes both shapes and extracts the code from the text prefix; handler
soft errors (toolError) never carry that prefix, so they are not double-logged.
Repro verified: the sniffer logs tool_call_rejected against a live
express + StreamableHTTPServerTransport + zod-tool stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:14:21 +02:00
Oliver Kieselbach 59ba8301a8 fix(mcp): rejection sniffer must capture res.write chunks, not only res.end
Live verification caught the miss: tool_call and search_zero_hit lines arrived,
tool_call_rejected did not. SDK 1.30 bridges the web-standard Response through
Hono's getRequestListener, which pipes ReadableStream bodies as Uint8Array
chunks via res.write followed by a BARE res.end() — the end(chunk) inspection
never saw the envelope. The sniffer (now attachToolCallRejectionSniffer in
telemetry.ts, unit-testable) wraps both write and end, reassembles up to 10 KB
and decodes string/Uint8Array chunks; tests pin the streaming path, the direct
end(string) path, the over-cap silence and the disabled no-wrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:05:22 +02:00
Oliver Kieselbach b142fa0994 updated bootstrapper for W365 devices 2026-08-05 19:49:39 +02:00
Oliver Kieselbach afa8750cbb feat(mcp): tool-call quality telemetry — soft errors, result size, zero-hit searches, arg rejections
withToolTelemetry now receives the tool args and inspects the RESULT: handlers
never throw (toolError returns isError:true), so the previous isError flag was
effectively always false. Each MCP_TOOL_LOGGING line now carries soft-error
status, resultChars + overCap (vs. the anthropic/maxResultSizeChars hint),
a size-bounded args summary and the caller scope — the only telemetry that
covers backend-free tools (search_docs, validate_rule, get_resource).

New signals: search_zero_hit on the three search tools (unmet-demand signal)
and tool_call_rejected for JSON-RPC-level Zod/unknown-tool rejections, sniffed
at the /mcp route since the SDK answers those before any handler runs.

Bicep default toolLoggingEnabled=true (desired state; live flag via
az containerapp update). Docs: docs/mcp/tool-telemetry.md with KQL examples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:16:35 +02:00
Oliver Kieselbach 3c1229a301 MCP rule authoring: deterministic context, validate_rule, and session dry-run (#124)
* feat(backend): dry-run endpoint for draft analyze rules (rule authoring)

POST /api/rules/analyze/dryrun evaluates a DRAFT analyze rule against one
session's events and returns a full diagnostic trace: per-condition
matched/evidence (incl. per-eventType occurrence counts), per-factor
confidence breakdown, verdict, and the would-be MatchedConditions map for
{{token}} interpolation preview. Persists nothing: no RuleResult row, no
session-status change, no notifications.

- RuleEngine.DryRun.cs: DryRunRuleAsync / DryRunEvaluate share the exact
  evaluators (EvaluatePrecondition/EvaluateCondition/EvaluateConfidenceFactor)
  with the production path but evaluate ALL conditions instead of breaking on
  the first failed required one; parity with EvaluateRule is pinned by tests.
- DryRunAnalyzeRuleFunction: TenantAdminOrGlobalReader (read-in-effect POST),
  GA/GlobalReader cross-tenant via session tenant resolution, 1 MB body cap,
  structural draft validation (unknown source/operator, correlation without
  joinField, invalid regex, non-evaluable confidenceFactor DSL) with
  actionable error messages.
- EndpointAccessPolicyCatalog: register the new route.
- RuleEngineDryRunTests: trace semantics, production parity matrix,
  side-effect freedom (VerifyNoOtherCalls), draft validation matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): rule-authoring resources — guide, schemas, guardrails

Deterministic authoring context for customer AIs, replacing chunked
search_docs retrieval for rule creation: get_resource (and the MCP-protocol
resources) now serve

- rule_authoring_guide: hand-written, complete authoring knowledge (collector
  types, triggers, condition sources, operators, confidence model incl. the
  exact factor-condition DSL shapes, {{token}} interpolation order, guardrail
  matching semantics, pitfalls, validate->dry-run workflow)
- rule_schemas: the JSON Schemas (2020-12) for gather + analyze rules
- rule_guardrails: the on-device collection allowlists

Schemas + guardrails are generated into rule-authoring.generated.ts by a new
combine.js emitter (same pattern as the web guardrails mirror; rules/ stays
the single source of truth and CI guardrails-in-sync guards the file).
rule-authoring-drift.test.ts additionally pins generated == repo source,
guide enums == schema enums, and the get_resource size cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): validate_rule + test_analyze_rule tools and author-rule prompt

Closes the authoring loop for customer AIs: draft -> validate -> dry-run
against a real session -> iterate -> create in the portal.

- validate_rule (MCP-local, no backend call): ajv 2020-12 validation against
  the baked schemas, guardrail pre-flight mirroring the agent matching
  semantics (hive-stripped segment-bounded registry/file prefixes,
  whitespace-bounded WMI, exact commands, /-bounded event log channels, hard
  blocks), and semantic lint for schema-valid-but-dead rules (unreachable
  threshold, non-evaluable factor DSL, unknown eventTypes, unanchored
  not_regex allowlists, unresolvable {{tokens}}, trigger cross-fields,
  outputEventType conventions/collisions).
- test_analyze_rule: calls the new POST /api/rules/analyze/dryrun and adds
  the {{token}}-interpolated explanation/remediation preview via the
  existing interpolate-rule-template.
- author-rule MCP prompt guiding scenario -> resources -> draft -> validate
  -> dry-run.
- Both tools are visible to every role (backend enforces
  TenantAdminOrGlobalReader on the dryrun route); catalog snapshot updated.
- deps: ajv + ajv-formats promoted to direct dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(okf): rule authoring surface concept doc

docs/rules/rule-authoring-surface.md: the AI-assisted authoring stack
(deterministic MCP resources via combine.js baking, MCP-local validate_rule
layers, the dry-run trace twin with its parity + side-effect invariants, and
what is deliberately absent); registered in index.md, logged 2026-08-05.

Customer docs (rules/ai-assisted-rule-authoring.md + SUMMARY + FAQ entry) are
committed in the autopilotmonitor-docs repository separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 17:24:28 +02:00
Oliver Kieselbach b4e409783e updated commands 2026-08-04 20:23:21 +02:00
312 changed files with 15118 additions and 1561 deletions
+6
View File
@@ -270,6 +270,12 @@ jobs:
if (Test-Path "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1") {
Send-Blob -Local "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1" -BlobName "Test-ShouldBootstrapAgent.ps1" -ContentType "text/plain; charset=utf-8"
}
# Customer-side Graph add-on grant script -- docs and admin UI link
# https://download.autopilotmonitor.com/agent/Grant-AutopilotMonitorAddOn.ps1
if (Test-Path "scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1") {
Send-Blob -Local "scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1" -BlobName "Grant-AutopilotMonitorAddOn.ps1" -ContentType "text/plain; charset=utf-8"
}
}
# Update AdminConfiguration LatestAgentV2* -- ONLY on stable cutover. The hash-oracle
+18 -5
View File
@@ -77,12 +77,11 @@ jobs:
- name: Typecheck
run: npx tsc --noEmit
# Errors fail outright. Warnings are ratcheted: the cap is the current
# count of accepted warnings (React-Compiler hooks-rule findings demoted
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
# the cap to match; never raise it.
# Errors fail outright, and so does ANY warning: the codebase is
# warning-clean and must stay that way — fix new findings, never
# raise the cap.
- name: Lint
run: npm run lint -- --max-warnings 115
run: npm run lint -- --max-warnings 0
- name: Tests
run: npm test
@@ -107,8 +106,22 @@ jobs:
- name: Typecheck
run: npx tsc --noEmit
# 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
# re-downloaded from huggingface.co and rate-limits (429) failed the job
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
# is honored by vector-search-provider.ts.
- name: Cache embedding model
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.hf-cache
key: hf-model-Xenova-all-MiniLM-L6-v2
- name: Tests
run: npm test
env:
HF_CACHE_DIR: ${{ github.workspace }}/.hf-cache
guardrails-in-sync:
name: Generated guardrails match rules/guardrails.json
+61
View File
@@ -0,0 +1,61 @@
name: Pester (Bootstrap scripts)
# Runs the Pester suite for the Intune bootstrap scripts under Windows PowerShell 5.1
# (the runtime IME uses to execute platform scripts) so guard/relax logic changes are
# verified without resetting a device. Like ci.yml it reports rather than blocks.
on:
push:
branches: [main]
paths: ['scripts/Bootstrap/**']
pull_request:
branches: [main]
paths: ['scripts/Bootstrap/**']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pester-${{ github.ref }}
cancel-in-progress: true
jobs:
pester:
name: Bootstrap guard tests (PS 5.1)
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- name: Install Pester 5+
shell: powershell
run: |
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
- name: Run Pester
shell: powershell
run: |
Import-Module Pester -MinimumVersion 5.5.0
$c = New-PesterConfiguration
$c.Run.Path = 'scripts/Bootstrap'
$c.Run.Exit = $true
$c.Output.Verbosity = 'Detailed'
Invoke-Pester -Configuration $c
- name: Guard scripts stay pure ASCII
shell: powershell
run: |
$failed = $false
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
for ($i = 0; $i -lt $bytes.Length; $i++) {
if ($bytes[$i] -gt 127) {
Write-Host "::error file=scripts/Bootstrap/$($_.Name)::Non-ASCII byte 0x$('{0:X2}' -f $bytes[$i]) at offset $i. PS 5.1 (IME) reads BOM-less files as ANSI and corrupts multi-byte chars."
$failed = $true
break
}
}
}
if ($failed) { exit 1 }
Write-Host 'All bootstrap scripts are pure ASCII.'
+12
View File
@@ -0,0 +1,12 @@
<Project>
<!-- Warning freeze (2026-08-13, backlog cleared to zero): every .NET build in this repo
must stay warning-free. Compiler, MSBuild and NuGet restore warnings are all fatal.
Exception: NuGet audit advisories (NU1901-NU1904) stay warnings — a CVE published
against an already-referenced package must not block an unrelated hotfix deploy;
advisories are handled by bumping the package, not by failing the build. -->
<PropertyGroup>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
</PropertyGroup>
</Project>
+8
View File
@@ -21,6 +21,14 @@ three of them to turn the `autopilot_profile_missing` warning from a guess into
evidence-backed verdict, and the backend rule engine uses the error-code map for
known-issue enrichment.
Windows 365 Cloud PC exception: on Cloud PCs (`CloudPcDetector.DetectIsCloudPc()`)
`ProfileAvailable=0` is the expected state — Cloud PCs are provisioned by the Windows 365
service, not by Autopilot. The event is still emitted with the full evidence payload plus
an `isCloudPc` marker, but as severity Info with a "this is expected" message instead of
a Warning (`DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation`). The portal's
Autopilot Profile card mirrors this: a neutral note for Cloud PC sessions
(`session.isCloudPc` or the event marker), the amber warning otherwise.
Consumers in this repo:
* `ZtdEvidence.cs` (agent) — one-shot event-log query + `Diagnostics\Autopilot` registry
+114
View File
@@ -0,0 +1,114 @@
---
type: Concept
title: Continue-Anyway Observation Mode — Device-Phase ESP Failure Defang
description: Operator-set tenant opt-in that turns an immediate Device-phase ESP terminal failure on a Continue-Anyway-enabled profile into a bounded 60-minute observation; the DAD-validated real-user desktop plus the Hello gate detect the true end, and the session completes as Succeeded with an esp-soft-failure marker.
resource: /src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
tags:
- agent
- decision-engine
- esp
- continue-anyway
- soft-failure
- tenant-config
timestamp: 2026-08-08T00:00:00+02:00
---
# Continue-Anyway Observation Mode
## Problem
A Classic user-driven enrollment whose Device ESP hits the configured terminal timeout
(`syncFailureTimeoutMinutes`, typically 30) fails **before AccountSetup is ever entered**.
The classic advisory defang (`HandleEspTerminalFailureV1`) requires
`AccountSetupEnteredUtc != null`, so these sessions hard-fail immediately — even when the
ESP profile allows "Continue anyway" and users demonstrably dismiss the failure screen and
reach a working desktop. A fleet held at the timeout wall by one slow blocking app then
shows ~100% Failed (tenant c9787ba2: 948/1000 `esp_terminal_failure`, culprit "Encompass
Hybrid Installer"), which tells the tenant nothing.
## Mechanism
Opt-in flow (default off — absence keeps the immediate hard fail):
1. **Tenant setting** `EnableEspContinueAnywayObservation` (`TenantConfiguration`,
operator-set only, NOT exposed in the tenant admin settings UI; the toggle lives in
the Global-Admin tenant management modal next to Bootstrap Token / Unrestricted Mode,
and the field is GA-gated on both write paths — the generic PUT silently reverts it
for tenant admins, the field patch rejects it with 400) → served via `agent/config`
(`AgentConfigResponse`, ConfigVersion 37).
2. **Agent** stamps `espContinueAnywayObservationEnabled=true` into the
`EspConfigDetected` signal payload (both emitters: the orchestrator FirstSync
bootstrap and `DeviceInfoCollector.PostEspConfigDetectedSignal`) — only when enabled,
so the kernel stays config-free and replay/recovery reconstruct the fact from the
signal log.
3. **Engine** (`HandleEspTerminalFailureV1`): a terminal ESP failure is defanged into an
`esp_failure_advisory` (`advisoryReason=esp_failure_defanged_continueanyway_observation`)
when ALL hold: profile allows Continue-Anyway, observation opt-in observed,
`AccountSetupEnteredUtc == null`, and the scenario is Classic/Unknown with
`PreProvisioningSide=None` and no registry self-deploying marker (WhiteGlove /
SelfDeploying / DevicePreparation have no interactive user to press the button).
4. **Observation window**: the shared `AdvisoryCompletion` deadline is armed with a
**60-minute** window (classic advisory: 30) — long enough for failure screen →
Continue anyway → sign-in → desktop, or a full device-phase re-run via "Try again".
## Detecting the real end
The active-advisory-without-AccountSetup shape is unique to this arming site, so
`IsContinueAnywayObservationActive(state)` (advisory recorded ∧ not resolved ∧
AccountSetup never entered) needs no separate opt-in re-check. Three outcomes:
* **Continue anyway** — the DAD-validated real-user desktop arrives (defaultuser0/SYSTEM
excluded at the detector) and the Hello gate is satisfied (resolved, or policy
explicitly disabled without an observed wizard): `HandleDesktopArrivedV1` completes
eagerly through Finalizing (trigger `DesktopArrived:ContinueAnywayObservation`) — the
session end reflects the real end, not the window edge. The IME user-session gate is
deliberately waived: it anchors on `AccountSetupEnteredUtc`, which is structurally
absent here. The terminal `enrollment_complete` carries `espSoftFailure=true`,
`completionSource=continue_anyway_observation` and the failed category; the backend
stamps `EspSoftFailure`/`CompletionSource` on the Sessions row (+ SessionsIndex
mirror) and the web renders the amber "with issues" badge plus a detail banner.
Sessions completing under the CLASSIC advisory (AccountSetup entered) carry the same
marker with `completionSource=continue_anyway_post_accountsetup`.
* **Try again** — the ESP re-runs the failed category; `DeviceSetupProvisioningComplete`
resolves the advisory via the 4910a5a5 recovery hook (`esp_failure_advisory_resolved`)
and the session continues on the normal rails, completing clean (no marker).
* **Nothing** — the window expires: un-defang to `enrollment_failed` with the original
`esp_terminal_failure` reason and
`advisoryReason=continue_anyway_observation_window_expired_without_completion_evidence`;
`LastFailureTrigger=EspTerminalFailure` keeps the likely-stuck app promotion on. Every
observed session therefore still gets a terminal verdict ≤ ~60 min after the failure.
Deadline-fire edge cases mirror the classic paths: desktop + unknown Hello promotes to
`AwaitingHello` with the HelloSafety window (never-observed promote), and enforcement
progress since arming re-arms the window (30-min re-arm, convergent) instead of failing a
device mid-"Try again".
## Status semantics
No new terminal status: the session is `Succeeded` + `EspSoftFailure=true` (user
decision 2026-08-08). Success-rate conventions stay untouched; honesty lives in the
badge, the detail banner, and ANALYZE-ESP-004 (which continues to fire on the un-defang
expiry path via `mayHaveContinuedAnyway`).
# Examples
Session `53d1e9f6-4eef-4f3b-a999-c63e96c5148d` (tenant c9787ba2, 2026-08-07) is the
motivating shape: DeviceSetup/Certificates failed at the 30-min wall while "Encompass
Hybrid Installer" was still installing, AccountSetup 0/5, Continue-Anyway allowed —
hard-failed under the default semantics.
Tests: `ContinueAnywayObservationTests` (DecisionCore.Tests) — defang, 60-min arming,
opt-out/scenario regressions, eager desktop completion with marker, clean-completion
marker absence, expiry un-defang, hello promotes, category recovery.
# Citations
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs` — arming site,
observation branches in `HandleAdvisoryCompletionDeadlineFired`,
`IsContinueAnywayObservationActive`.
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs` —
`HandleDesktopArrivedV1` observation completion, `BuildEnrollmentCompleteEffect`
soft-failure marker.
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs`
/ `TableStorageService.Sessions.cs` — session stamping + index mirror.
* `src/Web/autopilot-monitor-web/components/SessionStatusBadge.tsx` — amber badge.
+142
View File
@@ -0,0 +1,142 @@
---
type: Concept
title: W365 Cloud PC Device Validation — Cert-CN-Bound Fallback Validator
description: Why Windows 365 Cloud PCs can never pass the Autopilot serial lookup, and how the CloudPc validator admits exactly the tenant's service-provisioned Cloud PCs by binding the MDM certificate's Subject CN (= Intune device id) to the Graph virtualEndpoint/cloudPCs inventory.
resource: src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs
tags:
- backend
- security
- device-validation
- windows365
- cloudpc
- graph
timestamp: 2026-08-06T00:00:00+02:00
---
# W365 Cloud PC Device Validation
Windows 365 Cloud PCs are provisioned headless by the Windows 365 service and are
**structurally never Autopilot-registered** — the serial lookup against
`windowsAutopilotDeviceIdentities` (and the Corporate Identifier fallback, whose
serials nobody can pre-import for VMs minted at provisioning time) always misses.
Before this validator, a Cloud PC agent died in the 403 "Device not registered"
even though its mTLS cert was perfectly valid, so Cloud PC first-connect
enrollment (Account Setup, RealmJoin, Hello) could not be monitored.
# Schema — the validator chain
`SecurityValidator.ValidateRequestAsync` stage 4 runs the same chain for every
device; there is **no device-type routing** and the local W365 markers
(Windows365 registry key, CloudManagedDesktopExtension service) are never
consulted for auth — they gain an attacker nothing:
1. **Autopilot serial lookup** (`ValidateAutopilotDevice`) — unchanged primary.
2. **Corporate Identifier** (`ValidateCorporateIdentifier`) — unchanged fallback.
3. **CloudPc** (`ValidateCloudPcDevice`, default off) — only reached when 1+2
missed. Extracts the Intune device id from the **chain-validated** client
certificate's Subject CN (`TryGetIntuneDeviceIdFromCertSubject`: CN present
AND canonical GUID, else definitive reject) and requires a `cloudPC` object
with `managedDeviceId eq <CN>` in the claimed tenant
(`GET /v1.0/deviceManagement/virtualEndpoint/cloudPCs?$filter=…`).
`ValidatedBy = ValidatorType.CloudPc (5)`.
Only machines actually provisioned by the Windows 365 service have a `cloudPC`
object — a regular Intune-enrolled non-Autopilot device still ends in the 403,
so the "Autopilot devices only" contract of stages 1–2 is not widened. The
tenant hard gate accepts `ValidateCloudPcDevice` as a third satisfying flag.
## Why the cert CN, not headers or local markers
Field-verified 2026-08-06 on a real W365 Enterprise Cloud PC (gktatooine.net):
- MDM client cert Subject `CN=07623d56-…` == Intune `managedDevice.id` ==
`cloudPC.managedDeviceId`. The CN cannot be forged without the Intune MDM
Device CA (chain pinned to embedded roots, `CustomRootTrust`).
- `$filter=managedDeviceId eq '…'` is supported server-side on **v1.0** — no
paging fallback needed.
- `managedDevice.serialNumber` matches local `Win32_BIOS.SerialNumber` exactly;
the "Cloud PC Enterprise …" model string exists **only** in Graph, never in
local WMI (local WMI reports Manufacturer='Microsoft Corporation',
Model='Virtual Machine').
Threat model: a valid Intune cert from a *different* tenant fails (its CN does
not exist in the claimed tenant's Cloud PC inventory — this is a *tenant
binding* the serial-based validators do not have); spoofed serial headers and
faked local W365 markers are irrelevant (never consulted); a rogue enrolled VM
with doctored SMBIOS cannot mint a `cloudPC` object (service-side state, not
device-reported inventory — the reason the model-string gate was rejected).
Retired/deprovisioned Cloud PCs disappear from the inventory and their
validation dies with them (compensates the absent cert revocation check).
Residual: a compromised *legitimate* Cloud PC of the tenant — same trust level
as any tenant device today, rate-limited by cert thumbprint.
## Graph permission plumbing
- Optional add-on permission `CloudPC.Read.All` — NOT part of the default
consent set. Feature `W365CloudPcValidation` in `GraphFeatureCatalog` →
surfaces automatically in the "Optional Graph capabilities" UI (the feature
table and grant command are backend-driven) and in
`Grant-AutopilotMonitorAddOn.ps1` (`-Features W365CloudPcValidation`).
`GraphFeatureCatalogSyncTests` pins C# catalog ↔ grant script (previously
comment-only lock-step).
- A Graph **403** (permission not granted) is a definitive negative (cached
5 min), NOT transient — a 503 Retry-After loop cannot fix a missing grant.
The error message names the add-on feature.
- Same resilience contract as `AutopilotDeviceValidator`: 30/5 min
positive/negative cache, 2 attempts, transient → agent-facing 503.
## Bootstrap script interplay (v2.3-dev.3)
The Cloud-PC relax in `Install-AutopilotMonitor-dev.ps1` additionally exempts
**guard 4** (12 h uptime window): a Cloud PC runs headless for days before the
user's first connect, and the monitorable phase (Account Setup) only starts at
that first connect — the single-fresh-profile window (< 15 min) is the time
anchor there, not boot uptime. The OOBE-restore trigger keeps the uptime guard.
## Agent-side IsCloudPc flag + decision-engine expectation
Same change set, agent side. `CloudPcDetector` (V2.Core) re-uses the bootstrap's
field-verified marker AND — `HKLM\SOFTWARE\Microsoft\Windows365` key **plus**
installed `CloudManagedDesktopExtension` service (probed via its
`HKLM\SYSTEM\CurrentControlSet\Services` key; the agent deliberately has no
`System.ServiceProcess` dependency). SKIP-safe: any error resolves `false`.
Three independent transport rails, never an auth input:
- **Session metadata** — `SessionRegistration.IsCloudPc` →
Sessions table (sticky-true OR across re-registrations, like
`IsSelfDeployingProfile`) → SessionsIndex full mirror → `SessionSummary` →
search filter `isCloudPc` (portal, `/api/search/sessions`, `/api/raw/sessions`,
MCP `search_sessions`/`query_raw_sessions`). Agent-reported and unverified —
can legitimately disagree with the server-derived `ValidatedBy = CloudPc`
(e.g. Cloud PC admitted via a stage that ran earlier).
- **Distress channel** — `DistressReport.IsCloudPc` (captured once at
`DistressReporter` construction, `BackendClientFactory`) rides every pre-auth
distress report → `DistressReportEntry.IsCloudPc` → the "Devices Not
Registered" report aggregates it **sticky-true** per serial
(`GetDeviceNotRegisteredFunction`) and the portal insight renders a Cloud PC
badge plus a hint pointing at the Cloud PC validation toggle + the
`W365CloudPcValidation` add-on. Same UNVERIFIED contract as all distress
fields; rows from older agents read false. Fits the existing 1536-byte
payload cap without a bump.
- **Decision engine** — the marker rides the `EnrollmentFactsObserved` payload
(`isCloudPc`), is recorded set-once for BOTH values in
`EnrollmentScenarioObservations.CloudPc`, and annotates the scenario profile
reason `cloud_pc_first_connect:no_device_esp_expected`. That is the
engine-recorded expectation "no Device ESP phase will appear": Device-ESP ran
headless at provisioning, the session starts at Account Setup. Deliberately
reason+observation only — no completion arm depends on DeviceSetup, `Mode`
is still classified by AccountSetup/IME signals (Classic), and `EspConfig`
keeps the *configured* FirstSync semantics. The positive marker surfaces as
`cloud_pc_marker` in the terminal audit-trail signal census.
# Citations
- `src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs`
- `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — stage 4 chain, `TryGetIntuneDeviceIdFromCertSubject`
- `src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs` — `W365CloudPcValidation`
- `scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1` — customer-side grant
- `scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1` — Cloud-PC relax + guard-4 exemption
- `src/Backend/AutopilotMonitor.Functions.Tests/CloudPcDeviceValidatorTests.cs`, `SecurityValidatorTests.cs`, `GraphFeatureCatalogSyncTests.cs`
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CloudPcDetector.cs` — agent-side marker AND
- `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioObservations.cs` — `CloudPc` fact; reason annotation in `EnrollmentScenarioProfileUpdater`
+122
View File
@@ -0,0 +1,122 @@
---
type: Concept
title: Session Annotations — Role-Separated Human Verdicts
description: The SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}) storing per-session human verdicts + notes in three role lanes (operator / tenantadmin / globaladmin), the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal/MCP surfaces.
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs
tags:
- backend
- web
- mcp
- annotations
- authorization
- rule-quality
timestamp: 2026-08-11T00:00:00+02:00
---
# Schema
**Table.** `SessionAnnotations`: PartitionKey = `tenantId`, RowKey = `{sessionId}_{lane}` with
lane ∈ `operator | tenantadmin | globaladmin` (a closed set — `AnnotationLanes.All`). One row per
session + lane, edited in place; per-session reads use the RK range `ge '{sid}_' / lt '{sid}_~'`.
Serialization lives only in `TableSessionAnnotationRepository.StoreAnnotation`/`MapAnnotation`
(round-trip-pinned; corrupt `RuleIdsJson` degrades to an empty list on read).
**Row.** `Verdict` (nullable: `root_cause_confirmed | analysis_wrong | different_problem |
inconclusive`), `Note` (nullable, ≤ 4096 chars), `AuthorUpn`/`AuthorDisplayName` (last editor),
`CreatedByUpn`/`CreatedAtUtc` (first writer, immutable across edits), `UpdatedAtUtc`, and
`RuleIdsJson` — a **snapshot of the rule ids that had fired for the session at write time**
(from `IRuleRepository.GetRuleResultsAsync`, fail-soft to empty). The snapshot is what turns an
annotation into a rule-quality label without a join: filtering annotations by `ruleId` + `verdict`
yields confirmed vs false-positive counts per rule even after results are re-analyzed or deleted.
**Verdict + note are independently optional**; a PUT with both null **deletes** the lane row
(there is no DELETE route). Author identity is always stamped server-side from the JWT
(`TenantHelper.GetUserDisplayName` + `RequestContext.UserPrincipalName`) — a body-supplied
author never wins (same anti-spoof contract as the gather-rule PUT-upserts).
# Authorization
Routes (all in `EndpointAccessPolicyCatalog`):
| Route | Policy | Notes |
| --- | --- | --- |
| `GET sessions/{sessionId}/annotations` | `MemberRead` + QueryParam | handler filters the GA lane |
| `PUT sessions/{sessionId}/annotations/{lane}` | `TenantAdminOrOperator` + QueryParam | re-gated per lane in-function |
| `GET global/session-annotations` | `GlobalReadOrAdmin` + QueryParam | evaluation stream; delegated ("MSP") readers are rescue-admitted on a managed `?tenantId=` only |
**Per-lane write matrix** (`UpsertSessionAnnotationFunction.IsLaneWritableByCaller`, an
`internal static` pure function like `QueueSessionActionFunction.IsTypeAllowedForCaller`):
`operator` → tenant Operator or tenant Admin; `tenantadmin` → tenant Admin; `globaladmin` →
Global Admin only. **Own-tenant binding:** the session's tenant is resolved *before* the gate
(GA cross-tenant fallback via `FindSessionTenantIdAsync`), and tenant-role inputs are zeroed
when the resolved tenant differs from the caller's JWT tenant — so a GA who is also an admin of
their home tenant can never write another tenant's operator/tenantadmin lanes. Cross-tenant, a
GA writes exactly the `globaladmin` lane (the platform labeling flow).
**GA lane is platform-internal.** `GetSessionAnnotationsFunction.FilterLanesForCaller` drops the
`globaladmin` lane for every caller without `HasGlobalScope` (tenant members and delegated
admins). Both list endpoints enforce the same rule server-side via the repository's
`excludeGlobalAdminLane` OData clause — on the global route this matters exactly for the
delegated-rescue path, which reaches it without global scope. Tenant lanes are readable by all
members of that tenant.
**Audit.** Non-GA writes/clears log `LogAuditEntryAsync(..., "SessionAnnotation",
"{sessionId}/{lane}", ...)`; GA writes are skipped (platform-internal labeling must not surface
in the tenant-visible audit log — same convention as GA session-report submissions).
# Lifecycle
* **Per-session cascade delete**: three exact-RK steps (one per lane) in `DeletionManifestBuilder`
(orders 17–19; inventory 20/21, tombstone 22). `ComputePreflightCounts` sums repeated-table
steps so the `sessionAnnotations` count covers all lanes.
* **Tenant offboarding**: `SessionAnnotations` is in the PK=tenantId wipe list — deliberately
wiped, unlike `Feedback` (annotations are customer session data, not product feedback).
* **Backup**: member of `Constants.CriticalBackupTables.All` — hand-labeled data is the most
expensive data on the platform to lose.
* Being in `Constants.TableNames.All` auto-creates the table at startup and exposes it to the
raw `list_tables`/`query_table` tools.
# Surfaces
* **Portal**: `SessionAnnotationsCard` on the session detail page (`#section-annotations`, after
Analysis; registered in BOTH the section div and the `sessionSections` sidebar registry). The
card mirrors the matrix via the pure module `sessionAnnotationLogic.ts` (vitest-pinned) and
uses the explicit-Save pattern; the backend re-gates every save. Collapsed by default with a
count + verdict-pill summary header.
* **Portal overview page** `/annotations` (Monitoring sidebar group — deliberately NO new
"Insights" group; Fleet Health & co. are insights too and a second analytics group would
dilute both): tenant-scoped list backed by `GET sessions/annotations/list`
(`ListTenantSessionAnnotationsFunction`, MemberRead + QueryParam). The path sits under the
`/api/sessions` client-cert exclusion prefix (rename-over-exclusion convention; the Azure
exclusion list is portal-only config) and uses THREE segments because a two-segment literal
would be eaten by the `sessions/{sessionId}` route template. The tenant is always the
middleware-validated `TargetTenantId`, and for callers without global scope the repository
adds a server-side `Lane ne 'globaladmin'` OData clause — hidden rows never consume page
budget, and an explicit lane=globaladmin filter self-contradicts to an empty page. Rows
deep-link to `sessions?id=…#section-annotations`. The page carries the standard tenant
switcher (`useAggregatedAdminScope` + `TenantScopeSelector`): a GA/Reader defaults to the
"All tenants" aggregate (the portal twin of MCP's `list_session_annotations`) with per-tenant
drill-down, a delegated ("MSP") admin gets the managed subset; cross-tenant scopes route to
`GET global/session-annotations`, a tenant column appears, and deep links carry the row's
`tenantId`.
* **MCP read**: `get_session_summary` carries an `annotations` key (4th parallel leg, fail-soft
null); `list_session_annotations` (`ga`-gated) is the evaluation stream with `tenantId / lane /
verdict / ruleId / dateFrom / dateTo` filters and nextLink pagination. The `ruleId` filter is
applied server-side but client-of-Azure (substring on the JSON column), so the backend
back-fills short pages by looping the Azure continuation (bounded rounds) — filtered-out rows
never consume page budget.
* **MCP write**: `annotate_session` (`strictGa`-gated, MUTATING) always PUTs the `globaladmin`
lane — no lane argument by design. This is the frictionless labeling path after a session-debug.
* **Session reports**: `SessionReportService.SubmitReportAsync` snapshots ALL lanes into
`annotations.json` inside the report ZIP (`annotationCount` in report-metadata.json; fail-soft).
Safe because report blobs are readable only through the GA-only session-reports routes, and
valuable because the ZIP outlives the session's retention/delete.
# Citations
* `src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs` — model, lanes, verdicts.
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs` — Store/Map + query back-fill.
* `src/Backend/AutopilotMonitor.Functions/Functions/Annotations/` — the three endpoints.
* `src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs` — filter-bound continuation tokens.
* `src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts` — portal matrix mirror.
* `src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts` — `list_session_annotations`, `annotate_session`.
+5
View File
@@ -23,12 +23,14 @@ https://docs.autopilotmonitor.com (separate repository).
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
# Rules
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
* [Rule Authoring Surface](rules/rule-authoring-surface.md) - How customer AIs author rules against the real contract: the baked MCP resources (guide/schemas/guardrails via combine.js), the MCP-local validate_rule pre-flight, and the side-effect-free POST rules/analyze/dryrun whose trace twin shares the production evaluators (parity + side-effect-freedom pinned by tests).
# Backend
@@ -38,11 +40,14 @@ https://docs.autopilotmonitor.com (separate repository).
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
* [Session Annotations](backend/session-annotations.md) - Role-separated human verdicts on sessions (operator / tenantadmin / globaladmin lanes, one row each): the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal card + MCP list/annotate surfaces.
# MCP Server
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
* [MCP Tool Telemetry](mcp/tool-telemetry.md) - The three telemetry layers for judging tool quality: backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
# Web Portal
+43
View File
@@ -1,5 +1,48 @@
# Log
## 2026-08-13
* **Update**: `backend/session-annotations.md` — the `/annotations` portal overview page now carries the standard tenant switcher (GA/Reader: "All tenants" aggregate + per-tenant drill-down via `GET global/session-annotations`; delegated: managed subset), with a tenant column and tenant-qualified session deep links. The global list endpoint now derives `excludeGlobalAdminLane` from `HasGlobalScope`, so delegated-rescue callers on their managed `?tenantId=` path never receive the platform-internal `globaladmin` lane (endpoint-test-pinned).
## 2026-08-12
* **Update**: `agent/autopilot-ztd-diagnostics.md` — Windows 365 Cloud PC exception documented: on Cloud PCs `ProfileAvailable=0` is expected (provisioned by the Windows 365 service, not Autopilot), so `autopilot_profile_missing` is emitted as Info with a Cloud-PC-specific message and an `isCloudPc` payload marker (`ResolveAutopilotProfileMissingPresentation`); the portal's Autopilot Profile card renders a neutral note instead of the amber warning for Cloud PC sessions (`session.isCloudPc` or the event marker).
## 2026-08-11 (later)
* **Creation**: Added `backend/session-annotations.md` — session annotations: the new `SessionAnnotations` table (PK=tenantId, RK=`{sessionId}_{lane}`, lanes operator/tenantadmin/globaladmin) storing structured verdicts (`root_cause_confirmed | analysis_wrong | different_problem | inconclusive`) + notes with server-stamped authorship and a fired-rule-id snapshot for join-free rule-quality evaluation. Per-lane write matrix re-gated in-function with own-tenant binding (a GA writes only the platform-internal `globaladmin` lane cross-tenant; tenant callers never see that lane). Surfaces: portal `SessionAnnotationsCard` (session detail, after Analysis), MCP `list_session_annotations` (ga) + `annotate_session` (strictGa, always the GA lane) + `annotations` key in `get_session_summary`. Lifecycle: per-session cascade steps 17–19, offboarding wipe, critical-backup member.
## 2026-08-11
* **Update**: `rules/gather-rule-guardrails.md` — hard-block mirroring closed across all pre-flight layers: `rules/guardrails.json` now also mirrors `blockedCommandPatterns` + `maxCommandLength` and carries `C:\Windows\System32\config` in `blockedFilePrefixes`; portal (`guardValidation.ts`) and MCP (`rule-validation.ts`) apply them in agent order (before the unrestricted-mode early-return), so a command the agent hard-blocks no longer shows "Allowed" in unrestricted tenants. `GuardrailsParityTests` pins the JSON mirror to the C# constants. MCP `validate_rule` additionally validates `json`/`xml` targets with file semantics (the schema's `collectorType` enum was missing both) and rejects `activePhases`+`activeFromPhase` set together (backend 400 parity). Author stamping extended to PUT-upserts: tenant and global gather-rule updates stamp the caller's JWT display name before the service call, so an upsert of a non-existing ruleId can no longer store a payload-supplied author (true updates still preserve the original author).
## 2026-08-10
* **Update**: `rules/gather-rule-guardrails.md` — WMI matcher generalized from literal prefix matching to shape parsing: `SELECT <* | property-list> FROM <class>` is allowed when the class is derived from the existing `SELECT * FROM <class>` entries (a projection is a strict subset; motivated by `SELECT BatteryStatus FROM Win32_Battery` + `on_change` battery polling that `SELECT *` defeats via fluctuating charge %). Same algorithm mirrored agent/portal/MCP; guardrails.json unchanged. Portal now also gates enabling on the client-side validation (invalid-target custom rules create disabled, cannot be toggled on, force-disable on invalid edit, "Blocked on devices" badge) — UX only, the agent guard stays the boundary. Gather-rule Author is now stamped from the creator's JWT (display name → UPN) at create and immutable on update.
* **New**: `agent/continue-anyway-observation.md` — Continue-Anyway observation mode (`EnableEspContinueAnywayObservation`, ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles are defanged into a 60-min observation window (Classic/Unknown, non-pre-prov only); real-user desktop + Hello gate complete the session as Succeeded + `EspSoftFailure`/`CompletionSource` (amber "with issues" badge + detail banner), category recovery resolves clean, expiry un-defangs to the original `esp_terminal_failure`. Registered in the Agent index section. Related backend fix (no doc): the auto-analyze enqueue now carries a 30 s `visibilityTimeout` so rule evaluation no longer races the agent's terminal flush (ANALYZE-ESP-004's `{{appName}}` placeholder).
## 2026-08-07 (later)
* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap.
## 2026-08-07
* **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input.
## 2026-08-06
* **Creation**: Added `backend/cloudpc-device-validation.md` — the CloudPc fallback validator (`ValidatorType.CloudPc = 5`, tenant flag `ValidateCloudPcDevice`): W365 Cloud PCs are structurally never Autopilot-registered, so stage 4 gains a third stage that binds the chain-validated MDM cert's Subject CN (= Intune device id, field-verified on a real Cloud PC) to the tenant's `virtualEndpoint/cloudPCs` inventory — admits exactly the service-provisioned Cloud PCs, no device-type routing, local W365 markers never consulted for auth. New optional Graph add-on `W365CloudPcValidation` → `CloudPC.Read.All` (catalog + grant script now pinned by `GraphFeatureCatalogSyncTests`; Graph 403 = definitive "grant missing", not transient). Bootstrap dev script v2.3-dev.3 exempts guard 4 (uptime) while the Cloud-PC relax is active — the fresh-profile window is the time anchor at first connect, not boot uptime. Follow-up deliberately out of scope: agent-side `IsCloudPc` classification + decision-engine "no Device ESP" expectation.
## 2026-08-05 (later)
* **Update**: `rules/rule-authoring-surface.md` — new section `POST /api/rules/gather/test-pattern`: the logparser pattern tester (agent-exact .NET matching: no IgnoreCase, first-match-per-line, cmtrace vs text mode, verbatim format=text hint), motivated by a field incident where a PHP-verified regex behaved differently under .NET. `CmTraceLogParser` moved Agent → `AutopilotMonitor.Shared.Logging` so agent and backend share the ONE parsing implementation (timestamp caveat: AssumeLocal = server TZ on backend, endpoint reports no timestamps). MCP: new `test_log_pattern` tool, logparser lint in `validate_rule`, guide gains the case-sensitivity/engine-mismatch pitfalls.
## 2026-08-05
* **Creation**: Added `mcp/tool-telemetry.md` — the three MCP tool-telemetry layers (App Insights `McpToolName`, per-user usage table, `MCP_TOOL_LOGGING` stderr lines) and the new quality signals: soft-error capture (`toolError` returns instead of throwing, so backend telemetry counted those calls as successes), `resultChars`/`overCap` vs. the inline-size hint, bounded args summaries, caller scope, `search_zero_hit` on the three search tools (unmet-demand signal), and `tool_call_rejected` for JSON-RPC-level Zod/unknown-tool rejections sniffed at the `/mcp` route (previously invisible everywhere). Bicep default for `toolLoggingEnabled` flipped to `true` (documented desired state; live flag set via `az containerapp update`).
* **Creation**: Added `rules/rule-authoring-surface.md` — the AI-assisted rule-authoring stack: three deterministic MCP resources (`rule_authoring_guide` hand-written, `rule_schemas` + `rule_guardrails` generated into `rule-authoring.generated.ts` by a new combine.js emitter, drift-guarded twice), the MCP-local `validate_rule` (ajv 2020-12 against the single-rule $defs, guardrail pre-flight mirroring GatherRuleGuards matching semantics incl. hard blocks, semantic lint for schema-valid-but-dead rules), and the side-effect-free `POST /api/rules/analyze/dryrun` (TenantAdminOrGlobalReader; `RuleEngine.DryRun.cs` trace twin sharing the production evaluators, all-conditions trace with per-condition evidence + matchingEventCount, parity and side-effect-freedom pinned by RuleEngineDryRunTests). MCP `test_analyze_rule` wraps the endpoint with `{{token}}`-interpolated previews; `author-rule` prompt drives the draft → validate → dry-run loop. Deliberately absent: gather-rule remote execution, rule writes via MCP.
## 2026-08-04
* **Update**: `rules/gather-rule-phase-scoping.md` — new section "Which phase feeds the triggers": `GatherRuleExecutorHost` now drives `phase_change`/`phase_exit`/`on_event` gather triggers (and the phase-scope gates) from the post-reduce `TimelineEventStream` (every emitted event's `(eventType, phase)` published by `EventTimelineEmitter`) instead of the pre-reduce `SignalIngress.SignalPosted` payloads. Trigger: session `32312a32` (RealmJoin tenant rsneuffen.de) — a Phase-Start rule on FinalizingSetup fired at the raw ESP-exit `EspPhaseChanged(FinalizingSetup)` signal, 7 minutes before the engine's RealmJoin-gated `phase_transition(FinalizingSetup)`, reading `HKLM\SOFTWARE\RealmJoin\Custom\BIOS` before the RealmJoin package created it. Phase triggers now fire exactly when the timeline shows the phase; `on_event` additionally sees engine-emitted event types (`enrollment_complete`, `realmjoin_resolved`, …) for the first time in V2, restoring V1 semantics and the documented end-of-enrollment pattern. Other raw-signal hosts (DeviceInfo, keep-awake, PPKG) deliberately unchanged.
+81
View File
@@ -0,0 +1,81 @@
---
type: concept
title: MCP Tool Telemetry
description: The three telemetry layers for judging MCP tool quality — backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines that carry the signals the backend cannot see (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
resource: src/McpServer/autopilot-monitor-mcp/src/telemetry.ts
tags: [mcp, telemetry, observability, tool-quality]
timestamp: 2026-08-05
---
# Schema
Three layers answer "are the MCP tools doing their job, and are tools missing?":
1. **Backend App Insights** — every tool handler runs inside `withToolTelemetry`
(`src/telemetry.ts`), which propagates the tool name via AsyncLocalStorage so
`apiFetch` stamps `X-MCP-Tool-Name` on each backend request.
`RequestTelemetryMiddleware` copies it into the request telemetry as
`McpToolName`: per-tool call volume, latency percentiles and HTTP failure
rates by KQL. Blind spot: tools that never call the backend, and handler-level
soft errors (see layer 3).
2. **Usage table** — `McpQuotaEnforcementMiddleware.TrackUsage` counts per
user/day under the key `toolname:endpoint`; surfaced by the `get_api_usage`
tool. Counts only, every call counts as success.
3. **stderr JSON lines** (`MCP_TOOL_LOGGING=true`, queryable via Container App
Logs) — the layer that carries what the other two structurally cannot:
- `tool_call`: duration, `isError` **including soft errors** (handlers never
throw — `toolError` *returns* `isError: true`, so backend-only telemetry
records those calls as successes), `resultChars` + `overCap` (result
exceeds the tool's `anthropic/maxResultSizeChars` hint → the host truncates
it; a frequently-overCap tool needs tighter defaults or projections), a
size-bounded args summary, and the caller scope (ga/delegated/tenant).
This is the ONLY telemetry for backend-free tools: `search_docs`,
`validate_rule`, `get_resource`, the local ranking of `search_knowledge`.
- `search_zero_hit`: query text (capped) whenever `search_events` /
`search_knowledge` / `search_docs` return zero results — the most direct
"missing docs / missing knowledge / missing tool?" demand signal.
- `tool_call_rejected`: rejections the SDK answers *before* any handler runs
(Zod argument validation, unknown/disabled tool). Two non-obvious facts,
both found by live verification: SDK 1.30 wraps these McpErrors as a
*successful* JSON-RPC response whose result is `{ isError: true, text:
"MCP error <code>: …" }` — a JSON-RPC error envelope never exists for
them — and the Hono bridge (`getRequestListener`) may stream the body as
`res.write` chunks with a bare `res.end()`. The sniffer in the `/mcp`
route therefore wraps both `write` and `end`, reassembles up to 10 KB and
matches both shapes (`attachToolCallRejectionSniffer` in `telemetry.ts`).
Handler soft errors never carry the `MCP error` prefix, so they are not
double-logged. This is the strongest "the schema or description confuses
the model" signal and was previously invisible everywhere.
The env flag defaults to `true` in `infra/mcp-server.bicep` (documented desired
state — the template is not routinely deployed; the live flag is set with
`az containerapp update --set-env-vars MCP_TOOL_LOGGING=true`).
# Examples
Count zero-hit queries to find unmet demand (Log Analytics):
```kusto
ContainerAppConsoleLogs_CL
| where Log_s has '"type":"search_zero_hit"'
| extend d = parse_json(Log_s)
| summarize count() by tostring(d.tool), tostring(d.query)
```
Per-tool soft-error and truncation rates:
```kusto
ContainerAppConsoleLogs_CL
| where Log_s has '"type":"tool_call"'
| extend d = parse_json(Log_s)
| summarize calls = count(), errors = countif(d.isError == true),
overCap = countif(d.overCap == true), p95ms = percentile(tolong(d.durationMs), 95)
by tool = tostring(d.tool)
```
# Citations
- `src/McpServer/autopilot-monitor-mcp/src/telemetry.ts` — wrapper + log emitters
- `src/McpServer/autopilot-monitor-mcp/src/index.ts` — `/mcp` rejection sniffer
- `src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs` — `McpToolName` stamping
- `src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs` — usage-table counting
+19 -5
View File
@@ -7,14 +7,14 @@ description: >-
guard rather than trusting rule.Target.
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
tags: [gather-rules, security, guardrails, agent]
timestamp: 2026-07-22
timestamp: 2026-08-11
---
# Concept
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, and that the rule lands in the caller's own tenant partition. Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, that the rule lands in the caller's own tenant partition, and that `Author` is stamped from the caller's JWT (create and PUT-upsert; true updates preserve the original author). Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
This is the security boundary. The portal's `ValidationIndicator` is a convenience: it does not block submit, and the API is reachable directly.
This is the security boundary. The portal's `ValidationIndicator` is a convenience — the API stays reachable directly and the backend stays validation-free — but since 2026-08 the portal additionally **gates enabling** on the same client-side validation: a custom rule whose target fails validation cannot be toggled on, is created `enabled=false`, and an edit that makes the target invalid force-disables the rule (`targetBlocked` in `app/gather-rules/types.ts`). An enabled rule with an invalid target shows a "Blocked on devices" badge. All of this is UX, not security — the agent guard remains the enforcement point.
# Schema
@@ -27,7 +27,7 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
| --- | --- | --- |
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
| `IsFilePathAllowed` | `filePrefixes` | `Path.GetFullPath` first, then prefix + boundary |
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | prefix, boundary whitespace or end |
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | `SELECT <* \| property-list> FROM <class>`; class = case-insensitive equality against classes derived from the `SELECT * FROM <class>` entries, boundary whitespace or end (trailing `WHERE` allowed). Non-canonical entries (none today) fall back to literal prefix + boundary |
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
@@ -44,7 +44,12 @@ but the hard blocks must hold regardless of configuration:
PowerShell channels (script-block logging routinely contains secrets in clear text).
`rules/guardrails.json` mirrors the blocked lists (`blockedFilePrefixes`,
`blockedEventLogChannels`) **for display only**. Enforcement is the C# constant.
`blockedEventLogChannels`, `blockedCommandPatterns`, `maxCommandLength`) **for display
and pre-flight only**. Enforcement is the C# constant; `GuardrailsParityTests` (agent
test project) pins the JSON mirror to the constants so neither side can drift. The
portal (`utils/guardValidation.ts`) and the MCP validator (`rule-validation.ts`) apply
the mirrored hard blocks in the same order as the agent — before the unrestricted-mode
early-return — so a rule the agent would refuse is never shown as "Allowed".
# Examples
@@ -71,6 +76,15 @@ Two collectors were added to this contract on 2026-07-22 after shipping without
remote config. The operator using it is already a local admin, so it relaxes the
allowlist exactly the way unrestricted mode does — hard blocks still apply.
**WMI property projections (2026-08):** `SELECT BatteryStatus FROM Win32_Battery` is
allowed because `SELECT * FROM Win32_Battery` is — a projection exposes a strict subset
of the allowed class. The matcher parses the query shape instead of literal-prefix
matching; the allowed-class set is derived at load time from the existing
`wmiQueryPrefixes` entries, so `rules/guardrails.json` needs no new key. The same
algorithm is mirrored in the portal (`utils/guardValidation.ts`) and the MCP server
(`rule-validation.ts`); agents older than the change still block projections with a
`security_warning` until they self-update.
# Citations
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs`
+152
View File
@@ -0,0 +1,152 @@
---
type: Concept
title: Rule Authoring Surface — MCP Resources, validate_rule, and the Analyze Dry-Run
description: >-
How customer AIs author gather/analyze rules against the real contract: the baked
authoring resources (guide, schemas, guardrails) generated by combine.js, the
MCP-local validate_rule pre-flight, and the side-effect-free backend dry-run that
traces a draft analyze rule against one session's events.
resource: src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts
tags: [rules, mcp, analyze-rules, gather-rules, dry-run, authoring]
timestamp: 2026-08-05
---
# Concept
Customers author rules with an AI connected to the MCP server. Retrieval
(`search_docs`) is deliberately NOT the knowledge source for authoring: chunked
top-K retrieval can silently omit an operator, a guardrail prefix, or an enum,
and the AI then produces a plausible-but-dead rule. The authoring surface is
therefore **deterministic**: three `get_resource` entries carry the complete
contract, a local validator rejects what production would reject or silently
ignore, and a backend dry-run evaluates a draft against real session events with
a full trace.
The intended loop: draft → `validate_rule` → `test_analyze_rule` against a real
session (one that should fire, one that should not) → iterate → create in the
portal. The `author-rule` MCP prompt walks a client through exactly this order.
# Schema
## Resources (deterministic authoring context)
| Resource | Content | Source |
| --- | --- | --- |
| `rule_authoring_guide` | Hand-written complete authoring knowledge: collector types, triggers, condition sources, operators, confidence model incl. the exact factor DSL, `{{token}}` interpolation order, guardrail matching semantics, pitfalls | `src/rule-authoring-guide.ts` |
| `rule_schemas` | Both JSON Schemas (2020-12) verbatim | generated |
| `rule_guardrails` | `rules/guardrails.json` verbatim | generated |
Generation: `node rules/scripts/combine.js` bakes schemas + guardrails into
`src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts` (same
pattern as the web's `utils/guardrails.generated.ts`; the tsconfig `rootDir` and
the Docker build's exclusion of `rules/schema` rule out importing the JSON
directly). Drift is closed twice: CI's `guardrails-in-sync` job re-runs
combine.js and fails on any diff, and `rule-authoring-drift.test.ts` pins
generated == repo source plus guide enums == schema enums.
## validate_rule (MCP-local)
`src/rule-validation.ts`, no backend call. Three layers:
1. **Schema** — ajv (draft 2020-12) against the baked schemas, validating the
single-rule `$defs` entry directly (the top-level oneOf wrapper would double
every error).
2. **Guardrails** — mirrors the AGENT's matching semantics from
`GatherRuleGuards.cs` (see [gather-rule-guardrails.md](gather-rule-guardrails.md)):
hive-stripped segment-bounded registry/file prefixes, whitespace-bounded WMI
prefixes, EXACT command matching, `/`-bounded event-log channels, and the
code-enforced hard blocks (`Security` etc. channels, `C:\Users`,
`C:\Windows\System32\config`).
3. **Semantic lint** — schema-valid-but-dead rules: unreachable
confidenceThreshold, factor conditions outside the three parseable shapes
(`exists`, `count >= N`, `phase_duration > N`), unknown event types (warning;
`gather_*` softened to info), unanchored `not_regex` allow-lists,
unresolvable `{{token}}`s, trigger cross-fields, outputEventType
convention/collision checks.
The validator is advisory pre-flight only — the agent re-enforces guardrails
on-device and the backend re-validates structure. It must never be stricter
than production.
## POST /api/rules/analyze/dryrun (backend)
`DryRunAnalyzeRuleFunction` → `RuleEngine.DryRunRuleAsync`. Body
`{ sessionId, rule }` (draft `AnalyzeRule`, 1 MB cap). Policy tier
`TenantAdminOrGlobalReader` — a POST by shape but a pure read in effect, so the
read-only Global Reader is admitted; platform-scope callers reach any tenant's
session via `FindSessionTenantIdAsync` (same pattern as `GetRuleResultsFunction`).
`RuleEngine.DryRun.cs` is a partial-class trace twin of `EvaluateRule`: it
shares the exact evaluators (`EvaluatePrecondition` / `EvaluateCondition` /
`EvaluateConfidenceFactor`) but evaluates **all** conditions instead of breaking
on the first failed required one — the full picture is the point of a dry run.
Verdicts: `fired`, `skipped_by_precondition`, `required_condition_not_met`,
`no_conditions_matched`, `below_confidence_threshold`, `no_events`. Each
condition entry carries `matched`, the production evidence dictionary (or the
evaluator's reason string) and `matchingEventCount` — how many session events
even have that eventType, the first thing to check on an unexpected miss.
Invariants, pinned by `RuleEngineDryRunTests`:
* **Parity**: verdict `fired` ⇔ `EvaluateRule` produces a result, with identical
confidence and evidence keys. Factor evaluation stays sequential against the
accumulating `matchedConditions` (a factor can observe earlier `factor_*`
entries) — reordering would change results.
* **Side-effect freedom**: exactly one strict event read; never a RuleResult
write, never `TryMarkSessionFailedFromRuleAsync`, never a notification.
`wouldMarkSessionAsFailed` is reported, not acted on.
`test_analyze_rule` (MCP) wraps the endpoint and adds the interpolated
explanation/remediation preview via the existing `interpolate-rule-template.ts`
against the returned `matchedConditions`.
## POST /api/rules/gather/test-pattern (backend)
The logparser counterpart to the analyze dry-run, added after a field incident:
a customer verified a logparser regex with a PHP tester and the agent's .NET
engine matched differently. `TestLogPatternFunction` evaluates a pattern against
pasted sample lines with the AGENT's exact matching semantics:
* Regex construction identical to `LogParserCollector`: `Compiled`, 1 s timeout,
**no IgnoreCase** — logparser matching is case-sensitive (unlike analyze-rule
regex conditions, which run IgnoreCase).
* `format` behaves exactly like the rule parameter: `cmtrace` (default) parses
each line via the shared `CmTraceLogParser` and matches against the parsed
MESSAGE only; `text` matches the raw line. First match per line
(`Regex.Match`), group `"0"` excluded, unsuccessful groups omitted — the
returned `groups` are exactly the event-data fields the device would emit.
* Mirrors the agent's diagnostics: per-line outcomes
(`matched | no_match | parse_failed | regex_timeout`), the all-lines-parse-fail
hint ("set parameter format=text") verbatim, and a case-sensitivity note on
zero matches.
* Pure compute on the request body — no tenant data touched. Same policy tier
as the dry-run (`TenantAdminOrGlobalReader`); caps: 200 lines, 8 KB/line,
1 MB body.
Parity is structural, not duplicated: `CmTraceLogParser` moved from the agent
(`Monitoring/Enrollment/Ime`) to **Shared** (`AutopilotMonitor.Shared.Logging`)
so agent and backend run the SAME parsing implementation. Timestamp caveat:
bias-less CMTrace lines parse with `AssumeLocal`, which on the backend is the
server's timezone — the endpoint therefore reports match/parse results, never
timestamps. `test_log_pattern` (MCP) wraps the endpoint; `validate_rule`
additionally lints logparser parameters (missing/non-compiling pattern, unknown
format value) and its `nextStep` routes logparser drafts to `test_log_pattern`.
## What is deliberately absent
* **No gather-rule remote execution** — gather rules run as SYSTEM on devices;
session dry-run is analyze-only. Logparser authors get the pattern tester
above; all gather authors get guardrail pre-flight plus the on-device debug
log ([gather-rule-debug-log](../agent/gather-rule-debug-log.md)).
* **No rule write/deploy via MCP** — creating rules stays in the portal.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/TestLogPatternFunction.cs` — logparser pattern tester
* `src/Shared/AutopilotMonitor.Shared/Logging/CmTraceLogParser.cs` — shared CMTrace parser (agent + backend)
* `src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.DryRun.cs` — trace twin + DTOs
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs` — endpoint + draft validation
* `src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts` — validator
* `src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts` — validate_rule / test_analyze_rule
* `src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts` — guide resource
* `rules/scripts/combine.js` — MCP module generation
+2 -2
View File
@@ -80,8 +80,8 @@ param mcpPublicUrl string = ''
@description('Base64-encoded HMAC key (>=32 bytes after decode) for signing the OAuth proxy state. Naming and format match the backend\'s PaginationTokenSigningKey for consistency. Empty = generate per-instance random key on startup (acceptable for single-replica deployments since state has 10 min lifetime). Generate with PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))')
param oAuthStateSigningKey string = ''
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs)')
param toolLoggingEnabled bool = false
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs). Volume is one small JSON line per tool call - negligible at current usage.')
param toolLoggingEnabled bool = true
// --- Azure Container Registry ---
+78
View File
@@ -0,0 +1,78 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-SEC-008",
"title": "Secure Boot is disabled",
"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.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "single",
"baseConfidence": 70,
"preconditions": [
{
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "note",
"operator": "not_exists",
"value": "",
"description": "Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
}
],
"conditions": [
{
"signal": "secureboot_disabled",
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "uefiSecureBootEnabled",
"operator": "equals",
"value": "false",
"required": true
},
{
"signal": "physical_device",
"source": "event_data",
"eventType": "hardware_spec",
"dataField": "isVirtualMachine",
"operator": "equals",
"value": "false",
"required": false
}
],
"confidenceFactors": [
{ "signal": "physical_device", "condition": "exists", "weight": 20 }
],
"confidenceThreshold": 60,
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
"remediation": [
{
"title": "Enable Secure Boot in UEFI firmware",
"steps": [
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
]
},
{
"title": "Check compliance impact",
"steps": [
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Secure Boot",
"url": "https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot"
},
{
"title": "Microsoft Learn: Re-enable Secure Boot",
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
}
],
"tags": ["security", "secureboot", "firmware", "uefi", "compliance"]
}
+87
View File
@@ -2687,6 +2687,93 @@
"truncated",
"coverage"
]
},
{
"ruleId": "ANALYZE-SEC-008",
"title": "Secure Boot is disabled",
"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.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "single",
"baseConfidence": 70,
"preconditions": [
{
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "note",
"operator": "not_exists",
"value": "",
"description": "Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
}
],
"conditions": [
{
"signal": "secureboot_disabled",
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "uefiSecureBootEnabled",
"operator": "equals",
"value": "false",
"required": true
},
{
"signal": "physical_device",
"source": "event_data",
"eventType": "hardware_spec",
"dataField": "isVirtualMachine",
"operator": "equals",
"value": "false",
"required": false
}
],
"confidenceFactors": [
{
"signal": "physical_device",
"condition": "exists",
"weight": 20
}
],
"confidenceThreshold": 60,
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
"remediation": [
{
"title": "Enable Secure Boot in UEFI firmware",
"steps": [
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
]
},
{
"title": "Check compliance impact",
"steps": [
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Secure Boot",
"url": "https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot"
},
{
"title": "Microsoft Learn: Re-enable Secure Boot",
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
}
],
"tags": [
"security",
"secureboot",
"firmware",
"uefi",
"compliance"
]
}
]
}
+23 -2
View File
@@ -250,6 +250,27 @@
"C:\\Install\\Log"
],
"blockedFilePrefixes": [
"C:\\Users"
]
"C:\\Users",
"C:\\Windows\\System32\\config"
],
"blockedCommandPatterns": [
"Invoke-WebRequest",
"Invoke-RestMethod",
"Start-BitsTransfer",
"wget",
"curl",
"certutil -urlcache",
"New-LocalUser",
"Add-LocalGroupMember",
"net user",
"net localgroup",
"bcdedit",
"bcdboot",
"schtasks /create",
"Register-ScheduledTask",
"Remove-Item -Recurse",
"Format-Volume",
"Set-ExecutionPolicy"
],
"maxCommandLength": 2000
}
+1 -1
View File
@@ -67,7 +67,7 @@
"collectorType": {
"type": "string",
"description": "Type of data collection.",
"enum": ["registry", "eventlog", "wmi", "file", "command_allowlisted", "logparser"]
"enum": ["registry", "eventlog", "wmi", "file", "json", "xml", "command_allowlisted", "logparser"]
},
"target": {
"type": "string",
+48
View File
@@ -154,6 +154,12 @@ ${flatArray(flatEventLogChannels)}
export const BLOCKED_EVENT_LOG_CHANNELS: readonly string[] = [
${flatArray(guardrails.blockedEventLogChannels)}
];
export const BLOCKED_COMMAND_PATTERNS: readonly string[] = [
${flatArray(guardrails.blockedCommandPatterns)}
];
export const MAX_COMMAND_LENGTH = ${guardrails.maxCommandLength};
`;
fs.writeFileSync(guardrailsOutput, ts, 'utf8');
@@ -161,3 +167,45 @@ ${flatArray(guardrails.blockedEventLogChannels)}
} else {
console.warn('guardrails.json not found — skipping guardrails generation');
}
// ── MCP rule-authoring module generation ────────────────────────────────────
// The MCP server exposes the rule schemas + guardrails as get_resource content
// so a customer's AI can author rules against the real contract instead of
// retrieval fragments. The server's tsconfig (rootDir: src, no resolveJsonModule)
// and the Docker build (rules/schema not copied) rule out importing the JSON
// directly — so we bake it into a TS module, exactly like the web mirror above.
// CI's guardrails-in-sync job re-runs this script and fails on any diff, which
// makes drift between rules/* and this generated module impossible to merge.
const mcpAuthoringOutput = path.resolve(
rulesRoot, '..', 'src', 'McpServer', 'autopilot-monitor-mcp', 'src', 'rule-authoring.generated.ts'
);
{
const guardrails = JSON.parse(fs.readFileSync(guardrailsPath, 'utf8'));
const gatherSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'gather-rule.schema.json'), 'utf8'));
const analyzeSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'analyze-rule.schema.json'), 'utf8'));
const ts = `/**
* AUTO-GENERATED from rules/guardrails.json + rules/schema/*.schema.json — DO NOT EDIT.
* Run: node rules/scripts/combine.js
*
* Consumed by the MCP server's rule-authoring surface (get_resource +
* validate_rule): the JSON Schemas are the validation contract, the guardrails
* are the agent-side collection allowlists. Single source of truth is rules/;
* the CI guardrails-in-sync job guards this file against drift.
*/
/** JSON Schema (2020-12) for gather rules — rules/schema/gather-rule.schema.json verbatim. */
export const GATHER_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(gatherSchema, null, 2)};
/** JSON Schema (2020-12) for analyze rules — rules/schema/analyze-rule.schema.json verbatim. */
export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(analyzeSchema, null, 2)};
/** Gather-rule collection guardrails — rules/guardrails.json verbatim. */
export const RULE_GUARDRAILS = ${JSON.stringify(guardrails, null, 2)} as const;
`;
fs.writeFileSync(mcpAuthoringOutput, ts, 'utf8');
console.log('rule-authoring.generated.ts: schemas + guardrails baked for MCP server');
}
@@ -0,0 +1,307 @@
# Pester tests (Pester 5+) for the bootstrap guard/relax logic in
# Install-AutopilotMonitor.ps1.
#
# The script under test is dot-sourced; its entry guard prevents the bootstrap
# from executing, so loading it here is side-effect free. External state (WMI,
# registry, filesystem, WinRT) is mocked; profile directories are real folders
# under Pester's $TestDrive with a manipulated CreationTimeUtc.
#
# Run under Windows PowerShell 5.1 to match the IME runtime:
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
#
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
BeforeAll {
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1')
function New-FakeProfile {
param([string]$Name, [double]$AgeMinutes)
$p = Join-Path $TestDrive $Name
New-Item -ItemType Directory -Path $p -Force | Out-Null
(Get-Item $p).CreationTimeUtc = (Get-Date).ToUniversalTime().AddMinutes(-$AgeMinutes)
return $p
}
}
Describe 'Test-IsCloudPc' {
# Local WMI on a Cloud PC is generic ('Virtual Machine'), so detection rests on
# the Windows365 registry key AND the CloudManagedDesktopExtension service
# (both required).
BeforeEach {
Mock Write-Log { }
Mock Get-CimInstance { [pscustomobject]@{ Manufacturer = 'Microsoft Corporation'; Model = 'Virtual Machine' } }
}
It 'detects a Cloud PC when both markers are present' {
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
Test-IsCloudPc | Should -BeTrue
}
It 'does not detect with the registry key alone (e.g. W365-Boot physical client)' {
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { $null }
Test-IsCloudPc | Should -BeFalse
}
It 'does not detect with the service alone' {
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
Test-IsCloudPc | Should -BeFalse
}
It 'does not detect a plain device (no markers)' {
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { $null }
Test-IsCloudPc | Should -BeFalse
}
It 'still evaluates markers when the WMI identity query fails' {
Mock Get-CimInstance { throw 'RPC server unavailable' }
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
Test-IsCloudPc | Should -BeTrue
}
}
Describe 'Get-OobeState' {
It 'returns a non-empty state string and never throws' {
Get-OobeState | Should -Not -BeNullOrEmpty
}
}
Describe 'Get-RealUserProfilePaths' {
BeforeEach {
Mock Write-Log { }
}
It 'merges WMI and filesystem views, drops special/system profiles, dedupes' {
Mock Get-CimInstance {
@(
[pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\UserOne' }
[pscustomobject]@{ Special = $true; LocalPath = 'C:\Users\systemprofile' }
[pscustomobject]@{ Special = $false; LocalPath = 'D:\Profiles\Elsewhere' }
)
}
Mock Get-ChildItem {
@(
[pscustomobject]@{ FullName = 'C:\Users\UserOne' }
[pscustomobject]@{ FullName = 'C:\Users\Public' }
[pscustomobject]@{ FullName = 'C:\Users\defaultuser0' }
[pscustomobject]@{ FullName = 'C:\Users\WDAGUtilityAccount' }
)
}
$result = @(Get-RealUserProfilePaths)
$result | Should -Be @('C:\Users\UserOne')
}
It 'falls back to the filesystem view when the WMI query fails' {
Mock Get-CimInstance { throw 'WMI broken' }
Mock Get-ChildItem {
@(
[pscustomobject]@{ FullName = 'C:\Users\SomeUser' }
[pscustomobject]@{ FullName = 'C:\Users\Default' }
)
}
$result = @(Get-RealUserProfilePaths)
$result | Should -Be @('C:\Users\SomeUser')
}
It 'returns an empty result when only special/system profiles exist' {
Mock Get-CimInstance { @() }
Mock Get-ChildItem {
@(
[pscustomobject]@{ FullName = 'C:\Users\Public' }
[pscustomobject]@{ FullName = 'C:\Users\defaultuser1' }
)
}
@(Get-RealUserProfilePaths).Count | Should -Be 0
}
}
Describe 'Get-RelaxDecision' {
BeforeEach {
Mock Write-Log { }
# Defaults: physical device, OOBE long done. Individual tests override.
Mock Get-OobeState { 'Completed' }
Mock Test-IsCloudPc { $false }
}
Context 'Windows 365 Cloud PC' {
BeforeEach {
Mock Test-IsCloudPc { $true }
}
It 'relaxes for a single fresh profile although OOBE is Completed (first connect)' {
$p = New-FakeProfile 'CloudPcUser' 0.6
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
$d.Active | Should -BeTrue
$d.IsCloudPc | Should -BeTrue
}
It 'does not relax for an old profile (productive Cloud PC)' {
$p = New-FakeProfile 'CloudPcUser' 30
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
It 'does not relax for two profiles' {
$p1 = New-FakeProfile 'UserA' 2
$p2 = New-FakeProfile 'UserB' 400
(Get-RelaxDecision -ProfilePaths @($p1, $p2) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
It 'relaxes even when the WinRT OOBE API is unavailable (old build)' {
Mock Get-OobeState { 'Unavailable' }
$p = New-FakeProfile 'CpcUser' 1
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeTrue
}
It 'does not relax for a profile with a future creation time (clock skew)' {
$p = New-FakeProfile 'SkewUser' -10
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
}
Context 'OOBE InProgress (Windows Backup for Organizations)' {
It 'relaxes for a single fresh profile during OOBE' {
Mock Get-OobeState { 'InProgress' }
$p = New-FakeProfile 'RestoreUser' 3
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
$d.Active | Should -BeTrue
}
}
Context 'no trigger' {
It 'does not relax on a physical device with a fresh profile and OOBE Completed' {
$p = New-FakeProfile 'SomeUser' 2
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
It 'stays inactive with no profiles at all' {
(Get-RelaxDecision -ProfilePaths @() -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
}
}
Describe 'Get-BootstrapDecision' {
BeforeEach {
Mock Write-Log { }
# Baseline: clean device during Device ESP -- no marker, no profiles, no
# logged-on user, 1h uptime, agent not installed. Tests override selectively.
Mock Get-ItemProperty { $null }
Mock Get-RealUserProfilePaths { @() }
Mock Get-OobeState { 'Completed' }
Mock Test-IsCloudPc { $false }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-1) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$script:cleanAgentBin = Join-Path $TestDrive 'AgentBin'
}
It 'installs on a clean ESP device' {
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeTrue
$d.ReasonCode | Should -BeNullOrEmpty
$d.RelaxActive | Should -BeFalse
}
It 'skips when the Deployed marker exists (guard 1)' {
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'AlreadyDeployed'
}
It 'skips a productive device with an old profile (guard 2)' {
$script:fakeProfile = New-FakeProfile 'OldUser' 5000
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'ProfilesFound'
}
It 'installs on a freshly provisioned Cloud PC with one fresh profile' {
$script:fakeProfile = New-FakeProfile 'CloudPcUser' 0.6
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
Mock Test-IsCloudPc { $true }
# First-connect user is already visible in LogonUI on W365 -- must not block either.
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\CloudPcUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeTrue
$d.RelaxActive | Should -BeTrue
}
It 'skips when a real user has logged on and no relax applies (guard 3)' {
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\SomeUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'LastLoggedOnUser'
}
It 'does not skip for the defaultuser0 OOBE account (guard 3)' {
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'defaultuser0' } } -ParameterFilter { $Path -like '*LogonUI*' }
(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin).Install | Should -BeTrue
}
It 'skips when uptime exceeds the bootstrap window (guard 4)' {
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'UptimeExceeded'
}
It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption)' {
# W365 reality: provisioned days ago, running headless ever since; the user's
# first connect just created the profile. Uptime is meaningless here.
$script:fakeProfile = New-FakeProfile 'FirstConnectUser' 0.6
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
Mock Test-IsCloudPc { $true }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeTrue
$d.RelaxActive | Should -BeTrue
}
It 'still skips an over-uptime Cloud PC when the relax is NOT active (no fresh profile)' {
Mock Test-IsCloudPc { $true }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'UptimeExceeded'
}
It 'keeps guard 4 for the OOBE-restore relax (physical device, uptime is meaningful)' {
$script:fakeProfile = New-FakeProfile 'RestoreUser' 3
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
Mock Get-OobeState { 'InProgress' }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'UptimeExceeded'
}
It 'skips when the agent binary is already present (guard 5)' {
$binPath = Join-Path $TestDrive 'AgentBinExisting'
New-Item -ItemType Directory -Path $binPath -Force | Out-Null
Set-Content -Path (Join-Path $binPath 'AutopilotMonitor.Agent.exe') -Value 'stub'
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $binPath
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'AgentPresent'
}
}
Describe 'logging purity' {
# Regression guard: Write-Log runs inside functions that RETURN decision
# objects. If it ever emits to the output stream again (Write-Output), the
# log lines leak into the return values and turn them into arrays. This
# test deliberately does NOT mock Write-Log.
It 'Get-BootstrapDecision returns exactly one object with real logging' {
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
# Plain local assignment: PowerShell's dynamic scoping makes Write-Log
# (called from Get-BootstrapDecision) resolve this $LogFile instead of
# the ProgramData path defined by the dot-sourced script.
$LogFile = Join-Path $TestDrive 'bootstrap_test.log'
$d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath (Join-Path $TestDrive 'NoAgent'))
$d.Count | Should -Be 1
$d[0].ReasonCode | Should -Be 'AlreadyDeployed'
Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed'
}
}
+335 -210
View File
@@ -6,8 +6,9 @@
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
Runs very early in the enrollment process (first Intune action) and:
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
An OOBE-relax exemption allows install when a single freshly-created profile
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
A relax exemption allows install when a single freshly-created profile
appears DURING OOBE (Windows Backup for Organizations restore) or on a
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
@@ -15,14 +16,27 @@
6. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
normally means "productive -> skip". Windows Backup for Organizations breaks that
by restoring settings and creating ONE profile while still in OOBE. We relax guards
2+3 ONLY when all three positives hold, and nothing else:
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
(b) there is exactly ONE real user profile, AND
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
normally means "productive -> skip". Two legitimate flows break that assumption:
(1) Windows Backup for Organizations: one user signs in + restores DURING OOBE.
Trigger: the OS positively reports OOBE InProgress.
(2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment
complete before any user ever connects, so OOBE is never InProgress when
this script runs. The assigned user's profile is created at first connect,
typically seconds before Intune pulls this script. Trigger: the device
positively identifies as a Cloud PC via TWO local markers (both required):
the HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the
CloudManagedDesktopExtension service.
Either trigger relaxes guards 2+3 ONLY when additionally:
(a) there is exactly ONE real user profile, AND
(b) that profile was created within $OobeProfileMaxAgeMinutes minutes.
The Cloud PC trigger additionally exempts guard 4 (uptime window) -- see
.PARAMETER MaxBootstrapWindowHours. Any miss keeps the original skip
(SKIP-safe: we never install on uncertainty). On productive devices the
profile is old, so (b) keeps skipping correctly.
.PARAMETER AgentDownloadUrl
URL to download the agent ZIP from. Defaults to the production download endpoint;
@@ -36,11 +50,14 @@
Maximum device uptime (hours) within which the bootstrap is still considered
valid. Devices booted more than this many hours ago are skipped because we no
longer trust their OOBE state. Default: 12.
Not applied while the Cloud PC relax is active: a Cloud PC runs headless for
days between provisioning and the user's first connect, so the fresh-profile
window (OobeProfileMaxAgeMinutes) is the time anchor there, not boot uptime.
.PARAMETER OobeProfileMaxAgeMinutes
Maximum age (minutes) of the single profile that may appear during OOBE for the
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
older profile is treated as a productive device. Default: 15.
Maximum age (minutes) of the single profile that may appear for the relax
exemption to apply. The restore/first-connect -> bootstrap gap is always
minutes; an older profile is treated as a productive device. Default: 15.
.NOTES
- Agent is temporary and auto-removes after enrollment.
@@ -50,12 +67,24 @@
- Scheduled Task survives reboots during enrollment.
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
the class -> state reads as 'Unavailable' -> original (skip) behaviour. Docs:
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
- Guard/relax logic lives in functions; the script only executes when run
directly. Dot-sourcing it (as the Pester suite in
Install-AutopilotMonitor.Tests.ps1 does) loads the functions without side effects.
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-08-08 v2.3 Windows 365 Cloud PC support: a positively identified Cloud PC
(Windows365 registry key AND CloudManagedDesktopExtension
service) now triggers the guard 2+3 relax alongside OOBE
InProgress; the single-fresh-profile conditions are unchanged
and guard 4 (uptime window) is exempted for Cloud PCs.
Raw OOBE state and Cloud PC marker states are now logged.
Guard/relax logic refactored into functions with a dot-source
entry guard so the Pester suite can test it without executing
the bootstrap; behaviour when run directly is unchanged.
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
https://download.autopilotmonitor.com/agent/ (route /agent/*).
@@ -97,7 +126,7 @@ param(
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.2"
$ScriptVersion = "2.3"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
@@ -105,89 +134,151 @@ $AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap_agent.log"
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
# Write-Host (NOT Write-Output): log calls happen inside functions that return
# decision objects; Write-Output would leak every log line into those return values.
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Output $logMessage
Write-Host $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
# Positive OOBE check via the documented WinRT API
# Raw OOBE state via the documented WinRT API
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
# Returns the state string (InProgress/Completed/NotStarted). An older OS without the
# class (< Win10 1809 / 10.0.17763.0) or any error returns 'Unavailable'
# -> SKIP-safe (we never relax a guard on uncertainty).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
function Get-OobeState {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString()
}
catch {
return 'Unavailable'
}
}
# Positive Windows 365 Cloud PC detection. Local WMI/SMBIOS on a Cloud PC only
# reports the generic Manufacturer='Microsoft Corporation' / Model='Virtual Machine',
# so identification uses two W365-specific markers instead. BOTH are required (AND):
# each alone has look-alikes (e.g. W365 Boot physical clients, other managed-desktop
# agents). Identity and marker states are logged for diagnosis; any error returns
# $false (SKIP-safe).
function Test-IsCloudPc {
try {
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
Write-Log "Computer identity: Manufacturer='$($cs.Manufacturer)' Model='$($cs.Model)'."
}
catch { Write-Log "INFO: computer identity query failed: $($_.Exception.Message)" }
try {
$regMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
$svcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
Write-Log "Cloud PC markers: Windows365 registry key=$regMarker; CloudManagedDesktopExtension service=$svcMarker."
return ($regMarker -and $svcMarker)
}
catch {
Write-Log "INFO: Cloud PC marker check failed: $($_.Exception.Message)"
return $false
}
}
try {
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
# Collects real (non-special) user profiles as full paths (WMI Special flag + filesystem).
# NOTE: callers MUST wrap the result in @(...). PowerShell unrolls function output, so an
# empty result returns $null and a single path returns a scalar string, whose [0] indexer
# would yield its first char ('C') instead of the full path.
function Get-RealUserProfilePaths {
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
}
# Relax: the ONLY exemption to guards 2+3. Two triggers (see .DESCRIPTION):
# (1) OOBE InProgress -> Windows Backup for Organizations restore
# (2) Cloud PC identity -> Windows 365 (headless OOBE, profile at first connect)
# Either trigger additionally requires: exactly one real profile AND that profile
# created < $OobeProfileMaxAgeMinutes minutes ago. Any miss keeps the original skip.
# Returns an object with: Active (bool), OobeState (string), IsCloudPc (bool).
function Get-RelaxDecision {
param(
[string[]]$ProfilePaths,
[int]$OobeProfileMaxAgeMinutes
)
if ($null -eq $ProfilePaths) { $ProfilePaths = @() }
$relaxActive = $false
$oobeState = Get-OobeState
$oobeInProgress = ($oobeState -eq 'InProgress')
$isCloudPc = Test-IsCloudPc
if ($isCloudPc) {
Write-Log "Cloud PC detected (OOBE state: $oobeState)."
}
$relaxTrigger = $null
if ($oobeInProgress) {
$relaxTrigger = "OOBE InProgress (Windows Backup restore case)"
} elseif ($isCloudPc) {
$relaxTrigger = "Cloud PC (headless provisioning, OOBE state: $oobeState)"
}
if ($ProfilePaths.Count -eq 1 -and $relaxTrigger) {
try {
$created = (Get-Item -LiteralPath $ProfilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
$relaxActive = $true
Write-Log ("Relax active: {0} + single profile created {1:N1}min ago (< {2}min). Guards 2+3 relaxed." -f $relaxTrigger, $ageMin, $OobeProfileMaxAgeMinutes)
} else {
Write-Log ("Relax NOT applied: {0} + single profile '{1}', but profile created {2:N1}min ago (created {3:u}) is outside the [0, {4})min window." -f $relaxTrigger, (Split-Path $ProfilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
}
} catch { Write-Log "INFO: profile age check failed; relax not applied. $($_.Exception.Message)" }
} elseif ($ProfilePaths.Count -eq 1) {
Write-Log "Relax NOT applied: single profile present but neither OOBE InProgress nor Cloud PC (OOBE state: $oobeState)."
} elseif ($ProfilePaths.Count -gt 1) {
Write-Log ("Relax NOT applied: {0} real profiles present (relax requires exactly one). OOBE state: {1}; CloudPC: {2}." -f $ProfilePaths.Count, $oobeState, $isCloudPc)
}
return [pscustomobject]@{
Active = $relaxActive
OobeState = $oobeState
IsCloudPc = $isCloudPc
}
}
# Runs pre-flight guards 1-5 and returns the install decision WITHOUT side effects
# beyond logging. Returns an object with:
# Install (bool), ReasonCode (null | AlreadyDeployed | ProfilesFound |
# LastLoggedOnUser | UptimeExceeded | AgentPresent), RelaxActive (bool).
function Get-BootstrapDecision {
param(
[int]$MaxBootstrapWindowHours,
[int]$OobeProfileMaxAgeMinutes,
[string]$AgentBinPath
)
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
if ($deployed) {
Write-Log "SKIP: Agent was previously deployed at $deployed."
exit 0
return [pscustomobject]@{ Install = $false; ReasonCode = 'AlreadyDeployed'; RelaxActive = $false }
}
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
$oobeRelax = $false
$oobeInProgress = Test-OobeInProgress
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
} else {
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
}
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
} elseif ($profilePaths.Count -gt 1) {
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
}
# The @() wrap is required, see NOTE on Get-RealUserProfilePaths.
$profilePaths = @(Get-RealUserProfilePaths)
$relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
# Guard 2: No real user profile should exist yet (primary productive-device guard),
# unless the OOBE-relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
# unless the relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
$details = (($profilePaths | ForEach-Object {
$leaf = Split-Path $_ -Leaf
try {
@@ -195,26 +286,33 @@ try {
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
} catch { "$leaf (age unknown)" }
}) | Select-Object -First 3) -join '; '
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
exit 0
Write-Log "SKIP: Real user profile(s) found ($details). OOBE state: $($relax.OobeState). CloudPC: $($relax.IsCloudPc). Device appears productive."
return [pscustomobject]@{ Install = $false; ReasonCode = 'ProfilesFound'; RelaxActive = $false }
}
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
# (same relax exemption: the restoring/first-connect user signs in legitimately).
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $relax.Active) {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
return [pscustomobject]@{ Install = $false; ReasonCode = 'LastLoggedOnUser'; RelaxActive = $false }
}
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
# Sleep/standby does not reset uptime, only real boot/restart does.
# Exempted while the Cloud PC relax is active: a Cloud PC runs headless from
# provisioning until the user's first connect (often days), so boot uptime says
# nothing there -- the fresh-profile window of the relax is the time anchor.
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
exit 0
if ($relax.Active -and $relax.IsCloudPc) {
Write-Log "Uptime $([int]$uptimeHours)h exceeds the ${MaxBootstrapWindowHours}h window, but the Cloud PC relax is active (fresh first-connect profile is the time anchor). Continuing."
} else {
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
return [pscustomobject]@{ Install = $false; ReasonCode = 'UptimeExceeded'; RelaxActive = $relax.Active }
}
}
# Guard 5: Is the agent already installed? (leftover from previous run)
@@ -222,148 +320,175 @@ try {
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
if ($existingAgent) {
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
return [pscustomobject]@{ Install = $false; ReasonCode = 'AgentPresent'; RelaxActive = $relax.Active }
}
}
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
}
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
# at the bottom); never on dot-source.
function Invoke-Bootstrap {
try {
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes -AgentBinPath $AgentBinPath
if (-not $decision.Install) {
exit 0
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
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
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
}
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"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
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
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
}
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
}
}
}
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
$runtimeProcessName = 'AutopilotMonitor.Agent'
$verifyTimeoutSec = 10
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
$runtimeProc = $null
while ((Get-Date) -lt $verifyDeadline) {
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $runtimeProc) { break }
Start-Sleep -Milliseconds 500
}
if ($null -ne $runtimeProc) {
$startedUtc = 'unavailable'
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
} else {
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
}
$runtimeProcessName = 'AutopilotMonitor.Agent'
$verifyTimeoutSec = 10
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
$runtimeProc = $null
while ((Get-Date) -lt $verifyDeadline) {
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $runtimeProc) { break }
Start-Sleep -Milliseconds 500
}
if ($null -ne $runtimeProc) {
$startedUtc = 'unavailable'
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
} else {
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
}
Write-Log "===== Bootstrap Completed Successfully ====="
Write-Log "===== Bootstrap Completed Successfully ====="
exit 0
exit 0
}
catch {
Write-Log "===== Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Log "Please check log file: $LogFile"
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
[Console]::Error.WriteLine($errMsg)
exit 1
}
}
catch {
Write-Log "===== Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Log "Please check log file: $LogFile"
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
[Console]::Error.WriteLine($errMsg)
exit 1
# Entry guard: execute only when run as a script (IME, console). Dot-sourcing
# (". .\Install-AutopilotMonitor.ps1", as the Pester suite does) only loads
# the functions above and MUST stay side-effect free.
if ($MyInvocation.InvocationName -ne '.') {
Invoke-Bootstrap
}
+32 -12
View File
@@ -1,7 +1,9 @@
# Autopilot Monitor Bootstrap Guard Test
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
# Mirrors Install-AutopilotMonitor.ps1 incl. the OOBE-relax exemption for the
# "Windows Backup for Organizations" restore case (guards 2+3).
# Mirrors Install-AutopilotMonitor.ps1 (v2.3) incl. the relax exemption for
# guards 2+3, triggered by "Windows Backup for Organizations" (OOBE InProgress)
# or a positively identified Windows 365 Cloud PC, and the guard 4 (uptime)
# exemption while the Cloud PC relax is active.
param(
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
@@ -54,6 +56,12 @@ function Test-OobeInProgress {
}
}
# Positive Windows 365 Cloud PC detection: two local markers, BOTH required (AND),
# because each alone has look-alikes. Mirrors Test-IsCloudPc in the bootstrapper.
$cloudPcRegMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
$cloudPcSvcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
$isCloudPc = ($cloudPcRegMarker -and $cloudPcSvcMarker)
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
$MaxBootstrapWindowHours = 12
@@ -104,8 +112,9 @@ if ($wmiProfileQueryFailed) {
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
# Relax: the ONLY exemption to guards 2+3. Two triggers -- OOBE InProgress (Windows
# Backup for Organizations restore) or Cloud PC identity (headless provisioning,
# profile at first connect). Either additionally requires exactly one fresh profile.
$oobeInProgress = Test-OobeInProgress
$profileAgeMin = $null
$oobeRelax = $false
@@ -114,12 +123,13 @@ if ($profilePaths.Count -eq 1) {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
} catch { }
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
if (($oobeInProgress -or $isCloudPc) -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
}
}
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
Write-Step -Status 'INFO' -Message "Cloud PC markers: Windows365 registry key = $cloudPcRegMarker ; CloudManagedDesktopExtension service = $cloudPcSvcMarker ; IsCloudPc = $isCloudPc"
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; relax = $oobeRelax"
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
@@ -129,7 +139,8 @@ if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$reasons.Add("Real user profile(s) found: $names")
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
$trigger = if ($oobeInProgress) { 'OOBE InProgress' } else { 'Cloud PC' }
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but relax active ($trigger + single fresh profile) -- not blocking."
} else {
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
}
@@ -141,7 +152,7 @@ if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -no
$wouldInstall = $false
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but relax active -- not blocking."
} else {
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
}
@@ -155,9 +166,15 @@ try {
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
$wouldInstall = $false
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
if ($oobeRelax -and $isCloudPc) {
# Cloud PCs run headless for days before the user's first connect; the
# fresh-profile window is the time anchor there, not boot uptime.
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h (over ${MaxBootstrapWindowHours}h), but the Cloud PC relax is active -- not blocking."
} else {
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
$wouldInstall = $false
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
}
} else {
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
}
@@ -202,9 +219,12 @@ Write-Host "Deployed marker : $deployed"
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
Write-Host "ProfileCount : $($profilePaths.Count)"
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
Write-Host "CloudPcRegistryMarker : $cloudPcRegMarker"
Write-Host "CloudPcServiceMarker : $cloudPcSvcMarker"
Write-Host "IsCloudPc : $isCloudPc"
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
Write-Host "OobeRelaxActive : $oobeRelax"
Write-Host "RelaxActive : $oobeRelax"
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
Write-Host "LastBootUpTime : $lastBoot"
@@ -23,7 +23,11 @@
One or more high-level feature identifiers. The script translates each to the underlying
Graph permissions. Use this when you don't want to know permission strings.
Available features:
- ScriptDisplayNames -> DeviceManagementScripts.Read.All
- ScriptDisplayNames -> DeviceManagementScripts.Read.All
- W365CloudPcValidation -> CloudPC.Read.All
(validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory --
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
of Cloud PC first-connect enrollment)
.PARAMETER Permissions
Microsoft Graph application permission names to grant directly. Use when the admin UI
@@ -76,7 +80,7 @@ param(
[string]$ClientId,
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
[ValidateSet('ScriptDisplayNames')]
[ValidateSet('ScriptDisplayNames', 'W365CloudPcValidation')]
[string[]]$Features,
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
@@ -97,7 +101,8 @@ $ErrorActionPreference = 'Stop'
# ---- Feature -> Graph permission catalog -----------------------------------
# Keep in lock-step with src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs
$FeatureCatalog = @{
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
'W365CloudPcValidation' = @('CloudPC.Read.All')
}
# Translate Features -> Permissions when caller used the high-level form.
@@ -94,6 +94,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
Assert.Equal("older-002\n", File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + "_002.log")));
}
[Fact]
public void Log_transliterates_typographic_punctuation_to_ascii()
{
using var tmp = new TempDirectory();
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024L * 1024);
// The log file is UTF-8 without BOM; cp1252-guessing viewers (Outlook web
// preview) render em dashes as mojibake. Our own punctuation must come out
// as plain ASCII, while genuine non-ASCII payload stays untouched.
logger.Info("check failed \u2014 range 1\u20132 \u2192 done\u2026 \u2018a\u2019 \u201Cb\u201D nb\u00A0sp f\u00FCr M\u00FCller");
var text = File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + ".log"));
Assert.Contains("check failed - range 1-2 -> done... 'a' \"b\" nb sp f\u00FCr M\u00FCller", text);
}
[Fact]
public void Log_swallows_io_errors_silently()
{
@@ -308,9 +308,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
[InlineData("SELECT * FROM Win32_UserAccount")]
[InlineData("SELECT * FROM Win32_ShadowCopy")]
[InlineData("SELECT * FROM Win32_OperatingSystemExtra")] // prefix spoofing
[InlineData("SELECT Name FROM Win32_Process")] // projection of a disallowed class
[InlineData("SELECT BatteryStatus FROM Win32_BatteryX")] // class spoofing via projection
public void WmiCollector_OffAllowlistQuery_IsBlockedBeforeTheQueryRuns(string query)
=> AssertBlocked(new WmiCollector().Execute(Rule("wmi", query), Context()), query);
// -------------------------------------------------------------------
// WMI query matcher: "SELECT <* | property-list> FROM <allowed class>"
//
// Guard-level (no WMI touched): a property projection of an allowed class
// exposes a strict subset of the already-allowed SELECT *, so it is admitted.
// -------------------------------------------------------------------
[Theory]
[InlineData("SELECT * FROM Win32_Battery")] // legacy form unchanged
[InlineData("SELECT * FROM Win32_BIOS WHERE PrimaryBIOS = TRUE")] // legacy + WHERE
[InlineData("SELECT BatteryStatus FROM Win32_Battery")]
[InlineData("SELECT BatteryStatus, EstimatedChargeRemaining FROM Win32_Battery")]
[InlineData("select batterystatus from win32_battery")] // case-insensitive
[InlineData("SELECT\tBatteryStatus\tFROM\tWin32_Battery")] // tabs as separators
[InlineData(" SELECT BatteryStatus FROM Win32_Battery ")] // surrounding whitespace
[InlineData("SELECT BatteryStatus,EstimatedChargeRemaining FROM Win32_Battery")] // no space after comma
[InlineData("SELECT Status FROM Win32_Battery WHERE BatteryStatus = 1")] // projection + WHERE
public void WmiGuard_ProjectionOfAllowedClass_IsAllowed(string query)
=> Assert.True(GatherRuleGuards.IsWmiQueryAllowed(query));
[Theory]
[InlineData("SELECT Name FROM Win32_Process")] // class not on the allowlist
[InlineData("SELECT BatteryStatus FROM Win32_BatteryX")] // class spoofing
[InlineData("SELECT * FROM Win32_BatteryX")]
[InlineData("SELECT *, Name FROM Win32_BIOS")] // star mixed into a property list
[InlineData("SELECT FROM Win32_BIOS")] // empty property list
[InlineData("SELECT BatteryStatus FROM")] // missing class
[InlineData("SELECT BatteryStatus FROM Win32_Battery;DROP")] // non-whitespace boundary after class
[InlineData("SELECT BatteryStatus, FROM Win32_Battery")] // trailing comma
[InlineData("")]
public void WmiGuard_MalformedOrDisallowedQuery_IsBlocked(string query)
=> Assert.False(GatherRuleGuards.IsWmiQueryAllowed(query));
[Fact]
public void WmiGuard_NullQuery_IsBlocked()
=> Assert.False(GatherRuleGuards.IsWmiQueryAllowed(null));
[Fact]
public void WmiGuard_UnrestrictedMode_AllowsAnyQuery()
=> Assert.True(GatherRuleGuards.IsWmiQueryAllowed("SELECT Name FROM Win32_Process", unrestrictedMode: true));
[Fact]
public void JsonCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
{
@@ -1,5 +1,5 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Shared.Logging;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
@@ -107,6 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
Assert.True(ok);
Assert.NotNull(entry);
Assert.Equal(
new DateTime(2026, 7, 29, 11, 46, 19, DateTimeKind.Utc).AddTicks(226610).AddMinutes(expectedOffsetMinutes),
entry.Timestamp);
@@ -0,0 +1,69 @@
using System.IO;
using System.Linq;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
using Newtonsoft.Json.Linq;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Telemetry.Gather
{
/// <summary>
/// Pins the display mirror in rules/guardrails.json (embedded into this assembly)
/// to the agent's hard-block CONSTANTS in <see cref="GatherRuleGuards"/>.
/// Enforcement is code-only by design (a JSON parse error must never lift a hard
/// block); the JSON mirror exists so the portal and the MCP validator can
/// pre-flight the same blocks. This test keeps the two from drifting: an addition
/// on either side fails here until the other side follows.
/// </summary>
public sealed class GuardrailsParityTests
{
private static JObject LoadEmbeddedGuardrails()
{
var assembly = typeof(GatherRuleGuards).Assembly;
using var stream = assembly.GetManifestResourceStream(
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json");
Assert.NotNull(stream);
using var reader = new StreamReader(stream!);
return JObject.Parse(reader.ReadToEnd());
}
[Fact]
public void Blocked_file_prefixes_match_code_constants()
{
var json = LoadEmbeddedGuardrails()["blockedFilePrefixes"]!.ToObject<string[]>()!;
var code = new[] { GatherRuleGuards.BlockedUsersPrefix }
.Concat(GatherRuleGuards.AdditionalHardBlockedPathPrefixes);
Assert.Equal(
code.Select(p => p.ToLowerInvariant()).OrderBy(p => p),
json.Select(p => Path.GetFullPath(p).ToLowerInvariant()).OrderBy(p => p));
}
[Fact]
public void Blocked_command_patterns_match_code_constants()
{
var json = LoadEmbeddedGuardrails()["blockedCommandPatterns"]!.ToObject<string[]>()!;
Assert.Equal(
GatherRuleGuards.HardBlockedCommandPatterns.OrderBy(p => p),
json.OrderBy(p => p));
}
[Fact]
public void Max_command_length_matches_code_constant()
{
Assert.Equal(
GatherRuleGuards.MaxCommandLength,
LoadEmbeddedGuardrails()["maxCommandLength"]!.Value<int>());
}
[Fact]
public void Blocked_event_log_channels_match_code_constants()
{
var json = LoadEmbeddedGuardrails()["blockedEventLogChannels"]!.ToObject<string[]>()!;
Assert.Equal(
GatherRuleGuards.HardBlockedEventLogChannels.OrderBy(c => c),
json.OrderBy(c => c));
}
}
}
@@ -0,0 +1,124 @@
using System;
using System.IO;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Runtime;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Contract tests for <see cref="AgentBootstrap.RetrySelfUpdateAfterEnrollment"/> —
/// the post-cert one-shot self-update retry (field case sits-d.cloud, 2026-08-09):
/// guard on <see cref="SelfUpdater.LastVersionCheckSucceeded"/>, the relaxed
/// timeouts/trigger it passes to the updater, and the swallow-everything exception
/// contract (bootstrap must continue on any updater failure). Uses the
/// <see cref="AgentBootstrap.UpdateInvokerOverride"/> seam — no network, no process
/// side effects.
/// <para>
/// Static-hook state: each test saves/restores both statics in ctor/Dispose so
/// parallel-running test classes never observe a leaked value (idiom of
/// SelfUpdaterGracefulShutdownTests).
/// </para>
/// </summary>
public sealed class AgentBootstrapSelfUpdateRetryTests : IDisposable
{
private readonly AgentBootstrap.SelfUpdateInvoker _savedInvoker;
private readonly bool _savedVersionCheckFlag;
public AgentBootstrapSelfUpdateRetryTests()
{
_savedInvoker = AgentBootstrap.UpdateInvokerOverride;
_savedVersionCheckFlag = SelfUpdater.LastVersionCheckSucceeded;
AgentBootstrap.UpdateInvokerOverride = null;
SelfUpdater.LastVersionCheckSucceeded = false;
}
public void Dispose()
{
AgentBootstrap.UpdateInvokerOverride = _savedInvoker;
SelfUpdater.LastVersionCheckSucceeded = _savedVersionCheckFlag;
}
private static AgentLogger NewLogger(string path)
=> new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
[Fact]
public void Skips_retry_when_startup_version_check_already_succeeded()
{
using var tmp = new TempDirectory();
var invoked = false;
SelfUpdater.LastVersionCheckSucceeded = true;
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
{
invoked = true;
return Task.CompletedTask;
};
AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false);
Assert.False(invoked);
}
[Fact]
public void Retries_with_await_enrollment_trigger_and_relaxed_timeouts()
{
using var tmp = new TempDirectory();
string? capturedVersion = null, capturedDir = null, capturedReason = null;
bool? capturedConsole = null, capturedDowngrade = null;
int capturedDownloadMs = 0, capturedVersionCheckMs = 0;
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
{
capturedVersion = v;
capturedDir = dir;
capturedConsole = console;
capturedReason = reason;
capturedDownloadMs = dl;
capturedDowngrade = downgrade;
capturedVersionCheckMs = vc;
return Task.CompletedTask;
};
AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: true);
Assert.Equal("await_enrollment", capturedReason);
// Relaxed vs. the boot-speed-tuned startup check (10s/2.5s) — enrollment is
// already past OOBE's critical path here.
Assert.Equal(60000, capturedDownloadMs);
Assert.Equal(10000, capturedVersionCheckMs);
// No cached remote config on a fresh --await-enrollment install → no downgrade override.
Assert.False(capturedDowngrade);
Assert.True(capturedConsole);
Assert.False(string.IsNullOrEmpty(capturedVersion));
Assert.False(string.IsNullOrEmpty(capturedDir));
}
[Fact]
public void Synchronous_invoker_exception_is_swallowed()
{
using var tmp = new TempDirectory();
AgentBootstrap.UpdateInvokerOverride =
(v, dir, console, reason, dl, downgrade, vc) => throw new InvalidOperationException("boom");
var ex = Record.Exception(
() => AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false));
Assert.Null(ex);
}
[Fact]
public void Faulted_update_task_is_swallowed()
{
using var tmp = new TempDirectory();
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
Task.FromException(new IOException("download interrupted"));
var ex = Record.Exception(
() => AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false));
Assert.Null(ex);
}
}
}
@@ -132,10 +132,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
}
[Fact]
public void First_install_without_wait_arg_uses_agent_default_of_600()
public void First_install_without_wait_arg_persists_null_so_runtime_default_applies()
{
// Generic bootstrap path: PS1 calls `--install` without --tenant-id-wait,
// and there is no persisted config yet. Agent owns the default (600 s).
// and there is no persisted config yet. "Not configured" is persisted as null
// so BuildAgentConfiguration applies the agent-side default (600 s) — freezing
// a number into the file would decouple deployed devices from default changes.
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing: null,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
@@ -144,20 +146,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Null(merged.BootstrapToken);
Assert.Null(merged.TenantId);
Assert.Equal(600, merged.TenantIdWaitSeconds);
Assert.Null(merged.TenantIdWaitSeconds);
}
[Fact]
public void Existing_config_without_wait_field_treated_as_zero()
public void Legacy_config_without_wait_field_stays_null_on_plain_reinstall()
{
// BackCompat: pre-fix bootstrap-config.json has no TenantIdWaitSeconds field;
// Newtonsoft deserialises that to 0. New install with --tenant-id-wait 600
// overrides cleanly; no extra migration logic required.
// BackCompat: pre-feature bootstrap-config.json has no TenantIdWaitSeconds
// property → nullable deserialisation yields null. A reinstall that does not
// pass --tenant-id-wait must NOT turn that into 0 (which would silently
// disable the wait); null carries through and the runtime default applies.
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = 0,
TenantIdWaitSeconds = null,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
tenantIdArg: null!, tenantIdGiven: false,
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
Assert.Equal("tok-legacy", merged.BootstrapToken);
Assert.Equal("tenant-legacy", merged.TenantId);
Assert.Null(merged.TenantIdWaitSeconds);
}
[Fact]
public void Legacy_config_without_wait_field_takes_explicit_wait_arg()
{
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = null,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
@@ -170,5 +194,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Equal("tenant-legacy", merged.TenantId);
Assert.Equal(600, merged.TenantIdWaitSeconds);
}
[Fact]
public void Explicit_zero_opt_out_survives_redeploy_without_wait_arg()
{
// An admin's deliberate `--tenant-id-wait 0` must not be resurrected to a
// wait by a later redeploy that omits the flag.
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-abc",
TenantId = "tenant-xyz",
TenantIdWaitSeconds = 0,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
tenantIdArg: null!, tenantIdGiven: false,
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
Assert.Equal(0, merged.TenantIdWaitSeconds);
}
}
}
@@ -66,6 +66,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
// same facts signal (safe default "false" off-device).
var selfDeploying = payload["isSelfDeployingProfile"];
Assert.True(selfDeploying == "true" || selfDeploying == "false");
// W365 — the Cloud PC marker fact rides the same signal (safe default "false"
// off-device; a build host is not a Cloud PC).
Assert.Contains("isCloudPc", (IDictionary<string, string>)payload);
var cloudPc = payload["isCloudPc"];
Assert.True(cloudPc == "true" || cloudPc == "false");
}
[Fact]
@@ -0,0 +1,90 @@
using AutopilotMonitor.Agent.V2;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Pins the TenantIdWaitSeconds resolution order in
/// <see cref="AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration"/>:
/// CLI arg &gt; persisted bootstrap-config.json value &gt; agent default (600 s).
/// The regression this guards: a legacy bootstrap-config.json written before the
/// TenantIdWaitSeconds property existed deserialises to null — that must fall
/// through to the 600 s default, NOT be treated as an explicit 0 opt-out
/// (which silently disabled the wait and broke Hybrid-AAD-join devices whose
/// registry TenantId lands minutes after first boot).
/// </summary>
public sealed class TenantIdWaitResolutionTests
{
private static AgentConfiguration Build(BootstrapConfigFile? bootstrapConfig, params string[] args) =>
AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
args: args,
tenantId: "tenant-t",
sessionId: "session-s",
bootstrapConfig: bootstrapConfig,
awaitConfig: null);
[Fact]
public void No_cli_and_no_bootstrap_config_uses_default_600()
{
var cfg = Build(bootstrapConfig: null);
Assert.Equal(600, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Legacy_bootstrap_config_with_null_wait_uses_default_600()
{
var legacy = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = null,
};
var cfg = Build(legacy);
Assert.Equal(600, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Explicit_zero_in_bootstrap_config_disables_wait()
{
var optedOut = new BootstrapConfigFile { TenantIdWaitSeconds = 0 };
var cfg = Build(optedOut);
Assert.Equal(0, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Nonzero_bootstrap_config_value_wins_over_default()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured);
Assert.Equal(1800, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Cli_arg_wins_over_bootstrap_config_value()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured, "--tenant-id-wait", "300");
Assert.Equal(300, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Cli_zero_wins_over_bootstrap_config_value()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured, "--tenant-id-wait", "0");
Assert.Equal(0, cfg.TenantIdWaitSeconds);
}
}
}
@@ -260,6 +260,60 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Contains("agent/config", fakeDistress.Calls[0].Message);
}
[Fact]
public void RecordFailure_endpoint_unavailable_suppresses_distress_but_still_counts()
{
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
var fakeDistress = new FakeDistressReporter();
var tracker = new AuthFailureTracker(maxFailures: 2, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
int events = 0;
tracker.ThresholdExceeded += (_, _) => events++;
// Platform-403s from a stopped endpoint: a distress report cannot reach it and the
// DeviceNotRegistered classification would be wrong — nothing must be dispatched.
tracker.RecordFailure(403, "agent/config", endpointUnavailable: true);
tracker.RecordFailure(403, "agent/register-session", endpointUnavailable: true);
Assert.Empty(fakeDistress.Calls);
// The shutdown thresholds still apply — a dead endpoint must not retry forever.
Assert.Equal(2, tracker.ConsecutiveFailures);
Assert.Equal(1, events);
}
[Fact]
public void RecordFailure_endpoint_unavailable_does_not_consume_streak_report_slot()
{
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
var fakeDistress = new FakeDistressReporter();
var tracker = new AuthFailureTracker(maxFailures: 50, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
// The streak opens with a platform-403 (no dispatch), then a live backend answers
// with a genuine 403 — that one must still produce the streak's single report.
tracker.RecordFailure(403, "agent/config", endpointUnavailable: true);
tracker.RecordFailure(403, "agent/config");
tracker.RecordFailure(403, "agent/config");
Assert.Single(fakeDistress.Calls);
Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[0].ErrorType);
}
[Fact]
public void RecordSuccess_rearms_distress_dispatch_for_the_next_streak()
{
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
var fakeDistress = new FakeDistressReporter();
var tracker = new AuthFailureTracker(maxFailures: 50, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
tracker.RecordFailure(401, "agent/config");
tracker.RecordSuccess();
tracker.RecordFailure(403, "agent/config");
Assert.Equal(2, fakeDistress.Calls.Count);
Assert.Equal(DistressErrorType.AuthCertificateRejected, fakeDistress.Calls[0].ErrorType);
Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[1].ErrorType);
}
[Fact]
public void RecordFailure_without_distress_reporter_is_silent_but_still_tracks()
{
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// Pins the marker-AND contract of <see cref="CloudPcDetector.ResolveIsCloudPc"/>: BOTH
/// the Windows365 registry key and the CloudManagedDesktopExtension service key must be
/// present — each alone has plausible look-alikes (W365-Boot physical clients carry
/// Windows365 policy state; the MCMD agent family served other managed-desktop
/// offerings). Mirrors the bootstrap script's field-verified <c>Test-IsCloudPc</c>.
/// </summary>
public sealed class CloudPcDetectorTests
{
private static Func<string, bool> Probe(params string[] existingKeys)
{
var set = new HashSet<string>(existingKeys, StringComparer.OrdinalIgnoreCase);
return path => set.Contains(path);
}
[Fact]
public void Both_markers_present_resolves_true()
{
Assert.True(CloudPcDetector.ResolveIsCloudPc(Probe(
CloudPcDetector.Windows365MarkerKey,
CloudPcDetector.CmdeServiceKey)));
}
[Fact]
public void Windows365_key_alone_resolves_false()
{
// W365-Boot physical client shape: policy state without the on-device MCMD agent.
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.Windows365MarkerKey)));
}
[Fact]
public void Cmde_service_alone_resolves_false()
{
// MCMD agent family also served other managed-desktop offerings.
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.CmdeServiceKey)));
}
[Fact]
public void No_markers_resolves_false()
{
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe()));
}
[Fact]
public void Null_probe_resolves_false()
{
Assert.False(CloudPcDetector.ResolveIsCloudPc(null));
}
[Fact]
public void Throwing_probe_degrades_to_false()
{
// SKIP-safe contract: a broken registry read must never classify, let alone throw.
Assert.False(CloudPcDetector.ResolveIsCloudPc(_ => throw new UnauthorizedAccessException("registry denied")));
}
[Fact]
public void DetectIsCloudPc_never_throws_on_build_host()
{
// Real-registry shell: exercises the exception-swallowing wrapper end to end.
// A build host is not a Cloud PC, but the contract under test is "no throw",
// not the detected value.
var ex = Record.Exception(() => CloudPcDetector.DetectIsCloudPc());
Assert.Null(ex);
}
}
}
@@ -137,6 +137,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(Environment.MachineName, captured.DeviceName);
Assert.False(string.IsNullOrEmpty(captured.EnrollmentType));
Assert.True(captured.IsUserDriven);
// W365 marker rides the registration body. A build host is not a Cloud PC
// (marker-AND: Windows365 key + CloudManagedDesktopExtension service), so the
// detector's SKIP-safe default is what must land here.
Assert.False(captured.IsCloudPc);
}
[Fact]
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo;
@@ -109,4 +110,28 @@ public class DeviceInfoCollectorAutopilotProfileMissingTests
Assert.False(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(null, out var domain));
Assert.Null(domain);
}
[Fact]
public void ResolveAutopilotProfileMissingPresentation_PhysicalDevice_WarnsAboutAssignment()
{
var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: false);
Assert.Equal(EventSeverity.Warning, severity);
Assert.Equal(
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
message);
}
[Fact]
public void ResolveAutopilotProfileMissingPresentation_CloudPc_InformationalAndExpected()
{
// On a Windows 365 Cloud PC a missing Autopilot profile is the normal state —
// the event must not read as an alarm there.
var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: true);
Assert.Equal(EventSeverity.Info, severity);
Assert.Contains("Windows 365 Cloud PC", message);
Assert.Contains("expected", message);
Assert.DoesNotContain("no deployment profile was assigned", message);
}
}
@@ -0,0 +1,110 @@
#nullable enable
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
{
/// <summary>
/// 401/403 classification in <see cref="BackendApiClient"/>: a backend JSON 403 is a real
/// device-authorization verdict, while an HTML body means the response came from the Azure
/// platform / an edge proxy (stopped or retired app) and must NOT be reported as
/// "device is not authorized". Field case sits-d.cloud 2026-08-09: a pre-cutover agent hit
/// the stopped legacy Function App and support chased a non-existent authorization problem.
/// </summary>
public sealed class BackendApiClientAuthClassificationTests
{
private sealed class StubHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_responder(request));
}
private static BackendApiClient BuildClient(HttpStatusCode status, string body, string mediaType)
{
var handler = new StubHandler(_ => new HttpResponseMessage(status)
{
Content = new StringContent(body, Encoding.UTF8, mediaType)
});
return new BackendApiClient(
new HttpClient(handler),
baseUrl: "https://backend.example",
manufacturer: "Contoso",
model: "TestBox",
serialNumber: "SN123",
useBootstrapTokenAuth: false,
bootstrapToken: null,
agentVersion: "2.0.0",
logger: null);
}
[Fact]
public async Task Backend_json_403_is_reported_as_device_not_authorized()
{
using var client = BuildClient(
HttpStatusCode.Forbidden,
"{\"success\":false,\"message\":\"Device validation failed\"}",
"application/json");
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
Assert.Equal(403, ex.StatusCode);
Assert.False(ex.EndpointUnavailable);
Assert.Contains("The device is not authorized", ex.Message);
}
[Fact]
public async Task Platform_html_403_is_reported_as_endpoint_unavailable_with_page_title()
{
using var client = BuildClient(
HttpStatusCode.Forbidden,
"<!DOCTYPE html><html><head><title>Web App - Unavailable</title></head><body>stopped</body></html>",
"text/html");
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
Assert.Equal(403, ex.StatusCode);
Assert.True(ex.EndpointUnavailable);
Assert.Contains("Web App - Unavailable", ex.Message);
Assert.Contains("NOT a device-authorization failure", ex.Message);
Assert.DoesNotContain("Check client certificate", ex.Message);
}
[Fact]
public async Task Html_body_with_generic_content_type_is_still_sniffed_as_platform_page()
{
using var client = BuildClient(
HttpStatusCode.Forbidden,
" <html><body>Error 403 - This web app is stopped.</body></html>",
"text/plain");
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
Assert.True(ex.EndpointUnavailable);
Assert.Contains("HTML platform error page", ex.Message);
}
[Fact]
public async Task Backend_401_keeps_authorization_message_and_status()
{
using var client = BuildClient(
HttpStatusCode.Unauthorized,
"{\"success\":false,\"message\":\"certificate rejected\"}",
"application/json");
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
Assert.Equal(401, ex.StatusCode);
Assert.False(ex.EndpointUnavailable);
Assert.Contains("The device is not authorized", ex.Message);
}
}
}
@@ -109,6 +109,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// </summary>
public bool EnableTimezoneAutoSet { get; set; } = false;
/// <summary>
/// Continue-Anyway observation mode (operator-set tenant opt-in, delivered via
/// remote config): stamped into the esp_config_detected decision-signal payload so
/// the DecisionEngine defangs a Device-phase ESP terminal failure into a bounded
/// 60-min observation instead of failing immediately. Default: false.
/// </summary>
public bool EnableEspContinueAnywayObservation { get; set; } = false;
/// <summary>
/// Optional custom path to IME logs directory for testing.
/// If set, overrides the default %ProgramData%\Microsoft\IntuneManagementExtension\Logs path.
@@ -272,9 +280,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// CloudDomainJoin keys and re-probes on every change until either resolution
/// or this timeout. 0 = no wait, fast-fail on miss (legacy behaviour).
/// <para>
/// Set via the install-time CLI flag <c>--tenant-id-wait &lt;sec&gt;</c>, persisted
/// to <c>bootstrap-config.json</c>. The PS1 bootstrap script owns the production
/// default; the agent itself never assumes one.
/// Set via the CLI flag <c>--tenant-id-wait &lt;sec&gt;</c> or a persisted
/// <c>bootstrap-config.json</c> value; when neither supplies a value the agent
/// applies its own default of 600 s in BuildAgentConfiguration. The 0 default
/// here is only the POCO baseline, not the effective runtime default.
/// </para>
/// </summary>
public int TenantIdWaitSeconds { get; set; } = 0;
@@ -16,8 +16,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
/// <summary>
/// TenantId-wait timeout in seconds, persisted from the <c>--tenant-id-wait</c>
/// install-time arg. Read by AgentBootstrap when the registry probe finds no
/// TenantId on first try. 0 / missing = no wait, legacy fast-fail.
/// TenantId on first try. 0 = explicit opt-out (legacy fast-fail).
/// Null / missing (files written before this property existed) = not configured —
/// the agent-side default (600 s) applies. Nullable so a legacy file without the
/// property cannot masquerade as an explicit 0 and silently disable the wait.
/// </summary>
public int TenantIdWaitSeconds { get; set; }
public int? TenantIdWaitSeconds { get; set; }
}
}
@@ -103,6 +103,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
agentConfig.SendTraceEvents = remoteConfig.SendTraceEvents;
// ---------------- Continue-Anyway observation — remote wins (operator-set tenant
// opt-in; false must clear a stale cached true).
agentConfig.EnableEspContinueAnywayObservation = remoteConfig.EnableEspContinueAnywayObservation;
// ---------------- UnrestrictedMode audit — track old/new for the caller so the
// agent_unrestricted_mode_changed event can fire AFTER the
// orchestrator emitter is alive (V1 parity with
@@ -155,7 +155,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
// see the AuthCertificateRejected/DeviceNotRegistered distress signal
// without 100 s of needless retry delay.
_logger.Warning($"Config fetch authentication failed: {ex.Message}");
_authFailureTracker?.RecordFailure(ex.StatusCode, "agent/config");
_authFailureTracker?.RecordFailure(ex.StatusCode, "agent/config", ex.EndpointUnavailable);
lastException = ex;
LastFetchAuthStatusCode = ex.StatusCode;
break;
@@ -328,6 +328,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
EnableGeoLocation = true,
EnableImeMatchLog = false,
EnableGatherRuleDebugLog = false,
EnableEspContinueAnywayObservation = false,
Collectors = CollectorConfiguration.CreateDefault(),
GatherRules = new System.Collections.Generic.List<GatherRule>(),
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
@@ -197,7 +197,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
/// <summary>
/// Sanitizes log messages to prevent log injection attacks.
/// Escapes control characters that could forge log entries.
/// Redaction patterns can be added here later as needed.
/// Transliterates typographic punctuation to ASCII: the log file is written as
/// UTF-8 without BOM, and customer-side viewers (e.g. Outlook web attachment
/// preview) guess cp1252 and render an em dash as mojibake ("—"). Only our own
/// punctuation is mapped — genuine non-ASCII payload (device names, paths) is
/// kept verbatim. Redaction patterns can be added here later as needed.
/// </summary>
private static string SanitizeLogMessage(string message)
{
@@ -206,6 +210,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
// Escape newlines and carriage returns to prevent log injection
message = message.Replace("\r", "\\r").Replace("\n", "\\n");
message = message
.Replace('\u2014', '-') // — em dash
.Replace('\u2013', '-') // – en dash
.Replace("\u2192", "->") // → arrow
.Replace("\u2026", "...") // … ellipsis
.Replace('\u2018', '\'') // ' left single quote
.Replace('\u2019', '\'') // ' right single quote
.Replace('\u201C', '"') // " left double quote
.Replace('\u201D', '"') // " right double quote
.Replace('\u00A0', ' '); // non-breaking space
return message;
}
}
@@ -7,6 +7,7 @@ using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
@@ -88,6 +88,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// </summary>
public static string BackendExpectedSha256 { get; set; }
/// <summary>
/// True once a <see cref="CheckAndApplyUpdateAsync"/> run actually obtained a version
/// from the manifest (regardless of whether an update was then needed/applied).
/// <para>
/// Consumed by the <c>--await-enrollment</c> path: the startup check runs pre-enrollment
/// where OOBE frequently has no network yet, so a failed check there says nothing about
/// the latest version. Once the MDM certificate appears the device provably has
/// connectivity — the bootstrap retries the check exactly when this is still false.
/// </para>
/// </summary>
/// <remarks>
/// Setter is internal solely as a test seam (InternalsVisibleTo): the bootstrap-retry
/// tests arm/reset the guard without running the real network check. Production code
/// writes it only from <see cref="CheckAndApplyUpdateAsync"/>.
/// </remarks>
public static bool LastVersionCheckSucceeded { get; internal set; }
/// <summary>
/// Optional: graceful-shutdown request hook, set by the runtime host once its
/// shutdown machinery is armed (before the orchestrator starts) and cleared in its
@@ -130,14 +147,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// to cover hotfixes that reuse the same version number.
/// </param>
/// <param name="triggerReason">
/// "startup" (default) or "runtime_hash_mismatch". Recorded in the marker file so the
/// agent_version_check event (outcome=updated) can distinguish the two paths.
/// "startup" (default), "await_enrollment" (post-cert retry in the bootstrap), or
/// "runtime_hash_mismatch". Recorded in the marker file so the agent_version_check
/// event (outcome=updated) can distinguish the paths.
/// </param>
/// <param name="downloadTimeoutMsOverride">
/// Overrides the ZIP download timeout. Startup path leaves null (fast-fail, 10s) to preserve
/// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running
/// and we know an update is required.
/// </param>
/// <param name="versionCheckTimeoutMsOverride">
/// Overrides the version-v2.json fetch timeout (default 2500ms, tuned for boot speed).
/// The await-enrollment retry passes a relaxed value: enrollment just completed, the
/// device provably has network, and a stranded old build's last self-rescue chance is
/// worth a few extra seconds before session registration.
/// </param>
/// <param name="allowDowngrade">
/// When false (default), a latest version strictly lower than <paramref name="currentVersion"/>
/// is rejected even when <paramref name="forceUpdate"/> is true — prevents the production
@@ -151,7 +175,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
bool forceUpdate = false,
string triggerReason = "startup",
int? downloadTimeoutMsOverride = null,
bool allowDowngrade = false)
bool allowDowngrade = false,
int? versionCheckTimeoutMsOverride = null)
{
Action<string> log = msg =>
{
@@ -160,7 +185,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
};
var isStartupTrigger = string.Equals(triggerReason, "startup", StringComparison.Ordinal);
// The await-enrollment retry replaces the startup verdict — its markers must win so
// the agent_version_check event reflects the LATEST check, not the pre-network one.
var writeMarkers = isStartupTrigger || string.Equals(triggerReason, "await_enrollment", StringComparison.Ordinal);
var downloadTimeoutMs = downloadTimeoutMsOverride ?? DownloadTimeoutMs;
var versionCheckTimeoutMs = versionCheckTimeoutMsOverride ?? VersionCheckTimeoutMs;
var totalSw = Stopwatch.StartNew();
long versionCheckMs = 0, downloadMs = 0, verifyMs = 0, extractMs = 0, swapMs = 0;
long zipSizeBytes = 0;
@@ -174,16 +203,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
try
{
// Step 1: Fetch version-v2.json (2.5s timeout)
// Step 1: Fetch version-v2.json (default 2.5s timeout, relaxed on retry triggers)
var versionSw = Stopwatch.StartNew();
string manifestSha256;
(latestVersion, manifestSha256) = await GetLatestVersionAsync(log);
(latestVersion, manifestSha256) = await GetLatestVersionAsync(log, versionCheckTimeoutMs);
versionSw.Stop();
versionCheckMs = versionSw.ElapsedMilliseconds;
LastVersionCheckSucceeded = latestVersion != null;
if (latestVersion == null)
{
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("version_check_failed", currentVersion, null, "version-v2.json fetch failed or timed out", log);
return; // Could not determine latest version — continue with current
}
@@ -213,7 +243,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (!forceUpdate)
{
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
if (isStartupTrigger)
if (writeMarkers)
WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log);
return;
}
@@ -230,7 +260,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (!forceUpdate)
{
log($"Self-update: cannot compare versions (current={currentVersion}, latest={latestVersion}) — skipping");
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("version_compare_failed", currentVersion, latestVersion, "version parse failed", log);
return;
}
@@ -249,7 +279,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
downloadMs = downloadTimerSw.ElapsedMilliseconds;
if (!downloadOk)
{
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("download_failed", currentVersion, latestVersion, $"ZIP download failed within {downloadTimeoutMs}ms", log);
return;
}
@@ -282,7 +312,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (!integrityOk)
{
CleanupStaging(null, zipPath);
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log);
return;
}
@@ -297,7 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
extractMs = extractSw.ElapsedMilliseconds;
if (!extractOk)
{
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "ZIP extraction failed", log);
return;
}
@@ -310,7 +340,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP");
CleanupStaging(stagingDir, zipPath);
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "AutopilotMonitor.Agent.exe not found in ZIP", log);
return;
}
@@ -323,7 +353,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (!swapOk)
{
CleanupStaging(stagingDir, zipPath);
if (isStartupTrigger)
if (writeMarkers)
WriteSkipMarker("swap_failed", currentVersion, latestVersion, "file swap failed", log);
return;
}
@@ -355,7 +385,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
catch (Exception ex)
{
log($"Self-update: unexpected error — {ex.Message}. Continuing with current version.");
if (isStartupTrigger)
if (writeMarkers)
{
try { WriteSkipMarker("unexpected_error", currentVersion, latestVersion, ex.Message, log); } catch { }
}
@@ -366,7 +396,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
/// Fetches version-v2.json from blob storage and returns the version string and optional SHA-256 hash.
/// Returns (null, null) if the check fails or times out.
/// </summary>
private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action<string> log)
private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action<string> log, int timeoutMs)
{
try
{
@@ -375,7 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
var versionUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentVersionFileNameForLine(2)}";
using (var handler = new HttpClientHandler())
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(VersionCheckTimeoutMs) })
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(timeoutMs) })
{
var json = await client.GetStringAsync(versionUrl);
var obj = JObject.Parse(json);
@@ -396,7 +426,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
catch (TaskCanceledException)
{
log($"Self-update: version check timed out ({VersionCheckTimeoutMs}ms) — skipping update");
log($"Self-update: version check timed out ({timeoutMs}ms) — skipping update");
return (null, null);
}
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
@@ -305,10 +305,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
// (see docs/agent/autopilot-ztd-diagnostics.md for the evidence model).
if (IsAutopilotProfileMissing(data))
{
// On Windows 365 Cloud PCs a missing profile is the expected state — Cloud PCs
// are provisioned by the Windows 365 service, not by Autopilot — so the event
// is demoted to Info there instead of raising a misleading warning.
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
var missingData = new Dictionary<string, object>
{
{ "profileAvailable", "0" },
{ "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" }
{ "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" },
{ "isCloudPc", isCloudPc }
};
if (TryExtractZeroTouchTenantDomain(data, out var ztdTenantDomain))
{
@@ -347,12 +353,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
missingData["ztdEndpointLatencyMs"] = probe.LatencyMs;
missingData["ztdEndpointDetail"] = probe.Detail;
var presentation = ResolveAutopilotProfileMissingPresentation(isCloudPc);
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfileMissing,
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
presentation.Message,
missingData,
EventSeverity.Warning);
presentation.Severity);
_logger.Warning($"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}");
var logLine = $"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — isCloudPc={isCloudPc}, ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}";
if (isCloudPc)
_logger.Info(logLine);
else
_logger.Warning(logLine);
}
// Emit dedicated enrollment_type_detected event for easy filtering. Routed through
@@ -396,6 +407,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
&& profileAvailable?.ToString() == "0";
}
/// <summary>
/// Message and severity for the autopilot_profile_missing event. On Windows 365
/// Cloud PCs the missing profile is expected (Cloud PCs are provisioned by the
/// Windows 365 service, not by Autopilot), so the event is informational there;
/// on physical devices it stays a warning about a missing profile ASSIGNMENT.
/// </summary>
internal static (string Message, EventSeverity Severity) ResolveAutopilotProfileMissingPresentation(bool isCloudPc)
{
if (isCloudPc)
{
return (
"No Autopilot profile was available during OOBE (ProfileAvailable=0). This device is a Windows 365 Cloud PC — Cloud PCs are provisioned by the Windows 365 service and do not use Autopilot deployment profiles, so this is expected. OOBE ran as a standard Entra ID join, which is the normal path for Cloud PCs.",
EventSeverity.Info);
}
return (
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
EventSeverity.Warning);
}
/// <summary>
/// Extracts ZeroTouchConfig.CloudAssignedTenantDomain from the cached profile data.
/// The value lives in the CloudAssignedAadServerData JSON — either as a top-level
@@ -1021,6 +1052,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (snapshot.AllowContinueAnyway.HasValue)
payload[SignalPayloadKeys.EspAllowContinueAnyway] = snapshot.AllowContinueAnyway.Value ? "true" : "false";
// Tenant opt-in (remote config, not a registry fact) — only stamped when
// enabled; absent = default hard-fail semantics in the engine.
if (_espContinueAnywayObservationEnabled?.Invoke() == true)
payload[SignalPayloadKeys.EspContinueAnywayObservationEnabled] = "true";
var derivationInputs = new Dictionary<string, string>(payload, StringComparer.Ordinal)
{
@@ -35,6 +35,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
private readonly IClock _clock;
private readonly Persistence.StartupEventGate _startupGate;
// Live accessor for AgentConfiguration.EnableEspContinueAnywayObservation — read at
// esp_config_detected stamping time so a late remote-config merge takes effect on
// the phase-triggered re-emissions. Null (tests / legacy callers) means disabled.
private readonly Func<bool> _espContinueAnywayObservationEnabled;
private const string RegKeyWindowsCurrentVersion = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion";
// Restart dedup (StartupEventGate) — exemptions. boot_time is genuinely new per boot
@@ -65,7 +70,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
AgentLogger logger,
ISignalIngressSink signalIngress = null,
IClock clock = null,
Persistence.StartupEventGate startupGate = null)
Persistence.StartupEventGate startupGate = null,
Func<bool> espContinueAnywayObservationEnabled = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
@@ -74,6 +80,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
_signalIngress = signalIngress;
_clock = clock;
_startupGate = startupGate;
_espContinueAnywayObservationEnabled = espContinueAnywayObservationEnabled;
}
/// <summary>
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Shared.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
@@ -35,9 +36,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
public static readonly IReadOnlyCollection<string> AllowedCommands;
public static readonly IReadOnlyList<string> AllowedEventLogChannels;
// Hard blocks: never allowed, even in unrestricted mode
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
private static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
// Derived from AllowedWmiQueryPrefixes: entries of the canonical form
// "SELECT * FROM <class>" contribute their class name; any other entry
// (none today) stays on the legacy literal-prefix path.
private static readonly HashSet<string> AllowedWmiClasses;
private static readonly List<string> ResidualWmiPrefixes;
// Canonical allowlist entry: exactly "SELECT * FROM <class>".
private static readonly Regex CanonicalWmiEntryRegex = new Regex(
@"^SELECT\s+\*\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// WQL shape "SELECT <* | property-list> FROM <class>", class token bounded
// by whitespace or end of string (a trailing WHERE clause is permitted,
// "Win32_BIOSX" cannot pass as "Win32_BIOS").
private static readonly Regex WmiQueryRegex = new Regex(
@"^SELECT\s+(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)(?=\s|$)",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
// Hard blocks: never allowed, even in unrestricted mode. Internal (not private)
// so GuardrailsParityTests can pin the guardrails.json display mirror to these
// constants — enforcement stays code-only by design.
internal static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
internal static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
{
Path.GetFullPath(@"C:\Windows\System32\config"), // SAM, SECURITY, SYSTEM hives
};
@@ -50,14 +71,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
};
// Hard limit: maximum command length (even in unrestricted mode)
private const int MaxCommandLength = 2000;
internal const int MaxCommandLength = 2000;
// Event log channels that are never readable, even in unrestricted mode.
// Security carries the audit trail of user behaviour; the PowerShell channels
// carry script-block logging, which routinely contains secrets in clear text.
// Mirrored in rules/guardrails.json ("blockedEventLogChannels") for the portal
// to display — enforcement lives here so a parse error cannot lift it.
private static readonly string[] HardBlockedEventLogChannels = new[]
internal static readonly string[] HardBlockedEventLogChannels = new[]
{
"Security",
"Microsoft-Windows-PowerShell",
@@ -67,7 +88,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
// Hard-blocked command patterns: never allowed, even in unrestricted mode.
// These prevent privilege escalation, persistence, and data exfiltration.
private static readonly string[] HardBlockedCommandPatterns = new[]
// Mirrored in rules/guardrails.json ("blockedCommandPatterns") for portal/MCP
// pre-flight display — enforcement lives here so a parse error cannot lift it.
internal static readonly string[] HardBlockedCommandPatterns = new[]
{
// Download / exfiltration
"Invoke-WebRequest", "Invoke-RestMethod", "Start-BitsTransfer",
@@ -101,6 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
AllowedCommands = new HashSet<string>(commands, StringComparer.OrdinalIgnoreCase);
AllowedEventLogChannels = FlattenCategorized(obj, "eventLogChannels", "channels");
DeriveWmiClassAllowlist(AllowedWmiQueryPrefixes, out AllowedWmiClasses, out ResidualWmiPrefixes);
return;
}
}
@@ -115,6 +139,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
AllowedWmiQueryPrefixes = new List<string>();
AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
AllowedEventLogChannels = new List<string>();
AllowedWmiClasses = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
ResidualWmiPrefixes = new List<string>();
}
private static void DeriveWmiClassAllowlist(
IReadOnlyList<string> entries,
out HashSet<string> classes,
out List<string> residual)
{
classes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
residual = new List<string>();
foreach (var entry in entries)
{
var match = CanonicalWmiEntryRegex.Match(entry.Trim());
if (match.Success)
classes.Add(match.Groups[1].Value);
else
residual.Add(entry);
}
}
private static string LoadEmbeddedGuardrails()
@@ -250,8 +294,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
}
/// <summary>
/// Returns true if the WMI query (trimmed) matches an allowed prefix
/// with boundary matching (next char must be whitespace or end of string).
/// Returns true if the WMI query (trimmed) is "SELECT &lt;* | property-list&gt; FROM &lt;class&gt;"
/// (optionally followed by a WHERE clause) against a class on the allowlist.
/// A property projection exposes a strict subset of the allowed "SELECT *".
/// </summary>
public static bool IsWmiQueryAllowed(string query)
=> IsWmiQueryAllowed(query, unrestrictedMode: false);
@@ -269,7 +314,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
return true;
var trimmed = query.Trim();
return AllowedWmiQueryPrefixes.Any(prefix =>
var match = WmiQueryRegex.Match(trimmed);
if (match.Success && AllowedWmiClasses.Contains(match.Groups[1].Value))
return true;
// Non-canonical allowlist entries keep the legacy literal-prefix match.
return ResidualWmiPrefixes.Any(prefix =>
trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
(trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length])));
}
@@ -137,7 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
continue;
}
ThrowOnAuthFailure(response);
await ThrowOnAuthFailureAsync(response).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
@@ -236,7 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
using (response)
{
ThrowOnAuthFailure(response);
await ThrowOnAuthFailureAsync(response).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
@@ -282,16 +282,97 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// <summary>
/// Throws <see cref="BackendAuthException"/> for 401/403 responses so callers can
/// distinguish authentication failures from transient server errors.
/// <para>
/// Field case (sits-d.cloud, 2026-08-09): a pre-cutover agent build pointed at a
/// retired Function App; Azure answers for a stopped app with a platform-level 403
/// carrying an HTML error page ("Web App - Unavailable"). Our backend never returns
/// HTML on 401/403 — an HTML body therefore means the response did not come from the
/// backend's auth layer at all (stopped/retired app, edge proxy, WAF). Reporting that
/// as "device is not authorized" sends troubleshooting down the wrong path, so the
/// two cases get distinct messages and the exception carries a flag.
/// </para>
/// </summary>
private static void ThrowOnAuthFailure(HttpResponseMessage response)
private static async Task ThrowOnAuthFailureAsync(HttpResponseMessage response)
{
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
response.StatusCode == System.Net.HttpStatusCode.Forbidden)
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized &&
response.StatusCode != System.Net.HttpStatusCode.Forbidden)
{
return;
}
var statusText = $"{(int)response.StatusCode} {response.StatusCode}";
string body = null;
try
{
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
catch
{
// Body is diagnostic-only; classification below treats null as non-HTML.
}
if (LooksLikeHtmlErrorPage(response, body))
{
var title = TryExtractHtmlTitle(body);
var titleText = title != null ? $" (\"{title}\")" : string.Empty;
var host = response.RequestMessage?.RequestUri?.Host ?? "(unknown host)";
throw new BackendAuthException(
$"Backend returned {(int)response.StatusCode} {response.StatusCode}. " +
"The device is not authorized. Check client certificate and Autopilot device validation.",
(int)response.StatusCode);
$"Backend returned {statusText} with an HTML platform error page{titleText} from '{host}'. " +
"This response did not come from the Autopilot Monitor backend — the endpoint appears stopped, " +
"retired, or intercepted by a proxy. This is NOT a device-authorization failure; an outdated " +
"agent build pointing at a stale ApiBaseUrl is the most likely cause.",
(int)response.StatusCode,
endpointUnavailable: true);
}
throw new BackendAuthException(
$"Backend returned {statusText}. " +
"The device is not authorized. Check client certificate and Autopilot device validation.",
(int)response.StatusCode);
}
/// <summary>
/// True when a 401/403 response is an HTML page rather than a backend JSON/plain-text
/// reply. Checks the declared Content-Type first, then falls back to sniffing the body
/// prefix so a platform page served with a generic Content-Type is still recognized.
/// </summary>
private static bool LooksLikeHtmlErrorPage(HttpResponseMessage response, string body)
{
var mediaType = response.Content?.Headers?.ContentType?.MediaType;
if (string.Equals(mediaType, "text/html", StringComparison.OrdinalIgnoreCase))
return true;
if (string.IsNullOrWhiteSpace(body))
return false;
var head = body.TrimStart();
return head.StartsWith("<!DOCTYPE", StringComparison.OrdinalIgnoreCase) ||
head.StartsWith("<html", StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Best-effort extraction of the HTML &lt;title&gt; (e.g. "Web App - Unavailable") so the
/// log line names the platform page without dumping markup. Null when absent/oversized.
/// </summary>
private static string TryExtractHtmlTitle(string body)
{
if (string.IsNullOrEmpty(body)) return null;
try
{
var match = System.Text.RegularExpressions.Regex.Match(
body, @"<title[^>]*>\s*(.*?)\s*</title>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase |
System.Text.RegularExpressions.RegexOptions.Singleline,
TimeSpan.FromMilliseconds(250));
if (!match.Success) return null;
var title = match.Groups[1].Value;
return title.Length == 0 ? null : (title.Length > 80 ? title.Substring(0, 80) : title);
}
catch
{
return null;
}
}
@@ -330,7 +411,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
}
/// <summary>
/// Thrown when the backend returns 401 or 403, indicating the device is not authorized.
/// Thrown when the backend returns 401 or 403, indicating the device is not authorized —
/// or, when <see cref="EndpointUnavailable"/> is set, that the 401/403 was a platform-level
/// HTML response (stopped/retired app, edge proxy) rather than a backend auth verdict.
/// </summary>
public class BackendAuthException : Exception
{
@@ -340,9 +423,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
/// </summary>
public int StatusCode { get; }
public BackendAuthException(string message, int statusCode = 0) : base(message)
/// <summary>
/// True when the 401/403 carried an HTML platform error page — the endpoint itself is
/// stopped/retired/proxied and the status says nothing about this device's authorization.
/// </summary>
public bool EndpointUnavailable { get; }
public BackendAuthException(string message, int statusCode = 0, bool endpointUnavailable = false) : base(message)
{
StatusCode = statusCode;
EndpointUnavailable = endpointUnavailable;
}
}
}
@@ -56,6 +56,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
private DateTime? _lastReportTime;
private int _reportCount;
// Local W365 marker verdict, captured once at construction (same UNVERIFIED contract
// as the hardware fields). Optional ctor arg so test fakes stay source-compatible.
private readonly bool _isCloudPc;
public DistressReporter(
string baseUrl,
string tenantId,
@@ -64,7 +68,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
string serialNumber,
string agentVersion,
AgentLogger logger,
X509Certificate2 clientCertificate = null)
X509Certificate2 clientCertificate = null,
bool isCloudPc = false)
{
_tenantId = tenantId;
_manufacturer = manufacturer;
@@ -72,6 +77,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
_serialNumber = serialNumber;
_agentVersion = agentVersion;
_logger = logger;
_isCloudPc = isCloudPc;
// Capture cert context. Each field is independently nullable so the backend can tell
// "agent had no cert at all" (NoCertInStore + all-null fields) from "agent had a cert
@@ -169,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
HttpStatusCode = httpStatusCode,
Message = Truncate(message, 256),
Timestamp = DateTime.UtcNow,
IsCloudPc = _isCloudPc,
CertSourceState = _capturedCertState,
CertThumbprint = _capturedCertThumbprint,
CertSubject = Truncate(_capturedCertSubject, 96),
@@ -127,7 +127,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
ingress: ingress,
clock: clock,
logger: logger,
startupGate: _startupEventGate));
startupGate: _startupEventGate,
// Live accessor (not the current value): a remote-config merge landing after
// factory construction must still reach the phase-triggered re-emissions.
espContinueAnywayObservationEnabled: () => _agentConfig.EnableEspContinueAnywayObservation));
// Session caa6cf50 gate-starvation fix: the EspAndHelloTracker's user-ESP-apps-settled
// probe reads the IME log host, which is constructed further down. Same closure-over-
@@ -91,6 +91,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// (tests / legacy callers) means trace events always flow.
private readonly Func<bool>? _traceEventsEnabled;
// Live accessor for AgentConfiguration.EnableEspContinueAnywayObservation — read at
// esp_config_detected stamping time so a remote-config merge that lands after
// construction still takes effect. Null (tests / legacy callers) means disabled.
private readonly Func<bool>? _espContinueAnywayObservationEnabled;
// Max-lifetime watchdog (M4.6.α). Timer is armed in Start() when _agentMaxLifetime != null.
private System.Threading.Timer? _maxLifetimeTimer;
private int _terminatedFired;
@@ -173,7 +178,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
TimeSpan? agentMaxLifetime = null,
int uploadBatchSize = 100,
IDeadlineScheduler? schedulerOverride = null,
Func<bool>? traceEventsEnabled = null)
Func<bool>? traceEventsEnabled = null,
Func<bool>? espContinueAnywayObservationEnabled = null)
{
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
@@ -214,6 +220,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
_schedulerOverride = schedulerOverride;
_traceEventsEnabled = traceEventsEnabled;
_espContinueAnywayObservationEnabled = espContinueAnywayObservationEnabled;
}
/// <summary>
@@ -773,6 +780,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (snapshot.AllowContinueAnyway.HasValue)
payload[SignalPayloadKeys.EspAllowContinueAnyway] = snapshot.AllowContinueAnyway.Value ? "true" : "false";
// Tenant opt-in (remote config, not a registry fact) — only stamped when
// enabled; absent = default hard-fail semantics in the engine.
if (_espContinueAnywayObservationEnabled?.Invoke() == true)
payload[SignalPayloadKeys.EspContinueAnywayObservationEnabled] = "true";
_ingress!.Post(
kind: DecisionSignalKind.EspConfigDetected,
@@ -82,7 +82,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
ISignalIngressSink ingress,
IClock clock,
AgentLogger logger,
Persistence.StartupEventGate? startupGate = null)
Persistence.StartupEventGate? startupGate = null,
Func<bool>? espContinueAnywayObservationEnabled = null)
{
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
if (clock == null) throw new ArgumentNullException(nameof(clock));
@@ -92,7 +93,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
// when it reads the FirstSync SkipUser/SkipDevice registry values, so that Fix 8's
// reducer guards have the SkipUserEsp/SkipDeviceEsp state facts to read.
_collector = new Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector(
sessionId, tenantId, post, logger, ingress, clock, startupGate);
sessionId, tenantId, post, logger, ingress, clock, startupGate,
espContinueAnywayObservationEnabled);
_observableIngress = ingress as SignalIngress;
}
@@ -46,6 +46,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
private int _consecutiveFailures;
private DateTime? _firstFailureUtc;
private int _thresholdFired; // 0/1 — Interlocked exchange makes the event single-shot
private int _distressDispatched; // 0/1 per failure streak — reset by RecordSuccess
public AuthFailureTracker(
int maxFailures,
@@ -77,18 +78,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
public void RecordSuccess()
{
if (Interlocked.Exchange(ref _consecutiveFailures, 0) == 0) return; // no-op if already zero
Interlocked.Exchange(ref _distressDispatched, 0);
lock (_windowLock) { _firstFailureUtc = null; }
}
/// <summary>
/// Record a 401/403 from the given <paramref name="operation"/>. V1 parity — on the FIRST
/// failure this fires a single distress report via the constructor-injected
/// <see cref="DistressReporter"/> (401 → AuthCertificateRejected, 403 → DeviceNotRegistered).
/// Subsequent failures are logged + counted but do not dispatch further distress signals.
/// Emits <see cref="ThresholdExceeded"/> the first time either ceiling is crossed;
/// subsequent calls after termination is armed are no-ops.
/// Record a 401/403 from the given <paramref name="operation"/>. V1 parity — the first
/// qualifying failure of a streak fires a single distress report via the
/// constructor-injected <see cref="DistressReporter"/> (401 → AuthCertificateRejected,
/// 403 → DeviceNotRegistered). Subsequent failures are logged + counted but do not
/// dispatch further distress signals. Emits <see cref="ThresholdExceeded"/> the first
/// time either ceiling is crossed; subsequent calls after termination is armed are no-ops.
/// </summary>
public void RecordFailure(int statusCode, string operation)
/// <param name="endpointUnavailable">
/// True when the 401/403 was a platform-level HTML response (stopped/retired app, edge
/// proxy — see <see cref="BackendAuthException.EndpointUnavailable"/>). Suppresses the
/// distress dispatch: the report would go to the very endpoint that just answered with a
/// platform error page, and its DeviceNotRegistered classification would be wrong anyway.
/// Counting and the shutdown thresholds are unaffected. Should a later failure in the
/// same streak come from a live backend, that one still dispatches the streak's report.
/// </param>
public void RecordFailure(int statusCode, string operation, bool endpointUnavailable = false)
{
if (Volatile.Read(ref _thresholdFired) == 1) return;
@@ -102,10 +112,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
firstFailureAt = _firstFailureUtc.Value;
}
// V1 parity — only the first consecutive failure produces a distress report. Repeat
// hits at 2..N use the local log only; the backend already knows from the first
// report that the device is unauthorized.
if (count == 1 && _distressReporter != null)
// V1 parity — only one distress report per failure streak. Repeat hits use the local
// log only; the backend already knows from the first report that the device is
// unauthorized. Endpoint-unavailable failures never dispatch (nothing to send to) but
// also never consume the streak's report slot.
if (endpointUnavailable)
{
if (_distressReporter != null && Volatile.Read(ref _distressDispatched) == 0)
{
_logger.Info(
$"AuthFailureTracker: distress report suppressed for {operation} (http {statusCode}) — " +
"endpoint unavailable (platform error page), a report cannot reach a stopped endpoint.");
}
}
else if (_distressReporter != null &&
Interlocked.CompareExchange(ref _distressDispatched, 1, 0) == 0)
{
var distressType = statusCode == 403
? DistressErrorType.DeviceNotRegistered
@@ -0,0 +1,77 @@
using System;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Detects whether the agent runs on a Windows 365 Cloud PC, using the same marker
/// evidence the bootstrap script's <c>Test-IsCloudPc</c> settled on after the first
/// W365 field test (2026-08-06) disproved WMI-model matching (a Cloud PC reports only
/// Manufacturer='Microsoft Corporation' + Model='Virtual Machine' locally — identical
/// to any Hyper-V VM):
/// <list type="bullet">
/// <item><c>HKLM\SOFTWARE\Microsoft\Windows365</c> exists, AND</item>
/// <item>the <c>CloudManagedDesktopExtension</c> service (Microsoft Cloud Managed
/// Desktop Extension, the W365 management agent that runs ON Cloud PCs) is
/// installed — probed via its <c>HKLM\SYSTEM\CurrentControlSet\Services</c> key,
/// so the agent needs no System.ServiceProcess dependency.</item>
/// </list>
/// BOTH markers are required (AND): each alone has plausible look-alikes (W365-Boot
/// physical clients may carry Windows365 policy state; the MCMD agent family also
/// served other managed-desktop offerings).
/// <para>
/// Display/context metadata only — the flag rides on <c>SessionRegistration</c> and the
/// <c>EnrollmentFactsObserved</c> signal. It is NEVER an auth input: backend device
/// validation trusts exclusively the cert-CN-bound Graph lookup
/// (<c>CloudPcDeviceValidator</c>), not this locally-derived value.
/// Exception-swallowing, false on any error — mirrors the SKIP-safe bootstrap contract
/// and the <see cref="EnrollmentRegistryDetector"/> degradation rules.
/// </para>
/// </summary>
public static class CloudPcDetector
{
internal const string Windows365MarkerKey = @"SOFTWARE\Microsoft\Windows365";
internal const string CmdeServiceKey = @"SYSTEM\CurrentControlSet\Services\CloudManagedDesktopExtension";
public static bool DetectIsCloudPc()
{
try
{
return ResolveIsCloudPc(SubKeyExists);
}
catch
{
return false;
}
}
/// <summary>
/// Pure decision logic for <see cref="DetectIsCloudPc"/>. Exposed internally so the
/// marker-AND semantics can be exercised without a real registry (same seam contract
/// as <see cref="EnrollmentRegistryDetector.ResolveHybridJoinFromValues"/>).
/// <paramref name="subKeyExists"/> receives an HKLM-relative subkey path and reports
/// whether it exists; a throwing probe degrades to <c>false</c>.
/// </summary>
internal static bool ResolveIsCloudPc(Func<string, bool> subKeyExists)
{
if (subKeyExists == null) return false;
try
{
return subKeyExists(Windows365MarkerKey) && subKeyExists(CmdeServiceKey);
}
catch
{
return false;
}
}
private static bool SubKeyExists(string subKeyPath)
{
using (var key = Registry.LocalMachine.OpenSubKey(subKeyPath))
{
return key != null;
}
}
}
}
@@ -89,7 +89,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
// Feed the central auth-failure tracker so the first-failure distress dispatches
// and the shutdown threshold advances. V1 parity: no retry on auth-failure —
// backend has definitively rejected the cert/token.
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session");
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session", ex.EndpointUnavailable);
return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message);
}
catch (Exception ex)
@@ -176,6 +176,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
EnrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(),
IsHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(),
IsSelfDeployingProfile = EnrollmentRegistryDetector.DetectSelfDeployingProfile(),
IsCloudPc = CloudPcDetector.DetectIsCloudPc(),
// Deliberately stays true even for self-deploying profiles: the WhiteGlove
// Part1/Part2 merge paths and existing dashboards key on IsUserDriven;
// IsSelfDeployingProfile is the additive classification signal.
@@ -6,6 +6,7 @@ using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Shared.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
@@ -135,7 +135,7 @@ namespace AutopilotMonitor.Agent.V2
$"{(bootstrapTokenGiven ? "*" : "")}, " +
$"tenantId={(string.IsNullOrEmpty(merged.TenantId) ? "no" : "yes")}" +
$"{(tenantIdGiven ? "*" : "")}, " +
$"tenantIdWait={merged.TenantIdWaitSeconds}s" +
$"tenantIdWait={(merged.TenantIdWaitSeconds is int w ? $"{w}s" : "default")}" +
$"{(tenantIdWaitGiven ? "*" : "")})." +
" Star = explicitly set on this --install invocation, others were preserved from prior config.");
}
@@ -361,9 +361,12 @@ namespace AutopilotMonitor.Agent.V2
{
BootstrapToken = bootstrapTokenGiven ? bootstrapTokenArg : existing?.BootstrapToken,
TenantId = tenantIdGiven ? tenantIdArg : existing?.TenantId,
// Not given → carry the prior value through, INCLUDING null. Persisting a
// number here would freeze the agent-side default into the file; persisting
// null keeps "not configured" distinguishable from an explicit opt-out (0).
TenantIdWaitSeconds = tenantIdWaitGiven
? tenantIdWaitSeconds
: (existing?.TenantIdWaitSeconds ?? 600),
: existing?.TenantIdWaitSeconds,
};
}
@@ -339,13 +339,15 @@ namespace AutopilotMonitor.Agent.V2
// over the agent-side default (600 s). Hybrid-AAD-joined devices typically
// need ~5 min for the AAD device cert to land in the registry; 600 s leaves
// headroom. Pass `--tenant-id-wait 0` to opt out (legacy fast-fail).
// A bootstrap-config.json whose TenantIdWaitSeconds is null (file predates the
// property) is "not configured", NOT an opt-out — the default must still apply.
var tenantIdWaitRaw = GetArgValue(args, "--tenant-id-wait");
const int tenantIdWaitDefaultSeconds = 600;
int tenantIdWaitSeconds = tenantIdWaitDefaultSeconds;
if (!string.IsNullOrEmpty(tenantIdWaitRaw) && int.TryParse(tenantIdWaitRaw, out var parsedTenantIdWait))
tenantIdWaitSeconds = parsedTenantIdWait;
else if (bootstrapConfig != null)
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds;
else if (bootstrapConfig?.TenantIdWaitSeconds != null)
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds.Value;
var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken);
@@ -1,6 +1,7 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
@@ -121,6 +122,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
// Await-enrollment is one-shot — remove the persisted config so subsequent restarts proceed normally.
Program.DeleteAwaitEnrollmentConfig(dataDirectory, logger);
RetrySelfUpdateAfterEnrollment(logger, consoleMode);
}
// Event-driven TenantId wait — kicks in only when the user opted-in via
@@ -152,6 +155,81 @@ namespace AutopilotMonitor.Agent.V2.Runtime
isWhiteGloveResume: isWhiteGloveResume,
cleanupServiceFactory: cleanupServiceFactory);
}
/// <summary>
/// Signature of the self-update invocation performed by
/// <see cref="RetrySelfUpdateAfterEnrollment"/> — mirrors the
/// <see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> parameters this path uses.
/// </summary>
internal delegate Task SelfUpdateInvoker(
string currentVersion,
string agentDir,
bool consoleMode,
string triggerReason,
int downloadTimeoutMs,
bool allowDowngrade,
int versionCheckTimeoutMs);
/// <summary>
/// Test seam replacing the real <see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> call
/// in <see cref="RetrySelfUpdateAfterEnrollment"/> (same static-hook idiom as
/// <see cref="SelfUpdater.RequestGracefulShutdown"/>). Only the in-repo test project
/// (InternalsVisibleTo) can set it; production code never assigns this.
/// </summary>
internal static SelfUpdateInvoker UpdateInvokerOverride;
/// <summary>
/// One-shot self-update retry after the MDM certificate appeared. The startup check in
/// <c>Main</c> runs pre-enrollment, where an --await-enrollment install typically has no
/// network yet — its failure says nothing about the latest version. The certificate is
/// proof of connectivity, and this is the last chance to swap a stranded old build
/// (stale baked-in ApiBaseUrl) for a current one BEFORE session registration hits a
/// retired endpoint (field case sits-d.cloud, 2026-08-09). Timeouts are relaxed vs. the
/// boot-speed-tuned startup check; enrollment is already past OOBE's critical path here.
/// On update success the process restarts and this method never returns; the
/// await-enrollment config was already removed, so the new binary proceeds normally.
/// </summary>
internal static void RetrySelfUpdateAfterEnrollment(AgentLogger logger, bool consoleMode)
{
if (SelfUpdater.LastVersionCheckSucceeded)
return; // Startup check reached the manifest — its verdict stands.
try
{
logger.Info("Await-enrollment: startup self-update check had failed (no network in early OOBE) — retrying now that enrollment proves connectivity.");
var agentDir = System.IO.Path.GetDirectoryName(
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty;
SelfUpdateInvoker invoke = UpdateInvokerOverride;
if (invoke == null)
{
invoke = (version, dir, console, reason, downloadTimeoutMs, allowDowngrade, versionCheckTimeoutMs) =>
SelfUpdater.CheckAndApplyUpdateAsync(
currentVersion: version,
agentDir: dir,
consoleMode: console,
triggerReason: reason,
downloadTimeoutMsOverride: downloadTimeoutMs,
allowDowngrade: allowDowngrade,
versionCheckTimeoutMsOverride: versionCheckTimeoutMs);
}
invoke(
Program.GetAgentVersion(),
agentDir,
consoleMode,
"await_enrollment",
60000,
// Fresh --await-enrollment installs have no cached remote config yet, so
// there is no admin downgrade override to honour on this path.
false,
10000).GetAwaiter().GetResult();
}
catch (Exception ex)
{
// Same contract as the startup call site: the updater swallows its own errors;
// anything escaping must not break bootstrap — the current binary continues.
logger.Warning($"Await-enrollment: self-update retry failed unexpectedly: {ex.Message}. Continuing with current version.");
}
}
}
/// <summary>
@@ -135,7 +135,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
uploadBatchSize: uploadBatchSize,
// Live read: remote config is merged into agentConfig mid-session, so the
// trace-event gate must follow it rather than snapshot at construction.
traceEventsEnabled: () => agentConfig.SendTraceEvents))
traceEventsEnabled: () => agentConfig.SendTraceEvents,
espContinueAnywayObservationEnabled: () => agentConfig.EnableEspContinueAnywayObservation))
{
using (var shutdown = new ManualResetEventSlim(false))
using (var shutdownComplete = new ManualResetEventSlim(false))
@@ -102,7 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
serialNumber: hardware.SerialNumber,
agentVersion: agentVersion,
logger: logger,
clientCertificate: clientCertificate);
clientCertificate: clientCertificate,
isCloudPc: CloudPcDetector.DetectIsCloudPc());
var emergencyReporter = new EmergencyReporter(
apiClient: backendApiClient,
@@ -309,12 +309,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType();
var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin();
var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile();
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
{
["enrollmentType"] = enrollmentType,
["isHybridJoin"] = isHybridJoin ? "true" : "false",
["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false",
["isCloudPc"] = isCloudPc ? "true" : "false",
};
var evidence = new Evidence(
@@ -329,7 +331,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
evidence: evidence,
payload: payload);
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}).");
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}).");
}
catch (Exception ex)
{
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// W365 — reducer-level coverage for the Cloud PC marker fact riding
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/>: the observation records BOTH
/// values set-once (mirror of the registry self-deploying fact), the profile reason
/// carries the "no Device-ESP phase expected" expectation, and the signal census folds the
/// positive marker into terminal audit trails. The engine deliberately has no DeviceSetup
/// arrival requirement, so no behavioural gate is added — the observation is the
/// engine-side context that a session starting at Account Setup is the expected shape.
/// </summary>
public sealed class CloudPcObservationTests
{
private static readonly DateTime T0 = new DateTime(2026, 8, 7, 10, 0, 0, DateTimeKind.Utc);
private static DecisionSignal MakeSignal(
long ordinal,
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null) =>
new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: "test",
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
payload: payload);
private static DecisionState ReduceFacts(DecisionEngine engine, DecisionState state, long ordinal, string isCloudPc)
=> engine.Reduce(state, MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, T0.AddSeconds(ordinal),
new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsCloudPc] = isCloudPc,
})).NewState;
[Fact]
public void EnrollmentFactsObserved_true_recordsObservation_andExpectationReason()
{
var engine = new DecisionEngine();
var state = engine.Reduce(
DecisionState.CreateInitial("cpc-1", "t", T0),
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = ReduceFacts(engine, state, 1, "true");
Assert.NotNull(state.ScenarioObservations.CloudPc);
Assert.True(state.ScenarioObservations.CloudPc!.Value);
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
Assert.Equal(
EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason,
state.ScenarioProfile.Reason);
// Stage-agnostic fact signal — the reducer must not move the session anywhere.
Assert.Equal(SessionStage.SessionStarted, state.Stage);
}
[Fact]
public void EnrollmentFactsObserved_false_recordsObservation_setOnce()
{
var engine = new DecisionEngine();
var state = engine.Reduce(
DecisionState.CreateInitial("cpc-2", "t", T0),
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = ReduceFacts(engine, state, 1, "false");
Assert.NotNull(state.ScenarioObservations.CloudPc);
Assert.False(state.ScenarioObservations.CloudPc!.Value);
// Set-once: a later contradicting re-read must not overwrite the first sighting
// (same contract as RegistrySelfDeployingProfile — replay-deterministic evidence).
state = ReduceFacts(engine, state, 2, "true");
Assert.False(state.ScenarioObservations.CloudPc!.Value);
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
}
[Fact]
public void Census_foldsPositiveMarker_only()
{
var engine = new DecisionEngine();
var initial = engine.Reduce(
DecisionState.CreateInitial("cpc-3", "t", T0),
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
var cloudPcState = ReduceFacts(engine, initial, 1, "true");
var census = DecisionStateSignalCensus.Build(cloudPcState);
Assert.Contains("cloud_pc_marker", census.SignalsSeen);
Assert.True(census.SignalEvidence.ContainsKey("cloudPcMarker"));
var physicalState = ReduceFacts(engine, initial, 1, "false");
var physicalCensus = DecisionStateSignalCensus.Build(physicalState);
Assert.DoesNotContain("cloud_pc_marker", physicalCensus.SignalsSeen);
}
}
}
@@ -0,0 +1,383 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Continue-Anyway observation mode (tenant c9787ba2, session 53d1e9f6, 2026-08-08).
/// A Device-phase ESP terminal failure (AccountSetup never entered) on a
/// Continue-Anyway-enabled profile is a hard fail by default; with the tenant opt-in
/// (<c>EspContinueAnywayObservationEnabled</c> observation) it is defanged into a 60-min
/// observation advisory instead. Coverage:
/// <list type="bullet">
/// <item>Defang + 60-min AdvisoryCompletion arming (vs the classic 30-min window).</item>
/// <item>Opt-out / ContinueAnyway-off / self-deploying-scenario regressions still hard-fail.</item>
/// <item>Real-user desktop + Hello-disabled completes eagerly through Finalizing; the
/// terminal enrollment_complete carries the espSoftFailure marker.</item>
/// <item>Window expiry without desktop un-defangs to esp_terminal_failure.</item>
/// <item>Deadline fire with desktop but unknown Hello promotes to AwaitingHello.</item>
/// <item>Category recovery ("Try again") resolves the advisory (classic 4910a5a5 hook).</item>
/// </list>
/// </summary>
public sealed class ContinueAnywayObservationTests
{
private static readonly DateTime T0 = new DateTime(2026, 8, 7, 15, 16, 0, DateTimeKind.Utc);
private static DecisionSignal MakeSignal(
long ordinal,
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null,
string sourceOrigin = "test")
{
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: sourceOrigin,
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
payload: payload);
}
private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) =>
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) =>
step.Effects.Single(e =>
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
&& e.Parameters != null
&& e.Parameters.TryGetValue("eventType", out var et)
&& et == eventType);
private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
state.Deadlines.FirstOrDefault(d => d.Name == name);
/// <summary>
/// Replays the session-53d1e9f6 shape up to (but excluding) the ESP terminal failure:
/// Continue-Anyway profile with the tenant observation opt-in stamped into the
/// EspConfigDetected payload, Hello policy disabled, DeviceSetup entered —
/// AccountSetup never reached (the 30-min Device-ESP wall kills it first).
/// </summary>
private static DecisionState SetupObservationEligibleSession(
DecisionEngine engine,
bool observationOptIn = true,
bool continueAnyway = true,
bool helloPolicyDisabled = true)
{
var state = DecisionState.CreateInitial("sess-53d1e9f6", "tenant-c9787ba2", T0);
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
var espConfig = new Dictionary<string, string>
{
[SignalPayloadKeys.SkipUserEsp] = "false",
[SignalPayloadKeys.SkipDeviceEsp] = "false",
[SignalPayloadKeys.EspSyncFailureTimeoutMinutes] = "30",
[SignalPayloadKeys.EspAllowContinueAnyway] = continueAnyway ? "true" : "false",
};
if (observationOptIn)
{
espConfig[SignalPayloadKeys.EspContinueAnywayObservationEnabled] = "true";
}
state = engine.Reduce(state, MakeSignal(
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1), espConfig)).NewState;
if (helloPolicyDisabled)
{
state = engine.Reduce(state, MakeSignal(
8, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1),
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
}
state = engine.Reduce(state, MakeSignal(
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
return state;
}
private static DecisionStep ApplyDeviceSetupTerminalFailure(DecisionEngine engine, DecisionState state, long ordinal = 50) =>
engine.Reduce(state, MakeSignal(
ordinal, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(38),
new Dictionary<string, string>
{
["failureType"] = "Provisioning_DeviceSetup_Certificates_Failed",
["failedSubcategory"] = "Certificates",
["category"] = "DeviceSetup",
}));
// ============================================================ defang path ====
[Fact]
public void DeviceSetupFailure_WithOptIn_DefangsToObservationAdvisory()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
Assert.Null(state.AccountSetupEnteredUtc);
var preStage = state.Stage;
var step = ApplyDeviceSetupTerminalFailure(engine, state);
// No terminal — stage unchanged, advisory anchored, no LastFailureTrigger.
Assert.Equal(preStage, step.NewState.Stage);
Assert.Null(step.NewState.Outcome);
Assert.NotNull(step.NewState.EspAdvisoryFailureRecordedUtc);
Assert.Equal("DeviceSetup", step.NewState.EspAdvisoryFailureCategory!.Value);
Assert.Null(step.NewState.LastFailureTrigger);
var advisory = SingleTimelineEffect(step, "esp_failure_advisory");
Assert.Equal("esp_failure_defanged_continueanyway_observation", advisory.Parameters!["advisoryReason"]);
Assert.Equal("Warning", advisory.Parameters!["severity"]);
Assert.Equal("true", advisory.Parameters!["mayHaveContinuedAnyway"]);
Assert.DoesNotContain(step.Effects, e =>
e.Parameters != null
&& e.Parameters.TryGetValue("eventType", out var et)
&& et == "enrollment_failed");
}
[Fact]
public void DeviceSetupFailure_WithOptIn_Arms60MinObservationWindow()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
var step = ApplyDeviceSetupTerminalFailure(engine, state);
var deadline = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(deadline);
// 60-min observation window (user decision 2026-08-08) — NOT the classic 30-min
// advisory-completion window.
Assert.Equal(T0.AddMinutes(38).AddMinutes(60), deadline!.DueAtUtc);
}
// ==================================================== negative / regression ====
[Fact]
public void DeviceSetupFailure_WithoutOptIn_StillHardFails()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine, observationOptIn: false);
var step = ApplyDeviceSetupTerminalFailure(engine, state);
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
SingleTimelineEffect(step, "enrollment_failed");
}
[Fact]
public void DeviceSetupFailure_WithOptIn_ButContinueAnywayDisabled_StillHardFails()
{
// The observation only makes sense when the ESP profile actually offers the
// "Continue anyway" button — without it the user cannot reach the desktop.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine, continueAnyway: false);
var step = ApplyDeviceSetupTerminalFailure(engine, state);
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
SingleTimelineEffect(step, "enrollment_failed");
}
[Fact]
public void DeviceSetupFailure_WithOptIn_OnSelfDeployingProfile_StillHardFails()
{
// Scope gate: self-deploying/kiosk flows have no interactive user who could press
// "Continue anyway" — observation would only ever expire. Immediate fail is truthful.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EnrollmentFactsObserved, T0.AddMinutes(3),
new Dictionary<string, string> { [SignalPayloadKeys.IsSelfDeployingProfile] = "true" })).NewState;
var step = ApplyDeviceSetupTerminalFailure(engine, state);
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
}
[Fact]
public void ClassicAdvisory_WithAccountSetupEntered_KeepsClassicReasonAndWindow()
{
// Regression: the opt-in must not change the classic post-AccountSetup advisory —
// same advisoryReason literal, same 30-min window.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
Assert.NotNull(state.AccountSetupEnteredUtc);
var step = ApplyDeviceSetupTerminalFailure(engine, state);
var advisory = SingleTimelineEffect(step, "esp_failure_advisory");
Assert.Equal("esp_failure_defanged_continueanyway_with_accountsetup", advisory.Parameters!["advisoryReason"]);
var deadline = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.Equal(T0.AddMinutes(38).AddMinutes(30), deadline!.DueAtUtc);
}
// ========================================== real end: desktop completion ====
[Fact]
public void ObservationAdvisory_ThenRealUserDesktop_CompletesThroughFinalizing()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
// User pressed "Continue anyway" → signed in → DAD-validated real-user desktop.
var step = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42)));
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
Assert.Contains(":ContinueAnywayObservation", step.Transition.Trigger);
// Hello disabled → synthetic skip recorded by the fast-path.
Assert.NotNull(step.NewState.HelloResolvedUtc);
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.FinalizingGrace));
}
[Fact]
public void ObservationCompletion_EnrollmentComplete_CarriesEspSoftFailureMarker()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
state = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
Assert.Equal(SessionStage.Finalizing, state.Stage);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(42.1), DeadlineNames.FinalizingGrace));
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome);
var complete = SingleTimelineEffect(step, "enrollment_complete");
var payload = Assert.IsType<Dictionary<string, object>>(complete.TypedPayload);
Assert.Equal("true", payload["espSoftFailure"]);
Assert.Equal("continue_anyway_observation", payload["completionSource"]);
Assert.Equal("DeviceSetup", payload["espSoftFailureCategory"]);
}
[Fact]
public void CleanCompletion_EnrollmentComplete_OmitsEspSoftFailureMarker()
{
// A session without any advisory must not carry the marker — regression guard for
// the BuildEnrollmentCompleteEffect change.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(
30, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(20))).NewState;
state = engine.Reduce(state, MakeSignal(
40, DecisionSignalKind.DesktopArrived, T0.AddMinutes(21))).NewState;
Assert.Equal(SessionStage.Finalizing, state.Stage);
var step = engine.Reduce(state, DeadlineFired(50, T0.AddMinutes(21.1), DeadlineNames.FinalizingGrace));
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
var complete = SingleTimelineEffect(step, "enrollment_complete");
var payload = Assert.IsType<Dictionary<string, object>>(complete.TypedPayload);
Assert.False(payload.ContainsKey("espSoftFailure"));
Assert.False(payload.ContainsKey("completionSource"));
}
// =============================================== window expiry / deadline ====
[Fact]
public void ObservationWindow_ExpiresWithoutDesktop_UnDefangsToEspTerminalFailure()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
Assert.Null(state.DesktopArrivedUtc);
var step = engine.Reduce(state, DeadlineFired(60, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
// The failure cause IS the original ESP failure — same reason literal, likely-stuck
// promotion stays armed via LastFailureTrigger=EspTerminalFailure.
Assert.Equal(nameof(DecisionSignalKind.EspTerminalFailure), step.NewState.LastFailureTrigger!.Value);
var failed = SingleTimelineEffect(step, "enrollment_failed");
Assert.Equal("esp_terminal_failure", failed.Parameters!["reason"]);
Assert.Equal(
"continue_anyway_observation_window_expired_without_completion_evidence",
failed.Parameters!["advisoryReason"]);
}
[Fact]
public void ObservationWindow_FiresWithDesktopAndDisabledHello_CompletesInsteadOfFailing()
{
// Ordering variant: desktop arrived but (hypothetically) no eager completion ran —
// the deadline-fire conjunction must accept desktop + hello for the observation
// variant without the (structurally impossible) IME user-session gate.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine, helloPolicyDisabled: false);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
// Hello policy lands AFTER the failure; desktop arrives; no eager completion since
// the fast-path needs the policy fact at desktop-arrival time.
state = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
Assert.NotEqual(SessionStage.Finalizing, state.Stage);
state = engine.Reduce(state, MakeSignal(
65, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(43),
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
Assert.Null(step.NewState.Outcome);
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.FinalizingGrace));
}
[Fact]
public void ObservationWindow_FiresWithDesktopButUnknownHello_PromotesToAwaitingHello()
{
// Hello policy never observed (no PassportForWork value) — the hello prerequisite is
// structurally unresolvable. The never-observed promote must apply to the observation
// variant instead of failing a desktop-proven session.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine, helloPolicyDisabled: false);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
state = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
Assert.Null(state.HelloPolicyEnabled);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
Assert.Null(step.NewState.Outcome);
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.HelloSafety));
}
// ======================================================= category recovery ====
[Fact]
public void ObservationAdvisory_DeviceSetupRecovery_ResolvesAdvisory()
{
// "Try again" path: the ESP re-runs the device phase and DeviceSetup resolves to
// success — the 4910a5a5 recovery hook must match the observation advisory too.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
var step = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(55)));
Assert.NotNull(step.NewState.EspAdvisoryFailureResolvedUtc);
Assert.Contains(step.Effects, e =>
e.Parameters != null
&& e.Parameters.TryGetValue("eventType", out var et)
&& et == "esp_failure_advisory_resolved");
}
}
}
@@ -490,7 +490,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 3)
.WithSkipUserEsp(value: true, sourceSignalOrdinal: 4)
.WithEspSyncFailureTimeoutMinutes(value: 90, sourceSignalOrdinal: 5)
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6))
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6)
.WithCloudPc(value: true, sourceSignalOrdinal: 7))
.Build();
var json = StateSerializer.Serialize(state);
@@ -504,6 +505,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal(5, o.EspSyncFailureTimeoutMinutes!.SourceSignalOrdinal);
Assert.False(o.RegistrySelfDeployingProfile!.Value);
Assert.Equal(6, o.RegistrySelfDeployingProfile!.SourceSignalOrdinal);
Assert.True(o.CloudPc!.Value);
Assert.Equal(7, o.CloudPc!.SourceSignalOrdinal);
// Never-observed facts must rehydrate as null (null ≠ veto — session 62e603c9).
Assert.Null(o.WhiteGloveSealingPatternSeen);
@@ -515,6 +515,99 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
// ================================================================== helpers
// ------------------------------------------------ isCloudPc annotation (W365)
// Cloud PC first connect: Device-ESP already ran headless at provisioning, so the
// profile records the "no Device-ESP phase expected" expectation as the reason.
// Reason-only by design — Mode is classified by AccountSetup/IME signals (Classic),
// and EspConfig keeps the CONFIGURED FirstSync semantics.
[Fact]
public void EnrollmentFactsObserved_cloudPc_annotatesReason_keepsModeUnknown()
{
var signal = MakeSignal(
DecisionSignalKind.EnrollmentFactsObserved,
ordinal: 0,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsHybridJoin] = "false",
[SignalPayloadKeys.IsCloudPc] = "true",
});
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal(EnrollmentMode.Unknown, profile.Mode);
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode);
Assert.Equal(EspConfig.Unknown, profile.EspConfig);
}
[Fact]
public void EnrollmentFactsObserved_cloudPc_repeatPost_isIdempotent()
{
Dictionary<string, string> Facts() => new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsHybridJoin] = "false",
[SignalPayloadKeys.IsCloudPc] = "true",
};
var first = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 0, payload: Facts());
var second = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 9, payload: Facts());
var p1 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, first);
var p2 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(p1, second);
// The repeat must be a full no-op: the Cloud PC annotation already owns the
// reason slot, and the cloudPcAnnotates gate keeps the generic v1 annotation
// from re-claiming it (which would bump EvidenceOrdinal every post).
Assert.Same(p1, p2);
Assert.Equal(0, p2.EvidenceOrdinal);
}
[Fact]
public void EnrollmentFactsObserved_cloudPcFalse_keepsGenericV1Reason()
{
var signal = MakeSignal(
DecisionSignalKind.EnrollmentFactsObserved,
ordinal: 0,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsCloudPc] = "false",
});
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal("enrollment_facts_observed:v1", profile.Reason);
}
[Fact]
public void EnrollmentFactsObserved_cloudPc_doesNotDisturbSelfDeployingSeed()
{
// Defensive combination (not expected in the field — Cloud PCs are never
// Autopilot-registered): the deterministic self-deploying Mode seed must
// survive; only the reason slot goes to the more specific annotation.
var signal = MakeSignal(
DecisionSignalKind.EnrollmentFactsObserved,
ordinal: 0,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsSelfDeployingProfile] = "true",
[SignalPayloadKeys.IsCloudPc] = "true",
});
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode);
Assert.Equal(ProfileConfidence.High, profile.Confidence);
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
}
private static DecisionSignal MakeSignal(
DecisionSignalKind kind,
long ordinal,
@@ -8,6 +8,11 @@
</PropertyGroup>
<ItemGroup>
<!-- Same compile context as the product project: with the shared framework referenced,
NuGet prunes framework-provided transitive packages (e.g. the vulnerable
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
which would otherwise surface NU1903/NU1904 advisories here. -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="xunit" Version="2.9.2" />
@@ -0,0 +1,117 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="CloudPcDeviceValidator"/> — focused on pure-function pieces:
/// JSON-to-DTO mapping (incl. exact-match guard on managedDeviceId) and cache key shape.
/// The HTTP/cache/retry resilience is mirrored 1:1 from <c>AutopilotDeviceValidator</c>;
/// behavioural drift would surface here. Response shapes match a real
/// virtualEndpoint/cloudPCs payload (field-captured 2026-08-06 on a W365 tenant).
/// </summary>
public class CloudPcDeviceValidatorTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string DeviceId = "07623d56-1e77-4948-bff5-5bdac8167560";
// -- BuildCacheKey --
[Fact]
public void BuildCacheKey_StableShape()
{
var key = CloudPcDeviceValidator.BuildCacheKey(TenantId, DeviceId);
Assert.Equal($"cloudpc-device-validation:{TenantId}:{DeviceId}", key);
}
[Fact]
public void BuildCacheKey_DistinctFromOtherValidatorKeys()
{
// The validators must NOT share cache keys; otherwise a positive hit in one
// would incorrectly satisfy a lookup in another.
var key = CloudPcDeviceValidator.BuildCacheKey(TenantId, DeviceId);
Assert.StartsWith("cloudpc-device-validation:", key);
Assert.DoesNotContain("autopilot", key);
Assert.DoesNotContain("device-association", key);
}
// -- ParseCloudPcResponse: empty / missing / malformed --
[Fact]
public void Parse_EmptyValueArray_NotFound()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse("{\"value\":[]}", DeviceId);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
Assert.Equal(DeviceId, result.IntuneDeviceId);
Assert.Contains("not a Windows 365 Cloud PC", result.ErrorMessage);
}
[Fact]
public void Parse_MissingValueProperty_NotFound()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse("{}", DeviceId);
Assert.False(result.IsValid);
}
[Fact]
public void Parse_MalformedJson_NotFound_NotTransient()
{
// Defensive: bad payload is treated as "not a Cloud PC", not as a transient
// — transient handling is only triggered by HTTP-level failures.
var result = CloudPcDeviceValidator.ParseCloudPcResponse("this is not json", DeviceId);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
// -- ParseCloudPcResponse: exact-match guard --
[Fact]
public void Parse_DifferentManagedDeviceId_NotFound()
{
// Guards against widened filter semantics: even if Graph returns Cloud PCs whose
// managedDeviceId does not exactly match the queried id, we must reject.
var body = "{\"value\":[{\"id\":\"423a1fb4-6033-4b69-a31f-e7f3f54194dc\",\"managedDeviceId\":\"99999999-9999-9999-9999-999999999999\"}]}";
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
Assert.False(result.IsValid);
}
[Fact]
public void Parse_ExactMatch_PopulatesAllFields()
{
var body = $@"{{
""value"":[{{
""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",
""displayName"":""GKT-CloudPC - Obi-Wan Kenobi"",
""managedDeviceId"":""{DeviceId}"",
""managedDeviceName"":""CPC-cloud-AY5HT"",
""userPrincipalName"":""cloudadmin@gktatooine.net"",
""servicePlanName"":""Cloud PC Enterprise 2vCPU/4GB/128GB""
}}]
}}";
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
Assert.True(result.IsValid);
Assert.False(result.IsTransient);
Assert.Equal(DeviceId, result.IntuneDeviceId);
Assert.Equal("423a1fb4-6033-4b69-a31f-e7f3f54194dc", result.CloudPcId);
Assert.Equal("CPC-cloud-AY5HT", result.ManagedDeviceName);
Assert.Equal("Cloud PC Enterprise 2vCPU/4GB/128GB", result.ServicePlanName);
}
[Fact]
public void Parse_CaseInsensitiveIdMatch()
{
var body = $@"{{""value"":[{{""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",""managedDeviceId"":""{DeviceId.ToUpperInvariant()}""}}]}}";
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
Assert.True(result.IsValid);
}
[Fact]
public void Parse_TrimsWhitespace_OnGraphIdBeforeMatching()
{
var body = $@"{{""value"":[{{""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",""managedDeviceId"":"" {DeviceId} ""}}]}}";
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
Assert.True(result.IsValid);
}
}
@@ -259,20 +259,21 @@ public class ContinuationTokenTests
public void TryDecode_rejects_token_signed_with_a_different_key()
{
// Simulate key rotation: token issued under key A, validated under key B.
// (We toggle the test key around the encode/decode pair.)
// The scoped (AsyncLocal) override keeps the swap confined to this test's
// execution context — mutating the process-wide test key here raced the
// concurrently running pagination test classes.
var keyA = new byte[32]; for (int i = 0; i < keyA.Length; i++) keyA[i] = (byte)(i * 3 + 1);
var keyB = new byte[32]; for (int i = 0; i < keyB.Length; i++) keyB[i] = (byte)(i * 5 + 7);
var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
ContinuationToken.SetSigningKeyForTesting(keyA);
ContinuationToken.SetScopedSigningKeyForTesting(keyA);
var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp);
ContinuationToken.SetSigningKeyForTesting(keyB);
ContinuationToken.SetScopedSigningKeyForTesting(keyB);
var ok = ContinuationToken.TryDecode(encoded, TenantA, fp, out _, out var reason);
// Restore the test default before leaving so subsequent tests aren't affected.
TestSetup.Initialize();
ContinuationToken.SetScopedSigningKeyForTesting(null);
Assert.False(ok);
Assert.Equal("bad_signature", reason);
@@ -47,7 +47,7 @@ public class CriticalTableBackupServiceTests
Assert.Equal(BackupOutcome.Success, result.Outcome);
Assert.True(store.ManifestWritten, "manifest must be written LAST");
Assert.Equal(17, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups)
Assert.Equal(18, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups + SessionAnnotations)
Assert.All(result.Manifest.Tables, t =>
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
}
@@ -75,9 +75,9 @@ public class DeletionManifestBuilderTests
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
Assert.Equal(11, c["signalsByKind"]);
// 16 cascade tables (incl. SessionTimeBreakdowns, F1 PR2) + tombstone = 17 steps;
// no inventory steps when no contributions row.
Assert.Equal(17, manifest.Steps.Count);
// 19 cascade tables (incl. SessionTimeBreakdowns F1 PR2 + 3 SessionAnnotations lanes)
// + tombstone = 20 steps; no inventory steps when no contributions row.
Assert.Equal(20, manifest.Steps.Count);
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
}
@@ -104,7 +104,8 @@ public class DeletionManifestBuilderTests
.WithDeadEndsByReason(1, propValue: "deadend_marker")
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
.WithSignalsByKind(1, propValue: "signalkind_marker")
.WithSessionTimeBreakdown(propValue: "breakdown_marker"));
.WithSessionTimeBreakdown(propValue: "breakdown_marker")
.WithSessionAnnotations(propValue: "annotation_marker"));
var manifest = await NewBuilder(reader).BuildAsync(
TenantId, SessionId, "admin_delete",
@@ -147,8 +148,9 @@ public class DeletionManifestBuilderTests
new DeletionRetentionContext { TenantRetentionDays = 90 });
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
// Without a contributions row, neither inventory step is emitted → exactly 16 cascade-table steps.
Assert.Equal(16, nonTombstoneSteps.Count);
// Without a contributions row, neither inventory step is emitted → exactly 19 cascade-table steps
// (16 + the 3 SessionAnnotations lane steps).
Assert.Equal(19, nonTombstoneSteps.Count);
foreach (var step in nonTombstoneSteps)
{
Assert.Equal(0, step.RowCount);
@@ -177,8 +179,8 @@ public class DeletionManifestBuilderTests
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
new DeletionRetentionContext { TenantRetentionDays = 90 });
// Expect: 16 cascade + 2 inventory + 1 tombstone = 19 steps.
Assert.Equal(19, manifest.Steps.Count);
// Expect: 19 cascade + 2 inventory + 1 tombstone = 22 steps.
Assert.Equal(22, manifest.Steps.Count);
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
@@ -229,7 +231,7 @@ public class DeletionManifestBuilderTests
[Fact]
public async Task Build_omits_inventory_steps_when_no_side_row()
{
// The default reader returns no inventory row → 15 cascade + 1 tombstone, no inventory steps.
// The default reader returns no inventory row → 19 cascade + 1 tombstone, no inventory steps.
var reader = NewReader(seed => seed.WithSessionsRow().WithSessionsIndexRow());
var manifest = await NewBuilder(reader).BuildAsync(
@@ -644,6 +646,7 @@ public class DeletionManifestBuilderTests
private TableEntity? _eventSessionIndex;
private TableEntity? _sessionTimeBreakdown;
private TableEntity? _sessionInventoryContributions;
private readonly Dictionary<string, TableEntity> _sessionAnnotations = new(StringComparer.Ordinal);
private string _sessionsIndexRowKey = "INDEX_" + SessionId;
public ReaderSeed WithSessionsRow(string? diagnosticsBlobName = null)
@@ -774,6 +777,23 @@ public class DeletionManifestBuilderTests
public ReaderSeed WithClassifierVerdictsByIdLevel(int n, string propValue = "cls") => SeedDiscriminatorPkProp(Constants.TableNames.ClassifierVerdictsByIdLevel, n, propValue, "EspApps_High");
public ReaderSeed WithSignalsByKind(int n, string propValue = "sk") => SeedDiscriminatorPkProp(Constants.TableNames.SignalsByKind, n, propValue, "EspPhaseChanged");
public ReaderSeed WithSessionAnnotations(string propValue = "anno")
{
// One row per lane — the builder emits one exact-RK step per lane.
foreach (var lane in AutopilotMonitor.Shared.Models.AnnotationLanes.All)
{
var e = new TableEntity(TenantId, $"{SessionId}_{lane}")
{
["Marker"] = propValue,
["SessionId"] = SessionId,
["Lane"] = lane,
};
e.ETag = new ETag("0xANNO" + lane);
_sessionAnnotations[lane] = e;
}
return this;
}
public ReaderSeed WithSessionInventoryContributions(TableEntity row)
{
_sessionInventoryContributions = row;
@@ -813,6 +833,11 @@ public class DeletionManifestBuilderTests
.ReturnsAsync(_sessionTimeBreakdown);
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
.ReturnsAsync(_sessionInventoryContributions);
foreach (var (lane, row) in _sessionAnnotations)
{
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionAnnotations, TenantId, $"{SessionId}_{lane}", It.IsAny<CancellationToken>()))
.ReturnsAsync(row);
}
// QueryAsync: per-table seeded list, otherwise empty.
reader.Setup(r => r.QueryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
@@ -0,0 +1,51 @@
using System.Security.Claims;
using AutopilotMonitor.Functions.Helpers;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the claim precedence behind gather-rule Author stamping
/// (<see cref="TenantHelper.GetUserDisplayName(ClaimsPrincipal)"/>): display name
/// first, then UPN/email/preferred_username; null for anonymous/app-only callers
/// so CreateGatherRule falls back to the model default "Autopilot Monitor".
/// </summary>
public class GatherRuleAuthorStampingTests
{
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
[Fact]
public void DisplayName_wins_over_upn()
{
var user = Principal(("name", "Alice Admin"), ("upn", "alice@contoso.com"));
Assert.Equal("Alice Admin", TenantHelper.GetUserDisplayName(user));
}
[Fact]
public void Upn_used_when_no_name_claim()
{
var user = Principal(("upn", "alice@contoso.com"), ("preferred_username", "alice@contoso.onmicrosoft.com"));
Assert.Equal("alice@contoso.com", TenantHelper.GetUserDisplayName(user));
}
[Fact]
public void PreferredUsername_is_the_last_fallback()
{
var user = Principal(("preferred_username", "alice@contoso.com"));
Assert.Equal("alice@contoso.com", TenantHelper.GetUserDisplayName(user));
}
[Fact]
public void Null_for_principal_without_identifying_claims()
{
Assert.Null(TenantHelper.GetUserDisplayName(Principal(("tid", "a1b2c3d4-e5f6-7890-abcd-ef1234567890"))));
}
[Fact]
public void Null_for_unauthenticated_or_missing_principal()
{
Assert.Null(TenantHelper.GetUserDisplayName((ClaimsPrincipal?)null));
// ClaimsIdentity without an authentication type => IsAuthenticated == false.
Assert.Null(TenantHelper.GetUserDisplayName(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("name", "Ghost") }))));
}
}
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Functions.Tests;
public class GatherRuleUpdatePartialMergeTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string RuleId = "GATHER-CUST-001";
private const string RuleId = "GATHER-CUSTOM-001";
private static GatherRule ExistingCustomRule() => new()
{
@@ -33,6 +33,7 @@ public class GatherRuleUpdatePartialMergeTests
Enabled = true,
IsBuiltIn = false,
IsCommunity = false,
Author = "Alice Admin",
CreatedAt = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc),
ActiveFromPhase = "AccountSetup",
EmitMode = "on_change",
@@ -77,6 +78,7 @@ public class GatherRuleUpdatePartialMergeTests
Assert.Equal("AccountSetup", written.ActiveFromPhase);
Assert.Equal("on_change", written.EmitMode);
Assert.Equal(existing.CreatedAt, written.CreatedAt);
Assert.Equal("Alice Admin", written.Author);
}
[Fact]
@@ -126,6 +128,33 @@ public class GatherRuleUpdatePartialMergeTests
Assert.Equal(existing.CreatedAt, written.CreatedAt);
}
[Fact]
public async Task FullPayload_preserves_original_Author_even_when_payload_carries_one()
{
var existing = ExistingCustomRule();
var (service, _, stored) = BuildService(existing);
var fullPayload = new GatherRule
{
RuleId = RuleId,
Title = "Collect BIOS Config v2",
CollectorType = "registry",
Target = "HKLM\\SOFTWARE\\RealmJoin\\Custom\\BIOS2",
Trigger = "interval",
OutputEventType = "gather_bios_config",
Enabled = true,
// A hand-crafted PUT could try to rewrite attribution — Author is
// stamped at create time and immutable afterwards.
Author = "Spoofed Author",
};
var ok = await service.UpdateRuleAsync(TenantId, fullPayload);
Assert.True(ok);
var written = Assert.Single(stored);
Assert.Equal("Alice Admin", written.Author);
}
[Fact]
public async Task BuiltInRule_toggle_still_goes_through_RuleState_path()
{
@@ -0,0 +1,185 @@
using System.Net;
using System.Security.Claims;
using System.Text;
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Endpoint-level tests proving <see cref="GatherRulesFunction"/> stamps the rule
/// Author from the caller's JWT and never from the request body — through the real
/// request pipeline (body deserialization → ValidateScopeAndEmitMode → stamp →
/// service). Complements <see cref="GatherRuleAuthorStampingTests"/> (claim
/// precedence only) and GatherRuleUpdatePartialMergeTests (service-level
/// preservation): those pin the pieces, this pins the wiring.
/// Unlike most function tests in this project, it fakes HttpRequestData — the
/// stamp lives IN the endpoint method, so no smaller seam can prove it.
/// </summary>
public class GatherRulesFunctionAuthorEndpointTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string RuleId = "GATHER-CUSTOM-777";
// ── request/response fakes ──────────────────────────────────────────────
private sealed class FakeHttpResponseData : HttpResponseData
{
public FakeHttpResponseData(FunctionContext context) : base(context) { }
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public override HttpHeadersCollection Headers { get; set; } = new();
public override Stream Body { get; set; } = new MemoryStream();
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
}
private static FunctionContext BuildContext(ClaimsPrincipal? principal)
{
// WriteAsJsonAsync resolves the serializer from InstanceServices.
var services = new ServiceCollection();
services.AddOptions();
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object>();
if (principal != null)
{
items["ClaimsPrincipal"] = principal;
}
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(items);
context.SetupGet(c => c.InstanceServices).Returns(provider);
return context.Object;
}
private static HttpRequestData BuildRequest(ClaimsPrincipal? principal, object body)
{
var context = BuildContext(principal);
var req = new Mock<HttpRequestData>(context);
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
req.SetupGet(r => r.Body).Returns(
new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body))));
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context));
return req.Object;
}
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
private static ClaimsPrincipal AlicePrincipal()
=> Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com"));
// ── function under test with a captured repository ─────────────────────
private static (GatherRulesFunction function, List<GatherRule> stored) BuildFunction(
GatherRule? existingTenantRule = null)
{
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
var stored = new List<GatherRule>();
repo.Setup(r => r.GatherRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>());
repo.Setup(r => r.GetGatherRulesAsync(TenantId)).ReturnsAsync(
existingTenantRule == null ? new List<GatherRule>() : new List<GatherRule> { existingTenantRule });
repo.Setup(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), TenantId))
.Callback<GatherRule, string>((rule, _) => stored.Add(rule))
.ReturnsAsync(true);
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
return (new GatherRulesFunction(NullLogger<GatherRulesFunction>.Instance, service), stored);
}
private static GatherRule SpoofedPayload() => new()
{
RuleId = RuleId,
Title = "Collect BIOS Config",
CollectorType = "registry",
Target = "HKLM\\SOFTWARE\\RealmJoin\\Custom\\BIOS",
Trigger = "startup",
OutputEventType = "gather_bios_config",
Enabled = true,
Author = "Spoofed Author",
};
// ── CreateRule ──────────────────────────────────────────────────────────
[Fact]
public async Task CreateRule_stamps_author_from_jwt_not_from_body()
{
var (function, stored) = BuildFunction();
var req = BuildRequest(AlicePrincipal(), SpoofedPayload());
var response = await function.CreateRule(req);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var written = Assert.Single(stored);
Assert.Equal("Alice Admin", written.Author);
}
[Fact]
public async Task CreateRule_falls_back_to_product_name_without_identifying_claims()
{
// App-only-shaped token: authenticated with a tenant but no user display claims.
var (function, stored) = BuildFunction();
var req = BuildRequest(Principal(("tid", TenantId)), SpoofedPayload());
var response = await function.CreateRule(req);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var written = Assert.Single(stored);
Assert.Equal("Autopilot Monitor", written.Author);
}
// ── UpdateRule ──────────────────────────────────────────────────────────
[Fact]
public async Task UpdateRule_upsert_of_unknown_rule_stamps_author_from_jwt()
{
// Full-payload PUT for a ruleId with no existing tenant row upserts a new row —
// without the endpoint stamp the payload's author would be stored verbatim.
var (function, stored) = BuildFunction(existingTenantRule: null);
var req = BuildRequest(AlicePrincipal(), SpoofedPayload());
var response = await function.UpdateRule(req, RuleId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var written = Assert.Single(stored);
Assert.Equal("Alice Admin", written.Author);
}
[Fact]
public async Task UpdateRule_true_update_keeps_original_author_over_editor_and_body()
{
var existing = new GatherRule
{
RuleId = RuleId,
Title = "Collect BIOS Config",
CollectorType = "registry",
Target = "HKLM\\SOFTWARE\\RealmJoin\\Custom\\BIOS",
Trigger = "startup",
OutputEventType = "gather_bios_config",
IsBuiltIn = false,
IsCommunity = false,
Author = "Original Creator",
};
var (function, stored) = BuildFunction(existing);
// Bob edits Alice's rule; neither Bob nor the spoofed body author may win.
var req = BuildRequest(
Principal(("tid", TenantId), ("name", "Bob Editor")), SpoofedPayload());
var response = await function.UpdateRule(req, RuleId);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var written = Assert.Single(stored);
Assert.Equal("Original Creator", written.Author);
}
}
@@ -164,12 +164,49 @@ public class GetDeviceNotRegisteredFunctionTests
Assert.Equal(3, (int)item.attemptCount);
}
// =========================================================================
// Cloud PC marker aggregation
// =========================================================================
[Fact]
public void BuildAggregatedResult_IsCloudPc_StickyTrueAcrossGroup()
{
// One old agent (no field → false) + one new agent reporting the W365 marker:
// the aggregate must say Cloud PC even though the MOST RECENT report is false —
// same OR semantics as the Sessions-table IsCloudPc merge.
var reports = new List<DistressReportEntry>
{
MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0, isCloudPc: true),
MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0.AddMinutes(5), isCloudPc: false),
};
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.True((bool)item.isCloudPc);
}
[Fact]
public void BuildAggregatedResult_IsCloudPc_FalseWhenNoReportCarriesMarker()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0),
};
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.False((bool)item.isCloudPc);
}
// =========================================================================
// Helpers
// =========================================================================
private static DistressReportEntry MakeEntry(
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt) =>
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt,
bool isCloudPc = false) =>
new()
{
TenantId = "test-tenant",
@@ -179,6 +216,7 @@ public class GetDeviceNotRegisteredFunctionTests
SerialNumber = serial,
IngestedAt = ingestedAt,
AgentTimestamp = ingestedAt,
IsCloudPc = isCloudPc,
};
private static dynamic ToDynamic(object obj)
@@ -0,0 +1,79 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Models.Graph;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Guard: the customer-side grant script (scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1)
/// duplicates the feature→permission catalog from <see cref="GraphFeatureCatalog"/> — in its
/// <c>$FeatureCatalog</c> hashtable AND in the <c>[ValidateSet(...)]</c> of <c>-Features</c>.
/// The sync was previously comment-only ("Keep in lock-step with …") and had no enforcement;
/// this test fails the build when either side drifts.
/// </summary>
public class GraphFeatureCatalogSyncTests
{
[Fact]
public void GrantScript_FeatureCatalog_MatchesSharedCatalog()
{
var script = ReadGrantScript();
// Parse: $FeatureCatalog = @{ 'Name' = @('Perm', ...) ... }
var blockMatch = Regex.Match(script, @"\$FeatureCatalog\s*=\s*@\{(?<body>[^}]*)\}", RegexOptions.Singleline);
Assert.True(blockMatch.Success, "Could not locate the $FeatureCatalog hashtable in the grant script.");
var scriptCatalog = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
foreach (Match entry in Regex.Matches(blockMatch.Groups["body"].Value, @"'(?<feature>[^']+)'\s*=\s*@\((?<perms>[^)]*)\)"))
{
var perms = Regex.Matches(entry.Groups["perms"].Value, @"'(?<p>[^']+)'")
.Select(m => m.Groups["p"].Value)
.ToArray();
scriptCatalog[entry.Groups["feature"].Value] = perms;
}
Assert.Equal(
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
scriptCatalog.Keys.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
foreach (var feature in GraphFeatureCatalog.Features)
{
Assert.Equal(
GraphFeatureCatalog.RequiredPermissions(feature).OrderBy(p => p, StringComparer.OrdinalIgnoreCase),
scriptCatalog[feature].OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
}
}
[Fact]
public void GrantScript_ValidateSet_MatchesSharedCatalog()
{
var script = ReadGrantScript();
var vsMatch = Regex.Match(script, @"\[ValidateSet\((?<body>[^\)]*)\)\]");
Assert.True(vsMatch.Success, "Could not locate the [ValidateSet(...)] of -Features in the grant script.");
var scriptFeatures = Regex.Matches(vsMatch.Groups["body"].Value, @"'(?<f>[^']+)'")
.Select(m => m.Groups["f"].Value)
.ToArray();
Assert.Equal(
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
scriptFeatures.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
}
private static string ReadGrantScript()
{
var path = Path.Combine(FindRepoRoot(), "scripts", "CustomerSetup", "Grant-AutopilotMonitorAddOn.ps1");
Assert.True(File.Exists(path), $"Grant script not found at {path}");
return File.ReadAllText(path);
}
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;
}
}
@@ -42,6 +42,7 @@ public sealed class TenantCustomsArchiveContractTests
reEnqueuer: null!,
opsEvents: null!,
customsArchive: archive,
configRepo: null!,
farewellEmail: new FakeOffboardFarewellEmailSender(),
logger: NullLogger<TenantOffboardingHandler>.Instance);
}
@@ -9,6 +9,7 @@ using Azure;
using Azure.Data.Tables;
using Azure.Storage.Blobs;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Functions.Services.Notifications;
@@ -301,6 +302,47 @@ public class TenantOffboardingHandlerDrainContractTests
"Phase 2.G must delete the Expectations blob as its last step");
}
[Fact]
public async Task UnoffboardRace_ConfigNoLongerTombstone_FailsClosed_NoSafeWipe()
{
// 2.D-guard: an operator re-enabled the tenant while the cascade was queued/draining
// (the config write paths refuse this, but the guard is the last line of defense).
// Wiping now would destroy data of a live tenant → fail-closed, zero wipes.
var harness = Harness.New();
harness.ConfigRepo
.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId, Disabled = false });
await Assert.ThrowsAsync<InvalidOperationException>(() =>
harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
Assert.Equal("Failed", harness.Repo.Markers[TenantId].Status);
Assert.Equal("unoffboard_race", harness.Repo.Markers[TenantId].FailedPhase);
Assert.Equal("Failed", harness.Repo.History[HistoryRowKey].Status);
Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
}
[Fact]
public async Task UnoffboardGuard_TombstoneStillInPlace_ProceedsToWipeAndCompleted()
{
// The normal case: Phase 1's tombstone is untouched → the guard must NOT get in
// the way of a legitimate offboarding.
var harness = Harness.New();
harness.ConfigRepo
.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync(new TenantConfiguration
{
TenantId = TenantId,
Disabled = true,
DisabledReason = TenantOffboardFunction.OffboardingDisabledReason,
});
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
Assert.True(harness.SafeWipeProbe.WipeCallCount > 0);
}
[Fact]
public async Task SessionNotFound_CountsAsSatisfied_NoDrainProbeCall()
{
@@ -393,6 +435,7 @@ public class TenantOffboardingHandlerDrainContractTests
public Mock<OffboardingSessionEnumerator> Enumerator { get; }
public CountingSafeWipeService SafeWipeProbe { get; } = new();
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
public Mock<IConfigRepository> ConfigRepo { get; } = new();
public List<string> EnumeratorYields { get; } = new();
public Exception? EnumeratorThrow { get; set; }
@@ -450,6 +493,7 @@ public class TenantOffboardingHandlerDrainContractTests
h.ReEnqueuer.Object,
opsService,
Mock.Of<ITenantCustomsArchiveRepository>(),
h.ConfigRepo.Object,
new FakeOffboardFarewellEmailSender(),
NullLogger<TenantOffboardingHandler>.Instance);
@@ -622,10 +666,11 @@ public class TenantOffboardingHandlerDrainContractTests
ITenantOffboardingEnqueuer reEnqueuer,
OpsEventService opsEvents,
ITenantCustomsArchiveRepository customsArchive,
IConfigRepository configRepo,
IOffboardFarewellEmailSender farewellEmail,
ILogger<TenantOffboardingHandler> logger)
: base(auditRepo, enumerator, cascadeEnqueuer, expectations, drainProbe, safeWipe,
storage, maintenance, reEnqueuer, opsEvents, customsArchive, farewellEmail, logger)
storage, maintenance, reEnqueuer, opsEvents, customsArchive, configRepo, farewellEmail, logger)
{
}
@@ -358,6 +358,7 @@ public class TenantOffboardingHandlerOrderingTests
h.ReEnqueuer.Object,
BuildOpsService(),
Mock.Of<ITenantCustomsArchiveRepository>(),
Mock.Of<IConfigRepository>(),
h.FarewellEmail,
NullLogger<TenantOffboardingHandler>.Instance);
@@ -459,9 +460,9 @@ public class TenantOffboardingHandlerOrderingTests
IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IOffboardFarewellEmailSender fe,
ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, fe, log) { }
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
@@ -245,6 +245,7 @@ public class TenantOffboardingWorkerPoisonTests
Mock.Of<ITenantOffboardingEnqueuer>(),
BuildOpsService(),
Mock.Of<ITenantCustomsArchiveRepository>(),
Mock.Of<IConfigRepository>(),
new FakeOffboardFarewellEmailSender(),
NullLogger<TenantOffboardingHandler>.Instance);
}
@@ -255,9 +256,9 @@ public class TenantOffboardingWorkerPoisonTests
IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IOffboardFarewellEmailSender fe,
ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, fe, log) { }
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
@@ -0,0 +1,418 @@
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the dry-run (rule-authoring) evaluation path:
/// 1. The trace evaluates EVERY condition (no early break) so authors see the full picture.
/// 2. The verdict/confidence is outcome-equivalent to the production EvaluateRule path
/// (parity tests via AnalyzeSessionAsync).
/// 3. The dry-run is side-effect free: only the strict event read touches storage — never
/// UpdateSessionStatusAsync, never a rule-result write.
/// Plus the draft validation surface of <see cref="DryRunAnalyzeRuleFunction"/>.
/// </summary>
public class RuleEngineDryRunTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
// ===== Trace semantics =====
[Fact]
public async Task Fired_FullTrace_WithFactorAndInterpolationEvidence()
{
var rule = MakeRule();
rule.ConfidenceFactors = new List<ConfidenceFactor>
{
new() { Signal = "app_install_failed", Condition = "count >= 2", Weight = 15 },
};
var events = new List<EnrollmentEvent>
{
AppFailedEvent("app-1", "0x80070005"),
AppFailedEvent("app-1", "0x80070005"),
};
var (dry, sessionRepo, ruleRepo) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.Fired, dry.Verdict);
Assert.Equal(2, dry.EventCount);
Assert.Single(dry.Conditions);
Assert.True(dry.Conditions[0].Matched);
Assert.Equal(2, dry.Conditions[0].MatchingEventCount);
Assert.IsType<Dictionary<string, object>>(dry.Conditions[0].Evidence);
Assert.Single(dry.ConfidenceFactors);
Assert.True(dry.ConfidenceFactors[0].Matched);
Assert.Equal(50 + 15, dry.FinalConfidence);
// Evidence map matches what production would persist — including the factor marker —
// so clients can preview {{token}} interpolation against it.
Assert.NotNull(dry.MatchedConditions);
Assert.True(dry.MatchedConditions!.ContainsKey("app_failure"));
Assert.True(dry.MatchedConditions.ContainsKey("factor_app_install_failed"));
// Side-effect freedom: exactly one strict event read, nothing else.
sessionRepo.Verify(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>()), Times.Once);
sessionRepo.VerifyNoOtherCalls();
ruleRepo.VerifyNoOtherCalls();
}
[Fact]
public async Task RequiredConditionNotMet_StillEvaluatesAllConditions()
{
var rule = MakeRule();
rule.Conditions.Add(new RuleCondition
{
Signal = "os_edition", Source = "event_data", EventType = "os_info",
DataField = "edition", Operator = "equals", Value = "Enterprise", Required = false,
});
var events = new List<EnrollmentEvent> { OsInfoEvent("Enterprise") }; // no app_install_failed
var (dry, _, _) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.RequiredConditionNotMet, dry.Verdict);
// Production breaks on the first failed required condition; the dry-run keeps going —
// the second (optional) condition must appear in the trace, matched.
Assert.Equal(2, dry.Conditions.Count);
Assert.False(dry.Conditions[0].Matched);
Assert.Equal("no matching events", dry.Conditions[0].Evidence);
Assert.Equal(0, dry.Conditions[0].MatchingEventCount);
Assert.True(dry.Conditions[1].Matched);
Assert.Null(dry.FinalConfidence);
Assert.Empty(dry.ConfidenceFactors); // factor stage never reached, mirrors production
Assert.False(dry.WouldMarkSessionAsFailed);
}
[Fact]
public async Task PreconditionFails_VerdictSkipped_ConditionsStillTraced()
{
var rule = MakeRule();
rule.Preconditions = new List<RulePrecondition>
{
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" },
};
var events = new List<EnrollmentEvent>
{
HardwareSpecEvent(isVirtualMachine: "true"),
AppFailedEvent("app-1", "0x1"),
};
var (dry, _, _) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.SkippedByPrecondition, dry.Verdict);
Assert.Single(dry.Preconditions);
Assert.False(dry.Preconditions[0].Passed);
// Author still sees that the condition WOULD have matched.
Assert.Single(dry.Conditions);
Assert.True(dry.Conditions[0].Matched);
Assert.Null(dry.FinalConfidence);
}
[Fact]
public async Task BelowThreshold_ReportsFinalConfidence()
{
var rule = MakeRule();
rule.BaseConfidence = 30;
rule.ConfidenceThreshold = 60;
var events = new List<EnrollmentEvent> { AppFailedEvent("app-1", "0x1") };
var (dry, _, _) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.BelowConfidenceThreshold, dry.Verdict);
Assert.Equal(30, dry.FinalConfidence);
Assert.Equal(60, dry.ConfidenceThreshold);
Assert.False(dry.WouldMarkSessionAsFailed);
}
[Fact]
public async Task NoEvents_ShortCircuits()
{
var (dry, _, _) = await DryRunAsync(MakeRule(), new List<EnrollmentEvent>());
Assert.Equal(RuleDryRunVerdict.NoEvents, dry.Verdict);
Assert.Equal(0, dry.EventCount);
Assert.Empty(dry.Conditions);
}
[Fact]
public async Task AllOptionalNoneMatch_VerdictNoConditionsMatched()
{
var rule = MakeRule();
rule.Conditions[0].Required = false;
var events = new List<EnrollmentEvent> { OsInfoEvent("Pro") };
var (dry, _, _) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.NoConditionsMatched, dry.Verdict);
Assert.Null(dry.FinalConfidence);
}
[Fact]
public async Task Fired_WithMarkSessionAsFailed_OnlyReportsFlag()
{
var rule = MakeRule();
rule.MarkSessionAsFailedDefault = true;
var events = new List<EnrollmentEvent> { AppFailedEvent("app-1", "0x1") };
var (dry, sessionRepo, _) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.Fired, dry.Verdict);
Assert.True(dry.WouldMarkSessionAsFailed);
// The flag is REPORTED, never acted upon: no session read, no status write.
sessionRepo.Verify(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>()), Times.Once);
sessionRepo.VerifyNoOtherCalls();
}
// ===== Parity with the production path =====
public static TheoryData<string> ParityScenarios => new() { "fires", "required_miss", "below_threshold", "precondition_skip" };
/// <summary>
/// The dry-run verdict must be outcome-equivalent to EvaluateRule (via AnalyzeSessionAsync):
/// fired ⇔ a RuleResult is produced, and on fire the confidence and evidence keys are identical.
/// </summary>
[Theory]
[MemberData(nameof(ParityScenarios))]
public async Task DryRunVerdict_MatchesProductionOutcome(string scenario)
{
var rule = MakeRule();
rule.ConfidenceFactors = new List<ConfidenceFactor>
{
new() { Signal = "app_install_failed", Condition = "count >= 2", Weight = 15 },
};
List<EnrollmentEvent> events = scenario switch
{
"fires" => new() { AppFailedEvent("a", "0x1"), AppFailedEvent("a", "0x1") },
"required_miss" => new() { OsInfoEvent("Pro") },
"below_threshold" => new() { AppFailedEvent("a", "0x1") },
"precondition_skip" => new() { HardwareSpecEvent("true"), AppFailedEvent("a", "0x1") },
_ => throw new ArgumentOutOfRangeException(nameof(scenario)),
};
if (scenario == "below_threshold") { rule.BaseConfidence = 30; rule.ConfidenceThreshold = 60; }
if (scenario == "precondition_skip")
{
rule.Preconditions = new List<RulePrecondition>
{
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" },
};
}
var production = await RunProductionAsync(rule, events);
var (dry, _, _) = await DryRunAsync(rule, events);
var productionFired = production.Results.Count == 1;
Assert.Equal(productionFired, dry.Verdict == RuleDryRunVerdict.Fired);
if (productionFired)
{
var result = production.Results[0];
Assert.Equal(result.ConfidenceScore, dry.FinalConfidence);
Assert.Equal(
result.MatchedConditions.Keys.OrderBy(k => k),
dry.MatchedConditions!.Keys.OrderBy(k => k));
}
}
// ===== Draft validation (DryRunAnalyzeRuleFunction.ValidateDraftRule) =====
[Fact]
public void Validate_ValidRule_NoErrors()
{
Assert.Empty(DryRunAnalyzeRuleFunction.ValidateDraftRule(MakeRule()));
}
[Fact]
public void Validate_NullRule_Error()
{
Assert.Contains("rule is required", DryRunAnalyzeRuleFunction.ValidateDraftRule(null));
}
[Fact]
public void Validate_NoConditions_Error()
{
var rule = MakeRule();
rule.Conditions.Clear();
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("at least one condition"));
}
[Theory]
[InlineData("Event_Type")] // wrong case — the evaluator switch is case-sensitive
[InlineData("eventtype")]
[InlineData("")]
public void Validate_UnknownOrMissingSource_Error(string source)
{
var rule = MakeRule();
rule.Conditions[0].Source = source;
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("source"));
}
[Fact]
public void Validate_UnknownOperator_Error()
{
var rule = MakeRule();
rule.Conditions[0].Operator = "starts_with";
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("unknown operator 'starts_with'"));
}
[Fact]
public void Validate_CorrelationWithoutJoinField_Error()
{
var rule = MakeRule();
rule.Conditions[0].Source = "event_correlation";
rule.Conditions[0].CorrelateEventType = "app_install_completed";
rule.Conditions[0].JoinField = "";
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("joinField"));
}
[Fact]
public void Validate_InvalidRegex_Error()
{
var rule = MakeRule();
rule.Conditions[0].Operator = "regex";
rule.Conditions[0].Value = "([unclosed";
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("not a valid regex"));
}
[Theory]
[InlineData("count > 3")] // wrong comparator
[InlineData("Count >= 3")] // wrong case
[InlineData("phase_duration >= 3")] // wrong comparator
[InlineData("always")]
public void Validate_UnsupportedFactorCondition_Error(string condition)
{
var rule = MakeRule();
rule.ConfidenceFactors = new List<ConfidenceFactor> { new() { Signal = "x", Condition = condition, Weight = 10 } };
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("not evaluable"));
}
[Theory]
[InlineData("exists")]
[InlineData("count >= 3")]
[InlineData("count >=3")]
[InlineData("phase_duration > 300")]
public void Validate_SupportedFactorCondition_NoError(string condition)
{
var rule = MakeRule();
rule.ConfidenceFactors = new List<ConfidenceFactor> { new() { Signal = "x", Condition = condition, Weight = 10 } };
Assert.Empty(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule));
}
[Fact]
public void Validate_DuplicateSignals_Error()
{
var rule = MakeRule();
rule.Conditions.Add(new RuleCondition
{
Signal = "app_failure", Source = "event_type", EventType = "os_info", Operator = "exists", Required = false,
});
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("duplicate signal"));
}
[Fact]
public void Validate_ConfidenceOutOfRange_Error()
{
var rule = MakeRule();
rule.BaseConfidence = 120;
rule.ConfidenceThreshold = -5;
var errors = DryRunAnalyzeRuleFunction.ValidateDraftRule(rule);
Assert.Contains(errors, e => e.Contains("baseConfidence"));
Assert.Contains(errors, e => e.Contains("confidenceThreshold"));
}
// ===== Helpers =====
/// <summary>Simple single-condition rule: required event_type match on app_install_failed,
/// base 50 / threshold 40 — fires whenever the event exists.</summary>
private static AnalyzeRule MakeRule() => new()
{
RuleId = "ANALYZE-TEST-001",
Title = "Test rule",
Severity = "warning",
Category = "apps",
// Drafts behave like tenant custom rules; also keeps the parity path clear of the
// live-catalog sunset filter in GetAllRulesForTenantAsync (built-in ids only).
IsBuiltIn = false,
BaseConfidence = 50,
ConfidenceThreshold = 40,
Conditions = new List<RuleCondition>
{
new()
{
Signal = "app_failure", Source = "event_type", EventType = "app_install_failed",
Operator = "exists", Value = "", Required = true,
},
},
Explanation = "App {{appId}} failed with {{errorCode}}.",
};
private static EnrollmentEvent AppFailedEvent(string appId, string errorCode) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "app_install_failed",
Timestamp = DateTime.UtcNow,
Sequence = 1,
Data = new Dictionary<string, object> { ["appId"] = appId, ["errorCode"] = errorCode },
};
private static EnrollmentEvent OsInfoEvent(string edition) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "os_info",
Timestamp = DateTime.UtcNow,
Sequence = 2,
Data = new Dictionary<string, object> { ["edition"] = edition },
};
private static EnrollmentEvent HardwareSpecEvent(string isVirtualMachine) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "hardware_spec",
Timestamp = DateTime.UtcNow,
Sequence = 3,
Data = new Dictionary<string, object> { ["isVirtualMachine"] = isVirtualMachine },
};
private static (RuleEngine engine, Mock<ISessionRepository> sessionRepo, Mock<IRuleRepository> ruleRepo) MakeEngine(List<EnrollmentEvent> events)
{
var ruleRepo = new Mock<IRuleRepository>();
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleEngineDryRunTests>.Instance);
return (engine, sessionRepo, ruleRepo);
}
private static async Task<(RuleDryRun dry, Mock<ISessionRepository> sessionRepo, Mock<IRuleRepository> ruleRepo)> DryRunAsync(
AnalyzeRule rule, List<EnrollmentEvent> events)
{
var (engine, sessionRepo, ruleRepo) = MakeEngine(events);
var dry = await engine.DryRunRuleAsync(TenantId, SessionId, rule);
return (dry, sessionRepo, ruleRepo);
}
private static async Task<AnalysisOutcome> RunProductionAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
{
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule>());
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule> { rule });
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleEngineDryRunTests>.Instance);
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
}
}
@@ -0,0 +1,148 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the reserved built-in rule-ID namespace: tenant custom rules must never
/// occupy (ANALYZE|GATHER)-&lt;CATEGORY&gt;-&lt;NUMBER&gt; — a gap or retired ID there can be
/// re-shipped as a built-in later, which would silently shadow the tenant's copy at
/// merge time. The CUSTOM category ("ANALYZE-CUSTOM-001") is the sanctioned tenant
/// namespace; the platform never ships built-ins in a category named CUSTOM.
/// Enforced on BOTH the create path and the custom-branch of the update path
/// (updates upsert, so a PUT would otherwise bypass the create check).
/// </summary>
public class RuleIdReservationTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
[Theory]
[InlineData("ANALYZE-SEC-002")] // retired gap — the incident that motivated the policy
[InlineData("ANALYZE-APP-999")] // unused number in an existing category
[InlineData("GATHER-DEVICE-006")]
[InlineData("analyze-sec-002")] // case variants are equally confusing → reserved
[InlineData("Gather-Net-001")]
[InlineData("ANALYZE-NEWCAT-001")] // future built-in categories are covered too
public void Reserved_builtin_ids_are_detected(string ruleId)
=> Assert.True(RuleIdPolicy.IsReservedBuiltInId(ruleId));
[Theory]
[InlineData("ANALYZE-CUSTOM-001")] // sanctioned tenant namespace (portal suggestion)
[InlineData("GATHER-CUSTOM-042")]
[InlineData("ANALYZE-ID-001-CUSTOM")] // template copies end in -CUSTOM, not a number
[InlineData("CONTOSO-WIFI-001")] // organization prefix
[InlineData("ANALYZE-SIDECAR-PROVIDER")] // no trailing number (existing prod custom rule)
[InlineData("dcu-restart-required")] // freeform (existing prod custom rule)
[InlineData("")]
public void Custom_schemes_are_allowed(string ruleId)
=> Assert.False(RuleIdPolicy.IsReservedBuiltInId(ruleId));
// ---- create path -------------------------------------------------------
[Fact]
public async Task Analyze_create_rejects_reserved_id_before_touching_storage()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.CreateRuleAsync(TenantId, new AnalyzeRule { RuleId = "ANALYZE-SEC-002" }));
Assert.Contains("reserved", ex.Message, StringComparison.OrdinalIgnoreCase);
repo.VerifyNoOtherCalls(); // rejected before any storage lookup
}
[Fact]
public async Task Gather_create_rejects_reserved_id_before_touching_storage()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.CreateRuleAsync(TenantId, new GatherRule { RuleId = "GATHER-NET-099" }));
Assert.Contains("reserved", ex.Message, StringComparison.OrdinalIgnoreCase);
repo.VerifyNoOtherCalls();
}
[Fact]
public async Task Analyze_create_still_accepts_custom_namespace()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), TenantId)).ReturnsAsync(true);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
Assert.True(await service.CreateRuleAsync(TenantId, new AnalyzeRule { RuleId = "ANALYZE-CUSTOM-001" }));
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.Is<AnalyzeRule>(x => x.RuleId == "ANALYZE-CUSTOM-001"), TenantId), Times.Once);
}
// ---- update path (upserts — must not be a bypass) ----------------------
[Fact]
public async Task Analyze_update_custom_branch_rejects_reserved_id()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
// IsBuiltIn=false routes into the custom-rule upsert branch.
var rule = new AnalyzeRule { RuleId = "ANALYZE-SEC-002", IsBuiltIn = false, IsCommunity = false };
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateRuleAsync(TenantId, rule));
repo.VerifyNoOtherCalls();
}
[Fact]
public async Task Analyze_update_builtin_state_toggle_is_unaffected()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
repo.Setup(r => r.StoreRuleStateAsync(TenantId, "ANALYZE-SEC-001", It.IsAny<RuleState>())).ReturnsAsync(true);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
// Disabling a real built-in stays possible — that flow stores per-tenant state only.
var rule = new AnalyzeRule { RuleId = "ANALYZE-SEC-001", IsBuiltIn = true, Enabled = false };
Assert.True(await service.UpdateRuleAsync(TenantId, rule));
repo.Verify(r => r.StoreRuleStateAsync(TenantId, "ANALYZE-SEC-001", It.IsAny<RuleState>()), Times.Once);
}
[Fact]
public async Task Gather_update_custom_branch_rejects_reserved_id_when_no_global_row_exists()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
// Retired scenario: the ID is reserved but no global row exists anymore.
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>());
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
var rule = new GatherRule
{
RuleId = "GATHER-DEVICE-099",
Title = "squat",
CollectorType = "registry",
Target = "HKLM\\SOFTWARE\\Microsoft\\Provisioning",
};
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateRuleAsync(TenantId, rule));
repo.Verify(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Gather_update_builtin_state_toggle_is_unaffected()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>
{
new() { RuleId = "GATHER-DEVICE-001", IsBuiltIn = true },
});
repo.Setup(r => r.StoreRuleStateAsync(TenantId, "GATHER-DEVICE-001", It.IsAny<RuleState>())).ReturnsAsync(true);
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
var rule = new GatherRule { RuleId = "GATHER-DEVICE-001", Enabled = false };
Assert.True(await service.UpdateRuleAsync(TenantId, rule));
repo.Verify(r => r.StoreRuleStateAsync(TenantId, "GATHER-DEVICE-001", It.IsAny<RuleState>()), Times.Once);
}
}
@@ -90,4 +90,55 @@ public class SecurityValidatorTests
{
Assert.False(SecurityValidator.IsValidGuid(payload));
}
// --- Intune device id extraction from the MDM client certificate subject ---
// Intune MDM Device CA certs carry the Intune managedDevice id as the Subject CN
// (field-verified 2026-08-06 on a W365 Cloud PC). This is the identity anchor for the
// CloudPc validator, so the parse must be strict: CN present AND a canonical GUID.
[Fact]
public void TryGetIntuneDeviceId_PlainCnGuid_Extracts()
{
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
"CN=07623d56-1e77-4948-bff5-5bdac8167560", out var id));
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
}
[Fact]
public void TryGetIntuneDeviceId_UppercaseGuid_NormalizedToLower()
{
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
"CN=07623D56-1E77-4948-BFF5-5BDAC8167560", out var id));
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
}
[Fact]
public void TryGetIntuneDeviceId_MultiRdnSubject_FindsCn()
{
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
"O=Contoso, CN=07623d56-1e77-4948-bff5-5bdac8167560, C=DE", out var id));
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("O=Contoso, C=DE")] // no CN at all
[InlineData("CN=not-a-guid")] // CN present but not a GUID
[InlineData("CN=hostname.contoso.com")] // typical non-Intune device cert
[InlineData("CN=' or '1'='1")] // injection payload never survives
public void TryGetIntuneDeviceId_InvalidSubjects_ReturnFalse(string? subject)
{
Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(subject, out var id));
Assert.Null(id);
}
[Fact]
public void TryGetIntuneDeviceId_NonGuidFirstCn_IsDefinitive()
{
// A first CN that is not a GUID means "not an Intune MDM device cert shape" — a
// later GUID-valued CN must NOT resurrect the parse (no CN-shopping).
Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
"CN=hostname, CN=07623d56-1e77-4948-bff5-5bdac8167560", out _));
}
}
@@ -0,0 +1,425 @@
using System.Net;
using System.Security.Claims;
using System.Text;
using AutopilotMonitor.Functions.Functions.Annotations;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure.Core.Serialization;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Newtonsoft.Json;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the session-annotation authorization surface: the per-lane write matrix
/// (<see cref="UpsertSessionAnnotationFunction.IsLaneWritableByCaller"/>), the GA-lane
/// read filter (<see cref="GetSessionAnnotationsFunction.FilterLanesForCaller"/>), and —
/// endpoint-level, through the real request pipeline — that author identity is stamped
/// from the JWT and never from the body (same contract as the gather-rule PUT-upserts).
/// </summary>
public class SessionAnnotationFunctionsTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
// ── per-lane write matrix ───────────────────────────────────────────────
// Callers reaching the function already passed TenantAdminOrOperator (tenant
// Admin/Operator, or GA via bypass) — the matrix re-gates per lane.
[Theory]
// operator lane: Operator or Tenant Admin; GA (cross-tenant: not tenant admin) may not
[InlineData(AnnotationLanes.Operator, "Operator", false, false, true)]
[InlineData(AnnotationLanes.Operator, "Admin", true, false, true)]
[InlineData(AnnotationLanes.Operator, "GlobalAdmin", false, true, false)]
[InlineData(AnnotationLanes.Operator, "Viewer", false, false, false)]
// tenantadmin lane: Tenant Admin only
[InlineData(AnnotationLanes.TenantAdmin, "Admin", true, false, true)]
[InlineData(AnnotationLanes.TenantAdmin, "Operator", false, false, false)]
[InlineData(AnnotationLanes.TenantAdmin, "GlobalAdmin", false, true, false)]
// globaladmin lane: Global Admin only
[InlineData(AnnotationLanes.GlobalAdmin, "GlobalAdmin", false, true, true)]
[InlineData(AnnotationLanes.GlobalAdmin, "Admin", true, false, false)]
[InlineData(AnnotationLanes.GlobalAdmin, "Operator", false, false, false)]
// unknown lane: never writable
[InlineData("someotherlane", "GlobalAdmin", true, true, false)]
public void IsLaneWritableByCaller_matrix(
string lane, string userRole, bool isTenantAdmin, bool isGlobalAdmin, bool expected)
{
Assert.Equal(expected,
UpsertSessionAnnotationFunction.IsLaneWritableByCaller(lane, userRole, isTenantAdmin, isGlobalAdmin));
}
[Fact]
public void IsLaneWritableByCaller_ga_who_is_also_own_tenant_admin_may_write_tenant_lanes()
{
// A GA annotating a session of their OWN tenant (IsTenantAdmin=true there) may use
// the tenant lanes — the GA-only restriction applies to foreign tenants, where
// IsTenantAdmin is false.
Assert.True(UpsertSessionAnnotationFunction.IsLaneWritableByCaller(
AnnotationLanes.TenantAdmin, "GlobalAdmin", isTenantAdmin: true, isGlobalAdmin: true));
}
// ── GA-lane read filter ─────────────────────────────────────────────────
private static List<SessionAnnotation> AllLanes() => new()
{
new SessionAnnotation { Lane = AnnotationLanes.Operator, SessionId = SessionId },
new SessionAnnotation { Lane = AnnotationLanes.TenantAdmin, SessionId = SessionId },
new SessionAnnotation { Lane = AnnotationLanes.GlobalAdmin, SessionId = SessionId },
};
[Fact]
public void FilterLanesForCaller_hides_globaladmin_lane_without_global_scope()
{
var visible = GetSessionAnnotationsFunction.FilterLanesForCaller(AllLanes(), hasGlobalScope: false);
Assert.Equal(2, visible.Count);
Assert.DoesNotContain(visible, a => a.Lane == AnnotationLanes.GlobalAdmin);
}
[Fact]
public void FilterLanesForCaller_returns_all_lanes_for_global_scope()
{
var visible = GetSessionAnnotationsFunction.FilterLanesForCaller(AllLanes(), hasGlobalScope: true);
Assert.Equal(3, visible.Count);
}
// ── global list: GA-lane exclusion follows global scope ─────────────────
// The route is GlobalReadOrAdmin, but the delegated ("MSP") read rescue admits a
// delegated caller on its managed ?tenantId= path — the platform-internal
// globaladmin lane must be excluded server-side for anyone without global scope.
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task GlobalList_derives_globaladmin_lane_exclusion_from_global_scope(
bool hasGlobalScope, bool expectedExclude)
{
var annotationRepo = new Mock<ISessionAnnotationRepository>(MockBehavior.Loose);
bool? capturedExclude = null;
annotationRepo
.Setup(r => r.QueryPageAsync(
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
It.IsAny<DateTime?>(), It.IsAny<DateTime?>(), It.IsAny<int>(), It.IsAny<string?>(),
It.IsAny<bool>()))
.Callback<string?, string?, string?, string?, DateTime?, DateTime?, int, string?, bool>(
(_, _, _, _, _, _, _, _, exclude) => capturedExclude = exclude)
.ReturnsAsync((new List<SessionAnnotation>(), (string?)null));
var function = new ListSessionAnnotationsFunction(
NullLogger<ListSessionAnnotationsFunction>.Instance, annotationRepo.Object);
var requestCtx = new RequestContext
{
TenantId = TenantId,
TargetTenantId = TenantId,
UserPrincipalName = "reader@contoso.com",
IsGlobalAdmin = hasGlobalScope,
};
var req = BuildRequest(Principal(("tid", TenantId)), requestCtx, new { });
Mock.Get(req).SetupGet(r => r.Url)
.Returns(new Uri("https://localhost/api/global/session-annotations"));
var res = await function.Run(req);
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
Assert.Equal(expectedExclude, capturedExclude);
}
// ── endpoint-level tests (fake HttpRequestData, same harness as
// GatherRulesFunctionAuthorEndpointTests) ──────────────────────────────
private sealed class FakeHttpResponseData : HttpResponseData
{
public FakeHttpResponseData(FunctionContext context) : base(context) { }
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
public override HttpHeadersCollection Headers { get; set; } = new();
public override Stream Body { get; set; } = new MemoryStream();
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
}
private static FunctionContext BuildContext(ClaimsPrincipal? principal, RequestContext requestContext)
{
var services = new ServiceCollection();
services.AddOptions();
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
var provider = services.BuildServiceProvider();
var items = new Dictionary<object, object> { ["RequestContext"] = requestContext };
if (principal != null)
{
items["ClaimsPrincipal"] = principal;
}
var context = new Mock<FunctionContext>();
context.SetupGet(c => c.Items).Returns(items);
context.SetupGet(c => c.InstanceServices).Returns(provider);
return context.Object;
}
private static HttpRequestData BuildRequest(
ClaimsPrincipal? principal, RequestContext requestContext, object body)
{
var context = BuildContext(principal, requestContext);
var req = new Mock<HttpRequestData>(context);
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
req.SetupGet(r => r.Body).Returns(
new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body))));
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context));
return req.Object;
}
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
private static RequestContext TenantAdminContext() => new()
{
TenantId = TenantId,
TargetTenantId = TenantId,
UserPrincipalName = "alice@contoso.com",
IsTenantAdmin = true,
UserRole = Constants.TenantRoles.Admin,
};
private sealed class Harness
{
public UpsertSessionAnnotationFunction Function = default!;
public Mock<ISessionAnnotationRepository> AnnotationRepo = default!;
public List<SessionAnnotation> Stored = default!;
}
private static Harness BuildFunction(
SessionAnnotation? existing = null, List<RuleResult>? ruleResults = null)
{
var annotationRepo = new Mock<ISessionAnnotationRepository>(MockBehavior.Loose);
var stored = new List<SessionAnnotation>();
annotationRepo.Setup(r => r.GetAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(existing);
annotationRepo.Setup(r => r.UpsertAsync(It.IsAny<SessionAnnotation>()))
.Callback<SessionAnnotation>(stored.Add)
.Returns(Task.CompletedTask);
var sessionRepo = new Mock<ISessionRepository>(MockBehavior.Loose);
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = TenantId });
sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
var ruleRepo = new Mock<IRuleRepository>(MockBehavior.Loose);
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(ruleResults ?? new List<RuleResult>());
var maintenanceRepo = new Mock<IMaintenanceRepository>(MockBehavior.Loose);
return new Harness
{
Function = new UpsertSessionAnnotationFunction(
NullLogger<UpsertSessionAnnotationFunction>.Instance,
annotationRepo.Object, sessionRepo.Object, ruleRepo.Object, maintenanceRepo.Object),
AnnotationRepo = annotationRepo,
Stored = stored,
};
}
[Fact]
public async Task Upsert_stamps_author_from_jwt_not_from_body()
{
var h = BuildFunction();
// Body tries to smuggle author fields — the function must never read them.
var req = BuildRequest(
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new
{
verdict = AnnotationVerdicts.RootCauseConfirmed,
note = "checked on device",
authorUpn = "spoofed@evil.example",
authorDisplayName = "Spoofed Author",
createdByUpn = "spoofed@evil.example",
});
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var written = Assert.Single(h.Stored);
Assert.Equal("Alice Admin", written.AuthorDisplayName);
Assert.Equal("alice@contoso.com", written.AuthorUpn);
Assert.Equal("alice@contoso.com", written.CreatedByUpn);
}
[Fact]
public async Task Upsert_true_update_preserves_original_creator_and_createdAt()
{
var createdAt = new DateTime(2026, 7, 1, 8, 0, 0, DateTimeKind.Utc);
var h = BuildFunction(existing: new SessionAnnotation
{
TenantId = TenantId,
SessionId = SessionId,
Lane = AnnotationLanes.TenantAdmin,
CreatedByUpn = "bob@contoso.com",
CreatedAtUtc = createdAt,
});
var req = BuildRequest(
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new { verdict = AnnotationVerdicts.AnalysisWrong });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var written = Assert.Single(h.Stored);
Assert.Equal("bob@contoso.com", written.CreatedByUpn); // first writer survives
Assert.Equal(createdAt, written.CreatedAtUtc);
Assert.Equal("alice@contoso.com", written.AuthorUpn); // last editor stamped
}
[Fact]
public async Task Upsert_snapshots_fired_rule_ids()
{
var h = BuildFunction(ruleResults: new List<RuleResult>
{
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-ESP-001" },
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-ESP-001" }, // duplicate collapses
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-CORR-003" },
});
var req = BuildRequest(
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
var written = Assert.Single(h.Stored);
Assert.Equal(new[] { "ANALYZE-ESP-001", "ANALYZE-CORR-003" }, written.RuleIds);
}
[Fact]
public async Task Upsert_empty_body_clears_the_lane()
{
var h = BuildFunction();
var req = BuildRequest(
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new { verdict = (string?)null, note = "" });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Empty(h.Stored);
h.AnnotationRepo.Verify(
r => r.DeleteAsync(TenantId, SessionId, AnnotationLanes.TenantAdmin), Times.Once);
}
[Fact]
public async Task Upsert_unknown_verdict_is_rejected()
{
var h = BuildFunction();
var req = BuildRequest(
Principal(("tid", TenantId), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new { verdict = "totally_made_up" });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty(h.Stored);
}
[Fact]
public async Task Upsert_note_over_cap_is_rejected()
{
var h = BuildFunction();
var req = BuildRequest(
Principal(("tid", TenantId), ("upn", "alice@contoso.com")),
TenantAdminContext(),
new { note = new string('x', SessionAnnotation.MaxNoteLength + 1) });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Empty(h.Stored);
}
[Fact]
public async Task Upsert_forbids_lane_outside_caller_matrix()
{
var h = BuildFunction();
var operatorContext = new RequestContext
{
TenantId = TenantId,
TargetTenantId = TenantId,
UserPrincipalName = "op@contoso.com",
IsTenantAdmin = false,
UserRole = Constants.TenantRoles.Operator,
};
var req = BuildRequest(
Principal(("tid", TenantId), ("upn", "op@contoso.com")),
operatorContext,
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Empty(h.Stored);
}
[Fact]
public async Task Upsert_ga_with_own_tenant_admin_role_cannot_write_foreign_tenant_lanes()
{
// The session belongs to TenantId (foreign); the GA is Admin of their OWN tenant.
// Tenant-role lanes bind to the caller's own tenant, so this must 403 — otherwise a
// GA's home-tenant admin role would leak into every other tenant's lanes.
var h = BuildFunction();
var gaContext = new RequestContext
{
TenantId = "99999999-9999-9999-9999-999999999999",
TargetTenantId = "99999999-9999-9999-9999-999999999999",
UserPrincipalName = "ga@fabrikam.com",
IsGlobalAdmin = true,
IsTenantAdmin = true, // admin of the HOME tenant, not of TenantId
UserRole = Constants.TenantRoles.Admin,
};
var req = BuildRequest(
Principal(("tid", gaContext.TenantId), ("upn", "ga@fabrikam.com")),
gaContext,
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Empty(h.Stored);
}
[Fact]
public async Task Upsert_ga_writes_globaladmin_lane_on_foreign_tenant_session()
{
var h = BuildFunction();
var gaContext = new RequestContext
{
TenantId = "99999999-9999-9999-9999-999999999999", // GA's home tenant
TargetTenantId = "99999999-9999-9999-9999-999999999999",
UserPrincipalName = "ga@fabrikam.com",
IsGlobalAdmin = true,
UserRole = "GlobalAdmin",
};
var req = BuildRequest(
Principal(("tid", gaContext.TenantId), ("name", "Gerd Admin"), ("upn", "ga@fabrikam.com")),
gaContext,
new { verdict = AnnotationVerdicts.DifferentProblem, note = "actually a network issue" });
var response = await h.Function.Run(req, SessionId, AnnotationLanes.GlobalAdmin);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var written = Assert.Single(h.Stored);
// Tenant resolved from the session index, NOT the GA's own tenant.
Assert.Equal(TenantId, written.TenantId);
Assert.Equal(AnnotationLanes.GlobalAdmin, written.Lane);
}
}
@@ -43,6 +43,8 @@ public class SessionIndexProjectionTests
["ExcessiveEventsAutoActioned"] = true,
// Self-deploying/kiosk profile marker (session 320b3bf7) — search-filterable.
["IsSelfDeployingProfile"] = true,
// W365 Cloud PC marker (agent-detected, sticky-true) — search-filterable.
["IsCloudPc"] = true,
// Device-validation path recorded at registration — shown on the session detail page.
["ValidatedBy"] = "AutopilotV1",
// Agent→backend HTTP latency projection (UpdateSessionNetworkLatencyAsync merges these).
@@ -72,6 +74,7 @@ public class SessionIndexProjectionTests
Assert.True(idx.GetBoolean("ExcessiveEventsAlerted"));
Assert.True(idx.GetBoolean("ExcessiveEventsAutoActioned"));
Assert.True(idx.GetBoolean("IsSelfDeployingProfile"));
Assert.True(idx.GetBoolean("IsCloudPc"));
Assert.Equal("AutopilotV1", idx.GetString("ValidatedBy"));
Assert.Equal(513.5, idx.GetDouble("AvgApiLatencyMs"));
Assert.Equal(100, idx.GetInt32("ApiRequestCount"));
@@ -103,6 +106,7 @@ public class SessionIndexProjectionTests
Assert.Equal(0, idx.GetInt32("RebootCount"));
Assert.False(idx.GetBoolean("ExcessiveEventsAlerted"));
Assert.False(idx.GetBoolean("IsSelfDeployingProfile"));
Assert.False(idx.GetBoolean("IsCloudPc"));
Assert.Equal(string.Empty, idx.GetString("ImeAgentVersion"));
}
}
@@ -61,6 +61,16 @@ public class SessionStatusTransitionTests
Assert.True(TableStorageService.IsTerminalTransitionAllowed("Succeeded", incoming));
}
// ---- Pending is idempotent: the agent emits whiteglove_complete twice (raw Shell-Core
// event + engine timeline event) — the second Pending write must not count as a
// transition, it fired duplicate Pre-Provisioning Completed webhooks ----
[Fact]
public void Pending_over_Pending_is_refused()
{
Assert.False(TableStorageService.IsTerminalTransitionAllowed("Pending", SessionStatus.Pending));
Assert.True(TableStorageService.IsTerminalTransitionAllowed("InProgress", SessionStatus.Pending));
}
// ================= ComputeReconcileReason =================
// The reconcile hygiene clears FailureReason/FailureSnapshotJson on every Succeeded write;
// ReconcileReason is what keeps a backend-declared success explainable (session 294ab5b4).
@@ -168,6 +168,69 @@ public class StoreSessionReregistrationPreserveTests
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
}
[Fact]
public async Task StoreSessionAsync_cloudPc_isStickyTrue_acrossReregistration()
{
// W365 marker mirrors the self-deploying sticky-true contract: once a boot observed
// the Cloud PC markers, a later re-registration whose registry/service probe degraded
// (registration.IsCloudPc=false) must NOT clear the fact.
var existing = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
["Status"] = "InProgress",
["IsCloudPc"] = true,
};
existing.ETag = new ETag("0xEXISTING");
var harness = new Harness(existing);
var registration = new SessionRegistration
{
TenantId = TenantId,
SessionId = SessionId,
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
SerialNumber = "SN-1",
Manufacturer = "Contoso",
Model = "Model-X",
DeviceName = "PC-1",
IsCloudPc = false, // fresh marker read degraded
};
var ok = await harness.Sut.StoreSessionAsync(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
}
[Fact]
public async Task StoreSessionAsync_cloudPc_upgradesFalseToTrue_onReregistration()
{
var existing = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
["Status"] = "InProgress",
["IsCloudPc"] = false,
};
existing.ETag = new ETag("0xEXISTING");
var harness = new Harness(existing);
var registration = new SessionRegistration
{
TenantId = TenantId,
SessionId = SessionId,
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
SerialNumber = "SN-1",
Manufacturer = "Contoso",
Model = "Model-X",
DeviceName = "PC-1",
IsCloudPc = true,
};
var ok = await harness.Sut.StoreSessionAsync(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
}
// ============================================================ Harness ====
/// <summary>
@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Shared.Models;
using Azure.Data.Tables;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Round-trip pins for the SessionAnnotations Store/Map pair (memory:
/// feedback_table_storage_serialization — every new field in <see cref="SessionAnnotation"/>
/// MUST be exercised in Store+Map so silent drops surface here), plus the RowKey and
/// OData-filter builders.
/// </summary>
public class TableSessionAnnotationRepositoryTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string SessionId = "22222222-2222-2222-2222-222222222222";
private static SessionAnnotation FullAnnotation() => new()
{
TenantId = TenantId,
SessionId = SessionId,
Lane = AnnotationLanes.TenantAdmin,
Verdict = AnnotationVerdicts.RootCauseConfirmed,
Note = "Root cause matches what we saw on the device.",
AuthorUpn = "alice@contoso.com",
AuthorDisplayName = "Alice Admin",
CreatedByUpn = "bob@contoso.com",
CreatedAtUtc = new DateTime(2026, 8, 1, 10, 30, 0, DateTimeKind.Utc),
UpdatedAtUtc = new DateTime(2026, 8, 11, 9, 15, 0, DateTimeKind.Utc),
RuleIds = new List<string> { "ANALYZE-ESP-001", "ANALYZE-CORR-003" },
};
[Fact]
public void Roundtrip_all_fields_survive_store_and_map()
{
var original = FullAnnotation();
var mapped = TableSessionAnnotationRepository.MapAnnotation(
TableSessionAnnotationRepository.StoreAnnotation(original));
Assert.Equal(original.TenantId, mapped.TenantId);
Assert.Equal(original.SessionId, mapped.SessionId);
Assert.Equal(original.Lane, mapped.Lane);
Assert.Equal(original.Verdict, mapped.Verdict);
Assert.Equal(original.Note, mapped.Note);
Assert.Equal(original.AuthorUpn, mapped.AuthorUpn);
Assert.Equal(original.AuthorDisplayName, mapped.AuthorDisplayName);
Assert.Equal(original.CreatedByUpn, mapped.CreatedByUpn);
Assert.Equal(original.CreatedAtUtc, mapped.CreatedAtUtc);
Assert.Equal(original.UpdatedAtUtc, mapped.UpdatedAtUtc);
Assert.Equal(original.RuleIds, mapped.RuleIds);
}
[Fact]
public void Roundtrip_null_verdict_and_note_stay_null()
{
var original = FullAnnotation();
original.Verdict = null;
original.Note = null;
original.RuleIds = new List<string>();
var mapped = TableSessionAnnotationRepository.MapAnnotation(
TableSessionAnnotationRepository.StoreAnnotation(original));
Assert.Null(mapped.Verdict);
Assert.Null(mapped.Note);
Assert.Empty(mapped.RuleIds);
}
[Fact]
public void Store_uses_tenant_pk_and_sessionId_lane_rk()
{
var entity = TableSessionAnnotationRepository.StoreAnnotation(FullAnnotation());
Assert.Equal(TenantId, entity.PartitionKey);
Assert.Equal($"{SessionId}_{AnnotationLanes.TenantAdmin}", entity.RowKey);
}
[Theory]
[InlineData("not json at all")]
[InlineData("{\"truncated\":")]
[InlineData("")]
[InlineData(null)]
public void Map_corrupt_or_missing_ruleIds_degrades_to_empty_list(string? corruptJson)
{
var entity = TableSessionAnnotationRepository.StoreAnnotation(FullAnnotation());
entity["RuleIdsJson"] = corruptJson;
var mapped = TableSessionAnnotationRepository.MapAnnotation(entity);
Assert.Empty(mapped.RuleIds);
}
[Fact]
public void Map_tolerates_minimal_legacy_entity()
{
// A row carrying only PK/RK (e.g. hand-repaired) must map without throwing.
var entity = new TableEntity(TenantId, $"{SessionId}_{AnnotationLanes.Operator}");
var mapped = TableSessionAnnotationRepository.MapAnnotation(entity);
Assert.Equal(TenantId, mapped.TenantId);
Assert.Equal(string.Empty, mapped.SessionId);
Assert.Null(mapped.Verdict);
Assert.Null(mapped.Note);
Assert.Empty(mapped.RuleIds);
}
// ── query filter builder ────────────────────────────────────────────────
[Fact]
public void BuildQueryFilter_no_filters_returns_null()
{
Assert.Null(TableSessionAnnotationRepository.BuildQueryFilter(null, null, null, null, null));
}
[Fact]
public void BuildQueryFilter_combines_all_clauses_with_and()
{
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
TenantId, AnnotationLanes.GlobalAdmin, AnnotationVerdicts.AnalysisWrong,
new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc),
new DateTime(2026, 8, 11, 0, 0, 0, DateTimeKind.Utc));
Assert.Equal(
$"PartitionKey eq '{TenantId}'" +
" and Lane eq 'globaladmin'" +
" and Verdict eq 'analysis_wrong'" +
" and UpdatedAtUtc ge datetime'2026-08-01T00:00:00Z'" +
" and UpdatedAtUtc lt datetime'2026-08-11T00:00:00Z'",
filter);
}
[Fact]
public void BuildQueryFilter_escapes_single_quotes()
{
var filter = TableSessionAnnotationRepository.BuildQueryFilter("ten'ant", null, null, null, null);
Assert.Equal("PartitionKey eq 'ten''ant'", filter);
}
[Fact]
public void BuildQueryFilter_excludes_globaladmin_lane_for_tenant_scope()
{
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
TenantId, null, null, null, null, excludeGlobalAdminLane: true);
Assert.Equal($"PartitionKey eq '{TenantId}' and Lane ne 'globaladmin'", filter);
}
[Fact]
public void BuildQueryFilter_explicit_globaladmin_lane_with_exclusion_contradicts_to_empty()
{
// A tenant caller explicitly asking for lane=globaladmin gets a self-contradicting
// filter (eq + ne on the same value) — structurally an empty result, never a leak.
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
TenantId, AnnotationLanes.GlobalAdmin, null, null, null, excludeGlobalAdminLane: true);
Assert.Contains("Lane eq 'globaladmin'", filter);
Assert.Contains("Lane ne 'globaladmin'", filter);
}
}
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -154,6 +155,68 @@ public class TenantConfigPatchServiceTests
Assert.Null(harness.Current!.ContactEmail);
}
// ── Mid-offboarding freeze ──────────────────────────────────────────────
[Fact]
public async Task Patch_OffboardingTombstone_IsFrozen_EvenForGa()
{
// While the row is the offboarding tombstone, the cascade owns it: a GA patch
// lifting Disabled would un-gate agents against a tenant whose partitions are
// queued for deletion (un-offboard race).
var stored = Stored();
stored.Disabled = true;
stored.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
var harness = new Harness(stored);
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("disabled", false)), Ga, "mcp-patch", "trying to un-offboard");
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
Assert.Contains("offboarding", outcome.Error, StringComparison.OrdinalIgnoreCase);
Assert.True(harness.Current!.Disabled, "tombstone must be untouched");
Assert.Equal(0, harness.ReplaceCalls);
}
[Fact]
public async Task Patch_OrdinarySuspension_IsNotFrozen()
{
// The freeze is specific to the offboarding tombstone — a normally suspended
// tenant (different DisabledReason) stays fully patchable.
var stored = Stored();
stored.Disabled = true;
stored.DisabledReason = "Manual suspension";
var harness = new Harness(stored);
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 60)), Ga, "mcp-patch", null);
Assert.True(outcome.Success);
Assert.Equal(60, harness.Current!.DataRetentionDays);
}
[Fact]
public async Task Revert_OffboardingTombstone_IsFrozen()
{
// A revert restores a pre-offboarding snapshot with Disabled=false — the exact
// un-offboard race. Build a backup while the tenant is healthy, then tombstone.
var harness = new Harness(Stored());
var patched = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
Assert.True(patched.Success);
harness.Current!.Disabled = true;
harness.Current.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
var outcome = await harness.Sut.RevertAsync(
TenantId, backupId: null, includeProtectedFields: false, Ga, "mcp-revert", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
Assert.Contains("offboarding", outcome.Error, StringComparison.OrdinalIgnoreCase);
Assert.True(harness.Current.Disabled, "tombstone must be untouched");
}
[Fact]
public async Task Patch_NoOp_ValueEqualsStored_NoBackupNoWrite()
{
@@ -0,0 +1,145 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Functions.Rules;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the agent-parity semantics of the logparser pattern test endpoint: case-sensitive
/// matching (the field incident that motivated the endpoint: a pattern verified with a PHP
/// regex tester behaved differently under .NET), first-match-per-line, group extraction
/// identical to what LogParserCollector puts into the emitted event data, CMTrace parsing
/// via the SHARED CmTraceLogParser, and the format=text hint.
/// </summary>
public class TestLogPatternFunctionTests
{
private static Regex AgentRegex(string pattern)
=> new(pattern, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
private const string CmTraceLine =
"<![LOG[Installation failed for app Ivanti Secure Access Client with error 0x87D1041C]LOG]!>" +
"<time=\"07:48:35.1783405\" date=\"8-4-2026\" component=\"AppWorkload\" context=\"\" type=\"3\" thread=\"42\" file=\"\">";
// ===== text mode =====
[Fact]
public void TextMode_NamedGroups_LandInGroups()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex(@"(?<level>ERROR|FATAL)\s+(?<component>\w+):\s+(?<message>.+)"),
isTextMode: true,
new[] { "ERROR Installer: disk full", "INFO Installer: all good" });
Assert.Equal(1, result.MatchCount);
Assert.Equal("matched", result.Lines[0].Outcome);
Assert.Equal("ERROR", result.Lines[0].Groups!["level"]);
Assert.Equal("Installer", result.Lines[0].Groups!["component"]);
Assert.Equal("disk full", result.Lines[0].Groups!["message"]);
Assert.Equal("no_match", result.Lines[1].Outcome);
}
/// <summary>The motivating field case: logparser matching has NO IgnoreCase — a pattern
/// that matched in a case-insensitive tester silently never fires on the device.</summary>
[Fact]
public void TextMode_IsCaseSensitive_AndSaysSoInTheNote()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex("ERROR"), isTextMode: true, new[] { "error: something broke" });
Assert.Equal(0, result.MatchCount);
Assert.Contains(result.Notes, n => n.Contains("case-SENSITIVE"));
}
[Fact]
public void TextMode_UnsuccessfulOptionalGroup_IsOmitted()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex(@"ERROR(?<code>\s0x[0-9A-F]+)?"), isTextMode: true, new[] { "ERROR without code" });
Assert.Equal(1, result.MatchCount);
Assert.False(result.Lines[0].Groups!.ContainsKey("code"));
}
// ===== cmtrace mode =====
[Fact]
public void CmTraceMode_MatchesAgainstParsedMessage_AndReportsComponentAndType()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex(@"error (?<errorCode>0x[0-9A-Fa-f]+)"), isTextMode: false, new[] { CmTraceLine });
Assert.Equal(1, result.MatchCount);
var row = result.Lines[0];
Assert.Equal("matched", row.Outcome);
Assert.Equal("0x87D1041C", row.Groups!["errorCode"]);
Assert.Equal("AppWorkload", row.Component);
Assert.Equal(3, row.CmTraceType);
Assert.Contains("Installation failed", row.Message);
}
[Fact]
public void CmTraceMode_RegexDoesNotSeeTheEnvelope()
{
// "component=" only exists in the CMTrace envelope, never in the message the
// agent matches against — a pattern targeting it must NOT match.
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex("component="), isTextMode: false, new[] { CmTraceLine });
Assert.Equal(0, result.MatchCount);
Assert.Equal("no_match", result.Lines[0].Outcome);
}
[Fact]
public void CmTraceMode_PlainTextLines_ParseFail_WithFormatHint()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex("ERROR"), isTextMode: false, new[] { "ERROR plain line", "another plain line" });
Assert.Equal(2, result.ParseFailureCount);
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
Assert.Contains(result.Notes, n => n.Contains("format=text"));
}
// ===== request validation =====
[Fact]
public void Validate_RejectsMissingPatternAndLines()
{
Assert.Contains("pattern", TestLogPatternFunction.ValidateRequest(
new TestLogPatternFunction.TestLogPatternRequest { SampleLines = new List<string> { "x" } })!);
Assert.Contains("sampleLines", TestLogPatternFunction.ValidateRequest(
new TestLogPatternFunction.TestLogPatternRequest { Pattern = "a" })!);
}
[Fact]
public void Validate_RejectsTooManyLines_AndBadFormat()
{
var tooMany = new TestLogPatternFunction.TestLogPatternRequest
{
Pattern = "a",
SampleLines = Enumerable.Repeat("line", TestLogPatternFunction.MaxSampleLines + 1).ToList(),
};
Assert.Contains("at most", TestLogPatternFunction.ValidateRequest(tooMany)!);
var badFormat = new TestLogPatternFunction.TestLogPatternRequest
{
Pattern = "a",
Format = "json",
SampleLines = new List<string> { "x" },
};
Assert.Contains("format", TestLogPatternFunction.ValidateRequest(badFormat)!);
}
[Fact]
public void Validate_AcceptsBothFormatsAndDefault()
{
foreach (var format in new[] { null, "cmtrace", "text", "TEXT" })
{
Assert.Null(TestLogPatternFunction.ValidateRequest(new TestLogPatternFunction.TestLogPatternRequest
{
Pattern = "a",
Format = format,
SampleLines = new List<string> { "x" },
}));
}
}
}
@@ -55,12 +55,6 @@
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<!-- Security pins: Microsoft.Identity.Web 4.3.0 pulls these transitively at 10.0.0, which carries
known advisories (GHSA-9mv3-2cwr-p262 DataProtection; GHSA-37gx-xxp4-5rgx and siblings on
Cryptography.Xml). Force the patched 10.0.10 servicing build. Remove once Identity.Web ships
a dependency floor past the fix. -->
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.10" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
<PackageReference Include="Resend" Version="0.2.1" />
</ItemGroup>
@@ -31,6 +31,7 @@ namespace AutopilotMonitor.Functions.DataAccess
services.AddSingleton<IAdminRepository, TableAdminRepository>();
services.AddSingleton<IConfigRepository, TableConfigRepository>();
services.AddSingleton<IFeedbackRepository, TableFeedbackRepository>();
services.AddSingleton<ISessionAnnotationRepository, TableSessionAnnotationRepository>();
services.AddSingleton<IBootstrapRepository, TableBootstrapRepository>();
services.AddSingleton<INotificationRepository, TableNotificationRepository>();
services.AddSingleton<ITenantNotificationRepository, TableTenantNotificationRepository>();
@@ -603,6 +603,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "ValidateAutopilotDevice", config.ValidateAutopilotDevice },
{ "ValidateCorporateIdentifier", config.ValidateCorporateIdentifier },
{ "ValidateDeviceAssociation", config.ValidateDeviceAssociation },
{ "ValidateCloudPcDevice", config.ValidateCloudPcDevice },
{ "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests },
{ "DataRetentionDays", config.DataRetentionDays },
{ "SessionTimeoutHours", config.SessionTimeoutHours },
@@ -622,6 +623,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "NtpServer", config.NtpServer },
{ "EnableImeMatchLog", config.EnableImeMatchLog },
{ "EnableGatherRuleDebugLog", config.EnableGatherRuleDebugLog },
{ "EnableEspContinueAnywayObservation", config.EnableEspContinueAnywayObservation },
{ "LogLevel", config.LogLevel },
{ "MaxBatchSize", config.MaxBatchSize },
{ "DiagnosticsBlobSasUrl", config.DiagnosticsBlobSasUrl },
@@ -704,6 +706,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
ValidateAutopilotDevice = entity.GetBoolean("ValidateAutopilotDevice") ?? entity.GetBoolean("ValidateSerialNumber") ?? false,
ValidateCorporateIdentifier = entity.GetBoolean("ValidateCorporateIdentifier") ?? false,
ValidateDeviceAssociation = entity.GetBoolean("ValidateDeviceAssociation") ?? false,
ValidateCloudPcDevice = entity.GetBoolean("ValidateCloudPcDevice") ?? false,
AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false,
DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90,
SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5,
@@ -727,6 +730,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
NtpServer = string.IsNullOrWhiteSpace(entity.GetString("NtpServer")) ? "time.windows.com" : entity.GetString("NtpServer"),
EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"),
EnableGatherRuleDebugLog = entity.GetBoolean("EnableGatherRuleDebugLog"),
EnableEspContinueAnywayObservation = entity.GetBoolean("EnableEspContinueAnywayObservation"),
LogLevel = entity.GetString("LogLevel"),
MaxBatchSize = entity.GetInt32("MaxBatchSize"),
DiagnosticsBlobSasUrl = entity.GetString("DiagnosticsBlobSasUrl"),
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["AgentTimestamp"] = entry.AgentTimestamp,
["IngestedAt"] = entry.IngestedAt,
["SourceIp"] = Truncate(entry.SourceIp, 45), // max IPv6 length
["IsCloudPc"] = entry.IsCloudPc,
["CertSourceState"] = Truncate(entry.CertSourceState, 32),
["CertThumbprint"] = Truncate(entry.CertThumbprint, 40),
["CertSubject"] = Truncate(entry.CertSubject, 96),
@@ -125,6 +126,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
AgentTimestamp = entity.GetDateTimeOffset("AgentTimestamp")?.UtcDateTime ?? DateTime.MinValue,
IngestedAt = entity.GetDateTimeOffset("IngestedAt")?.UtcDateTime ?? DateTime.MinValue,
SourceIp = entity.GetString("SourceIp"),
IsCloudPc = entity.GetBoolean("IsCloudPc") ?? false,
CertSourceState = entity.GetString("CertSourceState"),
CertThumbprint = entity.GetString("CertThumbprint"),
CertSubject = entity.GetString("CertSubject"),
@@ -0,0 +1,243 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Data.Tables;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{
/// <summary>
/// Table-storage implementation of <see cref="ISessionAnnotationRepository"/>.
/// Backed by the dedicated <c>SessionAnnotations</c> table:
/// PK = tenantId, RK = <c>{sessionId}_{lane}</c>.
/// </summary>
public class TableSessionAnnotationRepository : ISessionAnnotationRepository
{
// Client-side ruleId filtering can shrink an Azure page below pageSize; the
// back-fill loop keeps fetching until enough matches accumulate, capped here so
// a pathological filter can never turn one request into an unbounded scan.
internal const int MaxBackfillRoundTrips = 10;
private readonly TableClient _tableClient;
private readonly ILogger<TableSessionAnnotationRepository> _logger;
public TableSessionAnnotationRepository(
TableStorageService storage, ILogger<TableSessionAnnotationRepository> logger)
{
_tableClient = storage.GetTableClient(Constants.TableNames.SessionAnnotations);
_logger = logger;
}
public async Task<SessionAnnotation?> GetAsync(string tenantId, string sessionId, string lane)
{
try
{
var entity = await _tableClient.GetEntityAsync<TableEntity>(
tenantId, BuildRowKey(sessionId, lane));
return MapAnnotation(entity.Value);
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
return null;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error loading annotation {SessionId}/{Lane} for tenant {TenantId}",
sessionId, lane, tenantId);
throw;
}
}
public async Task<List<SessionAnnotation>> GetForSessionAsync(string tenantId, string sessionId)
{
try
{
// '~' (0x7E) sorts after '_' (0x5F) and every lane letter: upper-bounds all
// "{sessionId}_{lane}" keys without matching a longer sessionId prefix.
var filter =
$"PartitionKey eq '{Escape(tenantId)}'" +
$" and RowKey ge '{Escape(sessionId)}_'" +
$" and RowKey lt '{Escape(sessionId)}_~'";
var results = new List<SessionAnnotation>();
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
{
results.Add(MapAnnotation(entity));
}
return results;
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error loading annotations for session {SessionId}, tenant {TenantId}",
sessionId, tenantId);
throw;
}
}
public async Task UpsertAsync(SessionAnnotation annotation)
{
try
{
await _tableClient.UpsertEntityAsync(StoreAnnotation(annotation), TableUpdateMode.Replace);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error saving annotation {SessionId}/{Lane} for tenant {TenantId}",
annotation.SessionId, annotation.Lane, annotation.TenantId);
throw;
}
}
public async Task DeleteAsync(string tenantId, string sessionId, string lane)
{
try
{
await _tableClient.DeleteEntityAsync(tenantId, BuildRowKey(sessionId, lane));
}
catch (RequestFailedException ex) when (ex.Status == 404)
{
// Missing row is a no-op — clearing an annotation twice must not fail.
}
catch (Exception ex)
{
_logger.LogError(ex,
"Error deleting annotation {SessionId}/{Lane} for tenant {TenantId}",
sessionId, lane, tenantId);
throw;
}
}
public async Task<(List<SessionAnnotation> Items, string? NextRawToken)> QueryPageAsync(
string? tenantId,
string? lane,
string? verdict,
string? ruleId,
DateTime? dateFrom,
DateTime? dateTo,
int pageSize,
string? continuation,
bool excludeGlobalAdminLane = false)
{
var filter = BuildQueryFilter(tenantId, lane, verdict, dateFrom, dateTo, excludeGlobalAdminLane);
var items = new List<SessionAnnotation>();
var token = continuation;
// ruleId cannot be pushed into OData (substring match on a JSON column), so it
// filters client-side. Back-fill whole Azure pages until pageSize matches
// accumulate or the scan is exhausted, so filtered-out rows never consume page
// budget (lesson: filter-after-pagination empties the list).
for (var round = 0; round < MaxBackfillRoundTrips; round++)
{
var (page, nextToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
_tableClient, filter, pageSize, token);
foreach (var entity in page)
{
var annotation = MapAnnotation(entity);
if (ruleId != null &&
!annotation.RuleIds.Contains(ruleId, StringComparer.OrdinalIgnoreCase))
{
continue;
}
items.Add(annotation);
}
token = nextToken;
if (token == null || items.Count >= pageSize)
{
break;
}
}
return (items, token);
}
internal static string BuildRowKey(string sessionId, string lane) => $"{sessionId}_{lane}";
internal static string? BuildQueryFilter(
string? tenantId, string? lane, string? verdict, DateTime? dateFrom, DateTime? dateTo,
bool excludeGlobalAdminLane = false)
{
var sb = new StringBuilder();
void Append(string clause)
{
if (sb.Length > 0) sb.Append(" and ");
sb.Append(clause);
}
if (!string.IsNullOrEmpty(tenantId)) Append($"PartitionKey eq '{Escape(tenantId!)}'");
if (!string.IsNullOrEmpty(lane)) Append($"Lane eq '{Escape(lane!)}'");
// Server-side, so hidden rows never consume page budget; combined with an explicit
// lane=globaladmin filter this contradicts to an empty result — fail-safe by shape.
if (excludeGlobalAdminLane) Append($"Lane ne '{AnnotationLanes.GlobalAdmin}'");
if (!string.IsNullOrEmpty(verdict)) Append($"Verdict eq '{Escape(verdict!)}'");
if (dateFrom.HasValue) Append($"UpdatedAtUtc ge datetime'{dateFrom.Value.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}'");
if (dateTo.HasValue) Append($"UpdatedAtUtc lt datetime'{dateTo.Value.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}'");
return sb.Length == 0 ? null : sb.ToString();
}
private static string Escape(string value) => value.Replace("'", "''");
// ── Store / Map (memory: feedback_table_storage_serialization) ─────────
// internal static: pinned by round-trip unit tests — every property must
// survive Store→Map, so any new field lands in BOTH methods plus the test.
internal static TableEntity StoreAnnotation(SessionAnnotation a) =>
new(a.TenantId, BuildRowKey(a.SessionId, a.Lane))
{
["SessionId"] = a.SessionId,
["Lane"] = a.Lane,
["Verdict"] = a.Verdict,
["Note"] = a.Note,
["AuthorUpn"] = a.AuthorUpn,
["AuthorDisplayName"] = a.AuthorDisplayName,
["CreatedByUpn"] = a.CreatedByUpn,
["CreatedAtUtc"] = EnsureUtc(a.CreatedAtUtc),
["UpdatedAtUtc"] = EnsureUtc(a.UpdatedAtUtc),
["RuleIdsJson"] = JsonSerializer.Serialize(a.RuleIds),
};
internal static SessionAnnotation MapAnnotation(TableEntity e) => new()
{
TenantId = e.PartitionKey,
SessionId = e.GetString("SessionId") ?? string.Empty,
Lane = e.GetString("Lane") ?? string.Empty,
Verdict = e.GetString("Verdict"),
Note = e.GetString("Note"),
AuthorUpn = e.GetString("AuthorUpn") ?? string.Empty,
AuthorDisplayName = e.GetString("AuthorDisplayName") ?? string.Empty,
CreatedByUpn = e.GetString("CreatedByUpn") ?? string.Empty,
CreatedAtUtc = e.GetDateTime("CreatedAtUtc") ?? DateTime.MinValue,
UpdatedAtUtc = e.GetDateTime("UpdatedAtUtc") ?? DateTime.MinValue,
RuleIds = DeserializeRuleIds(e.GetString("RuleIdsJson")),
};
private static List<string> DeserializeRuleIds(string? json)
{
if (string.IsNullOrEmpty(json)) return new List<string>();
try
{
return JsonSerializer.Deserialize<List<string>>(json!) ?? new List<string>();
}
catch (JsonException)
{
return new List<string>(); // corrupt/truncated column — degrade to empty, never throw on read
}
}
private static DateTime EnsureUtc(DateTime value) =>
value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
}
@@ -65,13 +65,14 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
bool? isUserDriven = null, DateTime? resumedAt = null,
DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false,
string? failureSource = null, string? adminMarkedAction = null,
string? failureSnapshotJson = null, bool allowTerminalReclassification = false)
string? failureSnapshotJson = null, bool allowTerminalReclassification = false,
bool espSoftFailure = false, string? completionSource = null)
{
var transitioned = await _storage.UpdateSessionStatusAsync(tenantId, sessionId, status,
currentPhase, failureReason, completedAt, earliestEventTimestamp,
latestEventTimestamp, isPreProvisioned, isUserDriven, resumedAt,
stalledAt, clearStalledAt, clearFailureReason, failureSource, adminMarkedAction,
failureSnapshotJson, allowTerminalReclassification);
failureSnapshotJson, allowTerminalReclassification, espSoftFailure, completionSource);
// Reconcile the authoritative EventCount + RebootCount whenever a session actually
// transitions to a terminal state through ANY caller — ingest, admin mark
@@ -517,6 +517,17 @@ public class TenantOffboardFunction
/// </summary>
internal const string OffboardingDisabledReason = "Offboarding in progress";
/// <summary>
/// True when the row is the offboarding tombstone written by
/// <c>EnsureTenantDisabledAsync</c>. Shared predicate for every config writer and
/// wiper that must distinguish the mid-offboarding freeze from an ordinary
/// suspended (or re-onboarded) tenant: while this is true, the tenant's
/// configuration is frozen and the offboarding cascade owns the row.
/// </summary>
internal static bool IsOffboardingTombstone(TenantConfiguration? config) =>
config is { Disabled: true }
&& string.Equals(config.DisabledReason, OffboardingDisabledReason, StringComparison.Ordinal);
/// <summary>
/// Result of <see cref="EnsureTenantDisabledAsync"/>. Carries both whether a fresh
/// write happened AND the tombstone's <c>LastUpdated</c> timestamp so the resume-path
@@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Annotations
{
/// <summary>
/// Returns the annotations (all lanes visible to the caller) for one session.
/// The <c>globaladmin</c> lane is platform-internal and filtered out for callers
/// without global scope.
/// </summary>
public class GetSessionAnnotationsFunction
{
private readonly ILogger<GetSessionAnnotationsFunction> _logger;
private readonly ISessionAnnotationRepository _annotationRepo;
private readonly ISessionRepository _sessionRepo;
public GetSessionAnnotationsFunction(
ILogger<GetSessionAnnotationsFunction> logger,
ISessionAnnotationRepository annotationRepo,
ISessionRepository sessionRepo)
{
_logger = logger;
_annotationRepo = annotationRepo;
_sessionRepo = sessionRepo;
}
[Function("GetSessionAnnotations")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/annotations")] HttpRequestData req,
string sessionId)
{
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var effectiveTenantId = requestCtx.TargetTenantId;
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: resolve
// the actual tenant upfront so the read works cross-tenant.
if (requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null
&& !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
{
effectiveTenantId = resolvedTenantId;
}
}
var annotations = await _annotationRepo.GetForSessionAsync(effectiveTenantId, sessionId);
var visible = FilterLanesForCaller(annotations, requestCtx.HasGlobalScope);
return await req.OkAsync(new
{
success = true,
sessionId,
tenantId = effectiveTenantId,
annotations = visible.Select(AnnotationWire.ToWire).ToList(),
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching annotations for session {SessionId}", sessionId);
var errorResponse = req.CreateResponse(System.Net.HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
return errorResponse;
}
}
/// <summary>
/// The globaladmin lane is a platform-internal quality label — callers without
/// global scope (tenant members and delegated admins) never see it.
/// </summary>
internal static List<SessionAnnotation> FilterLanesForCaller(
List<SessionAnnotation> annotations, bool hasGlobalScope)
{
if (hasGlobalScope) return annotations;
return annotations
.Where(a => !string.Equals(a.Lane, AnnotationLanes.GlobalAdmin, StringComparison.OrdinalIgnoreCase))
.ToList();
}
}
/// <summary>
/// Explicit camelCase wire shape shared by the annotation endpoints so the JSON
/// contract does not depend on serializer naming configuration.
/// </summary>
internal static class AnnotationWire
{
internal static object ToWire(SessionAnnotation a) => new
{
lane = a.Lane,
verdict = a.Verdict,
note = a.Note,
authorUpn = a.AuthorUpn,
authorDisplayName = a.AuthorDisplayName,
createdByUpn = a.CreatedByUpn,
createdAtUtc = a.CreatedAtUtc,
updatedAtUtc = a.UpdatedAtUtc,
ruleIds = a.RuleIds,
};
internal static object ToWireWithScope(SessionAnnotation a) => new
{
tenantId = a.TenantId,
sessionId = a.SessionId,
lane = a.Lane,
verdict = a.Verdict,
note = a.Note,
authorUpn = a.AuthorUpn,
authorDisplayName = a.AuthorDisplayName,
createdByUpn = a.CreatedByUpn,
createdAtUtc = a.CreatedAtUtc,
updatedAtUtc = a.UpdatedAtUtc,
ruleIds = a.RuleIds,
};
}
}

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