Compare commits

..

36 Commits

Author SHA1 Message Date
Oliver Kieselbach 54044c5d9c esp: continue-anyway observation mode — bounded 60-min defang for device-phase failures
Classic UD fleets held at the Device-ESP timeout wall by one slow blocking
app showed ~100% Failed (tenant c9787ba2: 948/1000 esp_terminal_failure)
although the profile allows "Continue anyway" and users demonstrably reach
the desktop — the classic advisory defang requires AccountSetupEnteredUtc
and never applied there.

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

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

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

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

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

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

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

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

Shared predicate: TenantOffboardFunction.IsOffboardingTombstone.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two transport rails:

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(okf): rule authoring surface concept doc

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

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

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 17:24:28 +02:00
Oliver Kieselbach b4e409783e updated commands 2026-08-04 20:23:21 +02:00
177 changed files with 9073 additions and 542 deletions
+1 -1
View File
@@ -82,7 +82,7 @@ jobs:
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
# the cap to match; never raise it.
- name: Lint
run: npm run lint -- --max-warnings 115
run: npm run lint -- --max-warnings 99
- name: Tests
run: npm test
+61
View File
@@ -0,0 +1,61 @@
name: Pester (Bootstrap scripts)
# Runs the Pester suite for the Intune bootstrap scripts under Windows PowerShell 5.1
# (the runtime IME uses to execute platform scripts) so guard/relax logic changes are
# verified without resetting a device. Like ci.yml it reports rather than blocks.
on:
push:
branches: [main]
paths: ['scripts/Bootstrap/**']
pull_request:
branches: [main]
paths: ['scripts/Bootstrap/**']
workflow_dispatch:
permissions:
contents: read
concurrency:
group: pester-${{ github.ref }}
cancel-in-progress: true
jobs:
pester:
name: Bootstrap guard tests (PS 5.1)
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- name: Install Pester 5+
shell: powershell
run: |
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
- name: Run Pester
shell: powershell
run: |
Import-Module Pester -MinimumVersion 5.5.0
$c = New-PesterConfiguration
$c.Run.Path = 'scripts/Bootstrap'
$c.Run.Exit = $true
$c.Output.Verbosity = 'Detailed'
Invoke-Pester -Configuration $c
- name: Guard scripts stay pure ASCII
shell: powershell
run: |
$failed = $false
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
for ($i = 0; $i -lt $bytes.Length; $i++) {
if ($bytes[$i] -gt 127) {
Write-Host "::error file=scripts/Bootstrap/$($_.Name)::Non-ASCII byte 0x$('{0:X2}' -f $bytes[$i]) at offset $i. PS 5.1 (IME) reads BOM-less files as ANSI and corrupts multi-byte chars."
$failed = $true
break
}
}
}
if ($failed) { exit 1 }
Write-Host 'All bootstrap scripts are pure ASCII.'
+114
View File
@@ -0,0 +1,114 @@
---
type: Concept
title: Continue-Anyway Observation Mode — Device-Phase ESP Failure Defang
description: Operator-set tenant opt-in that turns an immediate Device-phase ESP terminal failure on a Continue-Anyway-enabled profile into a bounded 60-minute observation; the DAD-validated real-user desktop plus the Hello gate detect the true end, and the session completes as Succeeded with an esp-soft-failure marker.
resource: /src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs
tags:
- agent
- decision-engine
- esp
- continue-anyway
- soft-failure
- tenant-config
timestamp: 2026-08-08T00:00:00+02:00
---
# Continue-Anyway Observation Mode
## Problem
A Classic user-driven enrollment whose Device ESP hits the configured terminal timeout
(`syncFailureTimeoutMinutes`, typically 30) fails **before AccountSetup is ever entered**.
The classic advisory defang (`HandleEspTerminalFailureV1`) requires
`AccountSetupEnteredUtc != null`, so these sessions hard-fail immediately — even when the
ESP profile allows "Continue anyway" and users demonstrably dismiss the failure screen and
reach a working desktop. A fleet held at the timeout wall by one slow blocking app then
shows ~100% Failed (tenant c9787ba2: 948/1000 `esp_terminal_failure`, culprit "Encompass
Hybrid Installer"), which tells the tenant nothing.
## Mechanism
Opt-in flow (default off — absence keeps the immediate hard fail):
1. **Tenant setting** `EnableEspContinueAnywayObservation` (`TenantConfiguration`,
operator-set only, NOT exposed in the tenant admin settings UI; the toggle lives in
the Global-Admin tenant management modal next to Bootstrap Token / Unrestricted Mode,
and the field is GA-gated on both write paths — the generic PUT silently reverts it
for tenant admins, the field patch rejects it with 400) → served via `agent/config`
(`AgentConfigResponse`, ConfigVersion 37).
2. **Agent** stamps `espContinueAnywayObservationEnabled=true` into the
`EspConfigDetected` signal payload (both emitters: the orchestrator FirstSync
bootstrap and `DeviceInfoCollector.PostEspConfigDetectedSignal`) — only when enabled,
so the kernel stays config-free and replay/recovery reconstruct the fact from the
signal log.
3. **Engine** (`HandleEspTerminalFailureV1`): a terminal ESP failure is defanged into an
`esp_failure_advisory` (`advisoryReason=esp_failure_defanged_continueanyway_observation`)
when ALL hold: profile allows Continue-Anyway, observation opt-in observed,
`AccountSetupEnteredUtc == null`, and the scenario is Classic/Unknown with
`PreProvisioningSide=None` and no registry self-deploying marker (WhiteGlove /
SelfDeploying / DevicePreparation have no interactive user to press the button).
4. **Observation window**: the shared `AdvisoryCompletion` deadline is armed with a
**60-minute** window (classic advisory: 30) — long enough for failure screen →
Continue anyway → sign-in → desktop, or a full device-phase re-run via "Try again".
## Detecting the real end
The active-advisory-without-AccountSetup shape is unique to this arming site, so
`IsContinueAnywayObservationActive(state)` (advisory recorded ∧ not resolved ∧
AccountSetup never entered) needs no separate opt-in re-check. Three outcomes:
* **Continue anyway** — the DAD-validated real-user desktop arrives (defaultuser0/SYSTEM
excluded at the detector) and the Hello gate is satisfied (resolved, or policy
explicitly disabled without an observed wizard): `HandleDesktopArrivedV1` completes
eagerly through Finalizing (trigger `DesktopArrived:ContinueAnywayObservation`) — the
session end reflects the real end, not the window edge. The IME user-session gate is
deliberately waived: it anchors on `AccountSetupEnteredUtc`, which is structurally
absent here. The terminal `enrollment_complete` carries `espSoftFailure=true`,
`completionSource=continue_anyway_observation` and the failed category; the backend
stamps `EspSoftFailure`/`CompletionSource` on the Sessions row (+ SessionsIndex
mirror) and the web renders the amber "with issues" badge plus a detail banner.
Sessions completing under the CLASSIC advisory (AccountSetup entered) carry the same
marker with `completionSource=continue_anyway_post_accountsetup`.
* **Try again** — the ESP re-runs the failed category; `DeviceSetupProvisioningComplete`
resolves the advisory via the 4910a5a5 recovery hook (`esp_failure_advisory_resolved`)
and the session continues on the normal rails, completing clean (no marker).
* **Nothing** — the window expires: un-defang to `enrollment_failed` with the original
`esp_terminal_failure` reason and
`advisoryReason=continue_anyway_observation_window_expired_without_completion_evidence`;
`LastFailureTrigger=EspTerminalFailure` keeps the likely-stuck app promotion on. Every
observed session therefore still gets a terminal verdict ≤ ~60 min after the failure.
Deadline-fire edge cases mirror the classic paths: desktop + unknown Hello promotes to
`AwaitingHello` with the HelloSafety window (never-observed promote), and enforcement
progress since arming re-arms the window (30-min re-arm, convergent) instead of failing a
device mid-"Try again".
## Status semantics
No new terminal status: the session is `Succeeded` + `EspSoftFailure=true` (user
decision 2026-08-08). Success-rate conventions stay untouched; honesty lives in the
badge, the detail banner, and ANALYZE-ESP-004 (which continues to fire on the un-defang
expiry path via `mayHaveContinuedAnyway`).
# Examples
Session `53d1e9f6-4eef-4f3b-a999-c63e96c5148d` (tenant c9787ba2, 2026-08-07) is the
motivating shape: DeviceSetup/Certificates failed at the 30-min wall while "Encompass
Hybrid Installer" was still installing, AccountSetup 0/5, Continue-Anyway allowed —
hard-failed under the default semantics.
Tests: `ContinueAnywayObservationTests` (DecisionCore.Tests) — defang, 60-min arming,
opt-out/scenario regressions, eager desktop completion with marker, clean-completion
marker absence, expiry un-defang, hello promotes, category recovery.
# Citations
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Edge.cs` — arming site,
observation branches in `HandleAdvisoryCompletionDeadlineFired`,
`IsContinueAnywayObservationActive`.
* `src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs` —
`HandleDesktopArrivedV1` observation completion, `BuildEnrollmentCompleteEffect`
soft-failure marker.
* `src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs`
/ `TableStorageService.Sessions.cs` — session stamping + index mirror.
* `src/Web/autopilot-monitor-web/components/SessionStatusBadge.tsx` — amber badge.
+142
View File
@@ -0,0 +1,142 @@
---
type: Concept
title: W365 Cloud PC Device Validation — Cert-CN-Bound Fallback Validator
description: Why Windows 365 Cloud PCs can never pass the Autopilot serial lookup, and how the CloudPc validator admits exactly the tenant's service-provisioned Cloud PCs by binding the MDM certificate's Subject CN (= Intune device id) to the Graph virtualEndpoint/cloudPCs inventory.
resource: src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs
tags:
- backend
- security
- device-validation
- windows365
- cloudpc
- graph
timestamp: 2026-08-06T00:00:00+02:00
---
# W365 Cloud PC Device Validation
Windows 365 Cloud PCs are provisioned headless by the Windows 365 service and are
**structurally never Autopilot-registered** — the serial lookup against
`windowsAutopilotDeviceIdentities` (and the Corporate Identifier fallback, whose
serials nobody can pre-import for VMs minted at provisioning time) always misses.
Before this validator, a Cloud PC agent died in the 403 "Device not registered"
even though its mTLS cert was perfectly valid, so Cloud PC first-connect
enrollment (Account Setup, RealmJoin, Hello) could not be monitored.
# Schema — the validator chain
`SecurityValidator.ValidateRequestAsync` stage 4 runs the same chain for every
device; there is **no device-type routing** and the local W365 markers
(Windows365 registry key, CloudManagedDesktopExtension service) are never
consulted for auth — they gain an attacker nothing:
1. **Autopilot serial lookup** (`ValidateAutopilotDevice`) — unchanged primary.
2. **Corporate Identifier** (`ValidateCorporateIdentifier`) — unchanged fallback.
3. **CloudPc** (`ValidateCloudPcDevice`, default off) — only reached when 1+2
missed. Extracts the Intune device id from the **chain-validated** client
certificate's Subject CN (`TryGetIntuneDeviceIdFromCertSubject`: CN present
AND canonical GUID, else definitive reject) and requires a `cloudPC` object
with `managedDeviceId eq <CN>` in the claimed tenant
(`GET /v1.0/deviceManagement/virtualEndpoint/cloudPCs?$filter=…`).
`ValidatedBy = ValidatorType.CloudPc (5)`.
Only machines actually provisioned by the Windows 365 service have a `cloudPC`
object — a regular Intune-enrolled non-Autopilot device still ends in the 403,
so the "Autopilot devices only" contract of stages 1–2 is not widened. The
tenant hard gate accepts `ValidateCloudPcDevice` as a third satisfying flag.
## Why the cert CN, not headers or local markers
Field-verified 2026-08-06 on a real W365 Enterprise Cloud PC (gktatooine.net):
- MDM client cert Subject `CN=07623d56-…` == Intune `managedDevice.id` ==
`cloudPC.managedDeviceId`. The CN cannot be forged without the Intune MDM
Device CA (chain pinned to embedded roots, `CustomRootTrust`).
- `$filter=managedDeviceId eq '…'` is supported server-side on **v1.0** — no
paging fallback needed.
- `managedDevice.serialNumber` matches local `Win32_BIOS.SerialNumber` exactly;
the "Cloud PC Enterprise …" model string exists **only** in Graph, never in
local WMI (local WMI reports Manufacturer='Microsoft Corporation',
Model='Virtual Machine').
Threat model: a valid Intune cert from a *different* tenant fails (its CN does
not exist in the claimed tenant's Cloud PC inventory — this is a *tenant
binding* the serial-based validators do not have); spoofed serial headers and
faked local W365 markers are irrelevant (never consulted); a rogue enrolled VM
with doctored SMBIOS cannot mint a `cloudPC` object (service-side state, not
device-reported inventory — the reason the model-string gate was rejected).
Retired/deprovisioned Cloud PCs disappear from the inventory and their
validation dies with them (compensates the absent cert revocation check).
Residual: a compromised *legitimate* Cloud PC of the tenant — same trust level
as any tenant device today, rate-limited by cert thumbprint.
## Graph permission plumbing
- Optional add-on permission `CloudPC.Read.All` — NOT part of the default
consent set. Feature `W365CloudPcValidation` in `GraphFeatureCatalog` →
surfaces automatically in the "Optional Graph capabilities" UI (the feature
table and grant command are backend-driven) and in
`Grant-AutopilotMonitorAddOn.ps1` (`-Features W365CloudPcValidation`).
`GraphFeatureCatalogSyncTests` pins C# catalog ↔ grant script (previously
comment-only lock-step).
- A Graph **403** (permission not granted) is a definitive negative (cached
5 min), NOT transient — a 503 Retry-After loop cannot fix a missing grant.
The error message names the add-on feature.
- Same resilience contract as `AutopilotDeviceValidator`: 30/5 min
positive/negative cache, 2 attempts, transient → agent-facing 503.
## Bootstrap script interplay (v2.3-dev.3)
The Cloud-PC relax in `Install-AutopilotMonitor-dev.ps1` additionally exempts
**guard 4** (12 h uptime window): a Cloud PC runs headless for days before the
user's first connect, and the monitorable phase (Account Setup) only starts at
that first connect — the single-fresh-profile window (< 15 min) is the time
anchor there, not boot uptime. The OOBE-restore trigger keeps the uptime guard.
## Agent-side IsCloudPc flag + decision-engine expectation
Same change set, agent side. `CloudPcDetector` (V2.Core) re-uses the bootstrap's
field-verified marker AND — `HKLM\SOFTWARE\Microsoft\Windows365` key **plus**
installed `CloudManagedDesktopExtension` service (probed via its
`HKLM\SYSTEM\CurrentControlSet\Services` key; the agent deliberately has no
`System.ServiceProcess` dependency). SKIP-safe: any error resolves `false`.
Three independent transport rails, never an auth input:
- **Session metadata** — `SessionRegistration.IsCloudPc` →
Sessions table (sticky-true OR across re-registrations, like
`IsSelfDeployingProfile`) → SessionsIndex full mirror → `SessionSummary` →
search filter `isCloudPc` (portal, `/api/search/sessions`, `/api/raw/sessions`,
MCP `search_sessions`/`query_raw_sessions`). Agent-reported and unverified —
can legitimately disagree with the server-derived `ValidatedBy = CloudPc`
(e.g. Cloud PC admitted via a stage that ran earlier).
- **Distress channel** — `DistressReport.IsCloudPc` (captured once at
`DistressReporter` construction, `BackendClientFactory`) rides every pre-auth
distress report → `DistressReportEntry.IsCloudPc` → the "Devices Not
Registered" report aggregates it **sticky-true** per serial
(`GetDeviceNotRegisteredFunction`) and the portal insight renders a Cloud PC
badge plus a hint pointing at the Cloud PC validation toggle + the
`W365CloudPcValidation` add-on. Same UNVERIFIED contract as all distress
fields; rows from older agents read false. Fits the existing 1536-byte
payload cap without a bump.
- **Decision engine** — the marker rides the `EnrollmentFactsObserved` payload
(`isCloudPc`), is recorded set-once for BOTH values in
`EnrollmentScenarioObservations.CloudPc`, and annotates the scenario profile
reason `cloud_pc_first_connect:no_device_esp_expected`. That is the
engine-recorded expectation "no Device ESP phase will appear": Device-ESP ran
headless at provisioning, the session starts at Account Setup. Deliberately
reason+observation only — no completion arm depends on DeviceSetup, `Mode`
is still classified by AccountSetup/IME signals (Classic), and `EspConfig`
keeps the *configured* FirstSync semantics. The positive marker surfaces as
`cloud_pc_marker` in the terminal audit-trail signal census.
# Citations
- `src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs`
- `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — stage 4 chain, `TryGetIntuneDeviceIdFromCertSubject`
- `src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs` — `W365CloudPcValidation`
- `scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1` — customer-side grant
- `scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1` — Cloud-PC relax + guard-4 exemption
- `src/Backend/AutopilotMonitor.Functions.Tests/CloudPcDeviceValidatorTests.cs`, `SecurityValidatorTests.cs`, `GraphFeatureCatalogSyncTests.cs`
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CloudPcDetector.cs` — agent-side marker AND
- `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioObservations.cs` — `CloudPc` fact; reason annotation in `EnrollmentScenarioProfileUpdater`
+4
View File
@@ -23,12 +23,14 @@ https://docs.autopilotmonitor.com (separate repository).
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
# Rules
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
* [Rule Authoring Surface](rules/rule-authoring-surface.md) - How customer AIs author rules against the real contract: the baked MCP resources (guide/schemas/guardrails via combine.js), the MCP-local validate_rule pre-flight, and the side-effect-free POST rules/analyze/dryrun whose trace twin shares the production evaluators (parity + side-effect-freedom pinned by tests).
# Backend
@@ -38,11 +40,13 @@ https://docs.autopilotmonitor.com (separate repository).
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
# MCP Server
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
* [MCP Tool Telemetry](mcp/tool-telemetry.md) - The three telemetry layers for judging tool quality: backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
# Web Portal
+25
View File
@@ -1,5 +1,30 @@
# Log
## 2026-08-08
* **New**: `agent/continue-anyway-observation.md` — Continue-Anyway observation mode (`EnableEspContinueAnywayObservation`, ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles are defanged into a 60-min observation window (Classic/Unknown, non-pre-prov only); real-user desktop + Hello gate complete the session as Succeeded + `EspSoftFailure`/`CompletionSource` (amber "with issues" badge + detail banner), category recovery resolves clean, expiry un-defangs to the original `esp_terminal_failure`. Registered in the Agent index section. Related backend fix (no doc): the auto-analyze enqueue now carries a 30 s `visibilityTimeout` so rule evaluation no longer races the agent's terminal flush (ANALYZE-ESP-004's `{{appName}}` placeholder).
## 2026-08-07 (later)
* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap.
## 2026-08-07
* **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input.
## 2026-08-06
* **Creation**: Added `backend/cloudpc-device-validation.md` — the CloudPc fallback validator (`ValidatorType.CloudPc = 5`, tenant flag `ValidateCloudPcDevice`): W365 Cloud PCs are structurally never Autopilot-registered, so stage 4 gains a third stage that binds the chain-validated MDM cert's Subject CN (= Intune device id, field-verified on a real Cloud PC) to the tenant's `virtualEndpoint/cloudPCs` inventory — admits exactly the service-provisioned Cloud PCs, no device-type routing, local W365 markers never consulted for auth. New optional Graph add-on `W365CloudPcValidation` → `CloudPC.Read.All` (catalog + grant script now pinned by `GraphFeatureCatalogSyncTests`; Graph 403 = definitive "grant missing", not transient). Bootstrap dev script v2.3-dev.3 exempts guard 4 (uptime) while the Cloud-PC relax is active — the fresh-profile window is the time anchor at first connect, not boot uptime. Follow-up deliberately out of scope: agent-side `IsCloudPc` classification + decision-engine "no Device ESP" expectation.
## 2026-08-05 (later)
* **Update**: `rules/rule-authoring-surface.md` — new section `POST /api/rules/gather/test-pattern`: the logparser pattern tester (agent-exact .NET matching: no IgnoreCase, first-match-per-line, cmtrace vs text mode, verbatim format=text hint), motivated by a field incident where a PHP-verified regex behaved differently under .NET. `CmTraceLogParser` moved Agent → `AutopilotMonitor.Shared.Logging` so agent and backend share the ONE parsing implementation (timestamp caveat: AssumeLocal = server TZ on backend, endpoint reports no timestamps). MCP: new `test_log_pattern` tool, logparser lint in `validate_rule`, guide gains the case-sensitivity/engine-mismatch pitfalls.
## 2026-08-05
* **Creation**: Added `mcp/tool-telemetry.md` — the three MCP tool-telemetry layers (App Insights `McpToolName`, per-user usage table, `MCP_TOOL_LOGGING` stderr lines) and the new quality signals: soft-error capture (`toolError` returns instead of throwing, so backend telemetry counted those calls as successes), `resultChars`/`overCap` vs. the inline-size hint, bounded args summaries, caller scope, `search_zero_hit` on the three search tools (unmet-demand signal), and `tool_call_rejected` for JSON-RPC-level Zod/unknown-tool rejections sniffed at the `/mcp` route (previously invisible everywhere). Bicep default for `toolLoggingEnabled` flipped to `true` (documented desired state; live flag set via `az containerapp update`).
* **Creation**: Added `rules/rule-authoring-surface.md` — the AI-assisted rule-authoring stack: three deterministic MCP resources (`rule_authoring_guide` hand-written, `rule_schemas` + `rule_guardrails` generated into `rule-authoring.generated.ts` by a new combine.js emitter, drift-guarded twice), the MCP-local `validate_rule` (ajv 2020-12 against the single-rule $defs, guardrail pre-flight mirroring GatherRuleGuards matching semantics incl. hard blocks, semantic lint for schema-valid-but-dead rules), and the side-effect-free `POST /api/rules/analyze/dryrun` (TenantAdminOrGlobalReader; `RuleEngine.DryRun.cs` trace twin sharing the production evaluators, all-conditions trace with per-condition evidence + matchingEventCount, parity and side-effect-freedom pinned by RuleEngineDryRunTests). MCP `test_analyze_rule` wraps the endpoint with `{{token}}`-interpolated previews; `author-rule` prompt drives the draft → validate → dry-run loop. Deliberately absent: gather-rule remote execution, rule writes via MCP.
## 2026-08-04
* **Update**: `rules/gather-rule-phase-scoping.md` — new section "Which phase feeds the triggers": `GatherRuleExecutorHost` now drives `phase_change`/`phase_exit`/`on_event` gather triggers (and the phase-scope gates) from the post-reduce `TimelineEventStream` (every emitted event's `(eventType, phase)` published by `EventTimelineEmitter`) instead of the pre-reduce `SignalIngress.SignalPosted` payloads. Trigger: session `32312a32` (RealmJoin tenant rsneuffen.de) — a Phase-Start rule on FinalizingSetup fired at the raw ESP-exit `EspPhaseChanged(FinalizingSetup)` signal, 7 minutes before the engine's RealmJoin-gated `phase_transition(FinalizingSetup)`, reading `HKLM\SOFTWARE\RealmJoin\Custom\BIOS` before the RealmJoin package created it. Phase triggers now fire exactly when the timeline shows the phase; `on_event` additionally sees engine-emitted event types (`enrollment_complete`, `realmjoin_resolved`, …) for the first time in V2, restoring V1 semantics and the documented end-of-enrollment pattern. Other raw-signal hosts (DeviceInfo, keep-awake, PPKG) deliberately unchanged.
+81
View File
@@ -0,0 +1,81 @@
---
type: concept
title: MCP Tool Telemetry
description: The three telemetry layers for judging MCP tool quality — backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines that carry the signals the backend cannot see (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
resource: src/McpServer/autopilot-monitor-mcp/src/telemetry.ts
tags: [mcp, telemetry, observability, tool-quality]
timestamp: 2026-08-05
---
# Schema
Three layers answer "are the MCP tools doing their job, and are tools missing?":
1. **Backend App Insights** — every tool handler runs inside `withToolTelemetry`
(`src/telemetry.ts`), which propagates the tool name via AsyncLocalStorage so
`apiFetch` stamps `X-MCP-Tool-Name` on each backend request.
`RequestTelemetryMiddleware` copies it into the request telemetry as
`McpToolName`: per-tool call volume, latency percentiles and HTTP failure
rates by KQL. Blind spot: tools that never call the backend, and handler-level
soft errors (see layer 3).
2. **Usage table** — `McpQuotaEnforcementMiddleware.TrackUsage` counts per
user/day under the key `toolname:endpoint`; surfaced by the `get_api_usage`
tool. Counts only, every call counts as success.
3. **stderr JSON lines** (`MCP_TOOL_LOGGING=true`, queryable via Container App
Logs) — the layer that carries what the other two structurally cannot:
- `tool_call`: duration, `isError` **including soft errors** (handlers never
throw — `toolError` *returns* `isError: true`, so backend-only telemetry
records those calls as successes), `resultChars` + `overCap` (result
exceeds the tool's `anthropic/maxResultSizeChars` hint → the host truncates
it; a frequently-overCap tool needs tighter defaults or projections), a
size-bounded args summary, and the caller scope (ga/delegated/tenant).
This is the ONLY telemetry for backend-free tools: `search_docs`,
`validate_rule`, `get_resource`, the local ranking of `search_knowledge`.
- `search_zero_hit`: query text (capped) whenever `search_events` /
`search_knowledge` / `search_docs` return zero results — the most direct
"missing docs / missing knowledge / missing tool?" demand signal.
- `tool_call_rejected`: rejections the SDK answers *before* any handler runs
(Zod argument validation, unknown/disabled tool). Two non-obvious facts,
both found by live verification: SDK 1.30 wraps these McpErrors as a
*successful* JSON-RPC response whose result is `{ isError: true, text:
"MCP error <code>: …" }` — a JSON-RPC error envelope never exists for
them — and the Hono bridge (`getRequestListener`) may stream the body as
`res.write` chunks with a bare `res.end()`. The sniffer in the `/mcp`
route therefore wraps both `write` and `end`, reassembles up to 10 KB and
matches both shapes (`attachToolCallRejectionSniffer` in `telemetry.ts`).
Handler soft errors never carry the `MCP error` prefix, so they are not
double-logged. This is the strongest "the schema or description confuses
the model" signal and was previously invisible everywhere.
The env flag defaults to `true` in `infra/mcp-server.bicep` (documented desired
state — the template is not routinely deployed; the live flag is set with
`az containerapp update --set-env-vars MCP_TOOL_LOGGING=true`).
# Examples
Count zero-hit queries to find unmet demand (Log Analytics):
```kusto
ContainerAppConsoleLogs_CL
| where Log_s has '"type":"search_zero_hit"'
| extend d = parse_json(Log_s)
| summarize count() by tostring(d.tool), tostring(d.query)
```
Per-tool soft-error and truncation rates:
```kusto
ContainerAppConsoleLogs_CL
| where Log_s has '"type":"tool_call"'
| extend d = parse_json(Log_s)
| summarize calls = count(), errors = countif(d.isError == true),
overCap = countif(d.overCap == true), p95ms = percentile(tolong(d.durationMs), 95)
by tool = tostring(d.tool)
```
# Citations
- `src/McpServer/autopilot-monitor-mcp/src/telemetry.ts` — wrapper + log emitters
- `src/McpServer/autopilot-monitor-mcp/src/index.ts` — `/mcp` rejection sniffer
- `src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs` — `McpToolName` stamping
- `src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs` — usage-table counting
+152
View File
@@ -0,0 +1,152 @@
---
type: Concept
title: Rule Authoring Surface — MCP Resources, validate_rule, and the Analyze Dry-Run
description: >-
How customer AIs author gather/analyze rules against the real contract: the baked
authoring resources (guide, schemas, guardrails) generated by combine.js, the
MCP-local validate_rule pre-flight, and the side-effect-free backend dry-run that
traces a draft analyze rule against one session's events.
resource: src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts
tags: [rules, mcp, analyze-rules, gather-rules, dry-run, authoring]
timestamp: 2026-08-05
---
# Concept
Customers author rules with an AI connected to the MCP server. Retrieval
(`search_docs`) is deliberately NOT the knowledge source for authoring: chunked
top-K retrieval can silently omit an operator, a guardrail prefix, or an enum,
and the AI then produces a plausible-but-dead rule. The authoring surface is
therefore **deterministic**: three `get_resource` entries carry the complete
contract, a local validator rejects what production would reject or silently
ignore, and a backend dry-run evaluates a draft against real session events with
a full trace.
The intended loop: draft → `validate_rule` → `test_analyze_rule` against a real
session (one that should fire, one that should not) → iterate → create in the
portal. The `author-rule` MCP prompt walks a client through exactly this order.
# Schema
## Resources (deterministic authoring context)
| Resource | Content | Source |
| --- | --- | --- |
| `rule_authoring_guide` | Hand-written complete authoring knowledge: collector types, triggers, condition sources, operators, confidence model incl. the exact factor DSL, `{{token}}` interpolation order, guardrail matching semantics, pitfalls | `src/rule-authoring-guide.ts` |
| `rule_schemas` | Both JSON Schemas (2020-12) verbatim | generated |
| `rule_guardrails` | `rules/guardrails.json` verbatim | generated |
Generation: `node rules/scripts/combine.js` bakes schemas + guardrails into
`src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts` (same
pattern as the web's `utils/guardrails.generated.ts`; the tsconfig `rootDir` and
the Docker build's exclusion of `rules/schema` rule out importing the JSON
directly). Drift is closed twice: CI's `guardrails-in-sync` job re-runs
combine.js and fails on any diff, and `rule-authoring-drift.test.ts` pins
generated == repo source plus guide enums == schema enums.
## validate_rule (MCP-local)
`src/rule-validation.ts`, no backend call. Three layers:
1. **Schema** — ajv (draft 2020-12) against the baked schemas, validating the
single-rule `$defs` entry directly (the top-level oneOf wrapper would double
every error).
2. **Guardrails** — mirrors the AGENT's matching semantics from
`GatherRuleGuards.cs` (see [gather-rule-guardrails.md](gather-rule-guardrails.md)):
hive-stripped segment-bounded registry/file prefixes, whitespace-bounded WMI
prefixes, EXACT command matching, `/`-bounded event-log channels, and the
code-enforced hard blocks (`Security` etc. channels, `C:\Users`,
`C:\Windows\System32\config`).
3. **Semantic lint** — schema-valid-but-dead rules: unreachable
confidenceThreshold, factor conditions outside the three parseable shapes
(`exists`, `count >= N`, `phase_duration > N`), unknown event types (warning;
`gather_*` softened to info), unanchored `not_regex` allow-lists,
unresolvable `{{token}}`s, trigger cross-fields, outputEventType
convention/collision checks.
The validator is advisory pre-flight only — the agent re-enforces guardrails
on-device and the backend re-validates structure. It must never be stricter
than production.
## POST /api/rules/analyze/dryrun (backend)
`DryRunAnalyzeRuleFunction` → `RuleEngine.DryRunRuleAsync`. Body
`{ sessionId, rule }` (draft `AnalyzeRule`, 1 MB cap). Policy tier
`TenantAdminOrGlobalReader` — a POST by shape but a pure read in effect, so the
read-only Global Reader is admitted; platform-scope callers reach any tenant's
session via `FindSessionTenantIdAsync` (same pattern as `GetRuleResultsFunction`).
`RuleEngine.DryRun.cs` is a partial-class trace twin of `EvaluateRule`: it
shares the exact evaluators (`EvaluatePrecondition` / `EvaluateCondition` /
`EvaluateConfidenceFactor`) but evaluates **all** conditions instead of breaking
on the first failed required one — the full picture is the point of a dry run.
Verdicts: `fired`, `skipped_by_precondition`, `required_condition_not_met`,
`no_conditions_matched`, `below_confidence_threshold`, `no_events`. Each
condition entry carries `matched`, the production evidence dictionary (or the
evaluator's reason string) and `matchingEventCount` — how many session events
even have that eventType, the first thing to check on an unexpected miss.
Invariants, pinned by `RuleEngineDryRunTests`:
* **Parity**: verdict `fired` ⇔ `EvaluateRule` produces a result, with identical
confidence and evidence keys. Factor evaluation stays sequential against the
accumulating `matchedConditions` (a factor can observe earlier `factor_*`
entries) — reordering would change results.
* **Side-effect freedom**: exactly one strict event read; never a RuleResult
write, never `TryMarkSessionFailedFromRuleAsync`, never a notification.
`wouldMarkSessionAsFailed` is reported, not acted on.
`test_analyze_rule` (MCP) wraps the endpoint and adds the interpolated
explanation/remediation preview via the existing `interpolate-rule-template.ts`
against the returned `matchedConditions`.
## POST /api/rules/gather/test-pattern (backend)
The logparser counterpart to the analyze dry-run, added after a field incident:
a customer verified a logparser regex with a PHP tester and the agent's .NET
engine matched differently. `TestLogPatternFunction` evaluates a pattern against
pasted sample lines with the AGENT's exact matching semantics:
* Regex construction identical to `LogParserCollector`: `Compiled`, 1 s timeout,
**no IgnoreCase** — logparser matching is case-sensitive (unlike analyze-rule
regex conditions, which run IgnoreCase).
* `format` behaves exactly like the rule parameter: `cmtrace` (default) parses
each line via the shared `CmTraceLogParser` and matches against the parsed
MESSAGE only; `text` matches the raw line. First match per line
(`Regex.Match`), group `"0"` excluded, unsuccessful groups omitted — the
returned `groups` are exactly the event-data fields the device would emit.
* Mirrors the agent's diagnostics: per-line outcomes
(`matched | no_match | parse_failed | regex_timeout`), the all-lines-parse-fail
hint ("set parameter format=text") verbatim, and a case-sensitivity note on
zero matches.
* Pure compute on the request body — no tenant data touched. Same policy tier
as the dry-run (`TenantAdminOrGlobalReader`); caps: 200 lines, 8 KB/line,
1 MB body.
Parity is structural, not duplicated: `CmTraceLogParser` moved from the agent
(`Monitoring/Enrollment/Ime`) to **Shared** (`AutopilotMonitor.Shared.Logging`)
so agent and backend run the SAME parsing implementation. Timestamp caveat:
bias-less CMTrace lines parse with `AssumeLocal`, which on the backend is the
server's timezone — the endpoint therefore reports match/parse results, never
timestamps. `test_log_pattern` (MCP) wraps the endpoint; `validate_rule`
additionally lints logparser parameters (missing/non-compiling pattern, unknown
format value) and its `nextStep` routes logparser drafts to `test_log_pattern`.
## What is deliberately absent
* **No gather-rule remote execution** — gather rules run as SYSTEM on devices;
session dry-run is analyze-only. Logparser authors get the pattern tester
above; all gather authors get guardrail pre-flight plus the on-device debug
log ([gather-rule-debug-log](../agent/gather-rule-debug-log.md)).
* **No rule write/deploy via MCP** — creating rules stays in the portal.
# Citations
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/TestLogPatternFunction.cs` — logparser pattern tester
* `src/Shared/AutopilotMonitor.Shared/Logging/CmTraceLogParser.cs` — shared CMTrace parser (agent + backend)
* `src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.DryRun.cs` — trace twin + DTOs
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs` — endpoint + draft validation
* `src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts` — validator
* `src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts` — validate_rule / test_analyze_rule
* `src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts` — guide resource
* `rules/scripts/combine.js` — MCP module generation
+2 -2
View File
@@ -80,8 +80,8 @@ param mcpPublicUrl string = ''
@description('Base64-encoded HMAC key (>=32 bytes after decode) for signing the OAuth proxy state. Naming and format match the backend\'s PaginationTokenSigningKey for consistency. Empty = generate per-instance random key on startup (acceptable for single-replica deployments since state has 10 min lifetime). Generate with PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))')
param oAuthStateSigningKey string = ''
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs)')
param toolLoggingEnabled bool = false
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs). Volume is one small JSON line per tool call - negligible at current usage.')
param toolLoggingEnabled bool = true
// --- Azure Container Registry ---
+78
View File
@@ -0,0 +1,78 @@
{
"$schema": "../schema/analyze-rule.schema.json",
"ruleId": "ANALYZE-SEC-008",
"title": "Secure Boot is disabled",
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
"severity": "warning",
"category": "security",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "single",
"baseConfidence": 70,
"preconditions": [
{
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "note",
"operator": "not_exists",
"value": "",
"description": "Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
}
],
"conditions": [
{
"signal": "secureboot_disabled",
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "uefiSecureBootEnabled",
"operator": "equals",
"value": "false",
"required": true
},
{
"signal": "physical_device",
"source": "event_data",
"eventType": "hardware_spec",
"dataField": "isVirtualMachine",
"operator": "equals",
"value": "false",
"required": false
}
],
"confidenceFactors": [
{ "signal": "physical_device", "condition": "exists", "weight": 20 }
],
"confidenceThreshold": 60,
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
"remediation": [
{
"title": "Enable Secure Boot in UEFI firmware",
"steps": [
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
]
},
{
"title": "Check compliance impact",
"steps": [
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Secure Boot",
"url": "https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot"
},
{
"title": "Microsoft Learn: Re-enable Secure Boot",
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
}
],
"tags": ["security", "secureboot", "firmware", "uefi", "compliance"]
}
+87
View File
@@ -2687,6 +2687,93 @@
"truncated",
"coverage"
]
},
{
"ruleId": "ANALYZE-SEC-008",
"title": "Secure Boot is disabled",
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
"severity": "warning",
"category": "security",
"version": "1.0.0",
"author": "Autopilot Monitor",
"enabled": true,
"trigger": "single",
"baseConfidence": 70,
"preconditions": [
{
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "note",
"operator": "not_exists",
"value": "",
"description": "Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
}
],
"conditions": [
{
"signal": "secureboot_disabled",
"source": "event_data",
"eventType": "secureboot_status",
"dataField": "uefiSecureBootEnabled",
"operator": "equals",
"value": "false",
"required": true
},
{
"signal": "physical_device",
"source": "event_data",
"eventType": "hardware_spec",
"dataField": "isVirtualMachine",
"operator": "equals",
"value": "false",
"required": false
}
],
"confidenceFactors": [
{
"signal": "physical_device",
"condition": "exists",
"weight": 20
}
],
"confidenceThreshold": 60,
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
"remediation": [
{
"title": "Enable Secure Boot in UEFI firmware",
"steps": [
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
]
},
{
"title": "Check compliance impact",
"steps": [
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
]
}
],
"relatedDocs": [
{
"title": "Microsoft Learn: Secure Boot",
"url": "https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot"
},
{
"title": "Microsoft Learn: Re-enable Secure Boot",
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
}
],
"tags": [
"security",
"secureboot",
"firmware",
"uefi",
"compliance"
]
}
]
}
+42
View File
@@ -161,3 +161,45 @@ ${flatArray(guardrails.blockedEventLogChannels)}
} else {
console.warn('guardrails.json not found — skipping guardrails generation');
}
// ── MCP rule-authoring module generation ────────────────────────────────────
// The MCP server exposes the rule schemas + guardrails as get_resource content
// so a customer's AI can author rules against the real contract instead of
// retrieval fragments. The server's tsconfig (rootDir: src, no resolveJsonModule)
// and the Docker build (rules/schema not copied) rule out importing the JSON
// directly — so we bake it into a TS module, exactly like the web mirror above.
// CI's guardrails-in-sync job re-runs this script and fails on any diff, which
// makes drift between rules/* and this generated module impossible to merge.
const mcpAuthoringOutput = path.resolve(
rulesRoot, '..', 'src', 'McpServer', 'autopilot-monitor-mcp', 'src', 'rule-authoring.generated.ts'
);
{
const guardrails = JSON.parse(fs.readFileSync(guardrailsPath, 'utf8'));
const gatherSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'gather-rule.schema.json'), 'utf8'));
const analyzeSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'analyze-rule.schema.json'), 'utf8'));
const ts = `/**
* AUTO-GENERATED from rules/guardrails.json + rules/schema/*.schema.json — DO NOT EDIT.
* Run: node rules/scripts/combine.js
*
* Consumed by the MCP server's rule-authoring surface (get_resource +
* validate_rule): the JSON Schemas are the validation contract, the guardrails
* are the agent-side collection allowlists. Single source of truth is rules/;
* the CI guardrails-in-sync job guards this file against drift.
*/
/** JSON Schema (2020-12) for gather rules — rules/schema/gather-rule.schema.json verbatim. */
export const GATHER_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(gatherSchema, null, 2)};
/** JSON Schema (2020-12) for analyze rules — rules/schema/analyze-rule.schema.json verbatim. */
export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(analyzeSchema, null, 2)};
/** Gather-rule collection guardrails — rules/guardrails.json verbatim. */
export const RULE_GUARDRAILS = ${JSON.stringify(guardrails, null, 2)} as const;
`;
fs.writeFileSync(mcpAuthoringOutput, ts, 'utf8');
console.log('rule-authoring.generated.ts: schemas + guardrails baked for MCP server');
}
@@ -0,0 +1,307 @@
# Pester tests (Pester 5+) for the bootstrap guard/relax logic in
# Install-AutopilotMonitor.ps1.
#
# The script under test is dot-sourced; its entry guard prevents the bootstrap
# from executing, so loading it here is side-effect free. External state (WMI,
# registry, filesystem, WinRT) is mocked; profile directories are real folders
# under Pester's $TestDrive with a manipulated CreationTimeUtc.
#
# Run under Windows PowerShell 5.1 to match the IME runtime:
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
#
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
BeforeAll {
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1')
function New-FakeProfile {
param([string]$Name, [double]$AgeMinutes)
$p = Join-Path $TestDrive $Name
New-Item -ItemType Directory -Path $p -Force | Out-Null
(Get-Item $p).CreationTimeUtc = (Get-Date).ToUniversalTime().AddMinutes(-$AgeMinutes)
return $p
}
}
Describe 'Test-IsCloudPc' {
# Local WMI on a Cloud PC is generic ('Virtual Machine'), so detection rests on
# the Windows365 registry key AND the CloudManagedDesktopExtension service
# (both required).
BeforeEach {
Mock Write-Log { }
Mock Get-CimInstance { [pscustomobject]@{ Manufacturer = 'Microsoft Corporation'; Model = 'Virtual Machine' } }
}
It 'detects a Cloud PC when both markers are present' {
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
Test-IsCloudPc | Should -BeTrue
}
It 'does not detect with the registry key alone (e.g. W365-Boot physical client)' {
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { $null }
Test-IsCloudPc | Should -BeFalse
}
It 'does not detect with the service alone' {
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
Test-IsCloudPc | Should -BeFalse
}
It 'does not detect a plain device (no markers)' {
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { $null }
Test-IsCloudPc | Should -BeFalse
}
It 'still evaluates markers when the WMI identity query fails' {
Mock Get-CimInstance { throw 'RPC server unavailable' }
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
Test-IsCloudPc | Should -BeTrue
}
}
Describe 'Get-OobeState' {
It 'returns a non-empty state string and never throws' {
Get-OobeState | Should -Not -BeNullOrEmpty
}
}
Describe 'Get-RealUserProfilePaths' {
BeforeEach {
Mock Write-Log { }
}
It 'merges WMI and filesystem views, drops special/system profiles, dedupes' {
Mock Get-CimInstance {
@(
[pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\UserOne' }
[pscustomobject]@{ Special = $true; LocalPath = 'C:\Users\systemprofile' }
[pscustomobject]@{ Special = $false; LocalPath = 'D:\Profiles\Elsewhere' }
)
}
Mock Get-ChildItem {
@(
[pscustomobject]@{ FullName = 'C:\Users\UserOne' }
[pscustomobject]@{ FullName = 'C:\Users\Public' }
[pscustomobject]@{ FullName = 'C:\Users\defaultuser0' }
[pscustomobject]@{ FullName = 'C:\Users\WDAGUtilityAccount' }
)
}
$result = @(Get-RealUserProfilePaths)
$result | Should -Be @('C:\Users\UserOne')
}
It 'falls back to the filesystem view when the WMI query fails' {
Mock Get-CimInstance { throw 'WMI broken' }
Mock Get-ChildItem {
@(
[pscustomobject]@{ FullName = 'C:\Users\SomeUser' }
[pscustomobject]@{ FullName = 'C:\Users\Default' }
)
}
$result = @(Get-RealUserProfilePaths)
$result | Should -Be @('C:\Users\SomeUser')
}
It 'returns an empty result when only special/system profiles exist' {
Mock Get-CimInstance { @() }
Mock Get-ChildItem {
@(
[pscustomobject]@{ FullName = 'C:\Users\Public' }
[pscustomobject]@{ FullName = 'C:\Users\defaultuser1' }
)
}
@(Get-RealUserProfilePaths).Count | Should -Be 0
}
}
Describe 'Get-RelaxDecision' {
BeforeEach {
Mock Write-Log { }
# Defaults: physical device, OOBE long done. Individual tests override.
Mock Get-OobeState { 'Completed' }
Mock Test-IsCloudPc { $false }
}
Context 'Windows 365 Cloud PC' {
BeforeEach {
Mock Test-IsCloudPc { $true }
}
It 'relaxes for a single fresh profile although OOBE is Completed (first connect)' {
$p = New-FakeProfile 'CloudPcUser' 0.6
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
$d.Active | Should -BeTrue
$d.IsCloudPc | Should -BeTrue
}
It 'does not relax for an old profile (productive Cloud PC)' {
$p = New-FakeProfile 'CloudPcUser' 30
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
It 'does not relax for two profiles' {
$p1 = New-FakeProfile 'UserA' 2
$p2 = New-FakeProfile 'UserB' 400
(Get-RelaxDecision -ProfilePaths @($p1, $p2) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
It 'relaxes even when the WinRT OOBE API is unavailable (old build)' {
Mock Get-OobeState { 'Unavailable' }
$p = New-FakeProfile 'CpcUser' 1
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeTrue
}
It 'does not relax for a profile with a future creation time (clock skew)' {
$p = New-FakeProfile 'SkewUser' -10
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
}
Context 'OOBE InProgress (Windows Backup for Organizations)' {
It 'relaxes for a single fresh profile during OOBE' {
Mock Get-OobeState { 'InProgress' }
$p = New-FakeProfile 'RestoreUser' 3
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
$d.Active | Should -BeTrue
}
}
Context 'no trigger' {
It 'does not relax on a physical device with a fresh profile and OOBE Completed' {
$p = New-FakeProfile 'SomeUser' 2
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
It 'stays inactive with no profiles at all' {
(Get-RelaxDecision -ProfilePaths @() -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
}
}
}
Describe 'Get-BootstrapDecision' {
BeforeEach {
Mock Write-Log { }
# Baseline: clean device during Device ESP -- no marker, no profiles, no
# logged-on user, 1h uptime, agent not installed. Tests override selectively.
Mock Get-ItemProperty { $null }
Mock Get-RealUserProfilePaths { @() }
Mock Get-OobeState { 'Completed' }
Mock Test-IsCloudPc { $false }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-1) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$script:cleanAgentBin = Join-Path $TestDrive 'AgentBin'
}
It 'installs on a clean ESP device' {
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeTrue
$d.ReasonCode | Should -BeNullOrEmpty
$d.RelaxActive | Should -BeFalse
}
It 'skips when the Deployed marker exists (guard 1)' {
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'AlreadyDeployed'
}
It 'skips a productive device with an old profile (guard 2)' {
$script:fakeProfile = New-FakeProfile 'OldUser' 5000
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'ProfilesFound'
}
It 'installs on a freshly provisioned Cloud PC with one fresh profile' {
$script:fakeProfile = New-FakeProfile 'CloudPcUser' 0.6
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
Mock Test-IsCloudPc { $true }
# First-connect user is already visible in LogonUI on W365 -- must not block either.
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\CloudPcUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeTrue
$d.RelaxActive | Should -BeTrue
}
It 'skips when a real user has logged on and no relax applies (guard 3)' {
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\SomeUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'LastLoggedOnUser'
}
It 'does not skip for the defaultuser0 OOBE account (guard 3)' {
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'defaultuser0' } } -ParameterFilter { $Path -like '*LogonUI*' }
(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin).Install | Should -BeTrue
}
It 'skips when uptime exceeds the bootstrap window (guard 4)' {
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'UptimeExceeded'
}
It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption)' {
# W365 reality: provisioned days ago, running headless ever since; the user's
# first connect just created the profile. Uptime is meaningless here.
$script:fakeProfile = New-FakeProfile 'FirstConnectUser' 0.6
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
Mock Test-IsCloudPc { $true }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeTrue
$d.RelaxActive | Should -BeTrue
}
It 'still skips an over-uptime Cloud PC when the relax is NOT active (no fresh profile)' {
Mock Test-IsCloudPc { $true }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'UptimeExceeded'
}
It 'keeps guard 4 for the OOBE-restore relax (physical device, uptime is meaningful)' {
$script:fakeProfile = New-FakeProfile 'RestoreUser' 3
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
Mock Get-OobeState { 'InProgress' }
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'UptimeExceeded'
}
It 'skips when the agent binary is already present (guard 5)' {
$binPath = Join-Path $TestDrive 'AgentBinExisting'
New-Item -ItemType Directory -Path $binPath -Force | Out-Null
Set-Content -Path (Join-Path $binPath 'AutopilotMonitor.Agent.exe') -Value 'stub'
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $binPath
$d.Install | Should -BeFalse
$d.ReasonCode | Should -Be 'AgentPresent'
}
}
Describe 'logging purity' {
# Regression guard: Write-Log runs inside functions that RETURN decision
# objects. If it ever emits to the output stream again (Write-Output), the
# log lines leak into the return values and turn them into arrays. This
# test deliberately does NOT mock Write-Log.
It 'Get-BootstrapDecision returns exactly one object with real logging' {
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
# Plain local assignment: PowerShell's dynamic scoping makes Write-Log
# (called from Get-BootstrapDecision) resolve this $LogFile instead of
# the ProgramData path defined by the dot-sourced script.
$LogFile = Join-Path $TestDrive 'bootstrap_test.log'
$d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath (Join-Path $TestDrive 'NoAgent'))
$d.Count | Should -Be 1
$d[0].ReasonCode | Should -Be 'AlreadyDeployed'
Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed'
}
}
+335 -210
View File
@@ -6,8 +6,9 @@
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
Runs very early in the enrollment process (first Intune action) and:
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
An OOBE-relax exemption allows install when a single freshly-created profile
appears DURING OOBE (the "Windows Backup for Organizations" restore case).
A relax exemption allows install when a single freshly-created profile
appears DURING OOBE (Windows Backup for Organizations restore) or on a
freshly provisioned Windows 365 Cloud PC.
2. Downloads the monitoring agent ZIP from Azure Blob Storage
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
@@ -15,14 +16,27 @@
6. Verifies the runtime process actually launched
Agent self-destructs when enrollment completes.
OOBE-relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
normally means "productive -> skip". Windows Backup for Organizations breaks that
by restoring settings and creating ONE profile while still in OOBE. We relax guards
2+3 ONLY when all three positives hold, and nothing else:
(a) the OS positively reports OOBE InProgress (Test-OobeInProgress), AND
(b) there is exactly ONE real user profile, AND
(c) that profile was created within $OobeProfileMaxAgeMinutes minutes.
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
normally means "productive -> skip". Two legitimate flows break that assumption:
(1) Windows Backup for Organizations: one user signs in + restores DURING OOBE.
Trigger: the OS positively reports OOBE InProgress.
(2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment
complete before any user ever connects, so OOBE is never InProgress when
this script runs. The assigned user's profile is created at first connect,
typically seconds before Intune pulls this script. Trigger: the device
positively identifies as a Cloud PC via TWO local markers (both required):
the HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the
CloudManagedDesktopExtension service.
Either trigger relaxes guards 2+3 ONLY when additionally:
(a) there is exactly ONE real user profile, AND
(b) that profile was created within $OobeProfileMaxAgeMinutes minutes.
The Cloud PC trigger additionally exempts guard 4 (uptime window) -- see
.PARAMETER MaxBootstrapWindowHours. Any miss keeps the original skip
(SKIP-safe: we never install on uncertainty). On productive devices the
profile is old, so (b) keeps skipping correctly.
.PARAMETER AgentDownloadUrl
URL to download the agent ZIP from. Defaults to the production download endpoint;
@@ -36,11 +50,14 @@
Maximum device uptime (hours) within which the bootstrap is still considered
valid. Devices booted more than this many hours ago are skipped because we no
longer trust their OOBE state. Default: 12.
Not applied while the Cloud PC relax is active: a Cloud PC runs headless for
days between provisioning and the user's first connect, so the fresh-profile
window (OobeProfileMaxAgeMinutes) is the time anchor there, not boot uptime.
.PARAMETER OobeProfileMaxAgeMinutes
Maximum age (minutes) of the single profile that may appear during OOBE for the
OOBE-relax exemption to apply. The restore->bootstrap gap is always minutes; an
older profile is treated as a productive device. Default: 15.
Maximum age (minutes) of the single profile that may appear for the relax
exemption to apply. The restore/first-connect -> bootstrap gap is always
minutes; an older profile is treated as a productive device. Default: 15.
.NOTES
- Agent is temporary and auto-removes after enrollment.
@@ -50,12 +67,24 @@
- Scheduled Task survives reboots during enrollment.
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
the class -> Test-OobeInProgress returns $false -> original (skip) behaviour. Docs:
the class -> state reads as 'Unavailable' -> original (skip) behaviour. Docs:
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
- Guard/relax logic lives in functions; the script only executes when run
directly. Dot-sourcing it (as the Pester suite in
Install-AutopilotMonitor.Tests.ps1 does) loads the functions without side effects.
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
.CHANGELOG
2026-08-08 v2.3 Windows 365 Cloud PC support: a positively identified Cloud PC
(Windows365 registry key AND CloudManagedDesktopExtension
service) now triggers the guard 2+3 relax alongside OOBE
InProgress; the single-fresh-profile conditions are unchanged
and guard 4 (uptime window) is exempted for Cloud PCs.
Raw OOBE state and Cloud PC marker states are now logged.
Guard/relax logic refactored into functions with a dot-source
entry guard so the Pester suite can test it without executing
the bootstrap; behaviour when run directly is unchanged.
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
https://download.autopilotmonitor.com/agent/ (route /agent/*).
@@ -97,7 +126,7 @@ param(
)
# Script version (bump on meaningful changes; see .CHANGELOG above)
$ScriptVersion = "2.2"
$ScriptVersion = "2.3"
# Configuration - Everything in ProgramData for easy cleanup
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
@@ -105,89 +134,151 @@ $AgentBinPath = "$AgentBasePath\Agent"
$AgentLogPath = "$AgentBasePath\Logs"
$LogFile = "$AgentLogPath\bootstrap_agent.log"
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
# Write-Host (NOT Write-Output): log calls happen inside functions that return
# decision objects; Write-Output would leak every log line into those return values.
function Write-Log {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Output $logMessage
Write-Host $logMessage
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
}
# Positive OOBE check via the documented WinRT API
# Raw OOBE state via the documented WinRT API
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
# Returns $true ONLY when the OS reports InProgress. Any other state (Completed/NotStarted),
# an older OS without the class (< Win10 1809 / 10.0.17763.0), or any error returns $false
# Returns the state string (InProgress/Completed/NotStarted). An older OS without the
# class (< Win10 1809 / 10.0.17763.0) or any error returns 'Unavailable'
# -> SKIP-safe (we never relax a guard on uncertainty).
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
function Test-OobeInProgress {
function Get-OobeState {
try {
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString() -eq 'InProgress'
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString()
}
catch {
return 'Unavailable'
}
}
# Positive Windows 365 Cloud PC detection. Local WMI/SMBIOS on a Cloud PC only
# reports the generic Manufacturer='Microsoft Corporation' / Model='Virtual Machine',
# so identification uses two W365-specific markers instead. BOTH are required (AND):
# each alone has look-alikes (e.g. W365 Boot physical clients, other managed-desktop
# agents). Identity and marker states are logged for diagnosis; any error returns
# $false (SKIP-safe).
function Test-IsCloudPc {
try {
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
Write-Log "Computer identity: Manufacturer='$($cs.Manufacturer)' Model='$($cs.Model)'."
}
catch { Write-Log "INFO: computer identity query failed: $($_.Exception.Message)" }
try {
$regMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
$svcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
Write-Log "Cloud PC markers: Windows365 registry key=$regMarker; CloudManagedDesktopExtension service=$svcMarker."
return ($regMarker -and $svcMarker)
}
catch {
Write-Log "INFO: Cloud PC marker check failed: $($_.Exception.Message)"
return $false
}
}
try {
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
# Collects real (non-special) user profiles as full paths (WMI Special flag + filesystem).
# NOTE: callers MUST wrap the result in @(...). PowerShell unrolls function output, so an
# empty result returns $null and a single path returns a scalar string, whose [0] indexer
# would yield its first char ('C') instead of the full path.
function Get-RealUserProfilePaths {
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
}
# Relax: the ONLY exemption to guards 2+3. Two triggers (see .DESCRIPTION):
# (1) OOBE InProgress -> Windows Backup for Organizations restore
# (2) Cloud PC identity -> Windows 365 (headless OOBE, profile at first connect)
# Either trigger additionally requires: exactly one real profile AND that profile
# created < $OobeProfileMaxAgeMinutes minutes ago. Any miss keeps the original skip.
# Returns an object with: Active (bool), OobeState (string), IsCloudPc (bool).
function Get-RelaxDecision {
param(
[string[]]$ProfilePaths,
[int]$OobeProfileMaxAgeMinutes
)
if ($null -eq $ProfilePaths) { $ProfilePaths = @() }
$relaxActive = $false
$oobeState = Get-OobeState
$oobeInProgress = ($oobeState -eq 'InProgress')
$isCloudPc = Test-IsCloudPc
if ($isCloudPc) {
Write-Log "Cloud PC detected (OOBE state: $oobeState)."
}
$relaxTrigger = $null
if ($oobeInProgress) {
$relaxTrigger = "OOBE InProgress (Windows Backup restore case)"
} elseif ($isCloudPc) {
$relaxTrigger = "Cloud PC (headless provisioning, OOBE state: $oobeState)"
}
if ($ProfilePaths.Count -eq 1 -and $relaxTrigger) {
try {
$created = (Get-Item -LiteralPath $ProfilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
$relaxActive = $true
Write-Log ("Relax active: {0} + single profile created {1:N1}min ago (< {2}min). Guards 2+3 relaxed." -f $relaxTrigger, $ageMin, $OobeProfileMaxAgeMinutes)
} else {
Write-Log ("Relax NOT applied: {0} + single profile '{1}', but profile created {2:N1}min ago (created {3:u}) is outside the [0, {4})min window." -f $relaxTrigger, (Split-Path $ProfilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
}
} catch { Write-Log "INFO: profile age check failed; relax not applied. $($_.Exception.Message)" }
} elseif ($ProfilePaths.Count -eq 1) {
Write-Log "Relax NOT applied: single profile present but neither OOBE InProgress nor Cloud PC (OOBE state: $oobeState)."
} elseif ($ProfilePaths.Count -gt 1) {
Write-Log ("Relax NOT applied: {0} real profiles present (relax requires exactly one). OOBE state: {1}; CloudPC: {2}." -f $ProfilePaths.Count, $oobeState, $isCloudPc)
}
return [pscustomobject]@{
Active = $relaxActive
OobeState = $oobeState
IsCloudPc = $isCloudPc
}
}
# Runs pre-flight guards 1-5 and returns the install decision WITHOUT side effects
# beyond logging. Returns an object with:
# Install (bool), ReasonCode (null | AlreadyDeployed | ProfilesFound |
# LastLoggedOnUser | UptimeExceeded | AgentPresent), RelaxActive (bool).
function Get-BootstrapDecision {
param(
[int]$MaxBootstrapWindowHours,
[int]$OobeProfileMaxAgeMinutes,
[string]$AgentBinPath
)
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
if ($deployed) {
Write-Log "SKIP: Agent was previously deployed at $deployed."
exit 0
return [pscustomobject]@{ Install = $false; ReasonCode = 'AlreadyDeployed'; RelaxActive = $false }
}
# Collect real (non-special) user profiles as full paths (WMI Special flag + filesystem).
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
# NOTE: the outer @() is required. Without it the trailing Where/Select pipeline
# unwraps a single result back to a scalar string, so $profilePaths[0] would index
# into the string and return its first char ('C') instead of the full path.
$profilePaths = @(
@(
try {
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
ForEach-Object { $_.LocalPath }
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
Select-Object -Unique
)
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore (one user signs in + restores DURING OOBE). All three must hold:
# OOBE InProgress AND exactly one profile AND that profile created < N minutes ago.
$oobeRelax = $false
$oobeInProgress = Test-OobeInProgress
if ($profilePaths.Count -eq 1 -and $oobeInProgress) {
try {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
Write-Log ("OOBE-relax active: OOBE InProgress + single profile created {0:N1}min ago (< {1}min). Guards 2+3 relaxed (Windows Backup restore case)." -f $ageMin, $OobeProfileMaxAgeMinutes)
} else {
Write-Log ("OOBE-relax NOT applied: OOBE InProgress + single profile '{0}', but profile created {1:N1}min ago (created {2:u}) is outside the [0, {3})min window." -f (Split-Path $profilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
}
} catch { Write-Log "INFO: profile age check failed; OOBE-relax not applied. $($_.Exception.Message)" }
} elseif ($profilePaths.Count -eq 1 -and -not $oobeInProgress) {
Write-Log "OOBE-relax NOT applied: single profile present but OutOfBoxExperienceState != InProgress (OOBE already finished / not detectable)."
} elseif ($profilePaths.Count -gt 1) {
Write-Log ("OOBE-relax NOT applied: {0} real profiles present (relax requires exactly one)." -f $profilePaths.Count)
}
# The @() wrap is required, see NOTE on Get-RealUserProfilePaths.
$profilePaths = @(Get-RealUserProfilePaths)
$relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
# Guard 2: No real user profile should exist yet (primary productive-device guard),
# unless the OOBE-relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
# unless the relax signature holds.
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
$details = (($profilePaths | ForEach-Object {
$leaf = Split-Path $_ -Leaf
try {
@@ -195,26 +286,33 @@ try {
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
} catch { "$leaf (age unknown)" }
}) | Select-Object -First 3) -join '; '
Write-Log "SKIP: Real user profile(s) found ($details). OOBE InProgress=$oobeInProgress. Device appears productive."
exit 0
Write-Log "SKIP: Real user profile(s) found ($details). OOBE state: $($relax.OobeState). CloudPC: $($relax.IsCloudPc). Device appears productive."
return [pscustomobject]@{ Install = $false; ReasonCode = 'ProfilesFound'; RelaxActive = $false }
}
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
# (same OOBE-relax exemption: the restoring user signs in during OOBE).
# (same relax exemption: the restoring/first-connect user signs in legitimately).
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $oobeRelax) {
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $relax.Active) {
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
exit 0
return [pscustomobject]@{ Install = $false; ReasonCode = 'LastLoggedOnUser'; RelaxActive = $false }
}
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
# Sleep/standby does not reset uptime, only real boot/restart does.
# Exempted while the Cloud PC relax is active: a Cloud PC runs headless from
# provisioning until the user's first connect (often days), so boot uptime says
# nothing there -- the fresh-profile window of the relax is the time anchor.
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
exit 0
if ($relax.Active -and $relax.IsCloudPc) {
Write-Log "Uptime $([int]$uptimeHours)h exceeds the ${MaxBootstrapWindowHours}h window, but the Cloud PC relax is active (fresh first-connect profile is the time anchor). Continuing."
} else {
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
return [pscustomobject]@{ Install = $false; ReasonCode = 'UptimeExceeded'; RelaxActive = $relax.Active }
}
}
# Guard 5: Is the agent already installed? (leftover from previous run)
@@ -222,148 +320,175 @@ try {
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
if ($existingAgent) {
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
return [pscustomobject]@{ Install = $false; ReasonCode = 'AgentPresent'; RelaxActive = $relax.Active }
}
}
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
}
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
# at the bottom); never on dot-source.
function Invoke-Bootstrap {
try {
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
Write-Log "Bootstrap script version: v$ScriptVersion"
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes -AgentBinPath $AgentBinPath
if (-not $decision.Install) {
exit 0
}
}
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (oobeRelax=$oobeRelax)"
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
# Download and extract agent binaries
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
else {
Write-Log "Downloading agent from $AgentDownloadUrl..."
try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
if (Test-Path $agentExePath) {
Write-Log "Agent already installed at $agentExePath"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
else {
Write-Log "Downloading agent from $AgentDownloadUrl..."
try {
# Derive manifest URL from the agent download URL (same blob container)
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
$expectedSha256 = $null
$manifestAttempts = 3
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
try {
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
if ($versionJsonResponse.sha256) {
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
} else {
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
}
break # manifest reached (with or without hash); retrying would not help
}
catch {
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
if ($attempt -lt $manifestAttempts) {
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
}
}
}
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
$maxDownloadAttempts = 3
$downloadAttempt = 0
do {
$downloadAttempt++
try {
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
Invoke-WebRequest `
-Uri $AgentDownloadUrl `
-OutFile $zipPath `
-UseBasicParsing `
-TimeoutSec 30 `
-ErrorAction Stop
$downloadStopwatch.Stop()
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
break
}
catch {
if ($downloadAttempt -ge $maxDownloadAttempts) {
throw
}
$retryDelaysInSeconds = @(2, 4, 8)
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
Start-Sleep -Seconds $retryDelaySeconds
}
} while ($downloadAttempt -lt $maxDownloadAttempts)
# SHA-256 from the version manifest is the only accepted integrity proof.
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
if (-not $expectedSha256) {
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
}
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
Write-Log "Validating SHA-256 hash against manifest"
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
}
Write-Log "SHA-256 integrity check passed"
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
Write-Log "Extracted agent to $AgentBinPath"
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
Write-Log "Cleaned up temporary files"
if (-not (Test-Path $agentExePath)) {
throw "Agent executable not found after extraction at $agentExePath"
}
Write-Log "Agent installation completed successfully"
}
catch {
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
throw
}
}
}
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
Write-Log "Calling agent install mode (--install)..."
& $agentExePath --install
$installExitCode = $LASTEXITCODE
if ($installExitCode -ne 0) {
throw "Agent install failed with exit code $installExitCode"
}
Write-Log "Agent install mode completed successfully"
$runtimeProcessName = 'AutopilotMonitor.Agent'
$verifyTimeoutSec = 10
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
$runtimeProc = $null
while ((Get-Date) -lt $verifyDeadline) {
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $runtimeProc) { break }
Start-Sleep -Milliseconds 500
}
if ($null -ne $runtimeProc) {
$startedUtc = 'unavailable'
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
} else {
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
}
$runtimeProcessName = 'AutopilotMonitor.Agent'
$verifyTimeoutSec = 10
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
$runtimeProc = $null
while ((Get-Date) -lt $verifyDeadline) {
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
if ($null -ne $runtimeProc) { break }
Start-Sleep -Milliseconds 500
}
if ($null -ne $runtimeProc) {
$startedUtc = 'unavailable'
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
} else {
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
}
Write-Log "===== Bootstrap Completed Successfully ====="
Write-Log "===== Bootstrap Completed Successfully ====="
exit 0
exit 0
}
catch {
Write-Log "===== Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Log "Please check log file: $LogFile"
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
[Console]::Error.WriteLine($errMsg)
exit 1
}
}
catch {
Write-Log "===== Bootstrap FAILED ====="
Write-Log "ERROR: $($_.Exception.Message)"
Write-Log "Stack trace: $($_.ScriptStackTrace)"
Write-Log "Please check log file: $LogFile"
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
[Console]::Error.WriteLine($errMsg)
exit 1
# Entry guard: execute only when run as a script (IME, console). Dot-sourcing
# (". .\Install-AutopilotMonitor.ps1", as the Pester suite does) only loads
# the functions above and MUST stay side-effect free.
if ($MyInvocation.InvocationName -ne '.') {
Invoke-Bootstrap
}
+32 -12
View File
@@ -1,7 +1,9 @@
# Autopilot Monitor Bootstrap Guard Test
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
# Mirrors Install-AutopilotMonitor.ps1 incl. the OOBE-relax exemption for the
# "Windows Backup for Organizations" restore case (guards 2+3).
# Mirrors Install-AutopilotMonitor.ps1 (v2.3) incl. the relax exemption for
# guards 2+3, triggered by "Windows Backup for Organizations" (OOBE InProgress)
# or a positively identified Windows 365 Cloud PC, and the guard 4 (uptime)
# exemption while the Cloud PC relax is active.
param(
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
@@ -54,6 +56,12 @@ function Test-OobeInProgress {
}
}
# Positive Windows 365 Cloud PC detection: two local markers, BOTH required (AND),
# because each alone has look-alikes. Mirrors Test-IsCloudPc in the bootstrapper.
$cloudPcRegMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
$cloudPcSvcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
$isCloudPc = ($cloudPcRegMarker -and $cloudPcSvcMarker)
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
$MaxBootstrapWindowHours = 12
@@ -104,8 +112,9 @@ if ($wmiProfileQueryFailed) {
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
# Relax: the ONLY exemption to guards 2+3. Two triggers -- OOBE InProgress (Windows
# Backup for Organizations restore) or Cloud PC identity (headless provisioning,
# profile at first connect). Either additionally requires exactly one fresh profile.
$oobeInProgress = Test-OobeInProgress
$profileAgeMin = $null
$oobeRelax = $false
@@ -114,12 +123,13 @@ if ($profilePaths.Count -eq 1) {
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
} catch { }
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
if (($oobeInProgress -or $isCloudPc) -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
$oobeRelax = $true
}
}
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
Write-Step -Status 'INFO' -Message "Cloud PC markers: Windows365 registry key = $cloudPcRegMarker ; CloudManagedDesktopExtension service = $cloudPcSvcMarker ; IsCloudPc = $isCloudPc"
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; relax = $oobeRelax"
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
@@ -129,7 +139,8 @@ if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
$reasons.Add("Real user profile(s) found: $names")
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
$names = ($profileNames | Select-Object -First 3) -join ', '
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
$trigger = if ($oobeInProgress) { 'OOBE InProgress' } else { 'Cloud PC' }
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but relax active ($trigger + single fresh profile) -- not blocking."
} else {
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
}
@@ -141,7 +152,7 @@ if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -no
$wouldInstall = $false
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but relax active -- not blocking."
} else {
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
}
@@ -155,9 +166,15 @@ try {
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
$wouldInstall = $false
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
if ($oobeRelax -and $isCloudPc) {
# Cloud PCs run headless for days before the user's first connect; the
# fresh-profile window is the time anchor there, not boot uptime.
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h (over ${MaxBootstrapWindowHours}h), but the Cloud PC relax is active -- not blocking."
} else {
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
$wouldInstall = $false
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
}
} else {
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
}
@@ -202,9 +219,12 @@ Write-Host "Deployed marker : $deployed"
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
Write-Host "ProfileCount : $($profilePaths.Count)"
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
Write-Host "CloudPcRegistryMarker : $cloudPcRegMarker"
Write-Host "CloudPcServiceMarker : $cloudPcSvcMarker"
Write-Host "IsCloudPc : $isCloudPc"
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
Write-Host "OobeRelaxActive : $oobeRelax"
Write-Host "RelaxActive : $oobeRelax"
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
Write-Host "LastBootUpTime : $lastBoot"
@@ -23,7 +23,11 @@
One or more high-level feature identifiers. The script translates each to the underlying
Graph permissions. Use this when you don't want to know permission strings.
Available features:
- ScriptDisplayNames -> DeviceManagementScripts.Read.All
- ScriptDisplayNames -> DeviceManagementScripts.Read.All
- W365CloudPcValidation -> CloudPC.Read.All
(validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory --
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
of Cloud PC first-connect enrollment)
.PARAMETER Permissions
Microsoft Graph application permission names to grant directly. Use when the admin UI
@@ -76,7 +80,7 @@ param(
[string]$ClientId,
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
[ValidateSet('ScriptDisplayNames')]
[ValidateSet('ScriptDisplayNames', 'W365CloudPcValidation')]
[string[]]$Features,
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
@@ -97,7 +101,8 @@ $ErrorActionPreference = 'Stop'
# ---- Feature -> Graph permission catalog -----------------------------------
# Keep in lock-step with src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs
$FeatureCatalog = @{
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
'W365CloudPcValidation' = @('CloudPC.Read.All')
}
# Translate Features -> Permissions when caller used the high-level form.
@@ -1,5 +1,5 @@
using System;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Shared.Logging;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
@@ -132,10 +132,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
}
[Fact]
public void First_install_without_wait_arg_uses_agent_default_of_600()
public void First_install_without_wait_arg_persists_null_so_runtime_default_applies()
{
// Generic bootstrap path: PS1 calls `--install` without --tenant-id-wait,
// and there is no persisted config yet. Agent owns the default (600 s).
// and there is no persisted config yet. "Not configured" is persisted as null
// so BuildAgentConfiguration applies the agent-side default (600 s) — freezing
// a number into the file would decouple deployed devices from default changes.
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing: null,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
@@ -144,20 +146,42 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Null(merged.BootstrapToken);
Assert.Null(merged.TenantId);
Assert.Equal(600, merged.TenantIdWaitSeconds);
Assert.Null(merged.TenantIdWaitSeconds);
}
[Fact]
public void Existing_config_without_wait_field_treated_as_zero()
public void Legacy_config_without_wait_field_stays_null_on_plain_reinstall()
{
// BackCompat: pre-fix bootstrap-config.json has no TenantIdWaitSeconds field;
// Newtonsoft deserialises that to 0. New install with --tenant-id-wait 600
// overrides cleanly; no extra migration logic required.
// BackCompat: pre-feature bootstrap-config.json has no TenantIdWaitSeconds
// property → nullable deserialisation yields null. A reinstall that does not
// pass --tenant-id-wait must NOT turn that into 0 (which would silently
// disable the wait); null carries through and the runtime default applies.
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = 0,
TenantIdWaitSeconds = null,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
tenantIdArg: null!, tenantIdGiven: false,
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
Assert.Equal("tok-legacy", merged.BootstrapToken);
Assert.Equal("tenant-legacy", merged.TenantId);
Assert.Null(merged.TenantIdWaitSeconds);
}
[Fact]
public void Legacy_config_without_wait_field_takes_explicit_wait_arg()
{
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = null,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
@@ -170,5 +194,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
Assert.Equal("tenant-legacy", merged.TenantId);
Assert.Equal(600, merged.TenantIdWaitSeconds);
}
[Fact]
public void Explicit_zero_opt_out_survives_redeploy_without_wait_arg()
{
// An admin's deliberate `--tenant-id-wait 0` must not be resurrected to a
// wait by a later redeploy that omits the flag.
var existing = new BootstrapConfigFile
{
BootstrapToken = "tok-abc",
TenantId = "tenant-xyz",
TenantIdWaitSeconds = 0,
};
var merged = AutopilotMonitor.Agent.V2.Program.MergeBootstrapConfig(
existing,
bootstrapTokenArg: null!, bootstrapTokenGiven: false,
tenantIdArg: null!, tenantIdGiven: false,
tenantIdWaitSeconds: 0, tenantIdWaitGiven: false);
Assert.Equal(0, merged.TenantIdWaitSeconds);
}
}
}
@@ -66,6 +66,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
// same facts signal (safe default "false" off-device).
var selfDeploying = payload["isSelfDeployingProfile"];
Assert.True(selfDeploying == "true" || selfDeploying == "false");
// W365 — the Cloud PC marker fact rides the same signal (safe default "false"
// off-device; a build host is not a Cloud PC).
Assert.Contains("isCloudPc", (IDictionary<string, string>)payload);
var cloudPc = payload["isCloudPc"];
Assert.True(cloudPc == "true" || cloudPc == "false");
}
[Fact]
@@ -0,0 +1,90 @@
using AutopilotMonitor.Agent.V2;
using AutopilotMonitor.Agent.V2.Core.Configuration;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Pins the TenantIdWaitSeconds resolution order in
/// <see cref="AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration"/>:
/// CLI arg &gt; persisted bootstrap-config.json value &gt; agent default (600 s).
/// The regression this guards: a legacy bootstrap-config.json written before the
/// TenantIdWaitSeconds property existed deserialises to null — that must fall
/// through to the 600 s default, NOT be treated as an explicit 0 opt-out
/// (which silently disabled the wait and broke Hybrid-AAD-join devices whose
/// registry TenantId lands minutes after first boot).
/// </summary>
public sealed class TenantIdWaitResolutionTests
{
private static AgentConfiguration Build(BootstrapConfigFile bootstrapConfig, params string[] args) =>
AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
args: args,
tenantId: "tenant-t",
sessionId: "session-s",
bootstrapConfig: bootstrapConfig,
awaitConfig: null);
[Fact]
public void No_cli_and_no_bootstrap_config_uses_default_600()
{
var cfg = Build(bootstrapConfig: null);
Assert.Equal(600, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Legacy_bootstrap_config_with_null_wait_uses_default_600()
{
var legacy = new BootstrapConfigFile
{
BootstrapToken = "tok-legacy",
TenantId = "tenant-legacy",
TenantIdWaitSeconds = null,
};
var cfg = Build(legacy);
Assert.Equal(600, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Explicit_zero_in_bootstrap_config_disables_wait()
{
var optedOut = new BootstrapConfigFile { TenantIdWaitSeconds = 0 };
var cfg = Build(optedOut);
Assert.Equal(0, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Nonzero_bootstrap_config_value_wins_over_default()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured);
Assert.Equal(1800, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Cli_arg_wins_over_bootstrap_config_value()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured, "--tenant-id-wait", "300");
Assert.Equal(300, cfg.TenantIdWaitSeconds);
}
[Fact]
public void Cli_zero_wins_over_bootstrap_config_value()
{
var configured = new BootstrapConfigFile { TenantIdWaitSeconds = 1800 };
var cfg = Build(configured, "--tenant-id-wait", "0");
Assert.Equal(0, cfg.TenantIdWaitSeconds);
}
}
}
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Security;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
{
/// <summary>
/// Pins the marker-AND contract of <see cref="CloudPcDetector.ResolveIsCloudPc"/>: BOTH
/// the Windows365 registry key and the CloudManagedDesktopExtension service key must be
/// present — each alone has plausible look-alikes (W365-Boot physical clients carry
/// Windows365 policy state; the MCMD agent family served other managed-desktop
/// offerings). Mirrors the bootstrap script's field-verified <c>Test-IsCloudPc</c>.
/// </summary>
public sealed class CloudPcDetectorTests
{
private static Func<string, bool> Probe(params string[] existingKeys)
{
var set = new HashSet<string>(existingKeys, StringComparer.OrdinalIgnoreCase);
return path => set.Contains(path);
}
[Fact]
public void Both_markers_present_resolves_true()
{
Assert.True(CloudPcDetector.ResolveIsCloudPc(Probe(
CloudPcDetector.Windows365MarkerKey,
CloudPcDetector.CmdeServiceKey)));
}
[Fact]
public void Windows365_key_alone_resolves_false()
{
// W365-Boot physical client shape: policy state without the on-device MCMD agent.
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.Windows365MarkerKey)));
}
[Fact]
public void Cmde_service_alone_resolves_false()
{
// MCMD agent family also served other managed-desktop offerings.
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.CmdeServiceKey)));
}
[Fact]
public void No_markers_resolves_false()
{
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe()));
}
[Fact]
public void Null_probe_resolves_false()
{
Assert.False(CloudPcDetector.ResolveIsCloudPc(null));
}
[Fact]
public void Throwing_probe_degrades_to_false()
{
// SKIP-safe contract: a broken registry read must never classify, let alone throw.
Assert.False(CloudPcDetector.ResolveIsCloudPc(_ => throw new UnauthorizedAccessException("registry denied")));
}
[Fact]
public void DetectIsCloudPc_never_throws_on_build_host()
{
// Real-registry shell: exercises the exception-swallowing wrapper end to end.
// A build host is not a Cloud PC, but the contract under test is "no throw",
// not the detected value.
var ex = Record.Exception(() => CloudPcDetector.DetectIsCloudPc());
Assert.Null(ex);
}
}
}
@@ -137,6 +137,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
Assert.Equal(Environment.MachineName, captured.DeviceName);
Assert.False(string.IsNullOrEmpty(captured.EnrollmentType));
Assert.True(captured.IsUserDriven);
// W365 marker rides the registration body. A build host is not a Cloud PC
// (marker-AND: Windows365 key + CloudManagedDesktopExtension service), so the
// detector's SKIP-safe default is what must land here.
Assert.False(captured.IsCloudPc);
}
[Fact]
@@ -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
@@ -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>()
@@ -7,6 +7,7 @@ using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Shared.Logging;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Logging;
using AutopilotMonitor.Shared.Models;
using Microsoft.Win32;
@@ -1021,6 +1021,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
.ToString(System.Globalization.CultureInfo.InvariantCulture);
if (snapshot.AllowContinueAnyway.HasValue)
payload[SignalPayloadKeys.EspAllowContinueAnyway] = snapshot.AllowContinueAnyway.Value ? "true" : "false";
// Tenant opt-in (remote config, not a registry fact) — only stamped when
// enabled; absent = default hard-fail semantics in the engine.
if (_espContinueAnywayObservationEnabled?.Invoke() == true)
payload[SignalPayloadKeys.EspContinueAnywayObservationEnabled] = "true";
var derivationInputs = new Dictionary<string, string>(payload, StringComparer.Ordinal)
{
@@ -35,6 +35,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
private readonly IClock _clock;
private readonly Persistence.StartupEventGate _startupGate;
// Live accessor for AgentConfiguration.EnableEspContinueAnywayObservation — read at
// esp_config_detected stamping time so a late remote-config merge takes effect on
// the phase-triggered re-emissions. Null (tests / legacy callers) means disabled.
private readonly Func<bool> _espContinueAnywayObservationEnabled;
private const string RegKeyWindowsCurrentVersion = @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion";
// Restart dedup (StartupEventGate) — exemptions. boot_time is genuinely new per boot
@@ -65,7 +70,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
AgentLogger logger,
ISignalIngressSink signalIngress = null,
IClock clock = null,
Persistence.StartupEventGate startupGate = null)
Persistence.StartupEventGate startupGate = null,
Func<bool> espContinueAnywayObservationEnabled = null)
{
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
@@ -74,6 +80,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
_signalIngress = signalIngress;
_clock = clock;
_startupGate = startupGate;
_espContinueAnywayObservationEnabled = espContinueAnywayObservationEnabled;
}
/// <summary>
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using AutopilotMonitor.Shared.Logging;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
{
@@ -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;
}
@@ -0,0 +1,77 @@
using System;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Security
{
/// <summary>
/// Detects whether the agent runs on a Windows 365 Cloud PC, using the same marker
/// evidence the bootstrap script's <c>Test-IsCloudPc</c> settled on after the first
/// W365 field test (2026-08-06) disproved WMI-model matching (a Cloud PC reports only
/// Manufacturer='Microsoft Corporation' + Model='Virtual Machine' locally — identical
/// to any Hyper-V VM):
/// <list type="bullet">
/// <item><c>HKLM\SOFTWARE\Microsoft\Windows365</c> exists, AND</item>
/// <item>the <c>CloudManagedDesktopExtension</c> service (Microsoft Cloud Managed
/// Desktop Extension, the W365 management agent that runs ON Cloud PCs) is
/// installed — probed via its <c>HKLM\SYSTEM\CurrentControlSet\Services</c> key,
/// so the agent needs no System.ServiceProcess dependency.</item>
/// </list>
/// BOTH markers are required (AND): each alone has plausible look-alikes (W365-Boot
/// physical clients may carry Windows365 policy state; the MCMD agent family also
/// served other managed-desktop offerings).
/// <para>
/// Display/context metadata only — the flag rides on <c>SessionRegistration</c> and the
/// <c>EnrollmentFactsObserved</c> signal. It is NEVER an auth input: backend device
/// validation trusts exclusively the cert-CN-bound Graph lookup
/// (<c>CloudPcDeviceValidator</c>), not this locally-derived value.
/// Exception-swallowing, false on any error — mirrors the SKIP-safe bootstrap contract
/// and the <see cref="EnrollmentRegistryDetector"/> degradation rules.
/// </para>
/// </summary>
public static class CloudPcDetector
{
internal const string Windows365MarkerKey = @"SOFTWARE\Microsoft\Windows365";
internal const string CmdeServiceKey = @"SYSTEM\CurrentControlSet\Services\CloudManagedDesktopExtension";
public static bool DetectIsCloudPc()
{
try
{
return ResolveIsCloudPc(SubKeyExists);
}
catch
{
return false;
}
}
/// <summary>
/// Pure decision logic for <see cref="DetectIsCloudPc"/>. Exposed internally so the
/// marker-AND semantics can be exercised without a real registry (same seam contract
/// as <see cref="EnrollmentRegistryDetector.ResolveHybridJoinFromValues"/>).
/// <paramref name="subKeyExists"/> receives an HKLM-relative subkey path and reports
/// whether it exists; a throwing probe degrades to <c>false</c>.
/// </summary>
internal static bool ResolveIsCloudPc(Func<string, bool> subKeyExists)
{
if (subKeyExists == null) return false;
try
{
return subKeyExists(Windows365MarkerKey) && subKeyExists(CmdeServiceKey);
}
catch
{
return false;
}
}
private static bool SubKeyExists(string subKeyPath)
{
using (var key = Registry.LocalMachine.OpenSubKey(subKeyPath))
{
return key != null;
}
}
}
}
@@ -176,6 +176,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
EnrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(),
IsHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(),
IsSelfDeployingProfile = EnrollmentRegistryDetector.DetectSelfDeployingProfile(),
IsCloudPc = CloudPcDetector.DetectIsCloudPc(),
// Deliberately stays true even for self-deploying profiles: the WhiteGlove
// Part1/Part2 merge paths and existing dashboards key on IsUserDriven;
// IsSelfDeployingProfile is the additive classification signal.
@@ -6,6 +6,7 @@ using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
using AutopilotMonitor.Shared.Logging;
using AutopilotMonitor.Shared.Models;
using Newtonsoft.Json;
@@ -135,7 +135,7 @@ namespace AutopilotMonitor.Agent.V2
$"{(bootstrapTokenGiven ? "*" : "")}, " +
$"tenantId={(string.IsNullOrEmpty(merged.TenantId) ? "no" : "yes")}" +
$"{(tenantIdGiven ? "*" : "")}, " +
$"tenantIdWait={merged.TenantIdWaitSeconds}s" +
$"tenantIdWait={(merged.TenantIdWaitSeconds is int w ? $"{w}s" : "default")}" +
$"{(tenantIdWaitGiven ? "*" : "")})." +
" Star = explicitly set on this --install invocation, others were preserved from prior config.");
}
@@ -361,9 +361,12 @@ namespace AutopilotMonitor.Agent.V2
{
BootstrapToken = bootstrapTokenGiven ? bootstrapTokenArg : existing?.BootstrapToken,
TenantId = tenantIdGiven ? tenantIdArg : existing?.TenantId,
// Not given → carry the prior value through, INCLUDING null. Persisting a
// number here would freeze the agent-side default into the file; persisting
// null keeps "not configured" distinguishable from an explicit opt-out (0).
TenantIdWaitSeconds = tenantIdWaitGiven
? tenantIdWaitSeconds
: (existing?.TenantIdWaitSeconds ?? 600),
: existing?.TenantIdWaitSeconds,
};
}
@@ -339,13 +339,15 @@ namespace AutopilotMonitor.Agent.V2
// over the agent-side default (600 s). Hybrid-AAD-joined devices typically
// need ~5 min for the AAD device cert to land in the registry; 600 s leaves
// headroom. Pass `--tenant-id-wait 0` to opt out (legacy fast-fail).
// A bootstrap-config.json whose TenantIdWaitSeconds is null (file predates the
// property) is "not configured", NOT an opt-out — the default must still apply.
var tenantIdWaitRaw = GetArgValue(args, "--tenant-id-wait");
const int tenantIdWaitDefaultSeconds = 600;
int tenantIdWaitSeconds = tenantIdWaitDefaultSeconds;
if (!string.IsNullOrEmpty(tenantIdWaitRaw) && int.TryParse(tenantIdWaitRaw, out var parsedTenantIdWait))
tenantIdWaitSeconds = parsedTenantIdWait;
else if (bootstrapConfig != null)
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds;
else if (bootstrapConfig?.TenantIdWaitSeconds != null)
tenantIdWaitSeconds = bootstrapConfig.TenantIdWaitSeconds.Value;
var useBootstrapTokenAuth = !string.IsNullOrEmpty(bootstrapToken);
@@ -135,7 +135,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
uploadBatchSize: uploadBatchSize,
// Live read: remote config is merged into agentConfig mid-session, so the
// trace-event gate must follow it rather than snapshot at construction.
traceEventsEnabled: () => agentConfig.SendTraceEvents))
traceEventsEnabled: () => agentConfig.SendTraceEvents,
espContinueAnywayObservationEnabled: () => agentConfig.EnableEspContinueAnywayObservation))
{
using (var shutdown = new ManualResetEventSlim(false))
using (var shutdownComplete = new ManualResetEventSlim(false))
@@ -102,7 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
serialNumber: hardware.SerialNumber,
agentVersion: agentVersion,
logger: logger,
clientCertificate: clientCertificate);
clientCertificate: clientCertificate,
isCloudPc: CloudPcDetector.DetectIsCloudPc());
var emergencyReporter = new EmergencyReporter(
apiClient: backendApiClient,
@@ -309,12 +309,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType();
var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin();
var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile();
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
{
["enrollmentType"] = enrollmentType,
["isHybridJoin"] = isHybridJoin ? "true" : "false",
["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false",
["isCloudPc"] = isCloudPc ? "true" : "false",
};
var evidence = new Evidence(
@@ -329,7 +331,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
evidence: evidence,
payload: payload);
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}).");
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}).");
}
catch (Exception ex)
{
@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// W365 — reducer-level coverage for the Cloud PC marker fact riding
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/>: the observation records BOTH
/// values set-once (mirror of the registry self-deploying fact), the profile reason
/// carries the "no Device-ESP phase expected" expectation, and the signal census folds the
/// positive marker into terminal audit trails. The engine deliberately has no DeviceSetup
/// arrival requirement, so no behavioural gate is added — the observation is the
/// engine-side context that a session starting at Account Setup is the expected shape.
/// </summary>
public sealed class CloudPcObservationTests
{
private static readonly DateTime T0 = new DateTime(2026, 8, 7, 10, 0, 0, DateTimeKind.Utc);
private static DecisionSignal MakeSignal(
long ordinal,
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null) =>
new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: "test",
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
payload: payload);
private static DecisionState ReduceFacts(DecisionEngine engine, DecisionState state, long ordinal, string isCloudPc)
=> engine.Reduce(state, MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, T0.AddSeconds(ordinal),
new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsCloudPc] = isCloudPc,
})).NewState;
[Fact]
public void EnrollmentFactsObserved_true_recordsObservation_andExpectationReason()
{
var engine = new DecisionEngine();
var state = engine.Reduce(
DecisionState.CreateInitial("cpc-1", "t", T0),
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = ReduceFacts(engine, state, 1, "true");
Assert.NotNull(state.ScenarioObservations.CloudPc);
Assert.True(state.ScenarioObservations.CloudPc!.Value);
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
Assert.Equal(
EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason,
state.ScenarioProfile.Reason);
// Stage-agnostic fact signal — the reducer must not move the session anywhere.
Assert.Equal(SessionStage.SessionStarted, state.Stage);
}
[Fact]
public void EnrollmentFactsObserved_false_recordsObservation_setOnce()
{
var engine = new DecisionEngine();
var state = engine.Reduce(
DecisionState.CreateInitial("cpc-2", "t", T0),
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
state = ReduceFacts(engine, state, 1, "false");
Assert.NotNull(state.ScenarioObservations.CloudPc);
Assert.False(state.ScenarioObservations.CloudPc!.Value);
// Set-once: a later contradicting re-read must not overwrite the first sighting
// (same contract as RegistrySelfDeployingProfile — replay-deterministic evidence).
state = ReduceFacts(engine, state, 2, "true");
Assert.False(state.ScenarioObservations.CloudPc!.Value);
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
}
[Fact]
public void Census_foldsPositiveMarker_only()
{
var engine = new DecisionEngine();
var initial = engine.Reduce(
DecisionState.CreateInitial("cpc-3", "t", T0),
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
var cloudPcState = ReduceFacts(engine, initial, 1, "true");
var census = DecisionStateSignalCensus.Build(cloudPcState);
Assert.Contains("cloud_pc_marker", census.SignalsSeen);
Assert.True(census.SignalEvidence.ContainsKey("cloudPcMarker"));
var physicalState = ReduceFacts(engine, initial, 1, "false");
var physicalCensus = DecisionStateSignalCensus.Build(physicalState);
Assert.DoesNotContain("cloud_pc_marker", physicalCensus.SignalsSeen);
}
}
}
@@ -0,0 +1,383 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Continue-Anyway observation mode (tenant c9787ba2, session 53d1e9f6, 2026-08-08).
/// A Device-phase ESP terminal failure (AccountSetup never entered) on a
/// Continue-Anyway-enabled profile is a hard fail by default; with the tenant opt-in
/// (<c>EspContinueAnywayObservationEnabled</c> observation) it is defanged into a 60-min
/// observation advisory instead. Coverage:
/// <list type="bullet">
/// <item>Defang + 60-min AdvisoryCompletion arming (vs the classic 30-min window).</item>
/// <item>Opt-out / ContinueAnyway-off / self-deploying-scenario regressions still hard-fail.</item>
/// <item>Real-user desktop + Hello-disabled completes eagerly through Finalizing; the
/// terminal enrollment_complete carries the espSoftFailure marker.</item>
/// <item>Window expiry without desktop un-defangs to esp_terminal_failure.</item>
/// <item>Deadline fire with desktop but unknown Hello promotes to AwaitingHello.</item>
/// <item>Category recovery ("Try again") resolves the advisory (classic 4910a5a5 hook).</item>
/// </list>
/// </summary>
public sealed class ContinueAnywayObservationTests
{
private static readonly DateTime T0 = new DateTime(2026, 8, 7, 15, 16, 0, DateTimeKind.Utc);
private static DecisionSignal MakeSignal(
long ordinal,
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null,
string sourceOrigin = "test")
{
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: sourceOrigin,
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
payload: payload);
}
private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) =>
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) =>
step.Effects.Single(e =>
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
&& e.Parameters != null
&& e.Parameters.TryGetValue("eventType", out var et)
&& et == eventType);
private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
state.Deadlines.FirstOrDefault(d => d.Name == name);
/// <summary>
/// Replays the session-53d1e9f6 shape up to (but excluding) the ESP terminal failure:
/// Continue-Anyway profile with the tenant observation opt-in stamped into the
/// EspConfigDetected payload, Hello policy disabled, DeviceSetup entered —
/// AccountSetup never reached (the 30-min Device-ESP wall kills it first).
/// </summary>
private static DecisionState SetupObservationEligibleSession(
DecisionEngine engine,
bool observationOptIn = true,
bool continueAnyway = true,
bool helloPolicyDisabled = true)
{
var state = DecisionState.CreateInitial("sess-53d1e9f6", "tenant-c9787ba2", T0);
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
var espConfig = new Dictionary<string, string>
{
[SignalPayloadKeys.SkipUserEsp] = "false",
[SignalPayloadKeys.SkipDeviceEsp] = "false",
[SignalPayloadKeys.EspSyncFailureTimeoutMinutes] = "30",
[SignalPayloadKeys.EspAllowContinueAnyway] = continueAnyway ? "true" : "false",
};
if (observationOptIn)
{
espConfig[SignalPayloadKeys.EspContinueAnywayObservationEnabled] = "true";
}
state = engine.Reduce(state, MakeSignal(
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1), espConfig)).NewState;
if (helloPolicyDisabled)
{
state = engine.Reduce(state, MakeSignal(
8, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1),
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
}
state = engine.Reduce(state, MakeSignal(
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
return state;
}
private static DecisionStep ApplyDeviceSetupTerminalFailure(DecisionEngine engine, DecisionState state, long ordinal = 50) =>
engine.Reduce(state, MakeSignal(
ordinal, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(38),
new Dictionary<string, string>
{
["failureType"] = "Provisioning_DeviceSetup_Certificates_Failed",
["failedSubcategory"] = "Certificates",
["category"] = "DeviceSetup",
}));
// ============================================================ defang path ====
[Fact]
public void DeviceSetupFailure_WithOptIn_DefangsToObservationAdvisory()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
Assert.Null(state.AccountSetupEnteredUtc);
var preStage = state.Stage;
var step = ApplyDeviceSetupTerminalFailure(engine, state);
// No terminal — stage unchanged, advisory anchored, no LastFailureTrigger.
Assert.Equal(preStage, step.NewState.Stage);
Assert.Null(step.NewState.Outcome);
Assert.NotNull(step.NewState.EspAdvisoryFailureRecordedUtc);
Assert.Equal("DeviceSetup", step.NewState.EspAdvisoryFailureCategory!.Value);
Assert.Null(step.NewState.LastFailureTrigger);
var advisory = SingleTimelineEffect(step, "esp_failure_advisory");
Assert.Equal("esp_failure_defanged_continueanyway_observation", advisory.Parameters!["advisoryReason"]);
Assert.Equal("Warning", advisory.Parameters!["severity"]);
Assert.Equal("true", advisory.Parameters!["mayHaveContinuedAnyway"]);
Assert.DoesNotContain(step.Effects, e =>
e.Parameters != null
&& e.Parameters.TryGetValue("eventType", out var et)
&& et == "enrollment_failed");
}
[Fact]
public void DeviceSetupFailure_WithOptIn_Arms60MinObservationWindow()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
var step = ApplyDeviceSetupTerminalFailure(engine, state);
var deadline = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.NotNull(deadline);
// 60-min observation window (user decision 2026-08-08) — NOT the classic 30-min
// advisory-completion window.
Assert.Equal(T0.AddMinutes(38).AddMinutes(60), deadline!.DueAtUtc);
}
// ==================================================== negative / regression ====
[Fact]
public void DeviceSetupFailure_WithoutOptIn_StillHardFails()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine, observationOptIn: false);
var step = ApplyDeviceSetupTerminalFailure(engine, state);
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
SingleTimelineEffect(step, "enrollment_failed");
}
[Fact]
public void DeviceSetupFailure_WithOptIn_ButContinueAnywayDisabled_StillHardFails()
{
// The observation only makes sense when the ESP profile actually offers the
// "Continue anyway" button — without it the user cannot reach the desktop.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine, continueAnyway: false);
var step = ApplyDeviceSetupTerminalFailure(engine, state);
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
SingleTimelineEffect(step, "enrollment_failed");
}
[Fact]
public void DeviceSetupFailure_WithOptIn_OnSelfDeployingProfile_StillHardFails()
{
// Scope gate: self-deploying/kiosk flows have no interactive user who could press
// "Continue anyway" — observation would only ever expire. Immediate fail is truthful.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EnrollmentFactsObserved, T0.AddMinutes(3),
new Dictionary<string, string> { [SignalPayloadKeys.IsSelfDeployingProfile] = "true" })).NewState;
var step = ApplyDeviceSetupTerminalFailure(engine, state);
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
}
[Fact]
public void ClassicAdvisory_WithAccountSetupEntered_KeepsClassicReasonAndWindow()
{
// Regression: the opt-in must not change the classic post-AccountSetup advisory —
// same advisoryReason literal, same 30-min window.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
Assert.NotNull(state.AccountSetupEnteredUtc);
var step = ApplyDeviceSetupTerminalFailure(engine, state);
var advisory = SingleTimelineEffect(step, "esp_failure_advisory");
Assert.Equal("esp_failure_defanged_continueanyway_with_accountsetup", advisory.Parameters!["advisoryReason"]);
var deadline = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
Assert.Equal(T0.AddMinutes(38).AddMinutes(30), deadline!.DueAtUtc);
}
// ========================================== real end: desktop completion ====
[Fact]
public void ObservationAdvisory_ThenRealUserDesktop_CompletesThroughFinalizing()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
// User pressed "Continue anyway" → signed in → DAD-validated real-user desktop.
var step = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42)));
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
Assert.Contains(":ContinueAnywayObservation", step.Transition.Trigger);
// Hello disabled → synthetic skip recorded by the fast-path.
Assert.NotNull(step.NewState.HelloResolvedUtc);
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.FinalizingGrace));
}
[Fact]
public void ObservationCompletion_EnrollmentComplete_CarriesEspSoftFailureMarker()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
state = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
Assert.Equal(SessionStage.Finalizing, state.Stage);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(42.1), DeadlineNames.FinalizingGrace));
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome);
var complete = SingleTimelineEffect(step, "enrollment_complete");
var payload = Assert.IsType<Dictionary<string, object>>(complete.TypedPayload);
Assert.Equal("true", payload["espSoftFailure"]);
Assert.Equal("continue_anyway_observation", payload["completionSource"]);
Assert.Equal("DeviceSetup", payload["espSoftFailureCategory"]);
}
[Fact]
public void CleanCompletion_EnrollmentComplete_OmitsEspSoftFailureMarker()
{
// A session without any advisory must not carry the marker — regression guard for
// the BuildEnrollmentCompleteEffect change.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(
30, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(20))).NewState;
state = engine.Reduce(state, MakeSignal(
40, DecisionSignalKind.DesktopArrived, T0.AddMinutes(21))).NewState;
Assert.Equal(SessionStage.Finalizing, state.Stage);
var step = engine.Reduce(state, DeadlineFired(50, T0.AddMinutes(21.1), DeadlineNames.FinalizingGrace));
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
var complete = SingleTimelineEffect(step, "enrollment_complete");
var payload = Assert.IsType<Dictionary<string, object>>(complete.TypedPayload);
Assert.False(payload.ContainsKey("espSoftFailure"));
Assert.False(payload.ContainsKey("completionSource"));
}
// =============================================== window expiry / deadline ====
[Fact]
public void ObservationWindow_ExpiresWithoutDesktop_UnDefangsToEspTerminalFailure()
{
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
Assert.Null(state.DesktopArrivedUtc);
var step = engine.Reduce(state, DeadlineFired(60, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
// The failure cause IS the original ESP failure — same reason literal, likely-stuck
// promotion stays armed via LastFailureTrigger=EspTerminalFailure.
Assert.Equal(nameof(DecisionSignalKind.EspTerminalFailure), step.NewState.LastFailureTrigger!.Value);
var failed = SingleTimelineEffect(step, "enrollment_failed");
Assert.Equal("esp_terminal_failure", failed.Parameters!["reason"]);
Assert.Equal(
"continue_anyway_observation_window_expired_without_completion_evidence",
failed.Parameters!["advisoryReason"]);
}
[Fact]
public void ObservationWindow_FiresWithDesktopAndDisabledHello_CompletesInsteadOfFailing()
{
// Ordering variant: desktop arrived but (hypothetically) no eager completion ran —
// the deadline-fire conjunction must accept desktop + hello for the observation
// variant without the (structurally impossible) IME user-session gate.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine, helloPolicyDisabled: false);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
// Hello policy lands AFTER the failure; desktop arrives; no eager completion since
// the fast-path needs the policy fact at desktop-arrival time.
state = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
Assert.NotEqual(SessionStage.Finalizing, state.Stage);
state = engine.Reduce(state, MakeSignal(
65, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(43),
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
Assert.Null(step.NewState.Outcome);
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.FinalizingGrace));
}
[Fact]
public void ObservationWindow_FiresWithDesktopButUnknownHello_PromotesToAwaitingHello()
{
// Hello policy never observed (no PassportForWork value) — the hello prerequisite is
// structurally unresolvable. The never-observed promote must apply to the observation
// variant instead of failing a desktop-proven session.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine, helloPolicyDisabled: false);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
state = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
Assert.Null(state.HelloPolicyEnabled);
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
Assert.Null(step.NewState.Outcome);
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.HelloSafety));
}
// ======================================================= category recovery ====
[Fact]
public void ObservationAdvisory_DeviceSetupRecovery_ResolvesAdvisory()
{
// "Try again" path: the ESP re-runs the device phase and DeviceSetup resolves to
// success — the 4910a5a5 recovery hook must match the observation advisory too.
var engine = new DecisionEngine();
var state = SetupObservationEligibleSession(engine);
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
var step = engine.Reduce(state, MakeSignal(
60, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(55)));
Assert.NotNull(step.NewState.EspAdvisoryFailureResolvedUtc);
Assert.Contains(step.Effects, e =>
e.Parameters != null
&& e.Parameters.TryGetValue("eventType", out var et)
&& et == "esp_failure_advisory_resolved");
}
}
}
@@ -490,7 +490,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 3)
.WithSkipUserEsp(value: true, sourceSignalOrdinal: 4)
.WithEspSyncFailureTimeoutMinutes(value: 90, sourceSignalOrdinal: 5)
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6))
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6)
.WithCloudPc(value: true, sourceSignalOrdinal: 7))
.Build();
var json = StateSerializer.Serialize(state);
@@ -504,6 +505,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
Assert.Equal(5, o.EspSyncFailureTimeoutMinutes!.SourceSignalOrdinal);
Assert.False(o.RegistrySelfDeployingProfile!.Value);
Assert.Equal(6, o.RegistrySelfDeployingProfile!.SourceSignalOrdinal);
Assert.True(o.CloudPc!.Value);
Assert.Equal(7, o.CloudPc!.SourceSignalOrdinal);
// Never-observed facts must rehydrate as null (null ≠ veto — session 62e603c9).
Assert.Null(o.WhiteGloveSealingPatternSeen);
@@ -515,6 +515,99 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
// ================================================================== helpers
// ------------------------------------------------ isCloudPc annotation (W365)
// Cloud PC first connect: Device-ESP already ran headless at provisioning, so the
// profile records the "no Device-ESP phase expected" expectation as the reason.
// Reason-only by design — Mode is classified by AccountSetup/IME signals (Classic),
// and EspConfig keeps the CONFIGURED FirstSync semantics.
[Fact]
public void EnrollmentFactsObserved_cloudPc_annotatesReason_keepsModeUnknown()
{
var signal = MakeSignal(
DecisionSignalKind.EnrollmentFactsObserved,
ordinal: 0,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsHybridJoin] = "false",
[SignalPayloadKeys.IsCloudPc] = "true",
});
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal(EnrollmentMode.Unknown, profile.Mode);
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode);
Assert.Equal(EspConfig.Unknown, profile.EspConfig);
}
[Fact]
public void EnrollmentFactsObserved_cloudPc_repeatPost_isIdempotent()
{
Dictionary<string, string> Facts() => new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsHybridJoin] = "false",
[SignalPayloadKeys.IsCloudPc] = "true",
};
var first = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 0, payload: Facts());
var second = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 9, payload: Facts());
var p1 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, first);
var p2 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(p1, second);
// The repeat must be a full no-op: the Cloud PC annotation already owns the
// reason slot, and the cloudPcAnnotates gate keeps the generic v1 annotation
// from re-claiming it (which would bump EvidenceOrdinal every post).
Assert.Same(p1, p2);
Assert.Equal(0, p2.EvidenceOrdinal);
}
[Fact]
public void EnrollmentFactsObserved_cloudPcFalse_keepsGenericV1Reason()
{
var signal = MakeSignal(
DecisionSignalKind.EnrollmentFactsObserved,
ordinal: 0,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsCloudPc] = "false",
});
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal("enrollment_facts_observed:v1", profile.Reason);
}
[Fact]
public void EnrollmentFactsObserved_cloudPc_doesNotDisturbSelfDeployingSeed()
{
// Defensive combination (not expected in the field — Cloud PCs are never
// Autopilot-registered): the deterministic self-deploying Mode seed must
// survive; only the reason slot goes to the more specific annotation.
var signal = MakeSignal(
DecisionSignalKind.EnrollmentFactsObserved,
ordinal: 0,
payload: new Dictionary<string, string>
{
[SignalPayloadKeys.EnrollmentType] = "v1",
[SignalPayloadKeys.IsSelfDeployingProfile] = "true",
[SignalPayloadKeys.IsCloudPc] = "true",
});
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
EnrollmentScenarioProfile.Empty, signal);
Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode);
Assert.Equal(ProfileConfidence.High, profile.Confidence);
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
}
private static DecisionSignal MakeSignal(
DecisionSignalKind kind,
long ordinal,
@@ -0,0 +1,117 @@
using AutopilotMonitor.Functions.Security;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Tests for <see cref="CloudPcDeviceValidator"/> — focused on pure-function pieces:
/// JSON-to-DTO mapping (incl. exact-match guard on managedDeviceId) and cache key shape.
/// The HTTP/cache/retry resilience is mirrored 1:1 from <c>AutopilotDeviceValidator</c>;
/// behavioural drift would surface here. Response shapes match a real
/// virtualEndpoint/cloudPCs payload (field-captured 2026-08-06 on a W365 tenant).
/// </summary>
public class CloudPcDeviceValidatorTests
{
private const string TenantId = "11111111-1111-1111-1111-111111111111";
private const string DeviceId = "07623d56-1e77-4948-bff5-5bdac8167560";
// -- BuildCacheKey --
[Fact]
public void BuildCacheKey_StableShape()
{
var key = CloudPcDeviceValidator.BuildCacheKey(TenantId, DeviceId);
Assert.Equal($"cloudpc-device-validation:{TenantId}:{DeviceId}", key);
}
[Fact]
public void BuildCacheKey_DistinctFromOtherValidatorKeys()
{
// The validators must NOT share cache keys; otherwise a positive hit in one
// would incorrectly satisfy a lookup in another.
var key = CloudPcDeviceValidator.BuildCacheKey(TenantId, DeviceId);
Assert.StartsWith("cloudpc-device-validation:", key);
Assert.DoesNotContain("autopilot", key);
Assert.DoesNotContain("device-association", key);
}
// -- ParseCloudPcResponse: empty / missing / malformed --
[Fact]
public void Parse_EmptyValueArray_NotFound()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse("{\"value\":[]}", DeviceId);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
Assert.Equal(DeviceId, result.IntuneDeviceId);
Assert.Contains("not a Windows 365 Cloud PC", result.ErrorMessage);
}
[Fact]
public void Parse_MissingValueProperty_NotFound()
{
var result = CloudPcDeviceValidator.ParseCloudPcResponse("{}", DeviceId);
Assert.False(result.IsValid);
}
[Fact]
public void Parse_MalformedJson_NotFound_NotTransient()
{
// Defensive: bad payload is treated as "not a Cloud PC", not as a transient
// — transient handling is only triggered by HTTP-level failures.
var result = CloudPcDeviceValidator.ParseCloudPcResponse("this is not json", DeviceId);
Assert.False(result.IsValid);
Assert.False(result.IsTransient);
}
// -- ParseCloudPcResponse: exact-match guard --
[Fact]
public void Parse_DifferentManagedDeviceId_NotFound()
{
// Guards against widened filter semantics: even if Graph returns Cloud PCs whose
// managedDeviceId does not exactly match the queried id, we must reject.
var body = "{\"value\":[{\"id\":\"423a1fb4-6033-4b69-a31f-e7f3f54194dc\",\"managedDeviceId\":\"99999999-9999-9999-9999-999999999999\"}]}";
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
Assert.False(result.IsValid);
}
[Fact]
public void Parse_ExactMatch_PopulatesAllFields()
{
var body = $@"{{
""value"":[{{
""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",
""displayName"":""GKT-CloudPC - Obi-Wan Kenobi"",
""managedDeviceId"":""{DeviceId}"",
""managedDeviceName"":""CPC-cloud-AY5HT"",
""userPrincipalName"":""cloudadmin@gktatooine.net"",
""servicePlanName"":""Cloud PC Enterprise 2vCPU/4GB/128GB""
}}]
}}";
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
Assert.True(result.IsValid);
Assert.False(result.IsTransient);
Assert.Equal(DeviceId, result.IntuneDeviceId);
Assert.Equal("423a1fb4-6033-4b69-a31f-e7f3f54194dc", result.CloudPcId);
Assert.Equal("CPC-cloud-AY5HT", result.ManagedDeviceName);
Assert.Equal("Cloud PC Enterprise 2vCPU/4GB/128GB", result.ServicePlanName);
}
[Fact]
public void Parse_CaseInsensitiveIdMatch()
{
var body = $@"{{""value"":[{{""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",""managedDeviceId"":""{DeviceId.ToUpperInvariant()}""}}]}}";
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
Assert.True(result.IsValid);
}
[Fact]
public void Parse_TrimsWhitespace_OnGraphIdBeforeMatching()
{
var body = $@"{{""value"":[{{""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",""managedDeviceId"":"" {DeviceId} ""}}]}}";
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
Assert.True(result.IsValid);
}
}
@@ -259,20 +259,21 @@ public class ContinuationTokenTests
public void TryDecode_rejects_token_signed_with_a_different_key()
{
// Simulate key rotation: token issued under key A, validated under key B.
// (We toggle the test key around the encode/decode pair.)
// The scoped (AsyncLocal) override keeps the swap confined to this test's
// execution context — mutating the process-wide test key here raced the
// concurrently running pagination test classes.
var keyA = new byte[32]; for (int i = 0; i < keyA.Length; i++) keyA[i] = (byte)(i * 3 + 1);
var keyB = new byte[32]; for (int i = 0; i < keyB.Length; i++) keyB[i] = (byte)(i * 5 + 7);
var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
ContinuationToken.SetSigningKeyForTesting(keyA);
ContinuationToken.SetScopedSigningKeyForTesting(keyA);
var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp);
ContinuationToken.SetSigningKeyForTesting(keyB);
ContinuationToken.SetScopedSigningKeyForTesting(keyB);
var ok = ContinuationToken.TryDecode(encoded, TenantA, fp, out _, out var reason);
// Restore the test default before leaving so subsequent tests aren't affected.
TestSetup.Initialize();
ContinuationToken.SetScopedSigningKeyForTesting(null);
Assert.False(ok);
Assert.Equal("bad_signature", reason);
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Functions.Tests;
public class GatherRuleUpdatePartialMergeTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string RuleId = "GATHER-CUST-001";
private const string RuleId = "GATHER-CUSTOM-001";
private static GatherRule ExistingCustomRule() => new()
{
@@ -164,12 +164,49 @@ public class GetDeviceNotRegisteredFunctionTests
Assert.Equal(3, (int)item.attemptCount);
}
// =========================================================================
// Cloud PC marker aggregation
// =========================================================================
[Fact]
public void BuildAggregatedResult_IsCloudPc_StickyTrueAcrossGroup()
{
// One old agent (no field → false) + one new agent reporting the W365 marker:
// the aggregate must say Cloud PC even though the MOST RECENT report is false —
// same OR semantics as the Sessions-table IsCloudPc merge.
var reports = new List<DistressReportEntry>
{
MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0, isCloudPc: true),
MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0.AddMinutes(5), isCloudPc: false),
};
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.True((bool)item.isCloudPc);
}
[Fact]
public void BuildAggregatedResult_IsCloudPc_FalseWhenNoReportCarriesMarker()
{
var reports = new List<DistressReportEntry>
{
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0),
};
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
var item = ToDynamic(aggregated[0]);
Assert.False((bool)item.isCloudPc);
}
// =========================================================================
// Helpers
// =========================================================================
private static DistressReportEntry MakeEntry(
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt) =>
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt,
bool isCloudPc = false) =>
new()
{
TenantId = "test-tenant",
@@ -179,6 +216,7 @@ public class GetDeviceNotRegisteredFunctionTests
SerialNumber = serial,
IngestedAt = ingestedAt,
AgentTimestamp = ingestedAt,
IsCloudPc = isCloudPc,
};
private static dynamic ToDynamic(object obj)
@@ -0,0 +1,79 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Models.Graph;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Guard: the customer-side grant script (scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1)
/// duplicates the feature→permission catalog from <see cref="GraphFeatureCatalog"/> — in its
/// <c>$FeatureCatalog</c> hashtable AND in the <c>[ValidateSet(...)]</c> of <c>-Features</c>.
/// The sync was previously comment-only ("Keep in lock-step with …") and had no enforcement;
/// this test fails the build when either side drifts.
/// </summary>
public class GraphFeatureCatalogSyncTests
{
[Fact]
public void GrantScript_FeatureCatalog_MatchesSharedCatalog()
{
var script = ReadGrantScript();
// Parse: $FeatureCatalog = @{ 'Name' = @('Perm', ...) ... }
var blockMatch = Regex.Match(script, @"\$FeatureCatalog\s*=\s*@\{(?<body>[^}]*)\}", RegexOptions.Singleline);
Assert.True(blockMatch.Success, "Could not locate the $FeatureCatalog hashtable in the grant script.");
var scriptCatalog = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
foreach (Match entry in Regex.Matches(blockMatch.Groups["body"].Value, @"'(?<feature>[^']+)'\s*=\s*@\((?<perms>[^)]*)\)"))
{
var perms = Regex.Matches(entry.Groups["perms"].Value, @"'(?<p>[^']+)'")
.Select(m => m.Groups["p"].Value)
.ToArray();
scriptCatalog[entry.Groups["feature"].Value] = perms;
}
Assert.Equal(
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
scriptCatalog.Keys.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
foreach (var feature in GraphFeatureCatalog.Features)
{
Assert.Equal(
GraphFeatureCatalog.RequiredPermissions(feature).OrderBy(p => p, StringComparer.OrdinalIgnoreCase),
scriptCatalog[feature].OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
}
}
[Fact]
public void GrantScript_ValidateSet_MatchesSharedCatalog()
{
var script = ReadGrantScript();
var vsMatch = Regex.Match(script, @"\[ValidateSet\((?<body>[^\)]*)\)\]");
Assert.True(vsMatch.Success, "Could not locate the [ValidateSet(...)] of -Features in the grant script.");
var scriptFeatures = Regex.Matches(vsMatch.Groups["body"].Value, @"'(?<f>[^']+)'")
.Select(m => m.Groups["f"].Value)
.ToArray();
Assert.Equal(
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
scriptFeatures.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
}
private static string ReadGrantScript()
{
var path = Path.Combine(FindRepoRoot(), "scripts", "CustomerSetup", "Grant-AutopilotMonitorAddOn.ps1");
Assert.True(File.Exists(path), $"Grant script not found at {path}");
return File.ReadAllText(path);
}
private static string FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
{
dir = dir.Parent;
}
Assert.NotNull(dir);
return dir!.FullName;
}
}
@@ -42,6 +42,7 @@ public sealed class TenantCustomsArchiveContractTests
reEnqueuer: null!,
opsEvents: null!,
customsArchive: archive,
configRepo: null!,
farewellEmail: new FakeOffboardFarewellEmailSender(),
logger: NullLogger<TenantOffboardingHandler>.Instance);
}
@@ -9,6 +9,7 @@ using Azure;
using Azure.Data.Tables;
using Azure.Storage.Blobs;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Functions.Services.Deletion;
using AutopilotMonitor.Functions.Services.Notifications;
@@ -301,6 +302,47 @@ public class TenantOffboardingHandlerDrainContractTests
"Phase 2.G must delete the Expectations blob as its last step");
}
[Fact]
public async Task UnoffboardRace_ConfigNoLongerTombstone_FailsClosed_NoSafeWipe()
{
// 2.D-guard: an operator re-enabled the tenant while the cascade was queued/draining
// (the config write paths refuse this, but the guard is the last line of defense).
// Wiping now would destroy data of a live tenant → fail-closed, zero wipes.
var harness = Harness.New();
harness.ConfigRepo
.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync(new TenantConfiguration { TenantId = TenantId, Disabled = false });
await Assert.ThrowsAsync<InvalidOperationException>(() =>
harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0)));
Assert.Equal("Failed", harness.Repo.Markers[TenantId].Status);
Assert.Equal("unoffboard_race", harness.Repo.Markers[TenantId].FailedPhase);
Assert.Equal("Failed", harness.Repo.History[HistoryRowKey].Status);
Assert.Equal(0, harness.SafeWipeProbe.WipeCallCount);
}
[Fact]
public async Task UnoffboardGuard_TombstoneStillInPlace_ProceedsToWipeAndCompleted()
{
// The normal case: Phase 1's tombstone is untouched → the guard must NOT get in
// the way of a legitimate offboarding.
var harness = Harness.New();
harness.ConfigRepo
.Setup(r => r.GetTenantConfigurationAsync(TenantId))
.ReturnsAsync(new TenantConfiguration
{
TenantId = TenantId,
Disabled = true,
DisabledReason = TenantOffboardFunction.OffboardingDisabledReason,
});
await harness.Sut.HandleAsync(harness.Envelope(drainPollCount: 0));
Assert.Equal("Completed", harness.Repo.Markers[TenantId].Status);
Assert.True(harness.SafeWipeProbe.WipeCallCount > 0);
}
[Fact]
public async Task SessionNotFound_CountsAsSatisfied_NoDrainProbeCall()
{
@@ -393,6 +435,7 @@ public class TenantOffboardingHandlerDrainContractTests
public Mock<OffboardingSessionEnumerator> Enumerator { get; }
public CountingSafeWipeService SafeWipeProbe { get; } = new();
public Mock<IMaintenanceRepository> Maintenance { get; } = new();
public Mock<IConfigRepository> ConfigRepo { get; } = new();
public List<string> EnumeratorYields { get; } = new();
public Exception? EnumeratorThrow { get; set; }
@@ -450,6 +493,7 @@ public class TenantOffboardingHandlerDrainContractTests
h.ReEnqueuer.Object,
opsService,
Mock.Of<ITenantCustomsArchiveRepository>(),
h.ConfigRepo.Object,
new FakeOffboardFarewellEmailSender(),
NullLogger<TenantOffboardingHandler>.Instance);
@@ -622,10 +666,11 @@ public class TenantOffboardingHandlerDrainContractTests
ITenantOffboardingEnqueuer reEnqueuer,
OpsEventService opsEvents,
ITenantCustomsArchiveRepository customsArchive,
IConfigRepository configRepo,
IOffboardFarewellEmailSender farewellEmail,
ILogger<TenantOffboardingHandler> logger)
: base(auditRepo, enumerator, cascadeEnqueuer, expectations, drainProbe, safeWipe,
storage, maintenance, reEnqueuer, opsEvents, customsArchive, farewellEmail, logger)
storage, maintenance, reEnqueuer, opsEvents, customsArchive, configRepo, farewellEmail, logger)
{
}
@@ -358,6 +358,7 @@ public class TenantOffboardingHandlerOrderingTests
h.ReEnqueuer.Object,
BuildOpsService(),
Mock.Of<ITenantCustomsArchiveRepository>(),
Mock.Of<IConfigRepository>(),
h.FarewellEmail,
NullLogger<TenantOffboardingHandler>.Instance);
@@ -459,9 +460,9 @@ public class TenantOffboardingHandlerOrderingTests
IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IOffboardFarewellEmailSender fe,
ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, fe, log) { }
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
@@ -245,6 +245,7 @@ public class TenantOffboardingWorkerPoisonTests
Mock.Of<ITenantOffboardingEnqueuer>(),
BuildOpsService(),
Mock.Of<ITenantCustomsArchiveRepository>(),
Mock.Of<IConfigRepository>(),
new FakeOffboardFarewellEmailSender(),
NullLogger<TenantOffboardingHandler>.Instance);
}
@@ -255,9 +256,9 @@ public class TenantOffboardingWorkerPoisonTests
IOffboardingAuditRepository a, OffboardingSessionEnumerator e, ISessionDeletionEnqueuer c,
IOffboardingExpectationsStore exp, IDeletionProgressDrainProbe d, SafeWipeService sw,
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IOffboardFarewellEmailSender fe,
ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, fe, log) { }
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> log)
: base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
@@ -0,0 +1,418 @@
using AutopilotMonitor.Functions.Functions.Rules;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the dry-run (rule-authoring) evaluation path:
/// 1. The trace evaluates EVERY condition (no early break) so authors see the full picture.
/// 2. The verdict/confidence is outcome-equivalent to the production EvaluateRule path
/// (parity tests via AnalyzeSessionAsync).
/// 3. The dry-run is side-effect free: only the strict event read touches storage — never
/// UpdateSessionStatusAsync, never a rule-result write.
/// Plus the draft validation surface of <see cref="DryRunAnalyzeRuleFunction"/>.
/// </summary>
public class RuleEngineDryRunTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
// ===== Trace semantics =====
[Fact]
public async Task Fired_FullTrace_WithFactorAndInterpolationEvidence()
{
var rule = MakeRule();
rule.ConfidenceFactors = new List<ConfidenceFactor>
{
new() { Signal = "app_install_failed", Condition = "count >= 2", Weight = 15 },
};
var events = new List<EnrollmentEvent>
{
AppFailedEvent("app-1", "0x80070005"),
AppFailedEvent("app-1", "0x80070005"),
};
var (dry, sessionRepo, ruleRepo) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.Fired, dry.Verdict);
Assert.Equal(2, dry.EventCount);
Assert.Single(dry.Conditions);
Assert.True(dry.Conditions[0].Matched);
Assert.Equal(2, dry.Conditions[0].MatchingEventCount);
Assert.IsType<Dictionary<string, object>>(dry.Conditions[0].Evidence);
Assert.Single(dry.ConfidenceFactors);
Assert.True(dry.ConfidenceFactors[0].Matched);
Assert.Equal(50 + 15, dry.FinalConfidence);
// Evidence map matches what production would persist — including the factor marker —
// so clients can preview {{token}} interpolation against it.
Assert.NotNull(dry.MatchedConditions);
Assert.True(dry.MatchedConditions!.ContainsKey("app_failure"));
Assert.True(dry.MatchedConditions.ContainsKey("factor_app_install_failed"));
// Side-effect freedom: exactly one strict event read, nothing else.
sessionRepo.Verify(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>()), Times.Once);
sessionRepo.VerifyNoOtherCalls();
ruleRepo.VerifyNoOtherCalls();
}
[Fact]
public async Task RequiredConditionNotMet_StillEvaluatesAllConditions()
{
var rule = MakeRule();
rule.Conditions.Add(new RuleCondition
{
Signal = "os_edition", Source = "event_data", EventType = "os_info",
DataField = "edition", Operator = "equals", Value = "Enterprise", Required = false,
});
var events = new List<EnrollmentEvent> { OsInfoEvent("Enterprise") }; // no app_install_failed
var (dry, _, _) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.RequiredConditionNotMet, dry.Verdict);
// Production breaks on the first failed required condition; the dry-run keeps going —
// the second (optional) condition must appear in the trace, matched.
Assert.Equal(2, dry.Conditions.Count);
Assert.False(dry.Conditions[0].Matched);
Assert.Equal("no matching events", dry.Conditions[0].Evidence);
Assert.Equal(0, dry.Conditions[0].MatchingEventCount);
Assert.True(dry.Conditions[1].Matched);
Assert.Null(dry.FinalConfidence);
Assert.Empty(dry.ConfidenceFactors); // factor stage never reached, mirrors production
Assert.False(dry.WouldMarkSessionAsFailed);
}
[Fact]
public async Task PreconditionFails_VerdictSkipped_ConditionsStillTraced()
{
var rule = MakeRule();
rule.Preconditions = new List<RulePrecondition>
{
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" },
};
var events = new List<EnrollmentEvent>
{
HardwareSpecEvent(isVirtualMachine: "true"),
AppFailedEvent("app-1", "0x1"),
};
var (dry, _, _) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.SkippedByPrecondition, dry.Verdict);
Assert.Single(dry.Preconditions);
Assert.False(dry.Preconditions[0].Passed);
// Author still sees that the condition WOULD have matched.
Assert.Single(dry.Conditions);
Assert.True(dry.Conditions[0].Matched);
Assert.Null(dry.FinalConfidence);
}
[Fact]
public async Task BelowThreshold_ReportsFinalConfidence()
{
var rule = MakeRule();
rule.BaseConfidence = 30;
rule.ConfidenceThreshold = 60;
var events = new List<EnrollmentEvent> { AppFailedEvent("app-1", "0x1") };
var (dry, _, _) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.BelowConfidenceThreshold, dry.Verdict);
Assert.Equal(30, dry.FinalConfidence);
Assert.Equal(60, dry.ConfidenceThreshold);
Assert.False(dry.WouldMarkSessionAsFailed);
}
[Fact]
public async Task NoEvents_ShortCircuits()
{
var (dry, _, _) = await DryRunAsync(MakeRule(), new List<EnrollmentEvent>());
Assert.Equal(RuleDryRunVerdict.NoEvents, dry.Verdict);
Assert.Equal(0, dry.EventCount);
Assert.Empty(dry.Conditions);
}
[Fact]
public async Task AllOptionalNoneMatch_VerdictNoConditionsMatched()
{
var rule = MakeRule();
rule.Conditions[0].Required = false;
var events = new List<EnrollmentEvent> { OsInfoEvent("Pro") };
var (dry, _, _) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.NoConditionsMatched, dry.Verdict);
Assert.Null(dry.FinalConfidence);
}
[Fact]
public async Task Fired_WithMarkSessionAsFailed_OnlyReportsFlag()
{
var rule = MakeRule();
rule.MarkSessionAsFailedDefault = true;
var events = new List<EnrollmentEvent> { AppFailedEvent("app-1", "0x1") };
var (dry, sessionRepo, _) = await DryRunAsync(rule, events);
Assert.Equal(RuleDryRunVerdict.Fired, dry.Verdict);
Assert.True(dry.WouldMarkSessionAsFailed);
// The flag is REPORTED, never acted upon: no session read, no status write.
sessionRepo.Verify(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>()), Times.Once);
sessionRepo.VerifyNoOtherCalls();
}
// ===== Parity with the production path =====
public static TheoryData<string> ParityScenarios => new() { "fires", "required_miss", "below_threshold", "precondition_skip" };
/// <summary>
/// The dry-run verdict must be outcome-equivalent to EvaluateRule (via AnalyzeSessionAsync):
/// fired ⇔ a RuleResult is produced, and on fire the confidence and evidence keys are identical.
/// </summary>
[Theory]
[MemberData(nameof(ParityScenarios))]
public async Task DryRunVerdict_MatchesProductionOutcome(string scenario)
{
var rule = MakeRule();
rule.ConfidenceFactors = new List<ConfidenceFactor>
{
new() { Signal = "app_install_failed", Condition = "count >= 2", Weight = 15 },
};
List<EnrollmentEvent> events = scenario switch
{
"fires" => new() { AppFailedEvent("a", "0x1"), AppFailedEvent("a", "0x1") },
"required_miss" => new() { OsInfoEvent("Pro") },
"below_threshold" => new() { AppFailedEvent("a", "0x1") },
"precondition_skip" => new() { HardwareSpecEvent("true"), AppFailedEvent("a", "0x1") },
_ => throw new ArgumentOutOfRangeException(nameof(scenario)),
};
if (scenario == "below_threshold") { rule.BaseConfidence = 30; rule.ConfidenceThreshold = 60; }
if (scenario == "precondition_skip")
{
rule.Preconditions = new List<RulePrecondition>
{
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" },
};
}
var production = await RunProductionAsync(rule, events);
var (dry, _, _) = await DryRunAsync(rule, events);
var productionFired = production.Results.Count == 1;
Assert.Equal(productionFired, dry.Verdict == RuleDryRunVerdict.Fired);
if (productionFired)
{
var result = production.Results[0];
Assert.Equal(result.ConfidenceScore, dry.FinalConfidence);
Assert.Equal(
result.MatchedConditions.Keys.OrderBy(k => k),
dry.MatchedConditions!.Keys.OrderBy(k => k));
}
}
// ===== Draft validation (DryRunAnalyzeRuleFunction.ValidateDraftRule) =====
[Fact]
public void Validate_ValidRule_NoErrors()
{
Assert.Empty(DryRunAnalyzeRuleFunction.ValidateDraftRule(MakeRule()));
}
[Fact]
public void Validate_NullRule_Error()
{
Assert.Contains("rule is required", DryRunAnalyzeRuleFunction.ValidateDraftRule(null));
}
[Fact]
public void Validate_NoConditions_Error()
{
var rule = MakeRule();
rule.Conditions.Clear();
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("at least one condition"));
}
[Theory]
[InlineData("Event_Type")] // wrong case — the evaluator switch is case-sensitive
[InlineData("eventtype")]
[InlineData("")]
public void Validate_UnknownOrMissingSource_Error(string source)
{
var rule = MakeRule();
rule.Conditions[0].Source = source;
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("source"));
}
[Fact]
public void Validate_UnknownOperator_Error()
{
var rule = MakeRule();
rule.Conditions[0].Operator = "starts_with";
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("unknown operator 'starts_with'"));
}
[Fact]
public void Validate_CorrelationWithoutJoinField_Error()
{
var rule = MakeRule();
rule.Conditions[0].Source = "event_correlation";
rule.Conditions[0].CorrelateEventType = "app_install_completed";
rule.Conditions[0].JoinField = "";
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("joinField"));
}
[Fact]
public void Validate_InvalidRegex_Error()
{
var rule = MakeRule();
rule.Conditions[0].Operator = "regex";
rule.Conditions[0].Value = "([unclosed";
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("not a valid regex"));
}
[Theory]
[InlineData("count > 3")] // wrong comparator
[InlineData("Count >= 3")] // wrong case
[InlineData("phase_duration >= 3")] // wrong comparator
[InlineData("always")]
public void Validate_UnsupportedFactorCondition_Error(string condition)
{
var rule = MakeRule();
rule.ConfidenceFactors = new List<ConfidenceFactor> { new() { Signal = "x", Condition = condition, Weight = 10 } };
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("not evaluable"));
}
[Theory]
[InlineData("exists")]
[InlineData("count >= 3")]
[InlineData("count >=3")]
[InlineData("phase_duration > 300")]
public void Validate_SupportedFactorCondition_NoError(string condition)
{
var rule = MakeRule();
rule.ConfidenceFactors = new List<ConfidenceFactor> { new() { Signal = "x", Condition = condition, Weight = 10 } };
Assert.Empty(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule));
}
[Fact]
public void Validate_DuplicateSignals_Error()
{
var rule = MakeRule();
rule.Conditions.Add(new RuleCondition
{
Signal = "app_failure", Source = "event_type", EventType = "os_info", Operator = "exists", Required = false,
});
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("duplicate signal"));
}
[Fact]
public void Validate_ConfidenceOutOfRange_Error()
{
var rule = MakeRule();
rule.BaseConfidence = 120;
rule.ConfidenceThreshold = -5;
var errors = DryRunAnalyzeRuleFunction.ValidateDraftRule(rule);
Assert.Contains(errors, e => e.Contains("baseConfidence"));
Assert.Contains(errors, e => e.Contains("confidenceThreshold"));
}
// ===== Helpers =====
/// <summary>Simple single-condition rule: required event_type match on app_install_failed,
/// base 50 / threshold 40 — fires whenever the event exists.</summary>
private static AnalyzeRule MakeRule() => new()
{
RuleId = "ANALYZE-TEST-001",
Title = "Test rule",
Severity = "warning",
Category = "apps",
// Drafts behave like tenant custom rules; also keeps the parity path clear of the
// live-catalog sunset filter in GetAllRulesForTenantAsync (built-in ids only).
IsBuiltIn = false,
BaseConfidence = 50,
ConfidenceThreshold = 40,
Conditions = new List<RuleCondition>
{
new()
{
Signal = "app_failure", Source = "event_type", EventType = "app_install_failed",
Operator = "exists", Value = "", Required = true,
},
},
Explanation = "App {{appId}} failed with {{errorCode}}.",
};
private static EnrollmentEvent AppFailedEvent(string appId, string errorCode) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "app_install_failed",
Timestamp = DateTime.UtcNow,
Sequence = 1,
Data = new Dictionary<string, object> { ["appId"] = appId, ["errorCode"] = errorCode },
};
private static EnrollmentEvent OsInfoEvent(string edition) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "os_info",
Timestamp = DateTime.UtcNow,
Sequence = 2,
Data = new Dictionary<string, object> { ["edition"] = edition },
};
private static EnrollmentEvent HardwareSpecEvent(string isVirtualMachine) => new()
{
EventId = Guid.NewGuid().ToString(),
TenantId = TenantId,
SessionId = SessionId,
EventType = "hardware_spec",
Timestamp = DateTime.UtcNow,
Sequence = 3,
Data = new Dictionary<string, object> { ["isVirtualMachine"] = isVirtualMachine },
};
private static (RuleEngine engine, Mock<ISessionRepository> sessionRepo, Mock<IRuleRepository> ruleRepo) MakeEngine(List<EnrollmentEvent> events)
{
var ruleRepo = new Mock<IRuleRepository>();
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleEngineDryRunTests>.Instance);
return (engine, sessionRepo, ruleRepo);
}
private static async Task<(RuleDryRun dry, Mock<ISessionRepository> sessionRepo, Mock<IRuleRepository> ruleRepo)> DryRunAsync(
AnalyzeRule rule, List<EnrollmentEvent> events)
{
var (engine, sessionRepo, ruleRepo) = MakeEngine(events);
var dry = await engine.DryRunRuleAsync(TenantId, SessionId, rule);
return (dry, sessionRepo, ruleRepo);
}
private static async Task<AnalysisOutcome> RunProductionAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
{
var ruleRepo = new Mock<IRuleRepository>();
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule>());
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule> { rule });
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
var sessionRepo = new Mock<ISessionRepository>();
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleEngineDryRunTests>.Instance);
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
}
}
@@ -0,0 +1,148 @@
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the reserved built-in rule-ID namespace: tenant custom rules must never
/// occupy (ANALYZE|GATHER)-&lt;CATEGORY&gt;-&lt;NUMBER&gt; — a gap or retired ID there can be
/// re-shipped as a built-in later, which would silently shadow the tenant's copy at
/// merge time. The CUSTOM category ("ANALYZE-CUSTOM-001") is the sanctioned tenant
/// namespace; the platform never ships built-ins in a category named CUSTOM.
/// Enforced on BOTH the create path and the custom-branch of the update path
/// (updates upsert, so a PUT would otherwise bypass the create check).
/// </summary>
public class RuleIdReservationTests
{
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
[Theory]
[InlineData("ANALYZE-SEC-002")] // retired gap — the incident that motivated the policy
[InlineData("ANALYZE-APP-999")] // unused number in an existing category
[InlineData("GATHER-DEVICE-006")]
[InlineData("analyze-sec-002")] // case variants are equally confusing → reserved
[InlineData("Gather-Net-001")]
[InlineData("ANALYZE-NEWCAT-001")] // future built-in categories are covered too
public void Reserved_builtin_ids_are_detected(string ruleId)
=> Assert.True(RuleIdPolicy.IsReservedBuiltInId(ruleId));
[Theory]
[InlineData("ANALYZE-CUSTOM-001")] // sanctioned tenant namespace (portal suggestion)
[InlineData("GATHER-CUSTOM-042")]
[InlineData("ANALYZE-ID-001-CUSTOM")] // template copies end in -CUSTOM, not a number
[InlineData("CONTOSO-WIFI-001")] // organization prefix
[InlineData("ANALYZE-SIDECAR-PROVIDER")] // no trailing number (existing prod custom rule)
[InlineData("dcu-restart-required")] // freeform (existing prod custom rule)
[InlineData("")]
public void Custom_schemes_are_allowed(string ruleId)
=> Assert.False(RuleIdPolicy.IsReservedBuiltInId(ruleId));
// ---- create path -------------------------------------------------------
[Fact]
public async Task Analyze_create_rejects_reserved_id_before_touching_storage()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.CreateRuleAsync(TenantId, new AnalyzeRule { RuleId = "ANALYZE-SEC-002" }));
Assert.Contains("reserved", ex.Message, StringComparison.OrdinalIgnoreCase);
repo.VerifyNoOtherCalls(); // rejected before any storage lookup
}
[Fact]
public async Task Gather_create_rejects_reserved_id_before_touching_storage()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.CreateRuleAsync(TenantId, new GatherRule { RuleId = "GATHER-NET-099" }));
Assert.Contains("reserved", ex.Message, StringComparison.OrdinalIgnoreCase);
repo.VerifyNoOtherCalls();
}
[Fact]
public async Task Analyze_create_still_accepts_custom_namespace()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), TenantId)).ReturnsAsync(true);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
Assert.True(await service.CreateRuleAsync(TenantId, new AnalyzeRule { RuleId = "ANALYZE-CUSTOM-001" }));
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.Is<AnalyzeRule>(x => x.RuleId == "ANALYZE-CUSTOM-001"), TenantId), Times.Once);
}
// ---- update path (upserts — must not be a bypass) ----------------------
[Fact]
public async Task Analyze_update_custom_branch_rejects_reserved_id()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
// IsBuiltIn=false routes into the custom-rule upsert branch.
var rule = new AnalyzeRule { RuleId = "ANALYZE-SEC-002", IsBuiltIn = false, IsCommunity = false };
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateRuleAsync(TenantId, rule));
repo.VerifyNoOtherCalls();
}
[Fact]
public async Task Analyze_update_builtin_state_toggle_is_unaffected()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
repo.Setup(r => r.StoreRuleStateAsync(TenantId, "ANALYZE-SEC-001", It.IsAny<RuleState>())).ReturnsAsync(true);
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
// Disabling a real built-in stays possible — that flow stores per-tenant state only.
var rule = new AnalyzeRule { RuleId = "ANALYZE-SEC-001", IsBuiltIn = true, Enabled = false };
Assert.True(await service.UpdateRuleAsync(TenantId, rule));
repo.Verify(r => r.StoreRuleStateAsync(TenantId, "ANALYZE-SEC-001", It.IsAny<RuleState>()), Times.Once);
}
[Fact]
public async Task Gather_update_custom_branch_rejects_reserved_id_when_no_global_row_exists()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
// Retired scenario: the ID is reserved but no global row exists anymore.
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>());
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
var rule = new GatherRule
{
RuleId = "GATHER-DEVICE-099",
Title = "squat",
CollectorType = "registry",
Target = "HKLM\\SOFTWARE\\Microsoft\\Provisioning",
};
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateRuleAsync(TenantId, rule));
repo.Verify(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task Gather_update_builtin_state_toggle_is_unaffected()
{
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>
{
new() { RuleId = "GATHER-DEVICE-001", IsBuiltIn = true },
});
repo.Setup(r => r.StoreRuleStateAsync(TenantId, "GATHER-DEVICE-001", It.IsAny<RuleState>())).ReturnsAsync(true);
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
var rule = new GatherRule { RuleId = "GATHER-DEVICE-001", Enabled = false };
Assert.True(await service.UpdateRuleAsync(TenantId, rule));
repo.Verify(r => r.StoreRuleStateAsync(TenantId, "GATHER-DEVICE-001", It.IsAny<RuleState>()), Times.Once);
}
}
@@ -90,4 +90,55 @@ public class SecurityValidatorTests
{
Assert.False(SecurityValidator.IsValidGuid(payload));
}
// --- Intune device id extraction from the MDM client certificate subject ---
// Intune MDM Device CA certs carry the Intune managedDevice id as the Subject CN
// (field-verified 2026-08-06 on a W365 Cloud PC). This is the identity anchor for the
// CloudPc validator, so the parse must be strict: CN present AND a canonical GUID.
[Fact]
public void TryGetIntuneDeviceId_PlainCnGuid_Extracts()
{
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
"CN=07623d56-1e77-4948-bff5-5bdac8167560", out var id));
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
}
[Fact]
public void TryGetIntuneDeviceId_UppercaseGuid_NormalizedToLower()
{
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
"CN=07623D56-1E77-4948-BFF5-5BDAC8167560", out var id));
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
}
[Fact]
public void TryGetIntuneDeviceId_MultiRdnSubject_FindsCn()
{
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
"O=Contoso, CN=07623d56-1e77-4948-bff5-5bdac8167560, C=DE", out var id));
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("O=Contoso, C=DE")] // no CN at all
[InlineData("CN=not-a-guid")] // CN present but not a GUID
[InlineData("CN=hostname.contoso.com")] // typical non-Intune device cert
[InlineData("CN=' or '1'='1")] // injection payload never survives
public void TryGetIntuneDeviceId_InvalidSubjects_ReturnFalse(string? subject)
{
Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(subject, out var id));
Assert.Null(id);
}
[Fact]
public void TryGetIntuneDeviceId_NonGuidFirstCn_IsDefinitive()
{
// A first CN that is not a GUID means "not an Intune MDM device cert shape" — a
// later GUID-valued CN must NOT resurrect the parse (no CN-shopping).
Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
"CN=hostname, CN=07623d56-1e77-4948-bff5-5bdac8167560", out _));
}
}
@@ -43,6 +43,8 @@ public class SessionIndexProjectionTests
["ExcessiveEventsAutoActioned"] = true,
// Self-deploying/kiosk profile marker (session 320b3bf7) — search-filterable.
["IsSelfDeployingProfile"] = true,
// W365 Cloud PC marker (agent-detected, sticky-true) — search-filterable.
["IsCloudPc"] = true,
// Device-validation path recorded at registration — shown on the session detail page.
["ValidatedBy"] = "AutopilotV1",
// Agent→backend HTTP latency projection (UpdateSessionNetworkLatencyAsync merges these).
@@ -72,6 +74,7 @@ public class SessionIndexProjectionTests
Assert.True(idx.GetBoolean("ExcessiveEventsAlerted"));
Assert.True(idx.GetBoolean("ExcessiveEventsAutoActioned"));
Assert.True(idx.GetBoolean("IsSelfDeployingProfile"));
Assert.True(idx.GetBoolean("IsCloudPc"));
Assert.Equal("AutopilotV1", idx.GetString("ValidatedBy"));
Assert.Equal(513.5, idx.GetDouble("AvgApiLatencyMs"));
Assert.Equal(100, idx.GetInt32("ApiRequestCount"));
@@ -103,6 +106,7 @@ public class SessionIndexProjectionTests
Assert.Equal(0, idx.GetInt32("RebootCount"));
Assert.False(idx.GetBoolean("ExcessiveEventsAlerted"));
Assert.False(idx.GetBoolean("IsSelfDeployingProfile"));
Assert.False(idx.GetBoolean("IsCloudPc"));
Assert.Equal(string.Empty, idx.GetString("ImeAgentVersion"));
}
}
@@ -61,6 +61,16 @@ public class SessionStatusTransitionTests
Assert.True(TableStorageService.IsTerminalTransitionAllowed("Succeeded", incoming));
}
// ---- Pending is idempotent: the agent emits whiteglove_complete twice (raw Shell-Core
// event + engine timeline event) — the second Pending write must not count as a
// transition, it fired duplicate Pre-Provisioning Completed webhooks ----
[Fact]
public void Pending_over_Pending_is_refused()
{
Assert.False(TableStorageService.IsTerminalTransitionAllowed("Pending", SessionStatus.Pending));
Assert.True(TableStorageService.IsTerminalTransitionAllowed("InProgress", SessionStatus.Pending));
}
// ================= ComputeReconcileReason =================
// The reconcile hygiene clears FailureReason/FailureSnapshotJson on every Succeeded write;
// ReconcileReason is what keeps a backend-declared success explainable (session 294ab5b4).
@@ -168,6 +168,69 @@ public class StoreSessionReregistrationPreserveTests
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
}
[Fact]
public async Task StoreSessionAsync_cloudPc_isStickyTrue_acrossReregistration()
{
// W365 marker mirrors the self-deploying sticky-true contract: once a boot observed
// the Cloud PC markers, a later re-registration whose registry/service probe degraded
// (registration.IsCloudPc=false) must NOT clear the fact.
var existing = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
["Status"] = "InProgress",
["IsCloudPc"] = true,
};
existing.ETag = new ETag("0xEXISTING");
var harness = new Harness(existing);
var registration = new SessionRegistration
{
TenantId = TenantId,
SessionId = SessionId,
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
SerialNumber = "SN-1",
Manufacturer = "Contoso",
Model = "Model-X",
DeviceName = "PC-1",
IsCloudPc = false, // fresh marker read degraded
};
var ok = await harness.Sut.StoreSessionAsync(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
}
[Fact]
public async Task StoreSessionAsync_cloudPc_upgradesFalseToTrue_onReregistration()
{
var existing = new TableEntity(TenantId, SessionId)
{
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
["Status"] = "InProgress",
["IsCloudPc"] = false,
};
existing.ETag = new ETag("0xEXISTING");
var harness = new Harness(existing);
var registration = new SessionRegistration
{
TenantId = TenantId,
SessionId = SessionId,
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
SerialNumber = "SN-1",
Manufacturer = "Contoso",
Model = "Model-X",
DeviceName = "PC-1",
IsCloudPc = true,
};
var ok = await harness.Sut.StoreSessionAsync(registration);
Assert.True(ok);
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
}
// ============================================================ Harness ====
/// <summary>
@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.DataAccess.TableStorage;
using AutopilotMonitor.Functions.Functions.Admin;
using AutopilotMonitor.Functions.Services;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
@@ -154,6 +155,68 @@ public class TenantConfigPatchServiceTests
Assert.Null(harness.Current!.ContactEmail);
}
// ── Mid-offboarding freeze ──────────────────────────────────────────────
[Fact]
public async Task Patch_OffboardingTombstone_IsFrozen_EvenForGa()
{
// While the row is the offboarding tombstone, the cascade owns it: a GA patch
// lifting Disabled would un-gate agents against a tenant whose partitions are
// queued for deletion (un-offboard race).
var stored = Stored();
stored.Disabled = true;
stored.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
var harness = new Harness(stored);
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("disabled", false)), Ga, "mcp-patch", "trying to un-offboard");
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
Assert.Contains("offboarding", outcome.Error, StringComparison.OrdinalIgnoreCase);
Assert.True(harness.Current!.Disabled, "tombstone must be untouched");
Assert.Equal(0, harness.ReplaceCalls);
}
[Fact]
public async Task Patch_OrdinarySuspension_IsNotFrozen()
{
// The freeze is specific to the offboarding tombstone — a normally suspended
// tenant (different DisabledReason) stays fully patchable.
var stored = Stored();
stored.Disabled = true;
stored.DisabledReason = "Manual suspension";
var harness = new Harness(stored);
var outcome = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 60)), Ga, "mcp-patch", null);
Assert.True(outcome.Success);
Assert.Equal(60, harness.Current!.DataRetentionDays);
}
[Fact]
public async Task Revert_OffboardingTombstone_IsFrozen()
{
// A revert restores a pre-offboarding snapshot with Disabled=false — the exact
// un-offboard race. Build a backup while the tenant is healthy, then tombstone.
var harness = new Harness(Stored());
var patched = await harness.Sut.ApplyFieldPatchAsync(
TenantId, Fields(("dataRetentionDays", 90)), Ga, "mcp-patch", null);
Assert.True(patched.Success);
harness.Current!.Disabled = true;
harness.Current.DisabledReason = TenantOffboardFunction.OffboardingDisabledReason;
var outcome = await harness.Sut.RevertAsync(
TenantId, backupId: null, includeProtectedFields: false, Ga, "mcp-revert", null);
Assert.False(outcome.Success);
Assert.Equal(PatchFailure.ValidationFailed, outcome.Failure);
Assert.Contains("offboarding", outcome.Error, StringComparison.OrdinalIgnoreCase);
Assert.True(harness.Current.Disabled, "tombstone must be untouched");
}
[Fact]
public async Task Patch_NoOp_ValueEqualsStored_NoBackupNoWrite()
{
@@ -0,0 +1,145 @@
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Functions.Rules;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Pins the agent-parity semantics of the logparser pattern test endpoint: case-sensitive
/// matching (the field incident that motivated the endpoint: a pattern verified with a PHP
/// regex tester behaved differently under .NET), first-match-per-line, group extraction
/// identical to what LogParserCollector puts into the emitted event data, CMTrace parsing
/// via the SHARED CmTraceLogParser, and the format=text hint.
/// </summary>
public class TestLogPatternFunctionTests
{
private static Regex AgentRegex(string pattern)
=> new(pattern, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
private const string CmTraceLine =
"<![LOG[Installation failed for app Ivanti Secure Access Client with error 0x87D1041C]LOG]!>" +
"<time=\"07:48:35.1783405\" date=\"8-4-2026\" component=\"AppWorkload\" context=\"\" type=\"3\" thread=\"42\" file=\"\">";
// ===== text mode =====
[Fact]
public void TextMode_NamedGroups_LandInGroups()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex(@"(?<level>ERROR|FATAL)\s+(?<component>\w+):\s+(?<message>.+)"),
isTextMode: true,
new[] { "ERROR Installer: disk full", "INFO Installer: all good" });
Assert.Equal(1, result.MatchCount);
Assert.Equal("matched", result.Lines[0].Outcome);
Assert.Equal("ERROR", result.Lines[0].Groups!["level"]);
Assert.Equal("Installer", result.Lines[0].Groups!["component"]);
Assert.Equal("disk full", result.Lines[0].Groups!["message"]);
Assert.Equal("no_match", result.Lines[1].Outcome);
}
/// <summary>The motivating field case: logparser matching has NO IgnoreCase — a pattern
/// that matched in a case-insensitive tester silently never fires on the device.</summary>
[Fact]
public void TextMode_IsCaseSensitive_AndSaysSoInTheNote()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex("ERROR"), isTextMode: true, new[] { "error: something broke" });
Assert.Equal(0, result.MatchCount);
Assert.Contains(result.Notes, n => n.Contains("case-SENSITIVE"));
}
[Fact]
public void TextMode_UnsuccessfulOptionalGroup_IsOmitted()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex(@"ERROR(?<code>\s0x[0-9A-F]+)?"), isTextMode: true, new[] { "ERROR without code" });
Assert.Equal(1, result.MatchCount);
Assert.False(result.Lines[0].Groups!.ContainsKey("code"));
}
// ===== cmtrace mode =====
[Fact]
public void CmTraceMode_MatchesAgainstParsedMessage_AndReportsComponentAndType()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex(@"error (?<errorCode>0x[0-9A-Fa-f]+)"), isTextMode: false, new[] { CmTraceLine });
Assert.Equal(1, result.MatchCount);
var row = result.Lines[0];
Assert.Equal("matched", row.Outcome);
Assert.Equal("0x87D1041C", row.Groups!["errorCode"]);
Assert.Equal("AppWorkload", row.Component);
Assert.Equal(3, row.CmTraceType);
Assert.Contains("Installation failed", row.Message);
}
[Fact]
public void CmTraceMode_RegexDoesNotSeeTheEnvelope()
{
// "component=" only exists in the CMTrace envelope, never in the message the
// agent matches against — a pattern targeting it must NOT match.
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex("component="), isTextMode: false, new[] { CmTraceLine });
Assert.Equal(0, result.MatchCount);
Assert.Equal("no_match", result.Lines[0].Outcome);
}
[Fact]
public void CmTraceMode_PlainTextLines_ParseFail_WithFormatHint()
{
var result = TestLogPatternFunction.EvaluatePattern(
AgentRegex("ERROR"), isTextMode: false, new[] { "ERROR plain line", "another plain line" });
Assert.Equal(2, result.ParseFailureCount);
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
Assert.Contains(result.Notes, n => n.Contains("format=text"));
}
// ===== request validation =====
[Fact]
public void Validate_RejectsMissingPatternAndLines()
{
Assert.Contains("pattern", TestLogPatternFunction.ValidateRequest(
new TestLogPatternFunction.TestLogPatternRequest { SampleLines = new List<string> { "x" } })!);
Assert.Contains("sampleLines", TestLogPatternFunction.ValidateRequest(
new TestLogPatternFunction.TestLogPatternRequest { Pattern = "a" })!);
}
[Fact]
public void Validate_RejectsTooManyLines_AndBadFormat()
{
var tooMany = new TestLogPatternFunction.TestLogPatternRequest
{
Pattern = "a",
SampleLines = Enumerable.Repeat("line", TestLogPatternFunction.MaxSampleLines + 1).ToList(),
};
Assert.Contains("at most", TestLogPatternFunction.ValidateRequest(tooMany)!);
var badFormat = new TestLogPatternFunction.TestLogPatternRequest
{
Pattern = "a",
Format = "json",
SampleLines = new List<string> { "x" },
};
Assert.Contains("format", TestLogPatternFunction.ValidateRequest(badFormat)!);
}
[Fact]
public void Validate_AcceptsBothFormatsAndDefault()
{
foreach (var format in new[] { null, "cmtrace", "text", "TEXT" })
{
Assert.Null(TestLogPatternFunction.ValidateRequest(new TestLogPatternFunction.TestLogPatternRequest
{
Pattern = "a",
Format = format,
SampleLines = new List<string> { "x" },
}));
}
}
}
@@ -603,6 +603,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "ValidateAutopilotDevice", config.ValidateAutopilotDevice },
{ "ValidateCorporateIdentifier", config.ValidateCorporateIdentifier },
{ "ValidateDeviceAssociation", config.ValidateDeviceAssociation },
{ "ValidateCloudPcDevice", config.ValidateCloudPcDevice },
{ "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests },
{ "DataRetentionDays", config.DataRetentionDays },
{ "SessionTimeoutHours", config.SessionTimeoutHours },
@@ -622,6 +623,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "NtpServer", config.NtpServer },
{ "EnableImeMatchLog", config.EnableImeMatchLog },
{ "EnableGatherRuleDebugLog", config.EnableGatherRuleDebugLog },
{ "EnableEspContinueAnywayObservation", config.EnableEspContinueAnywayObservation },
{ "LogLevel", config.LogLevel },
{ "MaxBatchSize", config.MaxBatchSize },
{ "DiagnosticsBlobSasUrl", config.DiagnosticsBlobSasUrl },
@@ -704,6 +706,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
ValidateAutopilotDevice = entity.GetBoolean("ValidateAutopilotDevice") ?? entity.GetBoolean("ValidateSerialNumber") ?? false,
ValidateCorporateIdentifier = entity.GetBoolean("ValidateCorporateIdentifier") ?? false,
ValidateDeviceAssociation = entity.GetBoolean("ValidateDeviceAssociation") ?? false,
ValidateCloudPcDevice = entity.GetBoolean("ValidateCloudPcDevice") ?? false,
AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false,
DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90,
SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5,
@@ -727,6 +730,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
NtpServer = string.IsNullOrWhiteSpace(entity.GetString("NtpServer")) ? "time.windows.com" : entity.GetString("NtpServer"),
EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"),
EnableGatherRuleDebugLog = entity.GetBoolean("EnableGatherRuleDebugLog"),
EnableEspContinueAnywayObservation = entity.GetBoolean("EnableEspContinueAnywayObservation"),
LogLevel = entity.GetString("LogLevel"),
MaxBatchSize = entity.GetInt32("MaxBatchSize"),
DiagnosticsBlobSasUrl = entity.GetString("DiagnosticsBlobSasUrl"),
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
["AgentTimestamp"] = entry.AgentTimestamp,
["IngestedAt"] = entry.IngestedAt,
["SourceIp"] = Truncate(entry.SourceIp, 45), // max IPv6 length
["IsCloudPc"] = entry.IsCloudPc,
["CertSourceState"] = Truncate(entry.CertSourceState, 32),
["CertThumbprint"] = Truncate(entry.CertThumbprint, 40),
["CertSubject"] = Truncate(entry.CertSubject, 96),
@@ -125,6 +126,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
AgentTimestamp = entity.GetDateTimeOffset("AgentTimestamp")?.UtcDateTime ?? DateTime.MinValue,
IngestedAt = entity.GetDateTimeOffset("IngestedAt")?.UtcDateTime ?? DateTime.MinValue,
SourceIp = entity.GetString("SourceIp"),
IsCloudPc = entity.GetBoolean("IsCloudPc") ?? false,
CertSourceState = entity.GetString("CertSourceState"),
CertThumbprint = entity.GetString("CertThumbprint"),
CertSubject = entity.GetString("CertSubject"),
@@ -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
@@ -25,6 +25,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly KillSwitchEvaluator _killSwitchEvaluator;
@@ -38,6 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
BootstrapSessionService bootstrapSessionService,
KillSwitchEvaluator killSwitchEvaluator)
{
@@ -50,6 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_bootstrapSessionService = bootstrapSessionService;
_killSwitchEvaluator = killSwitchEvaluator;
}
@@ -84,7 +87,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
_corporateIdentifierValidator,
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
);
if (errorResponse != null)
@@ -270,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;
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
@@ -63,6 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
BootstrapSessionService bootstrapSessionService,
HostedDiagnosticsBlobService hostedDiagnostics)
{
@@ -73,6 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_bootstrapSessionService = bootstrapSessionService;
_hostedDiagnostics = hostedDiagnostics;
}
@@ -123,7 +126,8 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
_corporateIdentifierValidator,
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
);
if (errorResponse != null)
@@ -125,6 +125,9 @@ public class GetGraphPermissionsStatusFunction
["ScriptDisplayNamesGranted"] = GraphFeatureCatalog
.IsFeatureGranted(GraphFeatureCatalog.FeatureScriptDisplayNames, snapshot.GrantedRoles)
.ToString(CultureInfo.InvariantCulture),
["W365CloudPcValidationGranted"] = GraphFeatureCatalog
.IsFeatureGranted(GraphFeatureCatalog.FeatureW365CloudPcValidation, snapshot.GrantedRoles)
.ToString(CultureInfo.InvariantCulture),
});
}
catch (Exception ex)
@@ -49,6 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly KillSwitchEvaluator _killSwitchEvaluator;
private readonly SessionDeletionGuard _deletionGuard;
@@ -66,6 +67,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
BootstrapSessionService bootstrapSessionService,
KillSwitchEvaluator killSwitchEvaluator,
SessionDeletionGuard deletionGuard)
@@ -82,6 +84,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_bootstrapSessionService = bootstrapSessionService;
_killSwitchEvaluator = killSwitchEvaluator;
_deletionGuard = deletionGuard;
@@ -111,7 +114,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_corporateIdentifierValidator,
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator);
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator);
if (errorResponse != null) return AsOutput(errorResponse);
@@ -34,6 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly TelemetryClient _telemetryClient;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly ISessionRepository _sessionRepo;
@@ -47,6 +48,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
TelemetryClient telemetryClient,
BootstrapSessionService bootstrapSessionService,
ISessionRepository sessionRepo,
@@ -59,6 +61,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_telemetryClient = telemetryClient;
_bootstrapSessionService = bootstrapSessionService;
_sessionRepo = sessionRepo;
@@ -94,7 +97,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
_corporateIdentifierValidator,
_logger,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
);
if (errorResponse != null)
@@ -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,
@@ -92,6 +92,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
var isPreProvisioned = query["isPreProvisioned"];
var isHybridJoin = query["isHybridJoin"];
var isSelfDeployingProfile = query["isSelfDeployingProfile"];
var isCloudPc = query["isCloudPc"];
var fieldsParam = query["fields"];
var pagination = SearchSessionsPagination.ParsePagination(query);
@@ -128,6 +129,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw
filter.IsHybridJoin = hj;
if (!string.IsNullOrEmpty(isSelfDeployingProfile) && bool.TryParse(isSelfDeployingProfile, out var sdp))
filter.IsSelfDeployingProfile = sdp;
if (!string.IsNullOrEmpty(isCloudPc) && bool.TryParse(isCloudPc, out var icp))
filter.IsCloudPc = icp;
if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax;
var connectionType = query["connectionType"];
@@ -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)
@@ -108,10 +108,19 @@ namespace AutopilotMonitor.Functions.Functions.Rules
rule.RuleId = ruleId;
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
try
{
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
}
catch (InvalidOperationException ex)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
return conflict;
}
}
[Function("CreateAnalyzeRuleFromTemplate")]
@@ -0,0 +1,233 @@
using System.Net;
using System.Text.RegularExpressions;
using AutopilotMonitor.Functions.Helpers;
using AutopilotMonitor.Functions.Services;
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;
namespace AutopilotMonitor.Functions.Functions.Rules
{
/// <summary>
/// Dry-runs a DRAFT analyze rule against one session's events and returns a full diagnostic
/// trace (per-condition matched/evidence, confidence breakdown, verdict) WITHOUT persisting
/// anything. Powers the rule-authoring loop in the portal and the MCP `test_analyze_rule` tool.
///
/// The rule arrives in the request body and never touches storage; the session status is never
/// modified (the engine's dry-run path has no MarkSessionAsFailed side effect by construction).
/// Global-scope callers may dry-run against any tenant's session — the tenant is resolved from
/// the session, same as GetRuleResultsFunction.
/// </summary>
public class DryRunAnalyzeRuleFunction
{
private readonly ILogger<DryRunAnalyzeRuleFunction> _logger;
private readonly IRuleRepository _ruleRepo;
private readonly ISessionRepository _sessionRepo;
private readonly AnalyzeRuleService _analyzeRuleService;
public DryRunAnalyzeRuleFunction(
ILogger<DryRunAnalyzeRuleFunction> logger,
IRuleRepository ruleRepo,
ISessionRepository sessionRepo,
AnalyzeRuleService analyzeRuleService)
{
_logger = logger;
_ruleRepo = ruleRepo;
_sessionRepo = sessionRepo;
_analyzeRuleService = analyzeRuleService;
}
public sealed class DryRunRequest
{
public string? SessionId { get; set; }
public AnalyzeRule? Rule { get; set; }
}
[Function("DryRunAnalyzeRule")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/analyze/dryrun")] HttpRequestData req)
{
// Authentication + TenantAdminOrGlobalReader authorization enforced by PolicyEnforcementMiddleware.
// Read-only diagnostic, so the read-only Global Reader is admitted alongside Tenant Admins and GAs.
var requestCtx = req.GetRequestContext();
var effectiveTenantId = requestCtx.TargetTenantId;
if (req.Headers.TryGetValues("Content-Length", out var clValues)
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
&& contentLength > 1_048_576) // 1 MB limit
{
return await BadRequestAsync(req, new[] { "Request body too large" });
}
var body = await new StreamReader(req.Body).ReadToEndAsync();
DryRunRequest? request;
try
{
request = JsonConvert.DeserializeObject<DryRunRequest>(body);
}
catch (JsonException ex)
{
return await BadRequestAsync(req, new[] { $"Request body is not valid JSON: {ex.Message}" });
}
if (request == null || string.IsNullOrWhiteSpace(request.SessionId))
{
return await BadRequestAsync(req, new[] { "sessionId is required" });
}
var errors = ValidateDraftRule(request.Rule);
if (errors.Count > 0)
{
return await BadRequestAsync(req, errors);
}
var sessionId = request.SessionId!.Trim();
var rule = request.Rule!;
// Global-scope cross-tenant fallback: resolve the session's actual tenant so a GA /
// Global Reader can dry-run against any tenant's session (mirrors GetRuleResultsFunction).
if (requestCtx.HasGlobalScope)
{
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
{
effectiveTenantId = resolvedTenantId;
}
}
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", sessionId });
return notFound;
}
var ruleEngine = new RuleEngine(_analyzeRuleService, _ruleRepo, _sessionRepo, _logger);
var result = await ruleEngine.DryRunRuleAsync(effectiveTenantId, sessionId, rule);
_logger.LogInformation(
"Dry-run of draft rule {RuleId} against session {SessionId}: verdict={Verdict}, confidence={Confidence}",
string.IsNullOrEmpty(rule.RuleId) ? "(no id)" : rule.RuleId, sessionId, result.Verdict, result.FinalConfidence);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, sessionId, result });
return response;
}
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, IReadOnlyList<string> errors)
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message = errors[0], errors });
return badRequest;
}
// ===== Draft validation =====
// Exact-match sets mirroring what the RuleEngine actually dispatches on. The evaluator
// switch is case-sensitive ("event_type", not "Event_Type"), and an unknown source/operator
// silently evaluates to false in production — for a dry-run that silence is exactly the
// confusion we want to catch up front.
private static readonly HashSet<string> KnownSources = new(StringComparer.Ordinal)
{
"event_type", "event_data", "event_data_array", "event_count",
"phase_duration", "app_install_duration", "event_correlation",
};
private static readonly HashSet<string> KnownOperators = new(StringComparer.OrdinalIgnoreCase)
{
"equals", "not_equals", "contains", "not_contains", "regex", "not_regex",
"gt", "lt", "gte", "lte", "exists", "not_exists",
"count_gte", "count_per_group_gte", "in", "not_in",
};
// The three shapes EvaluateConfidenceFactor actually parses. Anything else is silently
// false in production ("count > 3", "Count >= 3", "phase_duration>=300", …).
private static readonly Regex FactorConditionShape = new(
@"^(exists|count >= ?\d+|phase_duration > ?\d+)$", RegexOptions.Compiled);
/// <summary>
/// Structural validation of a draft rule before evaluation. Returns human-readable errors
/// (empty list = valid). Deliberately limited to what would make the dry-run silently
/// meaningless or throw — full authoring lint (schema, guardrails, event-type catalog)
/// lives client-side in the MCP `validate_rule` tool.
/// </summary>
internal static List<string> ValidateDraftRule(AnalyzeRule? rule)
{
var errors = new List<string>();
if (rule == null)
{
errors.Add("rule is required");
return errors;
}
if (rule.Conditions == null || rule.Conditions.Count == 0)
{
errors.Add("rule must contain at least one condition");
return errors;
}
if (rule.BaseConfidence < 0 || rule.BaseConfidence > 100)
errors.Add($"baseConfidence must be 0-100 (was {rule.BaseConfidence})");
if (rule.ConfidenceThreshold < 0 || rule.ConfidenceThreshold > 100)
errors.Add($"confidenceThreshold must be 0-100 (was {rule.ConfidenceThreshold})");
var seenSignals = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < rule.Conditions.Count; i++)
{
var c = rule.Conditions[i];
var label = $"conditions[{i}]";
if (string.IsNullOrWhiteSpace(c.Signal))
errors.Add($"{label}: signal is required");
else if (!seenSignals.Add(c.Signal))
errors.Add($"{label}: duplicate signal '{c.Signal}' — signals must be unique (evidence is keyed by signal)");
if (string.IsNullOrWhiteSpace(c.Source))
errors.Add($"{label}: source is required");
else if (!KnownSources.Contains(c.Source))
errors.Add($"{label}: unknown source '{c.Source}' (must be one of: {string.Join(", ", KnownSources)}; exact lowercase)");
if (!string.IsNullOrEmpty(c.Operator) && !KnownOperators.Contains(c.Operator))
errors.Add($"{label}: unknown operator '{c.Operator}'");
if (string.Equals(c.Source, "event_correlation", StringComparison.Ordinal))
{
if (string.IsNullOrWhiteSpace(c.CorrelateEventType))
errors.Add($"{label}: event_correlation requires correlateEventType");
if (string.IsNullOrWhiteSpace(c.JoinField))
errors.Add($"{label}: event_correlation requires joinField");
}
if ((string.Equals(c.Operator, "regex", StringComparison.OrdinalIgnoreCase)
|| string.Equals(c.Operator, "not_regex", StringComparison.OrdinalIgnoreCase))
&& !string.IsNullOrEmpty(c.Value))
{
try
{
_ = new Regex(c.Value, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
}
catch (ArgumentException ex)
{
errors.Add($"{label}: value is not a valid regex: {ex.Message}");
}
}
}
for (var i = 0; i < (rule.ConfidenceFactors?.Count ?? 0); i++)
{
var f = rule.ConfidenceFactors![i];
var label = $"confidenceFactors[{i}]";
if (string.IsNullOrWhiteSpace(f.Signal))
errors.Add($"{label}: signal is required");
if (string.IsNullOrEmpty(f.Condition) || !FactorConditionShape.IsMatch(f.Condition))
errors.Add($"{label}: condition '{f.Condition}' is not evaluable — supported shapes: \"exists\", \"count >= N\", \"phase_duration > N\" (exact spacing)");
}
return errors;
}
}
}
@@ -124,10 +124,19 @@ namespace AutopilotMonitor.Functions.Functions.Rules
return badRequest;
}
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
try
{
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
return response;
}
catch (InvalidOperationException ex)
{
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
return conflict;
}
}
/// <summary>
@@ -0,0 +1,244 @@
using System.Net;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Logging;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Functions.Rules
{
/// <summary>
/// Tests a logparser gather-rule regex against pasted sample log lines with the AGENT's
/// exact matching semantics — the ".NET dry-run" for logparser rules, which cannot be
/// dry-run against a session (they execute on devices).
///
/// Parity is the entire point: authors test regexes in whatever engine their AI client
/// has (JS, PHP, Python) and .NET behaves subtly differently (this endpoint exists
/// because exactly that happened in the field). Mirrored semantics, keep in lock-step
/// with LogParserCollector (agent):
/// - Regex options: Compiled, 1s timeout, NO IgnoreCase — logparser matching is
/// case-SENSITIVE (unlike analyze-rule regex conditions).
/// - First match per line (Regex.Match, not Matches).
/// - format "cmtrace" (default): CmTraceLogParser.TryParseLine first (shared code, the
/// same implementation the agent runs), regex against the parsed MESSAGE only.
/// - format "text": regex against the raw line.
/// - Captured data: every named/numbered group except "0", unsuccessful groups omitted.
///
/// No tenant data is touched; the endpoint is pure compute on the request body.
/// </summary>
public class TestLogPatternFunction
{
private readonly ILogger<TestLogPatternFunction> _logger;
public TestLogPatternFunction(ILogger<TestLogPatternFunction> logger)
{
_logger = logger;
}
public sealed class TestLogPatternRequest
{
public string? Pattern { get; set; }
public string? Format { get; set; }
public List<string>? SampleLines { get; set; }
}
internal const int MaxSampleLines = 200;
internal const int MaxLineLength = 8192;
[Function("TestLogPattern")]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/gather/test-pattern")] HttpRequestData req)
{
// Authentication + TenantAdminOrGlobalReader authorization enforced by PolicyEnforcementMiddleware.
if (req.Headers.TryGetValues("Content-Length", out var clValues)
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
&& contentLength > 1_048_576) // 1 MB limit
{
return await BadRequestAsync(req, "Request body too large");
}
var body = await new StreamReader(req.Body).ReadToEndAsync();
TestLogPatternRequest? request;
try
{
request = JsonConvert.DeserializeObject<TestLogPatternRequest>(body);
}
catch (JsonException ex)
{
return await BadRequestAsync(req, $"Request body is not valid JSON: {ex.Message}");
}
var validationError = ValidateRequest(request);
if (validationError != null)
{
return await BadRequestAsync(req, validationError);
}
var isTextMode = string.Equals(request!.Format, "text", StringComparison.OrdinalIgnoreCase);
Regex pattern;
try
{
// EXACT agent construction (LogParserCollector): Compiled + 1s timeout,
// no IgnoreCase — logparser matching is case-sensitive.
pattern = new Regex(request.Pattern!, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
}
catch (ArgumentException ex)
{
return await BadRequestAsync(req, $"Invalid regex pattern (this is the agent's .NET engine speaking): {ex.Message}");
}
var result = EvaluatePattern(pattern, isTextMode, request.SampleLines!);
_logger.LogInformation(
"Log-pattern test: {LineCount} lines, {MatchCount} matched, {ParseFailures} parse failures, mode={Mode}",
request.SampleLines!.Count, result.MatchCount, result.ParseFailureCount, isTextMode ? "text" : "cmtrace");
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new { success = true, format = isTextMode ? "text" : "cmtrace", result });
return response;
}
internal static string? ValidateRequest(TestLogPatternRequest? request)
{
if (request == null || string.IsNullOrEmpty(request.Pattern))
return "pattern is required";
if (request.SampleLines == null || request.SampleLines.Count == 0)
return "sampleLines is required — paste raw lines from the log file";
if (request.SampleLines.Count > MaxSampleLines)
return $"too many sampleLines ({request.SampleLines.Count}) — send at most {MaxSampleLines} representative lines";
if (request.SampleLines.Any(l => l != null && l.Length > MaxLineLength))
return $"a sample line exceeds {MaxLineLength} characters";
if (!string.IsNullOrEmpty(request.Format)
&& !string.Equals(request.Format, "cmtrace", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.Format, "text", StringComparison.OrdinalIgnoreCase))
return "format must be \"cmtrace\" (default) or \"text\"";
return null;
}
/// <summary>
/// Per-line evaluation mirroring LogParserCollector's inner loop. Returns one row per
/// sample line so the author sees exactly which lines matched, which failed CMTrace
/// parsing, and which data fields the emitted event would carry.
/// </summary>
internal static LogPatternTestResult EvaluatePattern(Regex pattern, bool isTextMode, IReadOnlyList<string> lines)
{
var result = new LogPatternTestResult();
for (var i = 0; i < lines.Count; i++)
{
var line = lines[i] ?? string.Empty;
var row = new LogPatternLineResult { LineNumber = i + 1 };
result.Lines.Add(row);
string textToMatch;
if (isTextMode)
{
textToMatch = line;
}
else
{
if (!CmTraceLogParser.TryParseLine(line, out var entry))
{
row.Outcome = "parse_failed";
result.ParseFailureCount++;
continue;
}
row.Component = entry.Component;
row.CmTraceType = entry.Type;
row.Message = Truncate(entry.Message, 500);
textToMatch = entry.Message;
}
Match match;
try
{
match = pattern.Match(textToMatch);
}
catch (RegexMatchTimeoutException)
{
row.Outcome = "regex_timeout";
result.TimeoutCount++;
continue;
}
if (!match.Success)
{
row.Outcome = "no_match";
continue;
}
row.Outcome = "matched";
result.MatchCount++;
row.MatchedText = Truncate(match.Value, 500);
var groups = new Dictionary<string, string>();
foreach (var groupName in pattern.GetGroupNames())
{
if (groupName == "0") continue;
var group = match.Groups[groupName];
if (group.Success)
groups[groupName] = Truncate(group.Value, 500);
}
row.Groups = groups;
}
// Mirror the agent's debug-log hint verbatim — the single most common logparser
// authoring mistake is running a plain-text log through the cmtrace default.
if (!isTextMode && result.Lines.Count > 0 && result.ParseFailureCount == result.Lines.Count)
{
result.Notes.Add("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
}
if (result.MatchCount == 0 && result.ParseFailureCount < result.Lines.Count)
{
result.Notes.Add("no line matched — remember logparser patterns are case-SENSITIVE (unlike analyze-rule regex conditions)");
}
return result;
}
private static string Truncate(string value, int maxLength)
{
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
return value;
return value.Substring(0, maxLength) + "...";
}
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
{
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
await badRequest.WriteAsJsonAsync(new { success = false, message });
return badRequest;
}
}
/// <summary>Aggregate outcome of one pattern test. Serialized camelCase to clients.</summary>
public sealed class LogPatternTestResult
{
public int MatchCount { get; set; }
public int ParseFailureCount { get; set; }
public int TimeoutCount { get; set; }
public List<LogPatternLineResult> Lines { get; } = new();
public List<string> Notes { get; } = new();
}
public sealed class LogPatternLineResult
{
public int LineNumber { get; set; }
/// <summary>matched | no_match | parse_failed | regex_timeout</summary>
public string Outcome { get; set; } = string.Empty;
/// <summary>The named/numbered capture groups exactly as they would land in the
/// emitted event's data (group "0" excluded, unsuccessful groups omitted).</summary>
public Dictionary<string, string>? Groups { get; set; }
public string? MatchedText { get; set; }
/// <summary>cmtrace mode only: the parsed component/type/message the regex ran against.</summary>
public string? Component { get; set; }
public int? CmTraceType { get; set; }
public string? Message { get; set; }
}
}
@@ -30,6 +30,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator _deviceAssociationValidator;
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
private readonly BootstrapSessionService _bootstrapSessionService;
private readonly WebhookNotificationService _webhookNotificationService;
private readonly SessionDeletionGuard _deletionGuard;
@@ -45,6 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
AutopilotDeviceValidator autopilotDeviceValidator,
CorporateIdentifierValidator corporateIdentifierValidator,
DeviceAssociationValidator deviceAssociationValidator,
CloudPcDeviceValidator cloudPcDeviceValidator,
BootstrapSessionService bootstrapSessionService,
WebhookNotificationService webhookNotificationService,
SessionDeletionGuard deletionGuard)
@@ -58,6 +60,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_bootstrapSessionService = bootstrapSessionService;
_webhookNotificationService = webhookNotificationService;
_deletionGuard = deletionGuard;
@@ -101,7 +104,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
_logger,
registration.SessionId,
bootstrapSessionService: _bootstrapSessionService,
deviceAssociationValidator: _deviceAssociationValidator
deviceAssociationValidator: _deviceAssociationValidator,
cloudPcDeviceValidator: _cloudPcDeviceValidator
);
if (errorResponse2 != null)
@@ -147,6 +147,7 @@ public class SearchSessionsFunction
if (bool.TryParse(query["isPreProvisioned"], out var ipp)) filter.IsPreProvisioned = ipp;
if (bool.TryParse(query["isHybridJoin"], out var ihj)) filter.IsHybridJoin = ihj;
if (bool.TryParse(query["isSelfDeployingProfile"], out var isdp)) filter.IsSelfDeployingProfile = isdp;
if (bool.TryParse(query["isCloudPc"], out var icp)) filter.IsCloudPc = icp;
if (DateTime.TryParse(query["startedAfter"], out var sa)) filter.StartedAfter = sa;
if (DateTime.TryParse(query["startedBefore"], out var sb)) filter.StartedBefore = sb;
if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
@@ -207,6 +208,7 @@ public class SearchSessionsFunction
if (fields.Contains("isUserDriven")) dict["isUserDriven"] = s.IsUserDriven;
if (fields.Contains("isHybridJoin")) dict["isHybridJoin"] = s.IsHybridJoin;
if (fields.Contains("isSelfDeployingProfile")) dict["isSelfDeployingProfile"] = s.IsSelfDeployingProfile;
if (fields.Contains("isCloudPc")) dict["isCloudPc"] = s.IsCloudPc;
if (fields.Contains("agentVersion")) dict["agentVersion"] = s.AgentVersion;
if (fields.Contains("imeAgentVersion")) dict["imeAgentVersion"] = s.ImeAgentVersion;
if (fields.Contains("geoCountry")) dict["geoCountry"] = s.GeoCountry;
@@ -48,6 +48,7 @@ namespace AutopilotMonitor.Functions.Pagination
new KeyValuePair<string, string?>("isPreProvisioned", filter.IsPreProvisioned?.ToString()),
new KeyValuePair<string, string?>("isHybridJoin", filter.IsHybridJoin?.ToString()),
new KeyValuePair<string, string?>("isSelfDeployingProfile", filter.IsSelfDeployingProfile?.ToString()),
new KeyValuePair<string, string?>("isCloudPc", filter.IsCloudPc?.ToString()),
new KeyValuePair<string, string?>("geoCountry", filter.GeoCountry),
new KeyValuePair<string, string?>("startedAfter", filter.StartedAfter?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)),
new KeyValuePair<string, string?>("startedBefore", filter.StartedBefore?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)),
@@ -394,6 +394,7 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.GraphTokenServ
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDeviceValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.DeviceAssociationValidator>();
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CloudPcDeviceValidator>();
// Graph add-on permission detection + script display-name resolution.
// Detector parses the `roles` claim of the SP access token; Resolver fetches
@@ -0,0 +1,278 @@
using AutopilotMonitor.Shared;
using System;
using System.Linq;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AutopilotMonitor.Functions.Security
{
/// <summary>
/// Validates Windows 365 Cloud PCs via the service-side Cloud PC inventory
/// (<c>virtualEndpoint/cloudPCs</c>). Cloud PCs are structurally never
/// Autopilot-registered, so instead of a serial lookup this validator binds the
/// device identity to the MDM client certificate: the caller passes the Intune
/// device id extracted from the chain-validated cert's Subject CN, and the device
/// is valid only when the tenant has a <c>cloudPC</c> object whose
/// <c>managedDeviceId</c> equals that id. Only machines provisioned by the
/// Windows 365 service have such an object — no other enrolled device can pass.
///
/// Built as a near-sibling of <see cref="AutopilotDeviceValidator"/> — same DI
/// dependencies, same retry/cache/transient contract:
/// - 30 min positive cache, 5 min negative cache (transient failures NOT cached)
/// - 2 attempts with 2s back-off between attempts
/// - <see cref="CloudPcValidationResult.IsTransient"/> on Graph 5xx, token failures, exceptions
/// Requires the optional Graph permission CloudPC.Read.All (a 403 from Graph is
/// treated as definitive "not granted", not transient — see TryValidateViaGraphAsync).
/// </summary>
public class CloudPcDeviceValidator
{
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
private readonly ILogger<CloudPcDeviceValidator> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
private readonly GraphTokenService _graphTokenService;
public CloudPcDeviceValidator(
ILogger<CloudPcDeviceValidator> logger,
IHttpClientFactory httpClientFactory,
IMemoryCache cache,
GraphTokenService graphTokenService)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_cache = cache;
_graphTokenService = graphTokenService;
}
public async Task<CloudPcValidationResult> ValidateCloudPcAsync(
string tenantId,
string? intuneDeviceId,
string? sessionId = null)
{
// The id is interpolated into an OData filter — a strict GUID gate is the
// injection defense (same rule as SecurityValidator.IsValidGuid everywhere else).
if (!SecurityValidator.IsValidGuid(intuneDeviceId))
{
return new CloudPcValidationResult
{
IsValid = false,
ErrorMessage = "Certificate subject does not carry a valid Intune device id"
};
}
var normalizedId = intuneDeviceId!.Trim().ToLowerInvariant();
var cacheKey = BuildCacheKey(tenantId, normalizedId);
if (_cache.TryGetValue(cacheKey, out CloudPcValidationResult? cached) && cached != null)
{
return cached;
}
const int maxAttempts = 2;
CloudPcValidationResult? lastTransient = null;
for (int attempt = 1; attempt <= maxAttempts; attempt++)
{
var result = await TryValidateViaGraphAsync(tenantId, normalizedId, sessionId, cacheKey, attempt);
if (result.IsValid || !result.IsTransient)
return result;
lastTransient = result;
if (attempt < maxAttempts)
{
_logger.LogWarning(
"Cloud PC validation transient failure for tenant {TenantId}, deviceId {IntuneDeviceId} (attempt {Attempt}/{MaxAttempts}). Retrying...",
tenantId, normalizedId, attempt, maxAttempts);
await Task.Delay(TimeSpan.FromSeconds(2));
}
}
_logger.LogWarning(
"Cloud PC validation failed after {MaxAttempts} attempts for tenant {TenantId}, deviceId {IntuneDeviceId}",
maxAttempts, tenantId, normalizedId);
return lastTransient!;
}
private async Task<CloudPcValidationResult> TryValidateViaGraphAsync(
string tenantId, string normalizedId, string? sessionId, string cacheKey, int attempt)
{
try
{
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId);
if (string.IsNullOrEmpty(tokenResult.AccessToken))
{
return new CloudPcValidationResult
{
IsValid = false,
IsTransient = true,
IntuneDeviceId = normalizedId,
ErrorMessage = "Graph access token could not be acquired"
};
}
var graphClient = _httpClientFactory.CreateClient();
graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.AccessToken);
// eq on managedDeviceId is supported server-side (field-verified 2026-08-06 on a
// real W365 tenant). normalizedId passed the strict GUID gate above, so the
// filter literal cannot carry injection payloads.
var filter = Uri.EscapeDataString($"managedDeviceId eq '{normalizedId}'");
var graphUrl = $"{Constants.GraphBaseUrl}/v1.0/deviceManagement/virtualEndpoint/cloudPCs"
+ $"?$select=id,displayName,managedDeviceId,managedDeviceName,servicePlanName&$filter={filter}";
var response = await graphClient.GetAsync(graphUrl);
var responseBody = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning(
"Cloud PC validation Graph query failed for tenant {TenantId} (attempt {Attempt}). Status: {StatusCode}. Body: {ResponseBody}",
tenantId, attempt, (int)response.StatusCode, responseBody);
// 403 = CloudPC.Read.All not granted in this tenant. That is a configuration
// state, not an outage: retrying cannot fix it, and a 503 Retry-After would
// keep every W365 agent in a futile retry loop. Cache it as a definitive
// negative (5 min) so a fresh grant is picked up quickly.
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
{
return CacheAndReturn(cacheKey, new CloudPcValidationResult
{
IsValid = false,
IntuneDeviceId = normalizedId,
ErrorMessage = "Cloud PC lookup not permitted — grant the optional 'W365CloudPcValidation' Graph feature (CloudPC.Read.All)"
}, isPositive: false);
}
return new CloudPcValidationResult
{
IsValid = false,
IsTransient = true,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Graph query failed with status {(int)response.StatusCode}"
};
}
var result = ParseCloudPcResponse(responseBody, normalizedId);
if (result.IsValid)
{
_logger.LogInformation(
"Cloud PC validation succeeded for tenant {TenantId}, session {SessionId}, deviceId {IntuneDeviceId}, cloudPcId {CloudPcId}, name {ManagedDeviceName}",
tenantId, sessionId ?? "<none>", normalizedId,
result.CloudPcId ?? "<none>", result.ManagedDeviceName ?? "<none>");
}
return CacheAndReturn(cacheKey, result, isPositive: result.IsValid);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error during Cloud PC validation for tenant {TenantId}, session {SessionId}, deviceId {IntuneDeviceId} (attempt {Attempt})",
tenantId, sessionId ?? "<none>", normalizedId, attempt);
return new CloudPcValidationResult
{
IsValid = false,
IsTransient = true,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Error during Cloud PC validation: {ex.Message}"
};
}
}
/// <summary>
/// Pure-function: maps a <c>virtualEndpoint/cloudPCs</c> Graph response body to a
/// <see cref="CloudPcValidationResult"/>. Performs an exact-match guard on
/// <c>managedDeviceId</c> so a widened server-side filter can never produce a
/// false positive.
/// </summary>
internal static CloudPcValidationResult ParseCloudPcResponse(string responseBody, string normalizedId)
{
var notFound = new CloudPcValidationResult
{
IsValid = false,
IntuneDeviceId = normalizedId,
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
};
JObject? data;
try
{
data = JsonConvert.DeserializeObject<JObject>(responseBody);
}
catch (JsonException)
{
return notFound;
}
var cloudPcs = data?["value"] as JArray;
if (cloudPcs == null || cloudPcs.Count == 0)
return notFound;
var match = cloudPcs.FirstOrDefault(c => string.Equals(
c?["managedDeviceId"]?.ToString()?.Trim(),
normalizedId,
StringComparison.OrdinalIgnoreCase));
if (match == null)
return notFound;
return new CloudPcValidationResult
{
IsValid = true,
IntuneDeviceId = normalizedId,
CloudPcId = match["id"]?.ToString(),
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
ServicePlanName = match["servicePlanName"]?.ToString()
};
}
internal static string BuildCacheKey(string tenantId, string intuneDeviceId)
=> $"cloudpc-device-validation:{tenantId}:{intuneDeviceId}";
private CloudPcValidationResult CacheAndReturn(
string cacheKey,
CloudPcValidationResult result,
bool isPositive)
{
var ttl = isPositive ? PositiveCacheTtl : NegativeCacheTtl;
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = ttl
});
return result;
}
}
public class CloudPcValidationResult
{
public bool IsValid { get; set; }
/// <summary>
/// True when the failure is transient (Graph API error, token issue, network timeout).
/// Transient failures are NOT cached and should trigger a 503 Retry-After to the agent.
/// </summary>
public bool IsTransient { get; set; }
/// <summary>Intune device id extracted from the MDM client certificate's Subject CN.</summary>
public string? IntuneDeviceId { get; set; }
/// <summary>Cloud PC object id (virtualEndpoint/cloudPCs) when validation succeeded.</summary>
public string? CloudPcId { get; set; }
/// <summary>Intune device name of the Cloud PC (CPC-*) — diagnostic only, never a gate.</summary>
public string? ManagedDeviceName { get; set; }
/// <summary>Windows 365 service plan (e.g. "Cloud PC Enterprise 2vCPU/4GB/128GB") — diagnostic only.</summary>
public string? ServicePlanName { get; set; }
public string? ErrorMessage { get; set; }
}
}
@@ -67,9 +67,9 @@ public enum EndpointPolicy
/// <summary>
/// Own-tenant Admin, OR a platform role (Global Admin / Global Reader). Tenant-scoped admin-tier
/// READ access (e.g. GET tenants/{tenantId}/graph-permissions/status). Like
/// <see cref="TenantAdminOrGA"/> but additionally admits the read-only Global Reader — used only
/// on GET routes so the reader gets visibility without write power, while tenant
/// Operators/Viewers stay excluded. (GET config/{tenantId} moved OFF this tier to MemberRead:
/// <see cref="TenantAdminOrGA"/> but additionally admits the read-only Global Reader — used on
/// GET routes (and read-in-effect POSTs like rules/analyze/dryrun) so the reader gets
/// visibility without write power, while tenant Operators/Viewers stay excluded. (GET config/{tenantId} moved OFF this tier to MemberRead:
/// its handler redacts secrets for every non-admin caller, so member-tier read is safe there.)
/// </summary>
TenantAdminOrGlobalReader,
@@ -264,6 +264,14 @@ public static class EndpointAccessPolicyCatalog
new("GET", "rules/gather", EndpointPolicy.MemberRead),
new("GET", "rules/analyze", EndpointPolicy.MemberRead),
new("GET", "rules/ime-log-patterns", EndpointPolicy.MemberRead),
// POST by shape (draft rule in the body) but a pure READ in effect: evaluates the draft
// against a session's events and persists NOTHING (DryRunAnalyzeRuleFunction). Admin-read
// tier so the read-only Global Reader can help debug rules while tenant Operators/Viewers
// stay excluded from the authoring surface.
new("POST", "rules/analyze/dryrun", EndpointPolicy.TenantAdminOrGlobalReader),
// Same shape: POST body in, pure compute out (regex vs pasted sample lines with the
// agent's exact .NET matching semantics) — touches no tenant data at all.
new("POST", "rules/gather/test-pattern", EndpointPolicy.TenantAdminOrGlobalReader),
// Member tier so Operators/Viewers can see their tenant's configuration read-only in the
// Settings UI. Safe because the handler redacts by default: only a Global Admin or the
// tenant's OWN admin gets the unredacted secrets — every other admitted caller (Operator,
@@ -33,9 +33,10 @@ namespace AutopilotMonitor.Functions.Security
ILogger logger,
string? sessionId = null,
BootstrapSessionService? bootstrapSessionService = null,
DeviceAssociationValidator? deviceAssociationValidator = null)
DeviceAssociationValidator? deviceAssociationValidator = null,
CloudPcDeviceValidator? cloudPcDeviceValidator = null)
{
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator);
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator, cloudPcDeviceValidator);
var validation = await validator.ValidateRequestAsync(req, tenantId, sessionId);
if (!validation.IsValid)
@@ -47,6 +47,7 @@ namespace AutopilotMonitor.Functions.Security
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
private readonly DeviceAssociationValidator? _deviceAssociationValidator;
private readonly CloudPcDeviceValidator? _cloudPcDeviceValidator;
private readonly BootstrapSessionService? _bootstrapSessionService;
private readonly ILogger _logger;
@@ -58,7 +59,8 @@ namespace AutopilotMonitor.Functions.Security
CorporateIdentifierValidator corporateIdentifierValidator,
ILogger logger,
BootstrapSessionService? bootstrapSessionService = null,
DeviceAssociationValidator? deviceAssociationValidator = null)
DeviceAssociationValidator? deviceAssociationValidator = null,
CloudPcDeviceValidator? cloudPcDeviceValidator = null)
{
_configService = configService;
_adminConfigService = adminConfigService;
@@ -66,6 +68,7 @@ namespace AutopilotMonitor.Functions.Security
_autopilotDeviceValidator = autopilotDeviceValidator;
_corporateIdentifierValidator = corporateIdentifierValidator;
_deviceAssociationValidator = deviceAssociationValidator;
_cloudPcDeviceValidator = cloudPcDeviceValidator;
_bootstrapSessionService = bootstrapSessionService;
_logger = logger;
}
@@ -235,14 +238,14 @@ namespace AutopilotMonitor.Functions.Security
// Security validation is always enforced (no longer configurable per tenant)
// Hard gate: tenant must enable at least one device validation method before agent traffic is accepted.
// Global Admins can set AllowInsecureAgentRequests=true in the config row for test tenants.
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.AllowInsecureAgentRequests)
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.ValidateCloudPcDevice && !config.AllowInsecureAgentRequests)
{
return new SecurityValidationResult
{
IsValid = false,
StatusCode = HttpStatusCode.Forbidden,
ErrorMessage = "Device validation is required",
Details = "Enable 'Autopilot Device Validation' or 'Corporate Identifier Validation' in Configuration before using the agent ingestion endpoints."
Details = "Enable 'Autopilot Device Validation', 'Corporate Identifier Validation' or 'Windows 365 Cloud PC Validation' in Configuration before using the agent ingestion endpoints."
};
}
@@ -364,7 +367,29 @@ namespace AutopilotMonitor.Functions.Security
}
}
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier) && !deviceValidated)
// W365 fallback: Cloud PCs are structurally never Autopilot-registered, so the serial
// lookups above always miss for them. Identity here comes from the chain-validated
// client certificate, NOT from spoofable headers: the Subject CN carries the Intune
// device id, and only a Windows-365-service-provisioned machine has a cloudPC object
// with that managedDeviceId — a regular Intune-enrolled (non-Autopilot) device still
// ends in the 403 below.
if (!deviceValidated && config.ValidateCloudPcDevice && _cloudPcDeviceValidator != null)
{
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var intuneDeviceId);
var cloudPcResult = await _cloudPcDeviceValidator.ValidateCloudPcAsync(tenantId, intuneDeviceId, sessionId);
if (cloudPcResult.IsValid)
{
deviceValidated = true;
validatedBy = ValidatorType.CloudPc;
}
else
{
deviceValidationError = cloudPcResult.ErrorMessage;
deviceValidationTransient = cloudPcResult.IsTransient;
}
}
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier || config.ValidateCloudPcDevice) && !deviceValidated)
{
// Transient failures (Graph API down, token issues) → 503 Retry-After so agent retries
// Definitive failures (device not registered) → 403 Forbidden
@@ -441,6 +466,37 @@ namespace AutopilotMonitor.Functions.Security
};
}
/// <summary>
/// Extracts the Intune device id from an MDM client certificate subject. Certs issued by
/// the Microsoft Intune MDM Device CA carry the Intune managedDevice id as the CN
/// (field-verified 2026-08-06 on a W365 Cloud PC). Returns false when the subject has no
/// CN or the CN is not a canonical GUID — the CloudPc validator then rejects it as
/// "not a valid Intune device id" without ever touching Graph.
/// </summary>
internal static bool TryGetIntuneDeviceIdFromCertSubject(string? subject, out string? intuneDeviceId)
{
intuneDeviceId = null;
if (string.IsNullOrWhiteSpace(subject))
return false;
foreach (var part in subject!.Split(','))
{
var trimmed = part.Trim();
if (!trimmed.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
continue;
var value = trimmed.Substring(3).Trim();
if (IsValidGuid(value))
{
intuneDeviceId = value.ToLowerInvariant();
return true;
}
return false; // first CN wins; a non-GUID CN is definitive (not an Intune MDM device cert shape)
}
return false;
}
/// <summary>
/// Emits a structured warning with request-side context (path, hardware headers, agent
/// version) at every fail-fast rejection point. Pairs with the cert-side warnings emitted
@@ -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);
@@ -259,6 +259,14 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
public async Task<bool> CreateRuleAsync(string tenantId, AnalyzeRule rule)
{
// The numeric built-in namespace stays closed to tenant rules even when an
// ID is currently unused — a later built-in with that ID would silently
// shadow the tenant copy (merge collision guard: global wins).
if (RuleIdPolicy.IsReservedBuiltInId(rule.RuleId))
{
throw new InvalidOperationException(RuleIdPolicy.ReservedMessage(rule.RuleId));
}
if (await _ruleRepo.AnalyzeRuleExistsAsync("global", rule.RuleId)
|| await _ruleRepo.AnalyzeRuleExistsAsync(tenantId, rule.RuleId))
{
@@ -293,6 +301,13 @@ namespace AutopilotMonitor.Functions.Services
return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, state);
}
// Custom-rule updates upsert into the tenant partition — without this guard a
// PUT with a reserved ID (and IsBuiltIn=false) would bypass the create-time check.
if (RuleIdPolicy.IsReservedBuiltInId(rule.RuleId))
{
throw new InvalidOperationException(RuleIdPolicy.ReservedMessage(rule.RuleId));
}
// Custom rules are fully tenant-owned: fold a notify override into the row default
// (there is no separate RuleState for them) — mirrors how the UI edits Enabled.
if (rule.Notify.HasValue)
@@ -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)
{
@@ -450,16 +463,29 @@ namespace AutopilotMonitor.Functions.Services
if (!whiteGloveStatusTransitioned)
{
_logger.LogWarning("{SessionPrefix} WhiteGlove UpdateSessionStatusAsync failed, attempting unconditional fallback for IsPreProvisioned + Status", sessionPrefix);
try
// Duplicate vs. genuine write failure: the agent emits whiteglove_complete twice
// (raw Shell-Core event + engine timeline event, ~1s apart, usually in separate
// batches). When the session is already sealed as Pending/IsPreProvisioned the
// refused write is the duplicate — the unconditional fallback below must NOT run,
// it forced whiteGloveStatusTransitioned=true and sent a second webhook.
var sealedSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId);
if (sealedSession?.Status == SessionStatus.Pending && sealedSession.IsPreProvisioned == true)
{
await _sessionRepo.SetSessionPreProvisionedAsync(request.TenantId, request.SessionId, true, SessionStatus.Pending, isUserDriven: false);
whiteGloveStatusTransitioned = true;
_logger.LogInformation("{SessionPrefix} WhiteGlove fallback succeeded: IsPreProvisioned + Status=Pending set via unconditional merge", sessionPrefix);
_logger.LogInformation("{SessionPrefix} WhiteGlove already sealed (Pending/IsPreProvisioned) — duplicate whiteglove_complete, no fallback/notification", sessionPrefix);
}
catch (Exception fallbackEx)
else
{
_logger.LogError(fallbackEx, "{SessionPrefix} WhiteGlove fallback SetSessionPreProvisionedAsync also failed", sessionPrefix);
_logger.LogWarning("{SessionPrefix} WhiteGlove UpdateSessionStatusAsync failed, attempting unconditional fallback for IsPreProvisioned + Status", sessionPrefix);
try
{
await _sessionRepo.SetSessionPreProvisionedAsync(request.TenantId, request.SessionId, true, SessionStatus.Pending, isUserDriven: false);
whiteGloveStatusTransitioned = true;
_logger.LogInformation("{SessionPrefix} WhiteGlove fallback succeeded: IsPreProvisioned + Status=Pending set via unconditional merge", sessionPrefix);
}
catch (Exception fallbackEx)
{
_logger.LogError(fallbackEx, "{SessionPrefix} WhiteGlove fallback SetSessionPreProvisionedAsync also failed", sessionPrefix);
}
}
}
@@ -133,6 +133,14 @@ namespace AutopilotMonitor.Functions.Services
/// </summary>
public async Task<bool> CreateRuleAsync(string tenantId, GatherRule rule)
{
// The numeric built-in namespace stays closed to tenant rules even when an
// ID is currently unused — a later built-in with that ID would silently
// shadow the tenant copy (merge collision guard: global wins).
if (RuleIdPolicy.IsReservedBuiltInId(rule.RuleId))
{
throw new InvalidOperationException(RuleIdPolicy.ReservedMessage(rule.RuleId));
}
if (await _ruleRepo.GatherRuleExistsAsync("global", rule.RuleId)
|| await _ruleRepo.GatherRuleExistsAsync(tenantId, rule.RuleId))
{
@@ -167,6 +175,14 @@ namespace AutopilotMonitor.Functions.Services
return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, new RuleState { Enabled = rule.Enabled });
}
// Custom-rule updates upsert into the tenant partition — without this guard a
// PUT with a reserved ID whose global row is absent (e.g. retired) would bypass
// the create-time check.
if (RuleIdPolicy.IsReservedBuiltInId(rule.RuleId))
{
throw new InvalidOperationException(RuleIdPolicy.ReservedMessage(rule.RuleId));
}
// Custom rule. The portal toggle PUTs only { enabled, isBuiltIn, isCommunity } —
// full-storing that payload would wipe Title/Target/Trigger/Parameters. Merge
// toggle-style partials into the existing row; full payloads (edit flow) replace
@@ -22,7 +22,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
TimeSpan? duration,
string? sessionUrl = null)
{
var title = success ? "\u2705 Enrollment Succeeded" : "\u274c Enrollment Failed";
var title = success ? "\ud83d\udfe2 Enrollment Succeeded" : "\ud83d\udd34 Enrollment Failed";
var themeColor = success ? "00B050" : "FF0000";
var severity = success ? NotificationSeverity.Success : NotificationSeverity.Error;
var summary = success
@@ -76,8 +76,8 @@ namespace AutopilotMonitor.Functions.Services.Notifications
string? sessionUrl = null)
{
var title = isResume
? "▶️ Pre-Provisioning Resumed"
: "🚀 Enrollment Started";
? "🟡 Pre-Provisioning Resumed"
: "🟡 Enrollment Started";
var summary = isResume
? $"Pre-Provisioning Resumed: {deviceName ?? "Unknown Device"}"
: $"Enrollment Started: {deviceName ?? "Unknown Device"}";
@@ -115,7 +115,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
TimeSpan? duration,
string? sessionUrl = null)
{
var title = success ? "\ud83d\udfe2 Pre-Provisioning Completed" : "\u274c Pre-Provisioning Failed";
var title = success ? "\ud83d\udfe2 Pre-Provisioning Completed" : "\ud83d\udd34 Pre-Provisioning Failed";
var themeColor = success ? "0078D4" : "FF0000";
var severity = success ? NotificationSeverity.Success : NotificationSeverity.Error;
var summary = success
@@ -181,7 +181,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
return new NotificationAlert
{
EventType = "hardware_rejected",
Title = "\u26a0\ufe0f Hardware Not Whitelisted",
Title = "\ud83d\udfe1 Hardware Not Whitelisted",
Summary = $"Device rejected: {hardwareText} is not in your hardware whitelist",
Severity = NotificationSeverity.Warning,
ThemeColor = "FFA500",
@@ -208,7 +208,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
{
var (emoji, themeColor, severity) = result.Severity switch
{
"critical" => ("🟥", "FF0000", NotificationSeverity.Error),
"critical" => ("🔴", "FF0000", NotificationSeverity.Error),
"high" => ("🟠", "FF0000", NotificationSeverity.Error),
"warning" => ("🟡", "FFA500", NotificationSeverity.Warning),
_ => ("🔵", "0078D4", NotificationSeverity.Info),
@@ -267,7 +267,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
{
var emoji = r.Severity switch
{
"critical" => "\ud83d\udfe5",
"critical" => "\ud83d\udd34",
"high" => "\ud83d\udfe0",
_ => "\ud83d\udfe1"
};
@@ -308,7 +308,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
{
case "SuccessRate":
label = "Success Rate";
title = $"\u26a0\ufe0f SLA Breach: Success Rate {currentRate:F1}%";
title = $"\ud83d\udfe1 SLA Breach: Success Rate {currentRate:F1}%";
summary = $"SLA breach: success rate {currentRate:F1}% is below target {targetRate:F1}%";
period = "Current Month";
facts.Add(new NotificationFact { Name = "Breach Type", Value = label });
@@ -320,7 +320,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
case "Duration":
label = "Duration (P95)";
title = "\u26a0\ufe0f SLA Breach: P95 Duration Exceeds Target";
title = "\ud83d\udfe1 SLA Breach: P95 Duration Exceeds Target";
summary = $"SLA breach: P95 duration {currentRate:F1}min exceeds target {targetRate:F0}min";
period = "Current Month";
facts.Add(new NotificationFact { Name = "Breach Type", Value = label });
@@ -330,7 +330,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
case "AppInstall":
label = "App Install Success";
title = $"\u26a0\ufe0f SLA Breach: App Install Success Rate {currentRate:F1}%";
title = $"\ud83d\udfe1 SLA Breach: App Install Success Rate {currentRate:F1}%";
summary = $"SLA breach: app install success rate {currentRate:F1}% is below target {targetRate:F1}%";
period = "Current Week";
facts.Add(new NotificationFact { Name = "Breach Type", Value = label });
@@ -342,7 +342,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
default:
label = breachType;
title = $"\u26a0\ufe0f SLA Breach: {breachType}";
title = $"\ud83d\udfe1 SLA Breach: {breachType}";
summary = $"SLA breach: {breachType} current {currentRate:F1} vs target {targetRate:F1}";
period = "Current Period";
facts.Add(new NotificationFact { Name = "Breach Type", Value = label });
@@ -405,7 +405,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
EventType = "sla_resolved",
Title = $"✅ SLA Breach Resolved: {label}",
Title = $"🟢 SLA Breach Resolved: {label}",
Summary = $"SLA breach resolved for tenant {tenantId}: {label} is back within target.",
Severity = NotificationSeverity.Success,
ThemeColor = "00B050",
@@ -435,7 +435,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
var alert = new NotificationAlert
{
EventType = "consecutive_failures",
Title = $"\ud83d\udea8 {consecutiveFailures} Consecutive Enrollment Failures",
Title = $"\ud83d\udd34 {consecutiveFailures} Consecutive Enrollment Failures",
Summary = $"Alert: {consecutiveFailures} enrollments failed in a row for tenant {tenantId}",
Severity = NotificationSeverity.Error,
ThemeColor = "FF0000",
@@ -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;
@@ -167,6 +168,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 +184,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
ITenantOffboardingEnqueuer reEnqueuer,
OpsEventService opsEvents,
ITenantCustomsArchiveRepository customsArchive,
IConfigRepository configRepo,
IOffboardFarewellEmailSender farewellEmail,
ILogger<TenantOffboardingHandler> logger)
{
@@ -196,6 +199,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
_reEnqueuer = reEnqueuer;
_opsEvents = opsEvents;
_customsArchive = customsArchive;
_configRepo = configRepo;
_farewellEmail = farewellEmail;
_logger = logger;
}
@@ -582,6 +586,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
@@ -0,0 +1,238 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services
{
/// <summary>
/// Dry-run (diagnostic) evaluation of a single draft analyze rule against a session's events.
/// Powers the rule-authoring loop (portal / MCP `test_analyze_rule`): the author gets a full
/// per-condition trace instead of the production path's fire-or-silent-null.
///
/// Deliberately side-effect free: nothing is persisted, the session status is never touched,
/// no notifications are emitted. The trace loop mirrors <see cref="RuleEngine"/>.EvaluateRule
/// but does NOT short-circuit on the first failed required condition — evaluating every
/// condition is the whole point of a dry run. Both paths share the same evaluators
/// (EvaluatePrecondition / EvaluateCondition / EvaluateConfidenceFactor), so a verdict of
/// "fired" here is equivalent to EvaluateRule returning a result; the parity test
/// (RuleEngineDryRunTests) pins that equivalence.
/// </summary>
public partial class RuleEngine
{
/// <summary>
/// Loads the session's events (strict read — storage failures propagate) and dry-runs the
/// given draft rule against them. Mirrors the event handling of AnalyzeSessionAsync
/// (strict load + BackfillDerivedEventFields) so the dry-run sees exactly what a real
/// analysis pass would see.
/// </summary>
public async Task<RuleDryRun> DryRunRuleAsync(string tenantId, string sessionId, AnalyzeRule rule)
{
var allEvents = await _sessionRepo.GetSessionEventsStrictAsync(tenantId, sessionId);
if (allEvents.Count == 0)
{
return new RuleDryRun
{
Verdict = RuleDryRunVerdict.NoEvents,
EventCount = 0,
BaseConfidence = rule.BaseConfidence,
ConfidenceThreshold = rule.ConfidenceThreshold,
};
}
BackfillDerivedEventFields(allEvents);
return DryRunEvaluate(rule, allEvents);
}
/// <summary>
/// Trace twin of EvaluateRule. Keep the confidence arithmetic and the factor evaluation
/// ORDER in lock-step with EvaluateRule — factors are evaluated sequentially against the
/// accumulating matchedConditions dictionary (a factor's "exists" check can observe
/// earlier factor_&lt;signal&gt; entries), so reordering would change results.
/// </summary>
internal RuleDryRun DryRunEvaluate(AnalyzeRule rule, List<EnrollmentEvent> events)
{
var dry = new RuleDryRun
{
EventCount = events.Count,
BaseConfidence = rule.BaseConfidence,
ConfidenceThreshold = rule.ConfidenceThreshold,
};
var preconditionsPassed = true;
if (rule.Preconditions != null)
{
foreach (var pre in rule.Preconditions)
{
var passed = EvaluatePrecondition(pre, events);
if (!passed) preconditionsPassed = false;
dry.Preconditions.Add(new RuleDryRunPrecondition
{
Source = pre.Source,
EventType = pre.EventType,
DataField = string.IsNullOrEmpty(pre.DataField) ? null : pre.DataField,
Operator = pre.Operator,
Value = pre.Value,
Passed = passed,
});
}
}
// Evaluate ALL conditions (no early break — the author wants the full picture).
// Outcome-equivalent to EvaluateRule: a failed required condition makes the
// production path return null before matchedConditions is ever used.
var matchedConditions = new Dictionary<string, object>();
var allRequiredMet = true;
foreach (var condition in rule.Conditions)
{
var (matched, evidence) = EvaluateCondition(condition, events);
if (condition.Required && !matched) allRequiredMet = false;
if (matched) matchedConditions[condition.Signal] = evidence;
dry.Conditions.Add(new RuleDryRunCondition
{
Signal = condition.Signal,
Source = condition.Source,
EventType = string.IsNullOrEmpty(condition.EventType) ? null : condition.EventType,
Required = condition.Required,
Matched = matched,
// Matched: the evidence dictionary the production path stores. Not matched:
// the evaluator's reason string ("no matching events", …).
Evidence = evidence,
MatchingEventCount = string.IsNullOrEmpty(condition.EventType)
? null
: events.Count(e => MatchesEventType(e, condition.EventType)),
});
}
dry.MatchedConditions = matchedConditions;
if (!preconditionsPassed)
{
dry.Verdict = RuleDryRunVerdict.SkippedByPrecondition;
return dry;
}
if (!allRequiredMet)
{
dry.Verdict = RuleDryRunVerdict.RequiredConditionNotMet;
return dry;
}
if (matchedConditions.Count == 0)
{
dry.Verdict = RuleDryRunVerdict.NoConditionsMatched;
return dry;
}
var confidence = rule.BaseConfidence;
foreach (var factor in rule.ConfidenceFactors)
{
var factorMatched = EvaluateConfidenceFactor(factor, events, matchedConditions);
if (factorMatched)
{
confidence += factor.Weight;
matchedConditions[$"factor_{factor.Signal}"] = true;
}
dry.ConfidenceFactors.Add(new RuleDryRunFactor
{
Signal = factor.Signal,
Condition = factor.Condition,
Weight = factor.Weight,
Matched = factorMatched,
});
}
confidence = Math.Min(confidence, 100);
dry.FinalConfidence = confidence;
if (confidence < rule.ConfidenceThreshold)
{
dry.Verdict = RuleDryRunVerdict.BelowConfidenceThreshold;
return dry;
}
dry.Verdict = RuleDryRunVerdict.Fired;
dry.WouldMarkSessionAsFailed = rule.MarkSessionAsFailed ?? rule.MarkSessionAsFailedDefault;
return dry;
}
}
/// <summary>Verdict strings for <see cref="RuleDryRun.Verdict"/>. Stable API contract.</summary>
public static class RuleDryRunVerdict
{
public const string Fired = "fired";
public const string SkippedByPrecondition = "skipped_by_precondition";
public const string RequiredConditionNotMet = "required_condition_not_met";
public const string NoConditionsMatched = "no_conditions_matched";
public const string BelowConfidenceThreshold = "below_confidence_threshold";
public const string NoEvents = "no_events";
}
/// <summary>Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients.</summary>
public sealed class RuleDryRun
{
public string Verdict { get; set; } = string.Empty;
/// <summary>Number of events in the session the rule was evaluated against.</summary>
public int EventCount { get; set; }
public List<RuleDryRunPrecondition> Preconditions { get; } = new();
public List<RuleDryRunCondition> Conditions { get; } = new();
/// <summary>Empty unless all required conditions were met (mirrors the production path,
/// which never reaches factor evaluation otherwise).</summary>
public List<RuleDryRunFactor> ConfidenceFactors { get; } = new();
public int BaseConfidence { get; set; }
/// <summary>base + matched factor weights, capped at 100. Null when the evaluation ended
/// before the confidence stage (precondition skip / required miss / nothing matched).</summary>
public int? FinalConfidence { get; set; }
public int ConfidenceThreshold { get; set; }
/// <summary>True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag.
/// The dry-run itself never touches the session.</summary>
public bool WouldMarkSessionAsFailed { get; set; }
/// <summary>The evidence map exactly as the production path would persist it on a
/// RuleResult — keys are condition signals (plus factor_* markers). Clients use it to
/// preview {{token}} interpolation of explanation/remediation.</summary>
public Dictionary<string, object>? MatchedConditions { get; set; }
}
public sealed class RuleDryRunPrecondition
{
public string Source { get; set; } = string.Empty;
public string EventType { get; set; } = string.Empty;
public string? DataField { get; set; }
public string? Operator { get; set; }
public string? Value { get; set; }
public bool Passed { get; set; }
}
public sealed class RuleDryRunCondition
{
public string Signal { get; set; } = string.Empty;
public string Source { get; set; } = string.Empty;
public string? EventType { get; set; }
public bool Required { get; set; }
public bool Matched { get; set; }
/// <summary>Matched: the evidence dictionary (eventId, timestamp, field, value, …).
/// Not matched: the evaluator's reason string (e.g. "no matching events").</summary>
public object? Evidence { get; set; }
/// <summary>How many session events have this condition's eventType at all — the first
/// thing an author checks when a condition unexpectedly doesn't match. Null when the
/// condition has no eventType.</summary>
public int? MatchingEventCount { get; set; }
}
public sealed class RuleDryRunFactor
{
public string Signal { get; set; } = string.Empty;
public string Condition { get; set; } = string.Empty;
public int Weight { get; set; }
public bool Matched { get; set; }
}
}
@@ -696,6 +696,7 @@ namespace AutopilotMonitor.Functions.Services
if (filter.IsPreProvisioned.HasValue && session.IsPreProvisioned != filter.IsPreProvisioned.Value) return false;
if (filter.IsHybridJoin.HasValue && session.IsHybridJoin != filter.IsHybridJoin.Value) return false;
if (filter.IsSelfDeployingProfile.HasValue && session.IsSelfDeployingProfile != filter.IsSelfDeployingProfile.Value) return false;
if (filter.IsCloudPc.HasValue && session.IsCloudPc != filter.IsCloudPc.Value) return false;
if (!string.IsNullOrEmpty(filter.GeoCountry) &&
!string.Equals(session.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase))
return false;
@@ -911,6 +912,7 @@ namespace AutopilotMonitor.Functions.Services
if (filter.IsPreProvisioned.HasValue && session.IsPreProvisioned != filter.IsPreProvisioned.Value) continue;
if (filter.IsHybridJoin.HasValue && session.IsHybridJoin != filter.IsHybridJoin.Value) continue;
if (filter.IsSelfDeployingProfile.HasValue && session.IsSelfDeployingProfile != filter.IsSelfDeployingProfile.Value) continue;
if (filter.IsCloudPc.HasValue && session.IsCloudPc != filter.IsCloudPc.Value) continue;
if (!string.IsNullOrEmpty(filter.GeoCountry) &&
!string.Equals(session.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase))
continue;
@@ -952,6 +954,7 @@ namespace AutopilotMonitor.Functions.Services
if (filter.IsPreProvisioned.HasValue && s.IsPreProvisioned != filter.IsPreProvisioned.Value) return false;
if (filter.IsHybridJoin.HasValue && s.IsHybridJoin != filter.IsHybridJoin.Value) return false;
if (filter.IsSelfDeployingProfile.HasValue && s.IsSelfDeployingProfile != filter.IsSelfDeployingProfile.Value) return false;
if (filter.IsCloudPc.HasValue && s.IsCloudPc != filter.IsCloudPc.Value) return false;
if (!string.IsNullOrEmpty(filter.GeoCountry) &&
!string.Equals(s.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase)) return false;
if (filter.StartedAfter.HasValue && s.StartedAt < filter.StartedAfter.Value) return false;
@@ -132,6 +132,7 @@ namespace AutopilotMonitor.Functions.Services
["IsHybridJoin"] = sessionEntity.GetBoolean("IsHybridJoin") ?? false,
["IsUserDriven"] = sessionEntity.GetBoolean("IsUserDriven") ?? false,
["IsSelfDeployingProfile"] = sessionEntity.GetBoolean("IsSelfDeployingProfile") ?? false,
["IsCloudPc"] = sessionEntity.GetBoolean("IsCloudPc") ?? false,
["AgentVersion"] = sessionEntity.GetString("AgentVersion") ?? string.Empty,
// Search-filterable column: search/MCP push an OData filter on ImeAgentVersion against
// the index, so it MUST be projected (was previously absent → filter matched nothing).
@@ -174,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");
@@ -433,6 +443,7 @@ namespace AutopilotMonitor.Functions.Services
bool isPreProvisioned = registration.IsPreProvisioned;
bool isHybridJoin = registration.IsHybridJoin;
bool isSelfDeployingProfile = registration.IsSelfDeployingProfile;
bool isCloudPc = registration.IsCloudPc;
DateTime? lastEventAt = null;
int? durationSeconds = null;
string? diagnosticsBlobName = null;
@@ -480,6 +491,10 @@ namespace AutopilotMonitor.Functions.Services
// the Autopilot policy cache was momentarily unreadable must not pin the
// self-deploying marker to false forever — once observed true, it stays true.
isSelfDeployingProfile = (existingEntity.GetBoolean("IsSelfDeployingProfile") ?? false) || isSelfDeployingProfile;
// Same sticky-true OR: the W365 markers are read at boot; a transient
// registry/service hiccup on a later re-registration must not clear an
// already-observed Cloud PC fact.
isCloudPc = (existingEntity.GetBoolean("IsCloudPc") ?? false) || isCloudPc;
lastEventAt = existingEntity.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
durationSeconds = existingEntity.GetInt32("DurationSeconds");
diagnosticsBlobName = existingEntity.GetString("DiagnosticsBlobName");
@@ -547,6 +562,7 @@ namespace AutopilotMonitor.Functions.Services
["IsPreProvisioned"] = isPreProvisioned,
["IsHybridJoin"] = isHybridJoin,
["IsSelfDeployingProfile"] = isSelfDeployingProfile,
["IsCloudPc"] = isCloudPc,
["StartedAt"] = EnsureUtc(startedAt),
["AgentVersion"] = registration.AgentVersion ?? string.Empty,
["EnrollmentType"] = registration.EnrollmentType ?? "v1",
@@ -640,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;
@@ -679,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;
@@ -1543,6 +1560,10 @@ namespace AutopilotMonitor.Functions.Services
SessionStatus.Succeeded => existingStatus != SessionStatus.Succeeded.ToString(),
SessionStatus.Failed or SessionStatus.Incomplete => !existingTerminal,
SessionStatus.AwaitingUser => !existingTerminal,
// Idempotency: the agent emits whiteglove_complete twice (raw Shell-Core event
// + engine timeline event, ~1s apart). The second Pending write must not report
// a "transition" — that fired duplicate Pre-Provisioning Completed webhooks.
SessionStatus.Pending => existingStatus != SessionStatus.Pending.ToString(),
_ => true,
};
}
@@ -1579,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));
@@ -1690,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)
{
@@ -1962,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)
{
@@ -2917,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,
@@ -2935,6 +2977,7 @@ namespace AutopilotMonitor.Functions.Services
IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false,
IsHybridJoin = entity.GetBoolean("IsHybridJoin") ?? false,
IsSelfDeployingProfile = entity.GetBoolean("IsSelfDeployingProfile") ?? false,
IsCloudPc = entity.GetBoolean("IsCloudPc") ?? false,
ResumedAt = SafeGetDateTime(entity, "ResumedAt"),
StalledAt = SafeGetDateTime(entity, "StalledAt"),
OsName = entity.GetString("OsName") ?? string.Empty,

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