mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c770bbffcf | |||
| 3fbdc8ef62 | |||
| 35cea4fe64 | |||
| ade6c4948f | |||
| 83da522dd7 | |||
| c15394df87 | |||
| 810a6f1f2f | |||
| 019ac3959e | |||
| 9236aba46e | |||
| fda52666d6 | |||
| dcec6a1863 | |||
| 041626494e | |||
| fd32a90d57 | |||
| 90b8243894 | |||
| 66d25f57db | |||
| 84a0424789 | |||
| 596788cc26 | |||
| aec2d249e1 | |||
| a63f126b88 | |||
| d94a91780c | |||
| 0b83fe2390 | |||
| 75c0c60717 | |||
| 29e7aa675a | |||
| c28d64fe27 | |||
| b2723168fd | |||
| d05d8ef302 | |||
| 2c48968ec6 | |||
| 2f8ad82b0a | |||
| 4b74995e79 | |||
| 5d8d9e8d40 | |||
| 25bd2c175e | |||
| 830edb98e7 | |||
| 1ebc2675b1 | |||
| 283c2ed214 | |||
| 13dd6723da | |||
| cb6d80a76e | |||
| 024c90d68f | |||
| 54044c5d9c | |||
| 3b3e8a7614 | |||
| 115b4ec8c2 |
@@ -270,6 +270,12 @@ jobs:
|
||||
if (Test-Path "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1") {
|
||||
Send-Blob -Local "scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1" -BlobName "Test-ShouldBootstrapAgent.ps1" -ContentType "text/plain; charset=utf-8"
|
||||
}
|
||||
|
||||
# Customer-side Graph add-on grant script -- docs and admin UI link
|
||||
# https://download.autopilotmonitor.com/agent/Grant-AutopilotMonitorAddOn.ps1
|
||||
if (Test-Path "scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1") {
|
||||
Send-Blob -Local "scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1" -BlobName "Grant-AutopilotMonitorAddOn.ps1" -ContentType "text/plain; charset=utf-8"
|
||||
}
|
||||
}
|
||||
|
||||
# Update AdminConfiguration LatestAgentV2* -- ONLY on stable cutover. The hash-oracle
|
||||
|
||||
@@ -77,12 +77,11 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Errors fail outright. Warnings are ratcheted: the cap is the current
|
||||
# count of accepted warnings (React-Compiler hooks-rule findings demoted
|
||||
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
|
||||
# the cap to match; never raise it.
|
||||
# Errors fail outright, and so does ANY warning: the codebase is
|
||||
# warning-clean and must stay that way — fix new findings, never
|
||||
# raise the cap.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 99
|
||||
run: npm run lint -- --max-warnings 0
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
@@ -107,8 +106,22 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# The vector-search tests tokenize with the real all-MiniLM-L6-v2 model
|
||||
# (~23 MB tokenizer + ONNX weights). transformers.js caches under
|
||||
# node_modules by default, which npm ci recreates — so every CI run
|
||||
# re-downloaded from huggingface.co and rate-limits (429) failed the job
|
||||
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
|
||||
# is honored by vector-search-provider.ts.
|
||||
- name: Cache embedding model
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ${{ github.workspace }}/.hf-cache
|
||||
key: hf-model-Xenova-all-MiniLM-L6-v2
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
env:
|
||||
HF_CACHE_DIR: ${{ github.workspace }}/.hf-cache
|
||||
|
||||
guardrails-in-sync:
|
||||
name: Generated guardrails match rules/guardrails.json
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project>
|
||||
<!-- Warning freeze (2026-08-13, backlog cleared to zero): every .NET build in this repo
|
||||
must stay warning-free. Compiler, MSBuild and NuGet restore warnings are all fatal.
|
||||
Exception: NuGet audit advisories (NU1901-NU1904) stay warnings — a CVE published
|
||||
against an already-referenced package must not block an unrelated hotfix deploy;
|
||||
advisories are handled by bumping the package, not by failing the build. -->
|
||||
<PropertyGroup>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
|
||||
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -21,6 +21,14 @@ three of them to turn the `autopilot_profile_missing` warning from a guess into
|
||||
evidence-backed verdict, and the backend rule engine uses the error-code map for
|
||||
known-issue enrichment.
|
||||
|
||||
Windows 365 Cloud PC exception: on Cloud PCs (`CloudPcDetector.DetectIsCloudPc()`)
|
||||
`ProfileAvailable=0` is the expected state — Cloud PCs are provisioned by the Windows 365
|
||||
service, not by Autopilot. The event is still emitted with the full evidence payload plus
|
||||
an `isCloudPc` marker, but as severity Info with a "this is expected" message instead of
|
||||
a Warning (`DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation`). The portal's
|
||||
Autopilot Profile card mirrors this: a neutral note for Cloud PC sessions
|
||||
(`session.isCloudPc` or the event marker), the amber warning otherwise.
|
||||
|
||||
Consumers in this repo:
|
||||
|
||||
* `ZtdEvidence.cs` (agent) — one-shot event-log query + `Diagnostics\Autopilot` registry
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Session Annotations — Role-Separated Human Verdicts
|
||||
description: The SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}) storing per-session human verdicts + notes in three role lanes (operator / tenantadmin / globaladmin), the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal/MCP surfaces.
|
||||
resource: /src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs
|
||||
tags:
|
||||
- backend
|
||||
- web
|
||||
- mcp
|
||||
- annotations
|
||||
- authorization
|
||||
- rule-quality
|
||||
timestamp: 2026-08-11T00:00:00+02:00
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
**Table.** `SessionAnnotations`: PartitionKey = `tenantId`, RowKey = `{sessionId}_{lane}` with
|
||||
lane ∈ `operator | tenantadmin | globaladmin` (a closed set — `AnnotationLanes.All`). One row per
|
||||
session + lane, edited in place; per-session reads use the RK range `ge '{sid}_' / lt '{sid}_~'`.
|
||||
Serialization lives only in `TableSessionAnnotationRepository.StoreAnnotation`/`MapAnnotation`
|
||||
(round-trip-pinned; corrupt `RuleIdsJson` degrades to an empty list on read).
|
||||
|
||||
**Row.** `Verdict` (nullable: `root_cause_confirmed | analysis_wrong | different_problem |
|
||||
inconclusive`), `Note` (nullable, ≤ 4096 chars), `AuthorUpn`/`AuthorDisplayName` (last editor),
|
||||
`CreatedByUpn`/`CreatedAtUtc` (first writer, immutable across edits), `UpdatedAtUtc`, and
|
||||
`RuleIdsJson` — a **snapshot of the rule ids that had fired for the session at write time**
|
||||
(from `IRuleRepository.GetRuleResultsAsync`, fail-soft to empty). The snapshot is what turns an
|
||||
annotation into a rule-quality label without a join: filtering annotations by `ruleId` + `verdict`
|
||||
yields confirmed vs false-positive counts per rule even after results are re-analyzed or deleted.
|
||||
|
||||
**Verdict + note are independently optional**; a PUT with both null **deletes** the lane row
|
||||
(there is no DELETE route). Author identity is always stamped server-side from the JWT
|
||||
(`TenantHelper.GetUserDisplayName` + `RequestContext.UserPrincipalName`) — a body-supplied
|
||||
author never wins (same anti-spoof contract as the gather-rule PUT-upserts).
|
||||
|
||||
# Authorization
|
||||
|
||||
Routes (all in `EndpointAccessPolicyCatalog`):
|
||||
|
||||
| Route | Policy | Notes |
|
||||
| --- | --- | --- |
|
||||
| `GET sessions/{sessionId}/annotations` | `MemberRead` + QueryParam | handler filters the GA lane |
|
||||
| `PUT sessions/{sessionId}/annotations/{lane}` | `TenantAdminOrOperator` + QueryParam | re-gated per lane in-function |
|
||||
| `GET global/session-annotations` | `GlobalReadOrAdmin` + QueryParam | evaluation stream; delegated ("MSP") readers are rescue-admitted on a managed `?tenantId=` only |
|
||||
|
||||
**Per-lane write matrix** (`UpsertSessionAnnotationFunction.IsLaneWritableByCaller`, an
|
||||
`internal static` pure function like `QueueSessionActionFunction.IsTypeAllowedForCaller`):
|
||||
`operator` → tenant Operator or tenant Admin; `tenantadmin` → tenant Admin; `globaladmin` →
|
||||
Global Admin only. **Own-tenant binding:** the session's tenant is resolved *before* the gate
|
||||
(GA cross-tenant fallback via `FindSessionTenantIdAsync`), and tenant-role inputs are zeroed
|
||||
when the resolved tenant differs from the caller's JWT tenant — so a GA who is also an admin of
|
||||
their home tenant can never write another tenant's operator/tenantadmin lanes. Cross-tenant, a
|
||||
GA writes exactly the `globaladmin` lane (the platform labeling flow).
|
||||
|
||||
**GA lane is platform-internal.** `GetSessionAnnotationsFunction.FilterLanesForCaller` drops the
|
||||
`globaladmin` lane for every caller without `HasGlobalScope` (tenant members and delegated
|
||||
admins). Both list endpoints enforce the same rule server-side via the repository's
|
||||
`excludeGlobalAdminLane` OData clause — on the global route this matters exactly for the
|
||||
delegated-rescue path, which reaches it without global scope. Tenant lanes are readable by all
|
||||
members of that tenant.
|
||||
|
||||
**Audit.** Non-GA writes/clears log `LogAuditEntryAsync(..., "SessionAnnotation",
|
||||
"{sessionId}/{lane}", ...)`; GA writes are skipped (platform-internal labeling must not surface
|
||||
in the tenant-visible audit log — same convention as GA session-report submissions).
|
||||
|
||||
# Lifecycle
|
||||
|
||||
* **Per-session cascade delete**: three exact-RK steps (one per lane) in `DeletionManifestBuilder`
|
||||
(orders 17–19; inventory 20/21, tombstone 22). `ComputePreflightCounts` sums repeated-table
|
||||
steps so the `sessionAnnotations` count covers all lanes.
|
||||
* **Tenant offboarding**: `SessionAnnotations` is in the PK=tenantId wipe list — deliberately
|
||||
wiped, unlike `Feedback` (annotations are customer session data, not product feedback).
|
||||
* **Backup**: member of `Constants.CriticalBackupTables.All` — hand-labeled data is the most
|
||||
expensive data on the platform to lose.
|
||||
* Being in `Constants.TableNames.All` auto-creates the table at startup and exposes it to the
|
||||
raw `list_tables`/`query_table` tools.
|
||||
|
||||
# Surfaces
|
||||
|
||||
* **Portal**: `SessionAnnotationsCard` on the session detail page (`#section-annotations`, after
|
||||
Analysis; registered in BOTH the section div and the `sessionSections` sidebar registry). The
|
||||
card mirrors the matrix via the pure module `sessionAnnotationLogic.ts` (vitest-pinned) and
|
||||
uses the explicit-Save pattern; the backend re-gates every save. Collapsed by default with a
|
||||
count + verdict-pill summary header.
|
||||
* **Portal overview page** `/annotations` (Monitoring sidebar group — deliberately NO new
|
||||
"Insights" group; Fleet Health & co. are insights too and a second analytics group would
|
||||
dilute both): tenant-scoped list backed by `GET sessions/annotations/list`
|
||||
(`ListTenantSessionAnnotationsFunction`, MemberRead + QueryParam). The path sits under the
|
||||
`/api/sessions` client-cert exclusion prefix (rename-over-exclusion convention; the Azure
|
||||
exclusion list is portal-only config) and uses THREE segments because a two-segment literal
|
||||
would be eaten by the `sessions/{sessionId}` route template. The tenant is always the
|
||||
middleware-validated `TargetTenantId`, and for callers without global scope the repository
|
||||
adds a server-side `Lane ne 'globaladmin'` OData clause — hidden rows never consume page
|
||||
budget, and an explicit lane=globaladmin filter self-contradicts to an empty page. Rows
|
||||
deep-link to `sessions?id=…#section-annotations`. The page carries the standard tenant
|
||||
switcher (`useAggregatedAdminScope` + `TenantScopeSelector`): a GA/Reader defaults to the
|
||||
"All tenants" aggregate (the portal twin of MCP's `list_session_annotations`) with per-tenant
|
||||
drill-down, a delegated ("MSP") admin gets the managed subset; cross-tenant scopes route to
|
||||
`GET global/session-annotations`, a tenant column appears, and deep links carry the row's
|
||||
`tenantId`.
|
||||
* **MCP read**: `get_session_summary` carries an `annotations` key (4th parallel leg, fail-soft
|
||||
null); `list_session_annotations` (`ga`-gated) is the evaluation stream with `tenantId / lane /
|
||||
verdict / ruleId / dateFrom / dateTo` filters and nextLink pagination. The `ruleId` filter is
|
||||
applied server-side but client-of-Azure (substring on the JSON column), so the backend
|
||||
back-fills short pages by looping the Azure continuation (bounded rounds) — filtered-out rows
|
||||
never consume page budget.
|
||||
* **MCP write**: `annotate_session` (`strictGa`-gated, MUTATING) always PUTs the `globaladmin`
|
||||
lane — no lane argument by design. This is the frictionless labeling path after a session-debug.
|
||||
* **Session reports**: `SessionReportService.SubmitReportAsync` snapshots ALL lanes into
|
||||
`annotations.json` inside the report ZIP (`annotationCount` in report-metadata.json; fail-soft).
|
||||
Safe because report blobs are readable only through the GA-only session-reports routes, and
|
||||
valuable because the ZIP outlives the session's retention/delete.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs` — model, lanes, verdicts.
|
||||
* `src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs` — Store/Map + query back-fill.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Annotations/` — the three endpoints.
|
||||
* `src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs` — filter-bound continuation tokens.
|
||||
* `src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts` — portal matrix mirror.
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts` — `list_session_annotations`, `annotate_session`.
|
||||
@@ -23,6 +23,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Gather Rule Debug Log](agent/gather-rule-debug-log.md) - EnableGatherRuleDebugLog (ConfigVersion 36): the per-tenant local trace file that explains every gather-rule evaluation decision — scope skips, on_change suppression, empty collector results, logparser position/parse-mode outcomes — for rules that produce no timeline events.
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
|
||||
* [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents.
|
||||
* [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure.
|
||||
* [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it.
|
||||
|
||||
# Rules
|
||||
@@ -41,6 +42,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
|
||||
* [Session Annotations](backend/session-annotations.md) - Role-separated human verdicts on sessions (operator / tenantadmin / globaladmin lanes, one row each): the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal card + MCP list/annotate surfaces.
|
||||
|
||||
# MCP Server
|
||||
|
||||
|
||||
+22
@@ -1,5 +1,27 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-13
|
||||
|
||||
* **Update**: `backend/session-annotations.md` — the `/annotations` portal overview page now carries the standard tenant switcher (GA/Reader: "All tenants" aggregate + per-tenant drill-down via `GET global/session-annotations`; delegated: managed subset), with a tenant column and tenant-qualified session deep links. The global list endpoint now derives `excludeGlobalAdminLane` from `HasGlobalScope`, so delegated-rescue callers on their managed `?tenantId=` path never receive the platform-internal `globaladmin` lane (endpoint-test-pinned).
|
||||
|
||||
## 2026-08-12
|
||||
|
||||
* **Update**: `agent/autopilot-ztd-diagnostics.md` — Windows 365 Cloud PC exception documented: on Cloud PCs `ProfileAvailable=0` is expected (provisioned by the Windows 365 service, not Autopilot), so `autopilot_profile_missing` is emitted as Info with a Cloud-PC-specific message and an `isCloudPc` payload marker (`ResolveAutopilotProfileMissingPresentation`); the portal's Autopilot Profile card renders a neutral note instead of the amber warning for Cloud PC sessions (`session.isCloudPc` or the event marker).
|
||||
|
||||
## 2026-08-11 (later)
|
||||
|
||||
* **Creation**: Added `backend/session-annotations.md` — session annotations: the new `SessionAnnotations` table (PK=tenantId, RK=`{sessionId}_{lane}`, lanes operator/tenantadmin/globaladmin) storing structured verdicts (`root_cause_confirmed | analysis_wrong | different_problem | inconclusive`) + notes with server-stamped authorship and a fired-rule-id snapshot for join-free rule-quality evaluation. Per-lane write matrix re-gated in-function with own-tenant binding (a GA writes only the platform-internal `globaladmin` lane cross-tenant; tenant callers never see that lane). Surfaces: portal `SessionAnnotationsCard` (session detail, after Analysis), MCP `list_session_annotations` (ga) + `annotate_session` (strictGa, always the GA lane) + `annotations` key in `get_session_summary`. Lifecycle: per-session cascade steps 17–19, offboarding wipe, critical-backup member.
|
||||
|
||||
## 2026-08-11
|
||||
|
||||
* **Update**: `rules/gather-rule-guardrails.md` — hard-block mirroring closed across all pre-flight layers: `rules/guardrails.json` now also mirrors `blockedCommandPatterns` + `maxCommandLength` and carries `C:\Windows\System32\config` in `blockedFilePrefixes`; portal (`guardValidation.ts`) and MCP (`rule-validation.ts`) apply them in agent order (before the unrestricted-mode early-return), so a command the agent hard-blocks no longer shows "Allowed" in unrestricted tenants. `GuardrailsParityTests` pins the JSON mirror to the C# constants. MCP `validate_rule` additionally validates `json`/`xml` targets with file semantics (the schema's `collectorType` enum was missing both) and rejects `activePhases`+`activeFromPhase` set together (backend 400 parity). Author stamping extended to PUT-upserts: tenant and global gather-rule updates stamp the caller's JWT display name before the service call, so an upsert of a non-existing ruleId can no longer store a payload-supplied author (true updates still preserve the original author).
|
||||
|
||||
## 2026-08-10
|
||||
|
||||
* **Update**: `rules/gather-rule-guardrails.md` — WMI matcher generalized from literal prefix matching to shape parsing: `SELECT <* | property-list> FROM <class>` is allowed when the class is derived from the existing `SELECT * FROM <class>` entries (a projection is a strict subset; motivated by `SELECT BatteryStatus FROM Win32_Battery` + `on_change` battery polling that `SELECT *` defeats via fluctuating charge %). Same algorithm mirrored agent/portal/MCP; guardrails.json unchanged. Portal now also gates enabling on the client-side validation (invalid-target custom rules create disabled, cannot be toggled on, force-disable on invalid edit, "Blocked on devices" badge) — UX only, the agent guard stays the boundary. Gather-rule Author is now stamped from the creator's JWT (display name → UPN) at create and immutable on update.
|
||||
|
||||
* **New**: `agent/continue-anyway-observation.md` — Continue-Anyway observation mode (`EnableEspContinueAnywayObservation`, ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles are defanged into a 60-min observation window (Classic/Unknown, non-pre-prov only); real-user desktop + Hello gate complete the session as Succeeded + `EspSoftFailure`/`CompletionSource` (amber "with issues" badge + detail banner), category recovery resolves clean, expiry un-defangs to the original `esp_terminal_failure`. Registered in the Agent index section. Related backend fix (no doc): the auto-analyze enqueue now carries a 30 s `visibilityTimeout` so rule evaluation no longer races the agent's terminal flush (ANALYZE-ESP-004's `{{appName}}` placeholder).
|
||||
|
||||
## 2026-08-07 (later)
|
||||
|
||||
* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap.
|
||||
|
||||
@@ -7,14 +7,14 @@ description: >-
|
||||
guard rather than trusting rule.Target.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
|
||||
tags: [gather-rules, security, guardrails, agent]
|
||||
timestamp: 2026-07-22
|
||||
timestamp: 2026-08-11
|
||||
---
|
||||
|
||||
# Concept
|
||||
|
||||
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, and that the rule lands in the caller's own tenant partition. Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
|
||||
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, that the rule lands in the caller's own tenant partition, and that `Author` is stamped from the caller's JWT (create and PUT-upsert; true updates preserve the original author). Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
|
||||
|
||||
This is the security boundary. The portal's `ValidationIndicator` is a convenience: it does not block submit, and the API is reachable directly.
|
||||
This is the security boundary. The portal's `ValidationIndicator` is a convenience — the API stays reachable directly and the backend stays validation-free — but since 2026-08 the portal additionally **gates enabling** on the same client-side validation: a custom rule whose target fails validation cannot be toggled on, is created `enabled=false`, and an edit that makes the target invalid force-disables the rule (`targetBlocked` in `app/gather-rules/types.ts`). An enabled rule with an invalid target shows a "Blocked on devices" badge. All of this is UX, not security — the agent guard remains the enforcement point.
|
||||
|
||||
# Schema
|
||||
|
||||
@@ -27,7 +27,7 @@ item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal
|
||||
| --- | --- | --- |
|
||||
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
|
||||
| `IsFilePathAllowed` | `filePrefixes` | `Path.GetFullPath` first, then prefix + boundary |
|
||||
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | prefix, boundary whitespace or end |
|
||||
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | `SELECT <* \| property-list> FROM <class>`; class = case-insensitive equality against classes derived from the `SELECT * FROM <class>` entries, boundary whitespace or end (trailing `WHERE` allowed). Non-canonical entries (none today) fall back to literal prefix + boundary |
|
||||
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
|
||||
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
|
||||
|
||||
@@ -44,7 +44,12 @@ but the hard blocks must hold regardless of configuration:
|
||||
PowerShell channels (script-block logging routinely contains secrets in clear text).
|
||||
|
||||
`rules/guardrails.json` mirrors the blocked lists (`blockedFilePrefixes`,
|
||||
`blockedEventLogChannels`) **for display only**. Enforcement is the C# constant.
|
||||
`blockedEventLogChannels`, `blockedCommandPatterns`, `maxCommandLength`) **for display
|
||||
and pre-flight only**. Enforcement is the C# constant; `GuardrailsParityTests` (agent
|
||||
test project) pins the JSON mirror to the constants so neither side can drift. The
|
||||
portal (`utils/guardValidation.ts`) and the MCP validator (`rule-validation.ts`) apply
|
||||
the mirrored hard blocks in the same order as the agent — before the unrestricted-mode
|
||||
early-return — so a rule the agent would refuse is never shown as "Allowed".
|
||||
|
||||
# Examples
|
||||
|
||||
@@ -71,6 +76,15 @@ Two collectors were added to this contract on 2026-07-22 after shipping without
|
||||
remote config. The operator using it is already a local admin, so it relaxes the
|
||||
allowlist exactly the way unrestricted mode does — hard blocks still apply.
|
||||
|
||||
**WMI property projections (2026-08):** `SELECT BatteryStatus FROM Win32_Battery` is
|
||||
allowed because `SELECT * FROM Win32_Battery` is — a projection exposes a strict subset
|
||||
of the allowed class. The matcher parses the query shape instead of literal-prefix
|
||||
matching; the allowed-class set is derived at load time from the existing
|
||||
`wmiQueryPrefixes` entries, so `rules/guardrails.json` needs no new key. The same
|
||||
algorithm is mirrored in the portal (`utils/guardValidation.ts`) and the MCP server
|
||||
(`rule-validation.ts`); agents older than the change still block projections with a
|
||||
`security_warning` until they self-update.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs`
|
||||
|
||||
+23
-2
@@ -250,6 +250,27 @@
|
||||
"C:\\Install\\Log"
|
||||
],
|
||||
"blockedFilePrefixes": [
|
||||
"C:\\Users"
|
||||
]
|
||||
"C:\\Users",
|
||||
"C:\\Windows\\System32\\config"
|
||||
],
|
||||
"blockedCommandPatterns": [
|
||||
"Invoke-WebRequest",
|
||||
"Invoke-RestMethod",
|
||||
"Start-BitsTransfer",
|
||||
"wget",
|
||||
"curl",
|
||||
"certutil -urlcache",
|
||||
"New-LocalUser",
|
||||
"Add-LocalGroupMember",
|
||||
"net user",
|
||||
"net localgroup",
|
||||
"bcdedit",
|
||||
"bcdboot",
|
||||
"schtasks /create",
|
||||
"Register-ScheduledTask",
|
||||
"Remove-Item -Recurse",
|
||||
"Format-Volume",
|
||||
"Set-ExecutionPolicy"
|
||||
],
|
||||
"maxCommandLength": 2000
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"collectorType": {
|
||||
"type": "string",
|
||||
"description": "Type of data collection.",
|
||||
"enum": ["registry", "eventlog", "wmi", "file", "command_allowlisted", "logparser"]
|
||||
"enum": ["registry", "eventlog", "wmi", "file", "json", "xml", "command_allowlisted", "logparser"]
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
|
||||
@@ -154,6 +154,12 @@ ${flatArray(flatEventLogChannels)}
|
||||
export const BLOCKED_EVENT_LOG_CHANNELS: readonly string[] = [
|
||||
${flatArray(guardrails.blockedEventLogChannels)}
|
||||
];
|
||||
|
||||
export const BLOCKED_COMMAND_PATTERNS: readonly string[] = [
|
||||
${flatArray(guardrails.blockedCommandPatterns)}
|
||||
];
|
||||
|
||||
export const MAX_COMMAND_LENGTH = ${guardrails.maxCommandLength};
|
||||
`;
|
||||
|
||||
fs.writeFileSync(guardrailsOutput, ts, 'utf8');
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
DEV bootstrap script (v2.3-dev) to deploy and start the Autopilot Monitor agent.
|
||||
|
||||
.DESCRIPTION
|
||||
DEV VARIANT of Install-AutopilotMonitor.ps1 for validating the v2.3 Cloud-PC-relax
|
||||
on Windows 365 before promoting it to the production script. Differences to v2.2:
|
||||
- Cloud-PC-relax (see below) as a second trigger for the guard 2+3 exemption
|
||||
- Raw OutOfBoxExperienceState value is logged (diagnosis)
|
||||
- Logs to bootstrap_agent_dev.log so runs never interleave with the prod script
|
||||
- Testable structure: guard/relax logic lives in functions; the script only
|
||||
executes when run directly. Dot-sourcing it (as the Pester suite in
|
||||
Install-AutopilotMonitor-dev.Tests.ps1 does) loads the functions without
|
||||
side effects.
|
||||
|
||||
Designed to be deployed via Intune as a PowerShell Script during Autopilot.
|
||||
Runs very early in the enrollment process (first Intune action) and:
|
||||
1. Runs pre-flight guards to skip productive devices and ghost re-installs.
|
||||
A relax exemption allows install when a single freshly-created profile
|
||||
appears DURING OOBE (Windows Backup for Organizations restore) or on a
|
||||
freshly provisioned Windows 365 Cloud PC.
|
||||
2. Downloads the monitoring agent ZIP from Azure Blob Storage
|
||||
3. Verifies integrity via SHA-256 hash from the version manifest (mandatory)
|
||||
4. Extracts the agent into %ProgramData%\AutopilotMonitor\Agent
|
||||
5. Runs the agent in --install mode (registers Scheduled Task, spawns the runtime)
|
||||
6. Verifies the runtime process actually launched
|
||||
Agent self-destructs when enrollment completes.
|
||||
|
||||
Relax flow (guards 2+3): a real user profile (or a real LastLoggedOnUser)
|
||||
normally means "productive -> skip". Two legitimate flows break that assumption:
|
||||
|
||||
(1) Windows Backup for Organizations: one user signs in + restores DURING OOBE.
|
||||
Trigger: the OS positively reports OOBE InProgress (Test via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState).
|
||||
|
||||
(2) Windows 365 Cloud PC: W365 provisions headless -- OOBE and Intune enrollment
|
||||
complete before any user ever connects, so OutOfBoxExperienceState is NEVER
|
||||
InProgress when this script runs. The assigned user's profile is created at
|
||||
first connect, typically seconds before IME pulls this script. Trigger: the
|
||||
device positively identifies as a Cloud PC via TWO local markers (both
|
||||
required; captured from a real Cloud PC -- local WMI reports only the
|
||||
generic 'Virtual Machine' model): HKLM\SOFTWARE\Microsoft\Windows365
|
||||
registry key AND the CloudManagedDesktopExtension service.
|
||||
|
||||
Either trigger relaxes guards 2+3 ONLY when additionally:
|
||||
(a) there is exactly ONE real user profile, AND
|
||||
(b) that profile was created within $OobeProfileMaxAgeMinutes minutes.
|
||||
The Cloud PC trigger additionally exempts guard 4 (uptime window) -- see
|
||||
.PARAMETER MaxBootstrapWindowHours for why boot uptime is meaningless there.
|
||||
Any miss keeps the original skip (SKIP-safe: we never install on uncertainty).
|
||||
On productive devices the profile is old, so (b) keeps skipping correctly.
|
||||
Known accepted edge: a W365 Frontline shared-mode device can legitimately show a
|
||||
single fresh profile while productive; a mis-install there is one-time (agent
|
||||
finds enrollment complete and self-destructs; Deployed marker prevents repeats).
|
||||
|
||||
.PARAMETER AgentDownloadUrl
|
||||
URL to download the agent ZIP from. Defaults to the production download endpoint;
|
||||
override only for parallel lab/dev assignments that point at a separate pre-release ZIP.
|
||||
|
||||
.PARAMETER VersionJsonName
|
||||
Filename of the integrity manifest in the same blob container as the agent ZIP.
|
||||
Defaults to "version.json"; override only for parallel lab/dev manifests.
|
||||
|
||||
.PARAMETER MaxBootstrapWindowHours
|
||||
Maximum device uptime (hours) within which the bootstrap is still considered
|
||||
valid. Devices booted more than this many hours ago are skipped because we no
|
||||
longer trust their OOBE state. Default: 12.
|
||||
Cloud PC note: NOT applied when the Cloud-PC relax is active. A Cloud PC runs
|
||||
headless for days between provisioning and the user's first connect, and the
|
||||
phase worth monitoring (Account Setup) only starts at that first connect --
|
||||
there the fresh-profile window (OobeProfileMaxAgeMinutes) is the time anchor,
|
||||
not boot uptime.
|
||||
|
||||
.PARAMETER OobeProfileMaxAgeMinutes
|
||||
Maximum age (minutes) of the single profile that may appear for the relax
|
||||
exemption to apply. The restore/first-connect -> bootstrap gap is always
|
||||
minutes; an older profile is treated as a productive device. Default: 15.
|
||||
|
||||
.NOTES
|
||||
- Agent is temporary and auto-removes after enrollment.
|
||||
- All files live under C:\ProgramData\AutopilotMonitor (easy cleanup).
|
||||
- One registry key remains after removal: HKLM\SOFTWARE\AutopilotMonitor\Deployed
|
||||
(prevents ghost re-installs on re-Autopilot of the same device).
|
||||
- Scheduled Task survives reboots during enrollment.
|
||||
- OOBE detection uses Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
Requires Windows 10, version 1809 (introduced in 10.0.17763.0). Older builds lack
|
||||
the class -> state reads as 'Unavailable' -> original (skip) behaviour. Docs:
|
||||
https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
- This script MUST remain pure ASCII (no Unicode/UTF-8 special chars).
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
2026-08-06 v2.3-dev.3 Guard 4 (uptime window) exempted while the Cloud-PC relax is
|
||||
active: Cloud PCs run headless for days before the user's first
|
||||
connect, so boot uptime says nothing about enrollment freshness --
|
||||
the single-fresh-profile window (< OobeProfileMaxAgeMinutes) already
|
||||
anchors the relax in time. OOBE-restore keeps the uptime guard.
|
||||
2026-08-06 v2.3-dev.2 Cloud PC detection rebuilt on captured evidence: first field
|
||||
test proved local WMI reports only Manufacturer='Microsoft
|
||||
Corporation' + Model='Virtual Machine' (the 'Cloud PC ...' model
|
||||
string exists only in Intune/Graph). Detection now requires the
|
||||
HKLM\SOFTWARE\Microsoft\Windows365 registry key AND the
|
||||
CloudManagedDesktopExtension service (both verified present on a
|
||||
real Cloud PC); raw identity + marker states are always logged.
|
||||
2026-08-05 v2.3-dev DEV variant (-dev.ps1, own log file bootstrap_agent_dev.log)
|
||||
to validate on Windows 365 before promotion to v2.3:
|
||||
Cloud-PC-relax -- a positively identified Cloud PC
|
||||
(Win32_ComputerSystem Manufacturer 'Microsoft Corporation' +
|
||||
Model 'Cloud PC*') now triggers the guard 2+3 relax alongside
|
||||
OOBE InProgress; the single-fresh-profile conditions are
|
||||
unchanged. Raw OutOfBoxExperienceState is now logged.
|
||||
Refactored into functions with a dot-source entry guard so the
|
||||
Pester suite (*.Tests.ps1) can load the logic without executing
|
||||
the bootstrap; behaviour when run directly is unchanged.
|
||||
2026-07-20 v2.2 Switched default download endpoint from the legacy blob
|
||||
(autopilotmonitor.blob.core.windows.net) to the Front Door alias
|
||||
https://download.autopilotmonitor.com/agent/ (route /agent/*).
|
||||
version.json is still derived from the same container.
|
||||
2026-06-25 v2.1 OOBE-relax: detect genuine OOBE via WinRT
|
||||
SystemSetupInfo.OutOfBoxExperienceState and exempt guards 2+3 when a
|
||||
single freshly-created profile appears in OOBE (Windows Backup for
|
||||
Organizations). SKIP-safe when the API is absent (< 1809).
|
||||
Removed legacy Content-MD5 fallback; SHA-256 manifest verification is
|
||||
now mandatory (no hash -> bootstrap aborts, no unverified install).
|
||||
2026-05-09 v2.0 Generic bootstrap: agent owns its own defaults (e.g. 600 s
|
||||
TenantId-wait), so the script only calls `--install` plain.
|
||||
Hardened post-install with a 10 s runtime-process verify.
|
||||
(older entries: see Install-AutopilotMonitor.ps1)
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$MaxBootstrapWindowHours = 12,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[int]$OobeProfileMaxAgeMinutes = 15
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.3-dev.3"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
$AgentBinPath = "$AgentBasePath\Agent"
|
||||
$AgentLogPath = "$AgentBasePath\Logs"
|
||||
$LogFile = "$AgentLogPath\bootstrap_agent_dev.log"
|
||||
|
||||
# Write-Host (NOT Write-Output): log calls happen inside functions that return
|
||||
# decision objects; Write-Output would leak every log line into those return
|
||||
# values and turn them into arrays. Write-Host goes to the console host only.
|
||||
function Write-Log {
|
||||
param([string]$Message)
|
||||
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
$logMessage = "[$timestamp] $Message"
|
||||
Write-Host $logMessage
|
||||
Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
# Raw OOBE state via the documented WinRT API
|
||||
# Windows.System.Profile.SystemSetupInfo.OutOfBoxExperienceState.
|
||||
# Returns the state string (InProgress/Completed/NotStarted). An older OS without the
|
||||
# class (< Win10 1809 / 10.0.17763.0) or any error returns 'Unavailable'
|
||||
# -> SKIP-safe (we never relax a guard on uncertainty).
|
||||
# Docs: https://learn.microsoft.com/en-us/uwp/api/windows.system.profile.systemsetupinfo
|
||||
function Get-OobeState {
|
||||
try {
|
||||
$null = [Windows.System.Profile.SystemSetupInfo, Windows.System.Profile, ContentType = WindowsRuntime]
|
||||
return ([Windows.System.Profile.SystemSetupInfo]::OutOfBoxExperienceState).ToString()
|
||||
}
|
||||
catch {
|
||||
return 'Unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
# Positive Windows 365 Cloud PC detection.
|
||||
# Evidence captured 2026-08-06 on a real, freshly provisioned W365 Enterprise Cloud PC:
|
||||
# - Win32_ComputerSystem is GENERIC there: Manufacturer='Microsoft Corporation',
|
||||
# Model='Virtual Machine'. The 'Cloud PC Enterprise ...' model string exists only
|
||||
# in the Intune/Graph inventory, NOT in local WMI/SMBIOS -- it cannot be used here.
|
||||
# - HKLM:\SOFTWARE\Microsoft\Windows365 exists.
|
||||
# - Service 'CloudManagedDesktopExtension' (Microsoft Cloud Managed Desktop
|
||||
# Extension, the W365 management agent that runs ON Cloud PCs) is installed.
|
||||
# BOTH markers are required (AND): each alone has plausible look-alikes (W365-Boot
|
||||
# physical clients may carry Windows365 policy state; the MCMD agent family also
|
||||
# served other managed-desktop offerings). Raw identity and both marker states are
|
||||
# always logged for field diagnosis; any error returns $false (SKIP-safe).
|
||||
function Test-IsCloudPc {
|
||||
try {
|
||||
$cs = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
|
||||
Write-Log "Computer identity: Manufacturer='$($cs.Manufacturer)' Model='$($cs.Model)'."
|
||||
}
|
||||
catch { Write-Log "INFO: computer identity query failed: $($_.Exception.Message)" }
|
||||
|
||||
try {
|
||||
$regMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
|
||||
$svcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
|
||||
Write-Log "Cloud PC markers: Windows365 registry key=$regMarker; CloudManagedDesktopExtension service=$svcMarker."
|
||||
return ($regMarker -and $svcMarker)
|
||||
}
|
||||
catch {
|
||||
Write-Log "INFO: Cloud PC marker check failed: $($_.Exception.Message)"
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Collects real (non-special) user profiles as full paths (WMI Special flag + filesystem).
|
||||
# NOTE: callers MUST wrap the result in @(...). PowerShell unrolls function output, so an
|
||||
# empty result returns $null and a single path returns a scalar string, whose [0] indexer
|
||||
# would yield its first char ('C') instead of the full path.
|
||||
function Get-RealUserProfilePaths {
|
||||
$excludePattern = '^(defaultuser\d*|Public|Default( User)?|All Users|WDAGUtilityAccount)$'
|
||||
@(
|
||||
try {
|
||||
Get-CimInstance Win32_UserProfile -ErrorAction Stop |
|
||||
Where-Object { -not $_.Special -and $_.LocalPath -like 'C:\Users\*' } |
|
||||
ForEach-Object { $_.LocalPath }
|
||||
} catch { Write-Log "INFO: WMI profile query failed, continuing with filesystem check." }
|
||||
|
||||
(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue).FullName
|
||||
) | Where-Object { $_ -and (Split-Path $_ -Leaf) -notmatch $excludePattern } |
|
||||
Select-Object -Unique
|
||||
}
|
||||
|
||||
# Relax: the ONLY exemption to guards 2+3. Two triggers (see .DESCRIPTION):
|
||||
# (1) OOBE InProgress -> Windows Backup for Organizations restore
|
||||
# (2) Cloud PC identity -> Windows 365 (headless OOBE, profile at first connect)
|
||||
# Either trigger additionally requires: exactly one real profile AND that profile
|
||||
# created < $OobeProfileMaxAgeMinutes minutes ago. Any miss keeps the original skip.
|
||||
# Returns an object with: Active (bool), OobeState (string), IsCloudPc (bool).
|
||||
function Get-RelaxDecision {
|
||||
param(
|
||||
[string[]]$ProfilePaths,
|
||||
[int]$OobeProfileMaxAgeMinutes
|
||||
)
|
||||
if ($null -eq $ProfilePaths) { $ProfilePaths = @() }
|
||||
|
||||
$relaxActive = $false
|
||||
$oobeState = Get-OobeState
|
||||
$oobeInProgress = ($oobeState -eq 'InProgress')
|
||||
$isCloudPc = Test-IsCloudPc
|
||||
if ($isCloudPc) {
|
||||
Write-Log "Cloud PC detected (OOBE state: $oobeState)."
|
||||
}
|
||||
|
||||
$relaxTrigger = $null
|
||||
if ($oobeInProgress) {
|
||||
$relaxTrigger = "OOBE InProgress (Windows Backup restore case)"
|
||||
} elseif ($isCloudPc) {
|
||||
$relaxTrigger = "Cloud PC (headless provisioning, OOBE state: $oobeState)"
|
||||
}
|
||||
|
||||
if ($ProfilePaths.Count -eq 1 -and $relaxTrigger) {
|
||||
try {
|
||||
$created = (Get-Item -LiteralPath $ProfilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$ageMin = ((Get-Date).ToUniversalTime() - $created).TotalMinutes
|
||||
if ($ageMin -ge 0 -and $ageMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$relaxActive = $true
|
||||
Write-Log ("Relax active: {0} + single profile created {1:N1}min ago (< {2}min). Guards 2+3 relaxed." -f $relaxTrigger, $ageMin, $OobeProfileMaxAgeMinutes)
|
||||
} else {
|
||||
Write-Log ("Relax NOT applied: {0} + single profile '{1}', but profile created {2:N1}min ago (created {3:u}) is outside the [0, {4})min window." -f $relaxTrigger, (Split-Path $ProfilePaths[0] -Leaf), $ageMin, $created, $OobeProfileMaxAgeMinutes)
|
||||
}
|
||||
} catch { Write-Log "INFO: profile age check failed; relax not applied. $($_.Exception.Message)" }
|
||||
} elseif ($ProfilePaths.Count -eq 1) {
|
||||
Write-Log "Relax NOT applied: single profile present but neither OOBE InProgress nor Cloud PC (OOBE state: $oobeState)."
|
||||
} elseif ($ProfilePaths.Count -gt 1) {
|
||||
Write-Log ("Relax NOT applied: {0} real profiles present (relax requires exactly one). OOBE state: {1}; CloudPC: {2}." -f $ProfilePaths.Count, $oobeState, $isCloudPc)
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Active = $relaxActive
|
||||
OobeState = $oobeState
|
||||
IsCloudPc = $isCloudPc
|
||||
}
|
||||
}
|
||||
|
||||
# Runs pre-flight guards 1-5 and returns the install decision WITHOUT side effects
|
||||
# beyond logging. Returns an object with:
|
||||
# Install (bool), ReasonCode (null | AlreadyDeployed | ProfilesFound |
|
||||
# LastLoggedOnUser | UptimeExceeded | AgentPresent), RelaxActive (bool).
|
||||
function Get-BootstrapDecision {
|
||||
param(
|
||||
[int]$MaxBootstrapWindowHours,
|
||||
[int]$OobeProfileMaxAgeMinutes,
|
||||
[string]$AgentBinPath
|
||||
)
|
||||
|
||||
# Guard 1: Agent was already deployed on this device (registry marker survives self-destruct)
|
||||
$deployed = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -ErrorAction SilentlyContinue).Deployed
|
||||
if ($deployed) {
|
||||
Write-Log "SKIP: Agent was previously deployed at $deployed."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AlreadyDeployed'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# The @() wrap is required, see NOTE on Get-RealUserProfilePaths.
|
||||
$profilePaths = @(Get-RealUserProfilePaths)
|
||||
$relax = Get-RelaxDecision -ProfilePaths $profilePaths -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes
|
||||
|
||||
# Guard 2: No real user profile should exist yet (primary productive-device guard),
|
||||
# unless the relax signature holds.
|
||||
if ($profilePaths.Count -gt 0 -and -not $relax.Active) {
|
||||
$details = (($profilePaths | ForEach-Object {
|
||||
$leaf = Split-Path $_ -Leaf
|
||||
try {
|
||||
$c = (Get-Item -LiteralPath $_ -Force -ErrorAction Stop).CreationTimeUtc
|
||||
"{0} (created {1:u}, {2:N1}min ago)" -f $leaf, $c, ((Get-Date).ToUniversalTime() - $c).TotalMinutes
|
||||
} catch { "$leaf (age unknown)" }
|
||||
}) | Select-Object -First 3) -join '; '
|
||||
Write-Log "SKIP: Real user profile(s) found ($details). OOBE state: $($relax.OobeState). CloudPC: $($relax.IsCloudPc). Device appears productive."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'ProfilesFound'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 3: LastLoggedOnUser - during Device ESP no real user has logged on yet
|
||||
# (same relax exemption: the restoring/first-connect user signs in legitimately).
|
||||
$lastLoggedOnUser = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Authentication\LogonUI' -Name 'LastLoggedOnUser' -EA SilentlyContinue).LastLoggedOnUser
|
||||
if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -not $relax.Active) {
|
||||
Write-Log "SKIP: LastLoggedOnUser found ($lastLoggedOnUser). Device appears productive."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'LastLoggedOnUser'; RelaxActive = $false }
|
||||
}
|
||||
|
||||
# Guard 4: Bootstrap window check - device uptime must be within accepted OOBE window.
|
||||
# Sleep/standby does not reset uptime, only real boot/restart does.
|
||||
# Cloud PC exemption (v2.3-dev.3): a Cloud PC runs headless from provisioning until the
|
||||
# user's first connect -- often days -- and the phase worth monitoring (Account Setup)
|
||||
# only STARTS at that first connect. Boot uptime is therefore meaningless there; the
|
||||
# single-fresh-profile window of the relax (< $OobeProfileMaxAgeMinutes min) is the time
|
||||
# anchor instead. The OOBE-restore trigger keeps the original uptime guard (physical
|
||||
# device, uptime is meaningful).
|
||||
$lastBoot = (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
Write-Log "Device uptime: $([int]$uptimeHours)h (last boot: $lastBoot)"
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
if ($relax.Active -and $relax.IsCloudPc) {
|
||||
Write-Log "Uptime $([int]$uptimeHours)h exceeds the ${MaxBootstrapWindowHours}h window, but the Cloud PC relax is active (fresh first-connect profile is the time anchor). Continuing."
|
||||
} else {
|
||||
Write-Log "SKIP: Device uptime is $([int]$uptimeHours)h. OOBE state is older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'UptimeExceeded'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
# Guard 5: Is the agent already installed? (leftover from previous run)
|
||||
if (Test-Path $AgentBinPath) {
|
||||
$existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue
|
||||
if ($existingAgent) {
|
||||
Write-Log "SKIP: Agent already installed at $($existingAgent.FullName)."
|
||||
return [pscustomobject]@{ Install = $false; ReasonCode = 'AgentPresent'; RelaxActive = $relax.Active }
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{ Install = $true; ReasonCode = $null; RelaxActive = $relax.Active }
|
||||
}
|
||||
|
||||
# Main bootstrap flow. Only invoked when the script is run directly (see entry guard
|
||||
# at the bottom); never on dot-source.
|
||||
function Invoke-Bootstrap {
|
||||
try {
|
||||
New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
|
||||
Write-Log "===== Autopilot Monitor Bootstrap Started ====="
|
||||
Write-Log "Bootstrap script version: v$ScriptVersion"
|
||||
|
||||
$decision = Get-BootstrapDecision -MaxBootstrapWindowHours $MaxBootstrapWindowHours -OobeProfileMaxAgeMinutes $OobeProfileMaxAgeMinutes -AgentBinPath $AgentBinPath
|
||||
if (-not $decision.Install) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Log "Pre-flight checks passed -- no prior deployment, no productive-device signals, within bootstrap window (relaxActive=$($decision.RelaxActive))"
|
||||
|
||||
# Download and extract agent binaries
|
||||
$agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe"
|
||||
|
||||
if (Test-Path $agentExePath) {
|
||||
Write-Log "Agent already installed at $agentExePath"
|
||||
}
|
||||
else {
|
||||
Write-Log "Downloading agent from $AgentDownloadUrl..."
|
||||
|
||||
try {
|
||||
# Derive manifest URL from the agent download URL (same blob container)
|
||||
$versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', $VersionJsonName
|
||||
$expectedSha256 = $null
|
||||
$manifestAttempts = 3
|
||||
$manifestDelays = @(1, 2) # short backoff between attempts; do not block enrollment
|
||||
|
||||
for ($attempt = 1; $attempt -le $manifestAttempts; $attempt++) {
|
||||
try {
|
||||
Write-Log "Fetching $VersionJsonName (attempt ${attempt}/${manifestAttempts}) from $versionJsonUrl for integrity verification..."
|
||||
$versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop
|
||||
if ($versionJsonResponse.sha256) {
|
||||
$expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant()
|
||||
Write-Log "SHA-256 hash from manifest: $expectedSha256 (version: $($versionJsonResponse.version))"
|
||||
} else {
|
||||
Write-Log "WARNING: Manifest has no sha256 field - integrity cannot be verified."
|
||||
}
|
||||
break # manifest reached (with or without hash); retrying would not help
|
||||
}
|
||||
catch {
|
||||
Write-Log "WARNING: Could not fetch integrity manifest (attempt $attempt): $($_.Exception.Message)"
|
||||
if ($attempt -lt $manifestAttempts) {
|
||||
Start-Sleep -Seconds $manifestDelays[$attempt - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip"
|
||||
$maxDownloadAttempts = 3
|
||||
$downloadAttempt = 0
|
||||
|
||||
do {
|
||||
$downloadAttempt++
|
||||
try {
|
||||
Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}"
|
||||
$downloadStopwatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
Invoke-WebRequest `
|
||||
-Uri $AgentDownloadUrl `
|
||||
-OutFile $zipPath `
|
||||
-UseBasicParsing `
|
||||
-TimeoutSec 30 `
|
||||
-ErrorAction Stop
|
||||
$downloadStopwatch.Stop()
|
||||
$downloadSeconds = [math]::Round($downloadStopwatch.Elapsed.TotalSeconds, 1)
|
||||
Write-Log "Downloaded agent to $zipPath (took ${downloadSeconds}s)"
|
||||
break
|
||||
}
|
||||
catch {
|
||||
if ($downloadAttempt -ge $maxDownloadAttempts) {
|
||||
throw
|
||||
}
|
||||
|
||||
$retryDelaysInSeconds = @(2, 4, 8)
|
||||
$retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1]
|
||||
Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..."
|
||||
Start-Sleep -Seconds $retryDelaySeconds
|
||||
}
|
||||
} while ($downloadAttempt -lt $maxDownloadAttempts)
|
||||
|
||||
# SHA-256 from the version manifest is the only accepted integrity proof.
|
||||
# No hash -> refuse to install an unverified ZIP (SKIP-safe security posture).
|
||||
if (-not $expectedSha256) {
|
||||
throw "No SHA-256 hash available from manifest - refusing to install unverified agent ZIP."
|
||||
}
|
||||
$actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Log "Validating SHA-256 hash against manifest"
|
||||
if ($actualSha256 -ne $expectedSha256) {
|
||||
throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted."
|
||||
}
|
||||
Write-Log "SHA-256 integrity check passed"
|
||||
|
||||
Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force
|
||||
Write-Log "Extracted agent to $AgentBinPath"
|
||||
|
||||
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
|
||||
Write-Log "Cleaned up temporary files"
|
||||
|
||||
if (-not (Test-Path $agentExePath)) {
|
||||
throw "Agent executable not found after extraction at $agentExePath"
|
||||
}
|
||||
|
||||
Write-Log "Agent installation completed successfully"
|
||||
}
|
||||
catch {
|
||||
Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)"
|
||||
throw
|
||||
}
|
||||
}
|
||||
|
||||
Write-Log "Calling agent install mode (--install)..."
|
||||
& $agentExePath --install
|
||||
$installExitCode = $LASTEXITCODE
|
||||
if ($installExitCode -ne 0) {
|
||||
throw "Agent install failed with exit code $installExitCode"
|
||||
}
|
||||
Write-Log "Agent install mode completed successfully"
|
||||
|
||||
$runtimeProcessName = 'AutopilotMonitor.Agent'
|
||||
$verifyTimeoutSec = 10
|
||||
$verifyDeadline = (Get-Date).AddSeconds($verifyTimeoutSec)
|
||||
$runtimeProc = $null
|
||||
while ((Get-Date) -lt $verifyDeadline) {
|
||||
$runtimeProc = Get-Process -Name $runtimeProcessName -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($null -ne $runtimeProc) { break }
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
if ($null -ne $runtimeProc) {
|
||||
$startedUtc = 'unavailable'
|
||||
try { $startedUtc = $runtimeProc.StartTime.ToUniversalTime().ToString('o') } catch { }
|
||||
Write-Log ("Runtime process verified: name={0}.exe pid={1} startedUtc={2}" -f $runtimeProcessName, $runtimeProc.Id, $startedUtc)
|
||||
} else {
|
||||
Write-Log ("WARNING: Runtime process verification FAILED. Agent --install reported success but no '{0}.exe' process appeared within {1}s. Likely silent block (AV/EDR, AppLocker/WDAC) of the runtime launch. Agent should still come up at next boot via the BootTrigger task. Check Event Viewer > Microsoft > Windows > TaskScheduler/Operational and AV/EDR logs for '{0}.exe'." -f $runtimeProcessName, $verifyTimeoutSec)
|
||||
}
|
||||
|
||||
Write-Log "===== Bootstrap Completed Successfully ====="
|
||||
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Log "===== Bootstrap FAILED ====="
|
||||
Write-Log "ERROR: $($_.Exception.Message)"
|
||||
Write-Log "Stack trace: $($_.ScriptStackTrace)"
|
||||
Write-Log "Please check log file: $LogFile"
|
||||
|
||||
$errMsg = "AutopilotMonitor bootstrap failed: $($_.Exception.Message)"
|
||||
if ($errMsg.Length -gt 2048) { $errMsg = $errMsg.Substring(0, 1045) + '...' }
|
||||
[Console]::Error.WriteLine($errMsg)
|
||||
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Entry guard: execute only when run as a script (IME, console). Dot-sourcing
|
||||
# (". .\Install-AutopilotMonitor-dev.ps1", as the Pester suite does) only loads
|
||||
# the functions above and MUST stay side-effect free.
|
||||
if ($MyInvocation.InvocationName -ne '.') {
|
||||
Invoke-Bootstrap
|
||||
}
|
||||
+16
-16
@@ -1,5 +1,5 @@
|
||||
# Pester tests (Pester 5+) for the bootstrap guard/relax logic in
|
||||
# Install-AutopilotMonitor-dev.ps1 (v2.3-dev).
|
||||
# Install-AutopilotMonitor.ps1.
|
||||
#
|
||||
# The script under test is dot-sourced; its entry guard prevents the bootstrap
|
||||
# from executing, so loading it here is side-effect free. External state (WMI,
|
||||
@@ -12,7 +12,7 @@
|
||||
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor-dev.ps1')
|
||||
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1')
|
||||
|
||||
function New-FakeProfile {
|
||||
param([string]$Name, [double]$AgeMinutes)
|
||||
@@ -24,9 +24,9 @@ BeforeAll {
|
||||
}
|
||||
|
||||
Describe 'Test-IsCloudPc' {
|
||||
# Marker set captured 2026-08-06 from a real W365 Enterprise Cloud PC: local WMI
|
||||
# is generic ('Virtual Machine'), so detection rests on the Windows365 registry
|
||||
# key AND the CloudManagedDesktopExtension service (both required).
|
||||
# Local WMI on a Cloud PC is generic ('Virtual Machine'), so detection rests on
|
||||
# the Windows365 registry key AND the CloudManagedDesktopExtension service
|
||||
# (both required).
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ Manufacturer = 'Microsoft Corporation'; Model = 'Virtual Machine' } }
|
||||
@@ -78,21 +78,21 @@ Describe 'Get-RealUserProfilePaths' {
|
||||
It 'merges WMI and filesystem views, drops special/system profiles, dedupes' {
|
||||
Mock Get-CimInstance {
|
||||
@(
|
||||
[pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\LeonGottschalk' }
|
||||
[pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\UserOne' }
|
||||
[pscustomobject]@{ Special = $true; LocalPath = 'C:\Users\systemprofile' }
|
||||
[pscustomobject]@{ Special = $false; LocalPath = 'D:\Profiles\Elsewhere' }
|
||||
)
|
||||
}
|
||||
Mock Get-ChildItem {
|
||||
@(
|
||||
[pscustomobject]@{ FullName = 'C:\Users\LeonGottschalk' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\UserOne' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\Public' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\defaultuser0' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\WDAGUtilityAccount' }
|
||||
)
|
||||
}
|
||||
$result = @(Get-RealUserProfilePaths)
|
||||
$result | Should -Be @('C:\Users\LeonGottschalk')
|
||||
$result | Should -Be @('C:\Users\UserOne')
|
||||
}
|
||||
|
||||
It 'falls back to the filesystem view when the WMI query fails' {
|
||||
@@ -132,15 +132,15 @@ Describe 'Get-RelaxDecision' {
|
||||
Mock Test-IsCloudPc { $true }
|
||||
}
|
||||
|
||||
It 'relaxes for a single fresh profile although OOBE is Completed (W365 field case)' {
|
||||
$p = New-FakeProfile 'LeonGottschalk' 0.6
|
||||
It 'relaxes for a single fresh profile although OOBE is Completed (first connect)' {
|
||||
$p = New-FakeProfile 'CloudPcUser' 0.6
|
||||
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
|
||||
$d.Active | Should -BeTrue
|
||||
$d.IsCloudPc | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not relax for an old profile (productive Cloud PC)' {
|
||||
$p = New-FakeProfile 'LeonGottschalk' 30
|
||||
$p = New-FakeProfile 'CloudPcUser' 30
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
|
||||
@@ -162,7 +162,7 @@ Describe 'Get-RelaxDecision' {
|
||||
}
|
||||
}
|
||||
|
||||
Context 'OOBE InProgress (Windows Backup for Organizations, v2.2 behaviour)' {
|
||||
Context 'OOBE InProgress (Windows Backup for Organizations)' {
|
||||
It 'relaxes for a single fresh profile during OOBE' {
|
||||
Mock Get-OobeState { 'InProgress' }
|
||||
$p = New-FakeProfile 'RestoreUser' 3
|
||||
@@ -218,12 +218,12 @@ Describe 'Get-BootstrapDecision' {
|
||||
$d.ReasonCode | Should -Be 'ProfilesFound'
|
||||
}
|
||||
|
||||
It 'installs on a freshly provisioned Cloud PC with one fresh profile (W365 field case)' {
|
||||
$script:fakeProfile = New-FakeProfile 'LeonGottschalk' 0.6
|
||||
It 'installs on a freshly provisioned Cloud PC with one fresh profile' {
|
||||
$script:fakeProfile = New-FakeProfile 'CloudPcUser' 0.6
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Test-IsCloudPc { $true }
|
||||
# First-connect user is already visible in LogonUI on W365 -- must not block either.
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\LeonGottschalk' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\CloudPcUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeTrue
|
||||
$d.RelaxActive | Should -BeTrue
|
||||
@@ -248,7 +248,7 @@ Describe 'Get-BootstrapDecision' {
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
|
||||
It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption, v2.3-dev.3)' {
|
||||
It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption)' {
|
||||
# W365 reality: provisioned days ago, running headless ever since; the user's
|
||||
# first connect just created the profile. Uptime is meaningless here.
|
||||
$script:fakeProfile = New-FakeProfile 'FirstConnectUser' 0.6
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
# Autopilot Monitor Bootstrap Guard Test
|
||||
# Dry-run only: does not exit, does not install, only reports what WOULD happen.
|
||||
# Mirrors Install-AutopilotMonitor.ps1 incl. the OOBE-relax exemption for the
|
||||
# "Windows Backup for Organizations" restore case (guards 2+3).
|
||||
# Mirrors Install-AutopilotMonitor.ps1 (v2.3) incl. the relax exemption for
|
||||
# guards 2+3, triggered by "Windows Backup for Organizations" (OOBE InProgress)
|
||||
# or a positively identified Windows 365 Cloud PC, and the guard 4 (uptime)
|
||||
# exemption while the Cloud PC relax is active.
|
||||
|
||||
param(
|
||||
[string]$AgentBinPath = 'C:\ProgramData\AutopilotMonitor\Agent',
|
||||
@@ -54,6 +56,12 @@ function Test-OobeInProgress {
|
||||
}
|
||||
}
|
||||
|
||||
# Positive Windows 365 Cloud PC detection: two local markers, BOTH required (AND),
|
||||
# because each alone has look-alikes. Mirrors Test-IsCloudPc in the bootstrapper.
|
||||
$cloudPcRegMarker = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows365'
|
||||
$cloudPcSvcMarker = $null -ne (Get-Service -Name 'CloudManagedDesktopExtension' -ErrorAction SilentlyContinue)
|
||||
$isCloudPc = ($cloudPcRegMarker -and $cloudPcSvcMarker)
|
||||
|
||||
$AgentExePath = Join-Path $AgentBinPath 'AutopilotMonitor.Agent.exe'
|
||||
$MaxBootstrapWindowHours = 12
|
||||
|
||||
@@ -104,8 +112,9 @@ if ($wmiProfileQueryFailed) {
|
||||
|
||||
$profileNames = $profilePaths | ForEach-Object { Split-Path $_ -Leaf }
|
||||
|
||||
# OOBE-relax: the ONLY exemption to guards 2+3. Matches exactly the Windows Backup for
|
||||
# Organizations restore: OOBE InProgress AND exactly one profile AND that profile fresh.
|
||||
# Relax: the ONLY exemption to guards 2+3. Two triggers -- OOBE InProgress (Windows
|
||||
# Backup for Organizations restore) or Cloud PC identity (headless provisioning,
|
||||
# profile at first connect). Either additionally requires exactly one fresh profile.
|
||||
$oobeInProgress = Test-OobeInProgress
|
||||
$profileAgeMin = $null
|
||||
$oobeRelax = $false
|
||||
@@ -114,12 +123,13 @@ if ($profilePaths.Count -eq 1) {
|
||||
$created = (Get-Item -LiteralPath $profilePaths[0] -Force -ErrorAction Stop).CreationTimeUtc
|
||||
$profileAgeMin = [math]::Round(((Get-Date).ToUniversalTime() - $created).TotalMinutes, 1)
|
||||
} catch { }
|
||||
if ($oobeInProgress -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
if (($oobeInProgress -or $isCloudPc) -and $null -ne $profileAgeMin -and $profileAgeMin -ge 0 -and $profileAgeMin -lt $OobeProfileMaxAgeMinutes) {
|
||||
$oobeRelax = $true
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; OOBE-relax = $oobeRelax"
|
||||
Write-Step -Status 'INFO' -Message "Cloud PC markers: Windows365 registry key = $cloudPcRegMarker ; CloudManagedDesktopExtension service = $cloudPcSvcMarker ; IsCloudPc = $isCloudPc"
|
||||
Write-Step -Status 'INFO' -Message "OOBE: OutOfBoxExperienceState InProgress = $oobeInProgress ; profileCount = $($profilePaths.Count) ; singleProfileAgeMin = $profileAgeMin ; relax = $oobeRelax"
|
||||
|
||||
# Guard 2: No real user profile should exist yet (unless OOBE-relax holds)
|
||||
if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
@@ -129,7 +139,8 @@ if ($profilePaths.Count -gt 0 -and -not $oobeRelax) {
|
||||
$reasons.Add("Real user profile(s) found: $names")
|
||||
} elseif ($profilePaths.Count -gt 0 -and $oobeRelax) {
|
||||
$names = ($profileNames | Select-Object -First 3) -join ', '
|
||||
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but OOBE-relax active (single fresh profile in OOBE) -- not blocking."
|
||||
$trigger = if ($oobeInProgress) { 'OOBE InProgress' } else { 'Cloud PC' }
|
||||
Write-Step -Status 'PASS' -Message "Guard 2: Profile ($names) present but relax active ($trigger + single fresh profile) -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message 'Guard 2: No real user profiles found (combined WMI/filesystem view).'
|
||||
}
|
||||
@@ -141,7 +152,7 @@ if ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and -no
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("LastLoggedOnUser found: $lastLoggedOnUser")
|
||||
} elseif ($lastLoggedOnUser -and $lastLoggedOnUser -notmatch 'defaultuser\d*' -and $oobeRelax) {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but OOBE-relax active -- not blocking."
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: LastLoggedOnUser ($lastLoggedOnUser) present but relax active -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 3: No real LastLoggedOnUser (value: '$lastLoggedOnUser')."
|
||||
}
|
||||
@@ -155,9 +166,15 @@ try {
|
||||
$uptimeHours = ((Get-Date) - $lastBoot).TotalHours
|
||||
|
||||
if ($uptimeHours -gt $MaxBootstrapWindowHours) {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
|
||||
if ($oobeRelax -and $isCloudPc) {
|
||||
# Cloud PCs run headless for days before the user's first connect; the
|
||||
# fresh-profile window is the time anchor there, not boot uptime.
|
||||
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h (over ${MaxBootstrapWindowHours}h), but the Cloud PC relax is active -- not blocking."
|
||||
} else {
|
||||
Write-Step -Status 'SKIP' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h. Older than accepted bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
$wouldInstall = $false
|
||||
$reasons.Add("Uptime exceeds bootstrap window: $([int]$uptimeHours)h > ${MaxBootstrapWindowHours}h")
|
||||
}
|
||||
} else {
|
||||
Write-Step -Status 'PASS' -Message "Guard 4: Device uptime is $([int]$uptimeHours)h and within bootstrap window of ${MaxBootstrapWindowHours}h."
|
||||
}
|
||||
@@ -202,9 +219,12 @@ Write-Host "Deployed marker : $deployed"
|
||||
Write-Host "DetectedProfileNames : $($profileNames -join ', ')"
|
||||
Write-Host "ProfileCount : $($profilePaths.Count)"
|
||||
Write-Host "OobeInProgress (WinRT) : $oobeInProgress"
|
||||
Write-Host "CloudPcRegistryMarker : $cloudPcRegMarker"
|
||||
Write-Host "CloudPcServiceMarker : $cloudPcSvcMarker"
|
||||
Write-Host "IsCloudPc : $isCloudPc"
|
||||
Write-Host "SingleProfileAgeMinutes : $profileAgeMin"
|
||||
Write-Host "OobeProfileMaxAgeMinutes : $OobeProfileMaxAgeMinutes"
|
||||
Write-Host "OobeRelaxActive : $oobeRelax"
|
||||
Write-Host "RelaxActive : $oobeRelax"
|
||||
Write-Host "LastLoggedOnUser : $lastLoggedOnUser"
|
||||
Write-Host "MaxBootstrapWindowHours : $MaxBootstrapWindowHours"
|
||||
Write-Host "LastBootUpTime : $lastBoot"
|
||||
|
||||
@@ -94,6 +94,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Logging
|
||||
Assert.Equal("older-002\n", File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + "_002.log")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_transliterates_typographic_punctuation_to_ascii()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info, maxFileSizeBytes: 1024L * 1024);
|
||||
|
||||
// The log file is UTF-8 without BOM; cp1252-guessing viewers (Outlook web
|
||||
// preview) render em dashes as mojibake. Our own punctuation must come out
|
||||
// as plain ASCII, while genuine non-ASCII payload stays untouched.
|
||||
logger.Info("check failed \u2014 range 1\u20132 \u2192 done\u2026 \u2018a\u2019 \u201Cb\u201D nb\u00A0sp f\u00FCr M\u00FCller");
|
||||
|
||||
var text = File.ReadAllText(Path.Combine(tmp.Path, TodayBaseName + ".log"));
|
||||
Assert.Contains("check failed - range 1-2 -> done... 'a' \"b\" nb sp f\u00FCr M\u00FCller", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Log_swallows_io_errors_silently()
|
||||
{
|
||||
|
||||
+43
@@ -308,9 +308,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
[InlineData("SELECT * FROM Win32_UserAccount")]
|
||||
[InlineData("SELECT * FROM Win32_ShadowCopy")]
|
||||
[InlineData("SELECT * FROM Win32_OperatingSystemExtra")] // prefix spoofing
|
||||
[InlineData("SELECT Name FROM Win32_Process")] // projection of a disallowed class
|
||||
[InlineData("SELECT BatteryStatus FROM Win32_BatteryX")] // class spoofing via projection
|
||||
public void WmiCollector_OffAllowlistQuery_IsBlockedBeforeTheQueryRuns(string query)
|
||||
=> AssertBlocked(new WmiCollector().Execute(Rule("wmi", query), Context()), query);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// WMI query matcher: "SELECT <* | property-list> FROM <allowed class>"
|
||||
//
|
||||
// Guard-level (no WMI touched): a property projection of an allowed class
|
||||
// exposes a strict subset of the already-allowed SELECT *, so it is admitted.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("SELECT * FROM Win32_Battery")] // legacy form unchanged
|
||||
[InlineData("SELECT * FROM Win32_BIOS WHERE PrimaryBIOS = TRUE")] // legacy + WHERE
|
||||
[InlineData("SELECT BatteryStatus FROM Win32_Battery")]
|
||||
[InlineData("SELECT BatteryStatus, EstimatedChargeRemaining FROM Win32_Battery")]
|
||||
[InlineData("select batterystatus from win32_battery")] // case-insensitive
|
||||
[InlineData("SELECT\tBatteryStatus\tFROM\tWin32_Battery")] // tabs as separators
|
||||
[InlineData(" SELECT BatteryStatus FROM Win32_Battery ")] // surrounding whitespace
|
||||
[InlineData("SELECT BatteryStatus,EstimatedChargeRemaining FROM Win32_Battery")] // no space after comma
|
||||
[InlineData("SELECT Status FROM Win32_Battery WHERE BatteryStatus = 1")] // projection + WHERE
|
||||
public void WmiGuard_ProjectionOfAllowedClass_IsAllowed(string query)
|
||||
=> Assert.True(GatherRuleGuards.IsWmiQueryAllowed(query));
|
||||
|
||||
[Theory]
|
||||
[InlineData("SELECT Name FROM Win32_Process")] // class not on the allowlist
|
||||
[InlineData("SELECT BatteryStatus FROM Win32_BatteryX")] // class spoofing
|
||||
[InlineData("SELECT * FROM Win32_BatteryX")]
|
||||
[InlineData("SELECT *, Name FROM Win32_BIOS")] // star mixed into a property list
|
||||
[InlineData("SELECT FROM Win32_BIOS")] // empty property list
|
||||
[InlineData("SELECT BatteryStatus FROM")] // missing class
|
||||
[InlineData("SELECT BatteryStatus FROM Win32_Battery;DROP")] // non-whitespace boundary after class
|
||||
[InlineData("SELECT BatteryStatus, FROM Win32_Battery")] // trailing comma
|
||||
[InlineData("")]
|
||||
public void WmiGuard_MalformedOrDisallowedQuery_IsBlocked(string query)
|
||||
=> Assert.False(GatherRuleGuards.IsWmiQueryAllowed(query));
|
||||
|
||||
[Fact]
|
||||
public void WmiGuard_NullQuery_IsBlocked()
|
||||
=> Assert.False(GatherRuleGuards.IsWmiQueryAllowed(null));
|
||||
|
||||
[Fact]
|
||||
public void WmiGuard_UnrestrictedMode_AllowsAnyQuery()
|
||||
=> Assert.True(GatherRuleGuards.IsWmiQueryAllowed("SELECT Name FROM Win32_Process", unrestrictedMode: true));
|
||||
|
||||
[Fact]
|
||||
public void JsonCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
|
||||
{
|
||||
|
||||
@@ -107,6 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
var ok = CmTraceLogParser.TryParseLine(line, out var entry);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.NotNull(entry);
|
||||
Assert.Equal(
|
||||
new DateTime(2026, 7, 29, 11, 46, 19, DateTimeKind.Utc).AddTicks(226610).AddMinutes(expectedOffsetMinutes),
|
||||
entry.Timestamp);
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Telemetry.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the display mirror in rules/guardrails.json (embedded into this assembly)
|
||||
/// to the agent's hard-block CONSTANTS in <see cref="GatherRuleGuards"/>.
|
||||
/// Enforcement is code-only by design (a JSON parse error must never lift a hard
|
||||
/// block); the JSON mirror exists so the portal and the MCP validator can
|
||||
/// pre-flight the same blocks. This test keeps the two from drifting: an addition
|
||||
/// on either side fails here until the other side follows.
|
||||
/// </summary>
|
||||
public sealed class GuardrailsParityTests
|
||||
{
|
||||
private static JObject LoadEmbeddedGuardrails()
|
||||
{
|
||||
var assembly = typeof(GatherRuleGuards).Assembly;
|
||||
using var stream = assembly.GetManifestResourceStream(
|
||||
"AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json");
|
||||
Assert.NotNull(stream);
|
||||
using var reader = new StreamReader(stream!);
|
||||
return JObject.Parse(reader.ReadToEnd());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blocked_file_prefixes_match_code_constants()
|
||||
{
|
||||
var json = LoadEmbeddedGuardrails()["blockedFilePrefixes"]!.ToObject<string[]>()!;
|
||||
var code = new[] { GatherRuleGuards.BlockedUsersPrefix }
|
||||
.Concat(GatherRuleGuards.AdditionalHardBlockedPathPrefixes);
|
||||
|
||||
Assert.Equal(
|
||||
code.Select(p => p.ToLowerInvariant()).OrderBy(p => p),
|
||||
json.Select(p => Path.GetFullPath(p).ToLowerInvariant()).OrderBy(p => p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blocked_command_patterns_match_code_constants()
|
||||
{
|
||||
var json = LoadEmbeddedGuardrails()["blockedCommandPatterns"]!.ToObject<string[]>()!;
|
||||
|
||||
Assert.Equal(
|
||||
GatherRuleGuards.HardBlockedCommandPatterns.OrderBy(p => p),
|
||||
json.OrderBy(p => p));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Max_command_length_matches_code_constant()
|
||||
{
|
||||
Assert.Equal(
|
||||
GatherRuleGuards.MaxCommandLength,
|
||||
LoadEmbeddedGuardrails()["maxCommandLength"]!.Value<int>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blocked_event_log_channels_match_code_constants()
|
||||
{
|
||||
var json = LoadEmbeddedGuardrails()["blockedEventLogChannels"]!.ToObject<string[]>()!;
|
||||
|
||||
Assert.Equal(
|
||||
GatherRuleGuards.HardBlockedEventLogChannels.OrderBy(c => c),
|
||||
json.OrderBy(c => c));
|
||||
}
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Runtime;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
{
|
||||
/// <summary>
|
||||
/// Contract tests for <see cref="AgentBootstrap.RetrySelfUpdateAfterEnrollment"/> —
|
||||
/// the post-cert one-shot self-update retry (field case sits-d.cloud, 2026-08-09):
|
||||
/// guard on <see cref="SelfUpdater.LastVersionCheckSucceeded"/>, the relaxed
|
||||
/// timeouts/trigger it passes to the updater, and the swallow-everything exception
|
||||
/// contract (bootstrap must continue on any updater failure). Uses the
|
||||
/// <see cref="AgentBootstrap.UpdateInvokerOverride"/> seam — no network, no process
|
||||
/// side effects.
|
||||
/// <para>
|
||||
/// Static-hook state: each test saves/restores both statics in ctor/Dispose so
|
||||
/// parallel-running test classes never observe a leaked value (idiom of
|
||||
/// SelfUpdaterGracefulShutdownTests).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class AgentBootstrapSelfUpdateRetryTests : IDisposable
|
||||
{
|
||||
private readonly AgentBootstrap.SelfUpdateInvoker _savedInvoker;
|
||||
private readonly bool _savedVersionCheckFlag;
|
||||
|
||||
public AgentBootstrapSelfUpdateRetryTests()
|
||||
{
|
||||
_savedInvoker = AgentBootstrap.UpdateInvokerOverride;
|
||||
_savedVersionCheckFlag = SelfUpdater.LastVersionCheckSucceeded;
|
||||
AgentBootstrap.UpdateInvokerOverride = null;
|
||||
SelfUpdater.LastVersionCheckSucceeded = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
AgentBootstrap.UpdateInvokerOverride = _savedInvoker;
|
||||
SelfUpdater.LastVersionCheckSucceeded = _savedVersionCheckFlag;
|
||||
}
|
||||
|
||||
private static AgentLogger NewLogger(string path)
|
||||
=> new AgentLogger(Path.Combine(path, "logs"), AgentLogLevel.Info);
|
||||
|
||||
[Fact]
|
||||
public void Skips_retry_when_startup_version_check_already_succeeded()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var invoked = false;
|
||||
SelfUpdater.LastVersionCheckSucceeded = true;
|
||||
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
|
||||
{
|
||||
invoked = true;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false);
|
||||
|
||||
Assert.False(invoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Retries_with_await_enrollment_trigger_and_relaxed_timeouts()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
string? capturedVersion = null, capturedDir = null, capturedReason = null;
|
||||
bool? capturedConsole = null, capturedDowngrade = null;
|
||||
int capturedDownloadMs = 0, capturedVersionCheckMs = 0;
|
||||
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
|
||||
{
|
||||
capturedVersion = v;
|
||||
capturedDir = dir;
|
||||
capturedConsole = console;
|
||||
capturedReason = reason;
|
||||
capturedDownloadMs = dl;
|
||||
capturedDowngrade = downgrade;
|
||||
capturedVersionCheckMs = vc;
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
|
||||
AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: true);
|
||||
|
||||
Assert.Equal("await_enrollment", capturedReason);
|
||||
// Relaxed vs. the boot-speed-tuned startup check (10s/2.5s) — enrollment is
|
||||
// already past OOBE's critical path here.
|
||||
Assert.Equal(60000, capturedDownloadMs);
|
||||
Assert.Equal(10000, capturedVersionCheckMs);
|
||||
// No cached remote config on a fresh --await-enrollment install → no downgrade override.
|
||||
Assert.False(capturedDowngrade);
|
||||
Assert.True(capturedConsole);
|
||||
Assert.False(string.IsNullOrEmpty(capturedVersion));
|
||||
Assert.False(string.IsNullOrEmpty(capturedDir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Synchronous_invoker_exception_is_swallowed()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
AgentBootstrap.UpdateInvokerOverride =
|
||||
(v, dir, console, reason, dl, downgrade, vc) => throw new InvalidOperationException("boom");
|
||||
|
||||
var ex = Record.Exception(
|
||||
() => AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Faulted_update_task_is_swallowed()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
AgentBootstrap.UpdateInvokerOverride = (v, dir, console, reason, dl, downgrade, vc) =>
|
||||
Task.FromException(new IOException("download interrupted"));
|
||||
|
||||
var ex = Record.Exception(
|
||||
() => AgentBootstrap.RetrySelfUpdateAfterEnrollment(NewLogger(tmp.Path), consoleMode: false));
|
||||
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
/// </summary>
|
||||
public sealed class TenantIdWaitResolutionTests
|
||||
{
|
||||
private static AgentConfiguration Build(BootstrapConfigFile bootstrapConfig, params string[] args) =>
|
||||
private static AgentConfiguration Build(BootstrapConfigFile? bootstrapConfig, params string[] args) =>
|
||||
AutopilotMonitor.Agent.V2.Program.BuildAgentConfiguration(
|
||||
args: args,
|
||||
tenantId: "tenant-t",
|
||||
|
||||
@@ -260,6 +260,60 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Contains("agent/config", fakeDistress.Calls[0].Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFailure_endpoint_unavailable_suppresses_distress_but_still_counts()
|
||||
{
|
||||
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
|
||||
var fakeDistress = new FakeDistressReporter();
|
||||
var tracker = new AuthFailureTracker(maxFailures: 2, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
|
||||
|
||||
int events = 0;
|
||||
tracker.ThresholdExceeded += (_, _) => events++;
|
||||
|
||||
// Platform-403s from a stopped endpoint: a distress report cannot reach it and the
|
||||
// DeviceNotRegistered classification would be wrong — nothing must be dispatched.
|
||||
tracker.RecordFailure(403, "agent/config", endpointUnavailable: true);
|
||||
tracker.RecordFailure(403, "agent/register-session", endpointUnavailable: true);
|
||||
|
||||
Assert.Empty(fakeDistress.Calls);
|
||||
// The shutdown thresholds still apply — a dead endpoint must not retry forever.
|
||||
Assert.Equal(2, tracker.ConsecutiveFailures);
|
||||
Assert.Equal(1, events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFailure_endpoint_unavailable_does_not_consume_streak_report_slot()
|
||||
{
|
||||
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
|
||||
var fakeDistress = new FakeDistressReporter();
|
||||
var tracker = new AuthFailureTracker(maxFailures: 50, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
|
||||
|
||||
// The streak opens with a platform-403 (no dispatch), then a live backend answers
|
||||
// with a genuine 403 — that one must still produce the streak's single report.
|
||||
tracker.RecordFailure(403, "agent/config", endpointUnavailable: true);
|
||||
tracker.RecordFailure(403, "agent/config");
|
||||
tracker.RecordFailure(403, "agent/config");
|
||||
|
||||
Assert.Single(fakeDistress.Calls);
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[0].ErrorType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordSuccess_rearms_distress_dispatch_for_the_next_streak()
|
||||
{
|
||||
var clock = new FakeClock(new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc));
|
||||
var fakeDistress = new FakeDistressReporter();
|
||||
var tracker = new AuthFailureTracker(maxFailures: 50, timeoutMinutes: 0, clock, NewLogger(), fakeDistress);
|
||||
|
||||
tracker.RecordFailure(401, "agent/config");
|
||||
tracker.RecordSuccess();
|
||||
tracker.RecordFailure(403, "agent/config");
|
||||
|
||||
Assert.Equal(2, fakeDistress.Calls.Count);
|
||||
Assert.Equal(DistressErrorType.AuthCertificateRejected, fakeDistress.Calls[0].ErrorType);
|
||||
Assert.Equal(DistressErrorType.DeviceNotRegistered, fakeDistress.Calls[1].ErrorType);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordFailure_without_distress_reporter_is_silent_but_still_tracks()
|
||||
{
|
||||
|
||||
+25
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.DeviceInfo;
|
||||
@@ -109,4 +110,28 @@ public class DeviceInfoCollectorAutopilotProfileMissingTests
|
||||
Assert.False(DeviceInfoCollector.TryExtractZeroTouchTenantDomain(null, out var domain));
|
||||
Assert.Null(domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAutopilotProfileMissingPresentation_PhysicalDevice_WarnsAboutAssignment()
|
||||
{
|
||||
var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: false);
|
||||
|
||||
Assert.Equal(EventSeverity.Warning, severity);
|
||||
Assert.Equal(
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
|
||||
message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveAutopilotProfileMissingPresentation_CloudPc_InformationalAndExpected()
|
||||
{
|
||||
// On a Windows 365 Cloud PC a missing Autopilot profile is the normal state —
|
||||
// the event must not read as an alarm there.
|
||||
var (message, severity) = DeviceInfoCollector.ResolveAutopilotProfileMissingPresentation(isCloudPc: true);
|
||||
|
||||
Assert.Equal(EventSeverity.Info, severity);
|
||||
Assert.Contains("Windows 365 Cloud PC", message);
|
||||
Assert.Contains("expected", message);
|
||||
Assert.DoesNotContain("no deployment profile was assigned", message);
|
||||
}
|
||||
}
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Transport;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Transport
|
||||
{
|
||||
/// <summary>
|
||||
/// 401/403 classification in <see cref="BackendApiClient"/>: a backend JSON 403 is a real
|
||||
/// device-authorization verdict, while an HTML body means the response came from the Azure
|
||||
/// platform / an edge proxy (stopped or retired app) and must NOT be reported as
|
||||
/// "device is not authorized". Field case sits-d.cloud 2026-08-09: a pre-cutover agent hit
|
||||
/// the stopped legacy Function App and support chased a non-existent authorization problem.
|
||||
/// </summary>
|
||||
public sealed class BackendApiClientAuthClassificationTests
|
||||
{
|
||||
private sealed class StubHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
|
||||
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(_responder(request));
|
||||
}
|
||||
|
||||
private static BackendApiClient BuildClient(HttpStatusCode status, string body, string mediaType)
|
||||
{
|
||||
var handler = new StubHandler(_ => new HttpResponseMessage(status)
|
||||
{
|
||||
Content = new StringContent(body, Encoding.UTF8, mediaType)
|
||||
});
|
||||
return new BackendApiClient(
|
||||
new HttpClient(handler),
|
||||
baseUrl: "https://backend.example",
|
||||
manufacturer: "Contoso",
|
||||
model: "TestBox",
|
||||
serialNumber: "SN123",
|
||||
useBootstrapTokenAuth: false,
|
||||
bootstrapToken: null,
|
||||
agentVersion: "2.0.0",
|
||||
logger: null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_json_403_is_reported_as_device_not_authorized()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Forbidden,
|
||||
"{\"success\":false,\"message\":\"Device validation failed\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
|
||||
|
||||
Assert.Equal(403, ex.StatusCode);
|
||||
Assert.False(ex.EndpointUnavailable);
|
||||
Assert.Contains("The device is not authorized", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Platform_html_403_is_reported_as_endpoint_unavailable_with_page_title()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Forbidden,
|
||||
"<!DOCTYPE html><html><head><title>Web App - Unavailable</title></head><body>stopped</body></html>",
|
||||
"text/html");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
|
||||
|
||||
Assert.Equal(403, ex.StatusCode);
|
||||
Assert.True(ex.EndpointUnavailable);
|
||||
Assert.Contains("Web App - Unavailable", ex.Message);
|
||||
Assert.Contains("NOT a device-authorization failure", ex.Message);
|
||||
Assert.DoesNotContain("Check client certificate", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Html_body_with_generic_content_type_is_still_sniffed_as_platform_page()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Forbidden,
|
||||
" <html><body>Error 403 - This web app is stopped.</body></html>",
|
||||
"text/plain");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
|
||||
|
||||
Assert.True(ex.EndpointUnavailable);
|
||||
Assert.Contains("HTML platform error page", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Backend_401_keeps_authorization_message_and_status()
|
||||
{
|
||||
using var client = BuildClient(
|
||||
HttpStatusCode.Unauthorized,
|
||||
"{\"success\":false,\"message\":\"certificate rejected\"}",
|
||||
"application/json");
|
||||
|
||||
var ex = await Assert.ThrowsAsync<BackendAuthException>(() => client.GetAgentConfigAsync("tenant-1"));
|
||||
|
||||
Assert.Equal(401, ex.StatusCode);
|
||||
Assert.False(ex.EndpointUnavailable);
|
||||
Assert.Contains("The device is not authorized", ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
/// </summary>
|
||||
public bool EnableTimezoneAutoSet { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Continue-Anyway observation mode (operator-set tenant opt-in, delivered via
|
||||
/// remote config): stamped into the esp_config_detected decision-signal payload so
|
||||
/// the DecisionEngine defangs a Device-phase ESP terminal failure into a bounded
|
||||
/// 60-min observation instead of failing immediately. Default: false.
|
||||
/// </summary>
|
||||
public bool EnableEspContinueAnywayObservation { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Optional custom path to IME logs directory for testing.
|
||||
/// If set, overrides the default %ProgramData%\Microsoft\IntuneManagementExtension\Logs path.
|
||||
|
||||
@@ -103,6 +103,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
|
||||
agentConfig.SendTraceEvents = remoteConfig.SendTraceEvents;
|
||||
|
||||
// ---------------- Continue-Anyway observation — remote wins (operator-set tenant
|
||||
// opt-in; false must clear a stale cached true).
|
||||
agentConfig.EnableEspContinueAnywayObservation = remoteConfig.EnableEspContinueAnywayObservation;
|
||||
|
||||
// ---------------- UnrestrictedMode audit — track old/new for the caller so the
|
||||
// agent_unrestricted_mode_changed event can fire AFTER the
|
||||
// orchestrator emitter is alive (V1 parity with
|
||||
|
||||
@@ -155,7 +155,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
// see the AuthCertificateRejected/DeviceNotRegistered distress signal
|
||||
// without 100 s of needless retry delay.
|
||||
_logger.Warning($"Config fetch authentication failed: {ex.Message}");
|
||||
_authFailureTracker?.RecordFailure(ex.StatusCode, "agent/config");
|
||||
_authFailureTracker?.RecordFailure(ex.StatusCode, "agent/config", ex.EndpointUnavailable);
|
||||
lastException = ex;
|
||||
LastFetchAuthStatusCode = ex.StatusCode;
|
||||
break;
|
||||
@@ -328,6 +328,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
|
||||
EnableGeoLocation = true,
|
||||
EnableImeMatchLog = false,
|
||||
EnableGatherRuleDebugLog = false,
|
||||
EnableEspContinueAnywayObservation = false,
|
||||
Collectors = CollectorConfiguration.CreateDefault(),
|
||||
GatherRules = new System.Collections.Generic.List<GatherRule>(),
|
||||
ImeLogPatterns = new System.Collections.Generic.List<ImeLogPattern>()
|
||||
|
||||
@@ -197,7 +197,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
/// <summary>
|
||||
/// Sanitizes log messages to prevent log injection attacks.
|
||||
/// Escapes control characters that could forge log entries.
|
||||
/// Redaction patterns can be added here later as needed.
|
||||
/// Transliterates typographic punctuation to ASCII: the log file is written as
|
||||
/// UTF-8 without BOM, and customer-side viewers (e.g. Outlook web attachment
|
||||
/// preview) guess cp1252 and render an em dash as mojibake ("—"). Only our own
|
||||
/// punctuation is mapped — genuine non-ASCII payload (device names, paths) is
|
||||
/// kept verbatim. Redaction patterns can be added here later as needed.
|
||||
/// </summary>
|
||||
private static string SanitizeLogMessage(string message)
|
||||
{
|
||||
@@ -206,6 +210,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Logging
|
||||
// Escape newlines and carriage returns to prevent log injection
|
||||
message = message.Replace("\r", "\\r").Replace("\n", "\\n");
|
||||
|
||||
message = message
|
||||
.Replace('\u2014', '-') // — em dash
|
||||
.Replace('\u2013', '-') // – en dash
|
||||
.Replace("\u2192", "->") // → arrow
|
||||
.Replace("\u2026", "...") // … ellipsis
|
||||
.Replace('\u2018', '\'') // ' left single quote
|
||||
.Replace('\u2019', '\'') // ' right single quote
|
||||
.Replace('\u201C', '"') // " left double quote
|
||||
.Replace('\u201D', '"') // " right double quote
|
||||
.Replace('\u00A0', ' '); // non-breaking space
|
||||
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// </summary>
|
||||
public static string BackendExpectedSha256 { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once a <see cref="CheckAndApplyUpdateAsync"/> run actually obtained a version
|
||||
/// from the manifest (regardless of whether an update was then needed/applied).
|
||||
/// <para>
|
||||
/// Consumed by the <c>--await-enrollment</c> path: the startup check runs pre-enrollment
|
||||
/// where OOBE frequently has no network yet, so a failed check there says nothing about
|
||||
/// the latest version. Once the MDM certificate appears the device provably has
|
||||
/// connectivity — the bootstrap retries the check exactly when this is still false.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Setter is internal solely as a test seam (InternalsVisibleTo): the bootstrap-retry
|
||||
/// tests arm/reset the guard without running the real network check. Production code
|
||||
/// writes it only from <see cref="CheckAndApplyUpdateAsync"/>.
|
||||
/// </remarks>
|
||||
public static bool LastVersionCheckSucceeded { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional: graceful-shutdown request hook, set by the runtime host once its
|
||||
/// shutdown machinery is armed (before the orchestrator starts) and cleared in its
|
||||
@@ -130,14 +147,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// to cover hotfixes that reuse the same version number.
|
||||
/// </param>
|
||||
/// <param name="triggerReason">
|
||||
/// "startup" (default) or "runtime_hash_mismatch". Recorded in the marker file so the
|
||||
/// agent_version_check event (outcome=updated) can distinguish the two paths.
|
||||
/// "startup" (default), "await_enrollment" (post-cert retry in the bootstrap), or
|
||||
/// "runtime_hash_mismatch". Recorded in the marker file so the agent_version_check
|
||||
/// event (outcome=updated) can distinguish the paths.
|
||||
/// </param>
|
||||
/// <param name="downloadTimeoutMsOverride">
|
||||
/// Overrides the ZIP download timeout. Startup path leaves null (fast-fail, 10s) to preserve
|
||||
/// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running
|
||||
/// and we know an update is required.
|
||||
/// </param>
|
||||
/// <param name="versionCheckTimeoutMsOverride">
|
||||
/// Overrides the version-v2.json fetch timeout (default 2500ms, tuned for boot speed).
|
||||
/// The await-enrollment retry passes a relaxed value: enrollment just completed, the
|
||||
/// device provably has network, and a stranded old build's last self-rescue chance is
|
||||
/// worth a few extra seconds before session registration.
|
||||
/// </param>
|
||||
/// <param name="allowDowngrade">
|
||||
/// When false (default), a latest version strictly lower than <paramref name="currentVersion"/>
|
||||
/// is rejected even when <paramref name="forceUpdate"/> is true — prevents the production
|
||||
@@ -151,7 +175,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
bool forceUpdate = false,
|
||||
string triggerReason = "startup",
|
||||
int? downloadTimeoutMsOverride = null,
|
||||
bool allowDowngrade = false)
|
||||
bool allowDowngrade = false,
|
||||
int? versionCheckTimeoutMsOverride = null)
|
||||
{
|
||||
Action<string> log = msg =>
|
||||
{
|
||||
@@ -160,7 +185,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
};
|
||||
|
||||
var isStartupTrigger = string.Equals(triggerReason, "startup", StringComparison.Ordinal);
|
||||
// The await-enrollment retry replaces the startup verdict — its markers must win so
|
||||
// the agent_version_check event reflects the LATEST check, not the pre-network one.
|
||||
var writeMarkers = isStartupTrigger || string.Equals(triggerReason, "await_enrollment", StringComparison.Ordinal);
|
||||
var downloadTimeoutMs = downloadTimeoutMsOverride ?? DownloadTimeoutMs;
|
||||
var versionCheckTimeoutMs = versionCheckTimeoutMsOverride ?? VersionCheckTimeoutMs;
|
||||
var totalSw = Stopwatch.StartNew();
|
||||
long versionCheckMs = 0, downloadMs = 0, verifyMs = 0, extractMs = 0, swapMs = 0;
|
||||
long zipSizeBytes = 0;
|
||||
@@ -174,16 +203,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
|
||||
try
|
||||
{
|
||||
// Step 1: Fetch version-v2.json (2.5s timeout)
|
||||
// Step 1: Fetch version-v2.json (default 2.5s timeout, relaxed on retry triggers)
|
||||
var versionSw = Stopwatch.StartNew();
|
||||
string manifestSha256;
|
||||
(latestVersion, manifestSha256) = await GetLatestVersionAsync(log);
|
||||
(latestVersion, manifestSha256) = await GetLatestVersionAsync(log, versionCheckTimeoutMs);
|
||||
versionSw.Stop();
|
||||
versionCheckMs = versionSw.ElapsedMilliseconds;
|
||||
LastVersionCheckSucceeded = latestVersion != null;
|
||||
|
||||
if (latestVersion == null)
|
||||
{
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("version_check_failed", currentVersion, null, "version-v2.json fetch failed or timed out", log);
|
||||
return; // Could not determine latest version — continue with current
|
||||
}
|
||||
@@ -213,7 +243,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!forceUpdate)
|
||||
{
|
||||
log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteCheckedMarker(currentVersion, latestVersion, versionCheckMs, log);
|
||||
return;
|
||||
}
|
||||
@@ -230,7 +260,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!forceUpdate)
|
||||
{
|
||||
log($"Self-update: cannot compare versions (current={currentVersion}, latest={latestVersion}) — skipping");
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("version_compare_failed", currentVersion, latestVersion, "version parse failed", log);
|
||||
return;
|
||||
}
|
||||
@@ -249,7 +279,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
downloadMs = downloadTimerSw.ElapsedMilliseconds;
|
||||
if (!downloadOk)
|
||||
{
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("download_failed", currentVersion, latestVersion, $"ZIP download failed within {downloadTimeoutMs}ms", log);
|
||||
return;
|
||||
}
|
||||
@@ -282,7 +312,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!integrityOk)
|
||||
{
|
||||
CleanupStaging(null, zipPath);
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log);
|
||||
return;
|
||||
}
|
||||
@@ -297,7 +327,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
extractMs = extractSw.ElapsedMilliseconds;
|
||||
if (!extractOk)
|
||||
{
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "ZIP extraction failed", log);
|
||||
return;
|
||||
}
|
||||
@@ -310,7 +340,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
{
|
||||
log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP");
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("extract_failed", currentVersion, latestVersion, "AutopilotMonitor.Agent.exe not found in ZIP", log);
|
||||
return;
|
||||
}
|
||||
@@ -323,7 +353,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
if (!swapOk)
|
||||
{
|
||||
CleanupStaging(stagingDir, zipPath);
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
WriteSkipMarker("swap_failed", currentVersion, latestVersion, "file swap failed", log);
|
||||
return;
|
||||
}
|
||||
@@ -355,7 +385,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
catch (Exception ex)
|
||||
{
|
||||
log($"Self-update: unexpected error — {ex.Message}. Continuing with current version.");
|
||||
if (isStartupTrigger)
|
||||
if (writeMarkers)
|
||||
{
|
||||
try { WriteSkipMarker("unexpected_error", currentVersion, latestVersion, ex.Message, log); } catch { }
|
||||
}
|
||||
@@ -366,7 +396,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
/// Fetches version-v2.json from blob storage and returns the version string and optional SHA-256 hash.
|
||||
/// Returns (null, null) if the check fails or times out.
|
||||
/// </summary>
|
||||
private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action<string> log)
|
||||
private static async Task<(string version, string sha256)> GetLatestVersionAsync(Action<string> log, int timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -375,7 +405,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
var versionUrl = $"{Constants.AgentDownloadBaseUrl}/{Constants.AgentVersionFileNameForLine(2)}";
|
||||
|
||||
using (var handler = new HttpClientHandler())
|
||||
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(VersionCheckTimeoutMs) })
|
||||
using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(timeoutMs) })
|
||||
{
|
||||
var json = await client.GetStringAsync(versionUrl);
|
||||
var obj = JObject.Parse(json);
|
||||
@@ -396,7 +426,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
log($"Self-update: version check timed out ({VersionCheckTimeoutMs}ms) — skipping update");
|
||||
log($"Self-update: version check timed out ({timeoutMs}ms) — skipping update");
|
||||
return (null, null);
|
||||
}
|
||||
catch (HttpRequestException ex) when (ex.Message.Contains("404"))
|
||||
|
||||
+39
-4
@@ -305,10 +305,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
// (see docs/agent/autopilot-ztd-diagnostics.md for the evidence model).
|
||||
if (IsAutopilotProfileMissing(data))
|
||||
{
|
||||
// On Windows 365 Cloud PCs a missing profile is the expected state — Cloud PCs
|
||||
// are provisioned by the Windows 365 service, not by Autopilot — so the event
|
||||
// is demoted to Info there instead of raising a misleading warning.
|
||||
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
|
||||
|
||||
var missingData = new Dictionary<string, object>
|
||||
{
|
||||
{ "profileAvailable", "0" },
|
||||
{ "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" }
|
||||
{ "likelyCauses", "profile_not_assigned,profile_assignment_not_propagated" },
|
||||
{ "isCloudPc", isCloudPc }
|
||||
};
|
||||
if (TryExtractZeroTouchTenantDomain(data, out var ztdTenantDomain))
|
||||
{
|
||||
@@ -347,12 +353,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
missingData["ztdEndpointLatencyMs"] = probe.LatencyMs;
|
||||
missingData["ztdEndpointDetail"] = probe.Detail;
|
||||
|
||||
var presentation = ResolveAutopilotProfileMissingPresentation(isCloudPc);
|
||||
EmitDeviceInfoEvent(Constants.EventTypes.AutopilotProfileMissing,
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
|
||||
presentation.Message,
|
||||
missingData,
|
||||
EventSeverity.Warning);
|
||||
presentation.Severity);
|
||||
|
||||
_logger.Warning($"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}");
|
||||
var logLine = $"EnrollmentTracker: no Autopilot profile available during OOBE (ProfileAvailable=0) — isCloudPc={isCloudPc}, ztdVerdict={missingData["ztdVerdict"]}, endpointReachable={probe.Reachable}";
|
||||
if (isCloudPc)
|
||||
_logger.Info(logLine);
|
||||
else
|
||||
_logger.Warning(logLine);
|
||||
}
|
||||
|
||||
// Emit dedicated enrollment_type_detected event for easy filtering. Routed through
|
||||
@@ -396,6 +407,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
&& profileAvailable?.ToString() == "0";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Message and severity for the autopilot_profile_missing event. On Windows 365
|
||||
/// Cloud PCs the missing profile is expected (Cloud PCs are provisioned by the
|
||||
/// Windows 365 service, not by Autopilot), so the event is informational there;
|
||||
/// on physical devices it stays a warning about a missing profile ASSIGNMENT.
|
||||
/// </summary>
|
||||
internal static (string Message, EventSeverity Severity) ResolveAutopilotProfileMissingPresentation(bool isCloudPc)
|
||||
{
|
||||
if (isCloudPc)
|
||||
{
|
||||
return (
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0). This device is a Windows 365 Cloud PC — Cloud PCs are provisioned by the Windows 365 service and do not use Autopilot deployment profiles, so this is expected. OOBE ran as a standard Entra ID join, which is the normal path for Cloud PCs.",
|
||||
EventSeverity.Info);
|
||||
}
|
||||
|
||||
return (
|
||||
"No Autopilot profile was available during OOBE (ProfileAvailable=0) — most likely no deployment profile was assigned to the device, or the assignment had not finished propagating when OOBE ran. OOBE proceeded as a standard Entra ID join instead of an Autopilot deployment.",
|
||||
EventSeverity.Warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts ZeroTouchConfig.CloudAssignedTenantDomain from the cached profile data.
|
||||
/// The value lives in the CloudAssignedAadServerData JSON — either as a top-level
|
||||
@@ -1021,6 +1052,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
|
||||
.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (snapshot.AllowContinueAnyway.HasValue)
|
||||
payload[SignalPayloadKeys.EspAllowContinueAnyway] = snapshot.AllowContinueAnyway.Value ? "true" : "false";
|
||||
// Tenant opt-in (remote config, not a registry fact) — only stamped when
|
||||
// enabled; absent = default hard-fail semantics in the engine.
|
||||
if (_espContinueAnywayObservationEnabled?.Invoke() == true)
|
||||
payload[SignalPayloadKeys.EspContinueAnywayObservationEnabled] = "true";
|
||||
|
||||
var derivationInputs = new Dictionary<string, string>(payload, StringComparer.Ordinal)
|
||||
{
|
||||
|
||||
+8
-1
@@ -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>
|
||||
|
||||
+60
-9
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.RegularExpressions;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
@@ -35,9 +36,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public static readonly IReadOnlyCollection<string> AllowedCommands;
|
||||
public static readonly IReadOnlyList<string> AllowedEventLogChannels;
|
||||
|
||||
// Hard blocks: never allowed, even in unrestricted mode
|
||||
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
||||
private static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
|
||||
// Derived from AllowedWmiQueryPrefixes: entries of the canonical form
|
||||
// "SELECT * FROM <class>" contribute their class name; any other entry
|
||||
// (none today) stays on the legacy literal-prefix path.
|
||||
private static readonly HashSet<string> AllowedWmiClasses;
|
||||
private static readonly List<string> ResidualWmiPrefixes;
|
||||
|
||||
// Canonical allowlist entry: exactly "SELECT * FROM <class>".
|
||||
private static readonly Regex CanonicalWmiEntryRegex = new Regex(
|
||||
@"^SELECT\s+\*\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// WQL shape "SELECT <* | property-list> FROM <class>", class token bounded
|
||||
// by whitespace or end of string (a trailing WHERE clause is permitted,
|
||||
// "Win32_BIOSX" cannot pass as "Win32_BIOS").
|
||||
private static readonly Regex WmiQueryRegex = new Regex(
|
||||
@"^SELECT\s+(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)(?=\s|$)",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
// Hard blocks: never allowed, even in unrestricted mode. Internal (not private)
|
||||
// so GuardrailsParityTests can pin the guardrails.json display mirror to these
|
||||
// constants — enforcement stays code-only by design.
|
||||
internal static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
||||
internal static readonly string[] AdditionalHardBlockedPathPrefixes = new[]
|
||||
{
|
||||
Path.GetFullPath(@"C:\Windows\System32\config"), // SAM, SECURITY, SYSTEM hives
|
||||
};
|
||||
@@ -50,14 +71,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
};
|
||||
|
||||
// Hard limit: maximum command length (even in unrestricted mode)
|
||||
private const int MaxCommandLength = 2000;
|
||||
internal const int MaxCommandLength = 2000;
|
||||
|
||||
// Event log channels that are never readable, even in unrestricted mode.
|
||||
// Security carries the audit trail of user behaviour; the PowerShell channels
|
||||
// carry script-block logging, which routinely contains secrets in clear text.
|
||||
// Mirrored in rules/guardrails.json ("blockedEventLogChannels") for the portal
|
||||
// to display — enforcement lives here so a parse error cannot lift it.
|
||||
private static readonly string[] HardBlockedEventLogChannels = new[]
|
||||
internal static readonly string[] HardBlockedEventLogChannels = new[]
|
||||
{
|
||||
"Security",
|
||||
"Microsoft-Windows-PowerShell",
|
||||
@@ -67,7 +88,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
// Hard-blocked command patterns: never allowed, even in unrestricted mode.
|
||||
// These prevent privilege escalation, persistence, and data exfiltration.
|
||||
private static readonly string[] HardBlockedCommandPatterns = new[]
|
||||
// Mirrored in rules/guardrails.json ("blockedCommandPatterns") for portal/MCP
|
||||
// pre-flight display — enforcement lives here so a parse error cannot lift it.
|
||||
internal static readonly string[] HardBlockedCommandPatterns = new[]
|
||||
{
|
||||
// Download / exfiltration
|
||||
"Invoke-WebRequest", "Invoke-RestMethod", "Start-BitsTransfer",
|
||||
@@ -101,6 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
AllowedCommands = new HashSet<string>(commands, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AllowedEventLogChannels = FlattenCategorized(obj, "eventLogChannels", "channels");
|
||||
DeriveWmiClassAllowlist(AllowedWmiQueryPrefixes, out AllowedWmiClasses, out ResidualWmiPrefixes);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -115,6 +139,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
AllowedWmiQueryPrefixes = new List<string>();
|
||||
AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AllowedEventLogChannels = new List<string>();
|
||||
AllowedWmiClasses = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
ResidualWmiPrefixes = new List<string>();
|
||||
}
|
||||
|
||||
private static void DeriveWmiClassAllowlist(
|
||||
IReadOnlyList<string> entries,
|
||||
out HashSet<string> classes,
|
||||
out List<string> residual)
|
||||
{
|
||||
classes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
residual = new List<string>();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var match = CanonicalWmiEntryRegex.Match(entry.Trim());
|
||||
if (match.Success)
|
||||
classes.Add(match.Groups[1].Value);
|
||||
else
|
||||
residual.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
private static string LoadEmbeddedGuardrails()
|
||||
@@ -250,8 +294,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the WMI query (trimmed) matches an allowed prefix
|
||||
/// with boundary matching (next char must be whitespace or end of string).
|
||||
/// Returns true if the WMI query (trimmed) is "SELECT <* | property-list> FROM <class>"
|
||||
/// (optionally followed by a WHERE clause) against a class on the allowlist.
|
||||
/// A property projection exposes a strict subset of the allowed "SELECT *".
|
||||
/// </summary>
|
||||
public static bool IsWmiQueryAllowed(string query)
|
||||
=> IsWmiQueryAllowed(query, unrestrictedMode: false);
|
||||
@@ -269,7 +314,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
return true;
|
||||
|
||||
var trimmed = query.Trim();
|
||||
return AllowedWmiQueryPrefixes.Any(prefix =>
|
||||
|
||||
var match = WmiQueryRegex.Match(trimmed);
|
||||
if (match.Success && AllowedWmiClasses.Contains(match.Groups[1].Value))
|
||||
return true;
|
||||
|
||||
// Non-canonical allowlist entries keep the legacy literal-prefix match.
|
||||
return ResidualWmiPrefixes.Any(prefix =>
|
||||
trimmed.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length])));
|
||||
}
|
||||
|
||||
+100
-10
@@ -137,7 +137,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
continue;
|
||||
}
|
||||
|
||||
ThrowOnAuthFailure(response);
|
||||
await ThrowOnAuthFailureAsync(response).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
@@ -236,7 +236,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
|
||||
using (response)
|
||||
{
|
||||
ThrowOnAuthFailure(response);
|
||||
await ThrowOnAuthFailureAsync(response).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
@@ -282,16 +282,97 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// <summary>
|
||||
/// Throws <see cref="BackendAuthException"/> for 401/403 responses so callers can
|
||||
/// distinguish authentication failures from transient server errors.
|
||||
/// <para>
|
||||
/// Field case (sits-d.cloud, 2026-08-09): a pre-cutover agent build pointed at a
|
||||
/// retired Function App; Azure answers for a stopped app with a platform-level 403
|
||||
/// carrying an HTML error page ("Web App - Unavailable"). Our backend never returns
|
||||
/// HTML on 401/403 — an HTML body therefore means the response did not come from the
|
||||
/// backend's auth layer at all (stopped/retired app, edge proxy, WAF). Reporting that
|
||||
/// as "device is not authorized" sends troubleshooting down the wrong path, so the
|
||||
/// two cases get distinct messages and the exception carries a flag.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static void ThrowOnAuthFailure(HttpResponseMessage response)
|
||||
private static async Task ThrowOnAuthFailureAsync(HttpResponseMessage response)
|
||||
{
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
|
||||
response.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized &&
|
||||
response.StatusCode != System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var statusText = $"{(int)response.StatusCode} {response.StatusCode}";
|
||||
|
||||
string body = null;
|
||||
try
|
||||
{
|
||||
body = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Body is diagnostic-only; classification below treats null as non-HTML.
|
||||
}
|
||||
|
||||
if (LooksLikeHtmlErrorPage(response, body))
|
||||
{
|
||||
var title = TryExtractHtmlTitle(body);
|
||||
var titleText = title != null ? $" (\"{title}\")" : string.Empty;
|
||||
var host = response.RequestMessage?.RequestUri?.Host ?? "(unknown host)";
|
||||
throw new BackendAuthException(
|
||||
$"Backend returned {(int)response.StatusCode} {response.StatusCode}. " +
|
||||
"The device is not authorized. Check client certificate and Autopilot device validation.",
|
||||
(int)response.StatusCode);
|
||||
$"Backend returned {statusText} with an HTML platform error page{titleText} from '{host}'. " +
|
||||
"This response did not come from the Autopilot Monitor backend — the endpoint appears stopped, " +
|
||||
"retired, or intercepted by a proxy. This is NOT a device-authorization failure; an outdated " +
|
||||
"agent build pointing at a stale ApiBaseUrl is the most likely cause.",
|
||||
(int)response.StatusCode,
|
||||
endpointUnavailable: true);
|
||||
}
|
||||
|
||||
throw new BackendAuthException(
|
||||
$"Backend returned {statusText}. " +
|
||||
"The device is not authorized. Check client certificate and Autopilot device validation.",
|
||||
(int)response.StatusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when a 401/403 response is an HTML page rather than a backend JSON/plain-text
|
||||
/// reply. Checks the declared Content-Type first, then falls back to sniffing the body
|
||||
/// prefix so a platform page served with a generic Content-Type is still recognized.
|
||||
/// </summary>
|
||||
private static bool LooksLikeHtmlErrorPage(HttpResponseMessage response, string body)
|
||||
{
|
||||
var mediaType = response.Content?.Headers?.ContentType?.MediaType;
|
||||
if (string.Equals(mediaType, "text/html", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
return false;
|
||||
|
||||
var head = body.TrimStart();
|
||||
return head.StartsWith("<!DOCTYPE", StringComparison.OrdinalIgnoreCase) ||
|
||||
head.StartsWith("<html", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort extraction of the HTML <title> (e.g. "Web App - Unavailable") so the
|
||||
/// log line names the platform page without dumping markup. Null when absent/oversized.
|
||||
/// </summary>
|
||||
private static string TryExtractHtmlTitle(string body)
|
||||
{
|
||||
if (string.IsNullOrEmpty(body)) return null;
|
||||
try
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(
|
||||
body, @"<title[^>]*>\s*(.*?)\s*</title>",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase |
|
||||
System.Text.RegularExpressions.RegexOptions.Singleline,
|
||||
TimeSpan.FromMilliseconds(250));
|
||||
if (!match.Success) return null;
|
||||
|
||||
var title = match.Groups[1].Value;
|
||||
return title.Length == 0 ? null : (title.Length > 80 ? title.Substring(0, 80) : title);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +411,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when the backend returns 401 or 403, indicating the device is not authorized.
|
||||
/// Thrown when the backend returns 401 or 403, indicating the device is not authorized —
|
||||
/// or, when <see cref="EndpointUnavailable"/> is set, that the 401/403 was a platform-level
|
||||
/// HTML response (stopped/retired app, edge proxy) rather than a backend auth verdict.
|
||||
/// </summary>
|
||||
public class BackendAuthException : Exception
|
||||
{
|
||||
@@ -340,9 +423,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
/// </summary>
|
||||
public int StatusCode { get; }
|
||||
|
||||
public BackendAuthException(string message, int statusCode = 0) : base(message)
|
||||
/// <summary>
|
||||
/// True when the 401/403 carried an HTML platform error page — the endpoint itself is
|
||||
/// stopped/retired/proxied and the status says nothing about this device's authorization.
|
||||
/// </summary>
|
||||
public bool EndpointUnavailable { get; }
|
||||
|
||||
public BackendAuthException(string message, int statusCode = 0, bool endpointUnavailable = false) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
EndpointUnavailable = endpointUnavailable;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
ingress: ingress,
|
||||
clock: clock,
|
||||
logger: logger,
|
||||
startupGate: _startupEventGate));
|
||||
startupGate: _startupEventGate,
|
||||
// Live accessor (not the current value): a remote-config merge landing after
|
||||
// factory construction must still reach the phase-triggered re-emissions.
|
||||
espContinueAnywayObservationEnabled: () => _agentConfig.EnableEspContinueAnywayObservation));
|
||||
|
||||
// Session caa6cf50 gate-starvation fix: the EspAndHelloTracker's user-ESP-apps-settled
|
||||
// probe reads the IME log host, which is constructed further down. Same closure-over-
|
||||
|
||||
@@ -91,6 +91,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// (tests / legacy callers) means trace events always flow.
|
||||
private readonly Func<bool>? _traceEventsEnabled;
|
||||
|
||||
// Live accessor for AgentConfiguration.EnableEspContinueAnywayObservation — read at
|
||||
// esp_config_detected stamping time so a remote-config merge that lands after
|
||||
// construction still takes effect. Null (tests / legacy callers) means disabled.
|
||||
private readonly Func<bool>? _espContinueAnywayObservationEnabled;
|
||||
|
||||
// Max-lifetime watchdog (M4.6.α). Timer is armed in Start() when _agentMaxLifetime != null.
|
||||
private System.Threading.Timer? _maxLifetimeTimer;
|
||||
private int _terminatedFired;
|
||||
@@ -173,7 +178,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
TimeSpan? agentMaxLifetime = null,
|
||||
int uploadBatchSize = 100,
|
||||
IDeadlineScheduler? schedulerOverride = null,
|
||||
Func<bool>? traceEventsEnabled = null)
|
||||
Func<bool>? traceEventsEnabled = null,
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
@@ -214,6 +220,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
|
||||
_schedulerOverride = schedulerOverride;
|
||||
_traceEventsEnabled = traceEventsEnabled;
|
||||
_espContinueAnywayObservationEnabled = espContinueAnywayObservationEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -773,6 +780,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
if (snapshot.AllowContinueAnyway.HasValue)
|
||||
payload[SignalPayloadKeys.EspAllowContinueAnyway] = snapshot.AllowContinueAnyway.Value ? "true" : "false";
|
||||
// Tenant opt-in (remote config, not a registry fact) — only stamped when
|
||||
// enabled; absent = default hard-fail semantics in the engine.
|
||||
if (_espContinueAnywayObservationEnabled?.Invoke() == true)
|
||||
payload[SignalPayloadKeys.EspContinueAnywayObservationEnabled] = "true";
|
||||
|
||||
_ingress!.Post(
|
||||
kind: DecisionSignalKind.EspConfigDetected,
|
||||
|
||||
@@ -82,7 +82,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
ISignalIngressSink ingress,
|
||||
IClock clock,
|
||||
AgentLogger logger,
|
||||
Persistence.StartupEventGate? startupGate = null)
|
||||
Persistence.StartupEventGate? startupGate = null,
|
||||
Func<bool>? espContinueAnywayObservationEnabled = null)
|
||||
{
|
||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||
@@ -92,7 +93,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// when it reads the FirstSync SkipUser/SkipDevice registry values, so that Fix 8's
|
||||
// reducer guards have the SkipUserEsp/SkipDeviceEsp state facts to read.
|
||||
_collector = new Monitoring.Telemetry.DeviceInfo.DeviceInfoCollector(
|
||||
sessionId, tenantId, post, logger, ingress, clock, startupGate);
|
||||
sessionId, tenantId, post, logger, ingress, clock, startupGate,
|
||||
espContinueAnywayObservationEnabled);
|
||||
_observableIngress = ingress as SignalIngress;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
private int _consecutiveFailures;
|
||||
private DateTime? _firstFailureUtc;
|
||||
private int _thresholdFired; // 0/1 — Interlocked exchange makes the event single-shot
|
||||
private int _distressDispatched; // 0/1 per failure streak — reset by RecordSuccess
|
||||
|
||||
public AuthFailureTracker(
|
||||
int maxFailures,
|
||||
@@ -77,18 +78,27 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
public void RecordSuccess()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _consecutiveFailures, 0) == 0) return; // no-op if already zero
|
||||
Interlocked.Exchange(ref _distressDispatched, 0);
|
||||
lock (_windowLock) { _firstFailureUtc = null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record a 401/403 from the given <paramref name="operation"/>. V1 parity — on the FIRST
|
||||
/// failure this fires a single distress report via the constructor-injected
|
||||
/// <see cref="DistressReporter"/> (401 → AuthCertificateRejected, 403 → DeviceNotRegistered).
|
||||
/// Subsequent failures are logged + counted but do not dispatch further distress signals.
|
||||
/// Emits <see cref="ThresholdExceeded"/> the first time either ceiling is crossed;
|
||||
/// subsequent calls after termination is armed are no-ops.
|
||||
/// Record a 401/403 from the given <paramref name="operation"/>. V1 parity — the first
|
||||
/// qualifying failure of a streak fires a single distress report via the
|
||||
/// constructor-injected <see cref="DistressReporter"/> (401 → AuthCertificateRejected,
|
||||
/// 403 → DeviceNotRegistered). Subsequent failures are logged + counted but do not
|
||||
/// dispatch further distress signals. Emits <see cref="ThresholdExceeded"/> the first
|
||||
/// time either ceiling is crossed; subsequent calls after termination is armed are no-ops.
|
||||
/// </summary>
|
||||
public void RecordFailure(int statusCode, string operation)
|
||||
/// <param name="endpointUnavailable">
|
||||
/// True when the 401/403 was a platform-level HTML response (stopped/retired app, edge
|
||||
/// proxy — see <see cref="BackendAuthException.EndpointUnavailable"/>). Suppresses the
|
||||
/// distress dispatch: the report would go to the very endpoint that just answered with a
|
||||
/// platform error page, and its DeviceNotRegistered classification would be wrong anyway.
|
||||
/// Counting and the shutdown thresholds are unaffected. Should a later failure in the
|
||||
/// same streak come from a live backend, that one still dispatches the streak's report.
|
||||
/// </param>
|
||||
public void RecordFailure(int statusCode, string operation, bool endpointUnavailable = false)
|
||||
{
|
||||
if (Volatile.Read(ref _thresholdFired) == 1) return;
|
||||
|
||||
@@ -102,10 +112,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
firstFailureAt = _firstFailureUtc.Value;
|
||||
}
|
||||
|
||||
// V1 parity — only the first consecutive failure produces a distress report. Repeat
|
||||
// hits at 2..N use the local log only; the backend already knows from the first
|
||||
// report that the device is unauthorized.
|
||||
if (count == 1 && _distressReporter != null)
|
||||
// V1 parity — only one distress report per failure streak. Repeat hits use the local
|
||||
// log only; the backend already knows from the first report that the device is
|
||||
// unauthorized. Endpoint-unavailable failures never dispatch (nothing to send to) but
|
||||
// also never consume the streak's report slot.
|
||||
if (endpointUnavailable)
|
||||
{
|
||||
if (_distressReporter != null && Volatile.Read(ref _distressDispatched) == 0)
|
||||
{
|
||||
_logger.Info(
|
||||
$"AuthFailureTracker: distress report suppressed for {operation} (http {statusCode}) — " +
|
||||
"endpoint unavailable (platform error page), a report cannot reach a stopped endpoint.");
|
||||
}
|
||||
}
|
||||
else if (_distressReporter != null &&
|
||||
Interlocked.CompareExchange(ref _distressDispatched, 1, 0) == 0)
|
||||
{
|
||||
var distressType = statusCode == 403
|
||||
? DistressErrorType.DeviceNotRegistered
|
||||
|
||||
@@ -89,7 +89,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
// Feed the central auth-failure tracker so the first-failure distress dispatches
|
||||
// and the shutdown threshold advances. V1 parity: no retry on auth-failure —
|
||||
// backend has definitively rejected the cert/token.
|
||||
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session");
|
||||
authFailureTracker?.RecordFailure(ex.StatusCode, "agent/register-session", ex.EndpointUnavailable);
|
||||
return SessionRegistrationResult.AuthFailed(ex.StatusCode, ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Configuration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
@@ -121,6 +122,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
|
||||
// Await-enrollment is one-shot — remove the persisted config so subsequent restarts proceed normally.
|
||||
Program.DeleteAwaitEnrollmentConfig(dataDirectory, logger);
|
||||
|
||||
RetrySelfUpdateAfterEnrollment(logger, consoleMode);
|
||||
}
|
||||
|
||||
// Event-driven TenantId wait — kicks in only when the user opted-in via
|
||||
@@ -152,6 +155,81 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
isWhiteGloveResume: isWhiteGloveResume,
|
||||
cleanupServiceFactory: cleanupServiceFactory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Signature of the self-update invocation performed by
|
||||
/// <see cref="RetrySelfUpdateAfterEnrollment"/> — mirrors the
|
||||
/// <see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> parameters this path uses.
|
||||
/// </summary>
|
||||
internal delegate Task SelfUpdateInvoker(
|
||||
string currentVersion,
|
||||
string agentDir,
|
||||
bool consoleMode,
|
||||
string triggerReason,
|
||||
int downloadTimeoutMs,
|
||||
bool allowDowngrade,
|
||||
int versionCheckTimeoutMs);
|
||||
|
||||
/// <summary>
|
||||
/// Test seam replacing the real <see cref="SelfUpdater.CheckAndApplyUpdateAsync"/> call
|
||||
/// in <see cref="RetrySelfUpdateAfterEnrollment"/> (same static-hook idiom as
|
||||
/// <see cref="SelfUpdater.RequestGracefulShutdown"/>). Only the in-repo test project
|
||||
/// (InternalsVisibleTo) can set it; production code never assigns this.
|
||||
/// </summary>
|
||||
internal static SelfUpdateInvoker UpdateInvokerOverride;
|
||||
|
||||
/// <summary>
|
||||
/// One-shot self-update retry after the MDM certificate appeared. The startup check in
|
||||
/// <c>Main</c> runs pre-enrollment, where an --await-enrollment install typically has no
|
||||
/// network yet — its failure says nothing about the latest version. The certificate is
|
||||
/// proof of connectivity, and this is the last chance to swap a stranded old build
|
||||
/// (stale baked-in ApiBaseUrl) for a current one BEFORE session registration hits a
|
||||
/// retired endpoint (field case sits-d.cloud, 2026-08-09). Timeouts are relaxed vs. the
|
||||
/// boot-speed-tuned startup check; enrollment is already past OOBE's critical path here.
|
||||
/// On update success the process restarts and this method never returns; the
|
||||
/// await-enrollment config was already removed, so the new binary proceeds normally.
|
||||
/// </summary>
|
||||
internal static void RetrySelfUpdateAfterEnrollment(AgentLogger logger, bool consoleMode)
|
||||
{
|
||||
if (SelfUpdater.LastVersionCheckSucceeded)
|
||||
return; // Startup check reached the manifest — its verdict stands.
|
||||
|
||||
try
|
||||
{
|
||||
logger.Info("Await-enrollment: startup self-update check had failed (no network in early OOBE) — retrying now that enrollment proves connectivity.");
|
||||
var agentDir = System.IO.Path.GetDirectoryName(
|
||||
System.Reflection.Assembly.GetExecutingAssembly().Location) ?? string.Empty;
|
||||
SelfUpdateInvoker invoke = UpdateInvokerOverride;
|
||||
if (invoke == null)
|
||||
{
|
||||
invoke = (version, dir, console, reason, downloadTimeoutMs, allowDowngrade, versionCheckTimeoutMs) =>
|
||||
SelfUpdater.CheckAndApplyUpdateAsync(
|
||||
currentVersion: version,
|
||||
agentDir: dir,
|
||||
consoleMode: console,
|
||||
triggerReason: reason,
|
||||
downloadTimeoutMsOverride: downloadTimeoutMs,
|
||||
allowDowngrade: allowDowngrade,
|
||||
versionCheckTimeoutMsOverride: versionCheckTimeoutMs);
|
||||
}
|
||||
invoke(
|
||||
Program.GetAgentVersion(),
|
||||
agentDir,
|
||||
consoleMode,
|
||||
"await_enrollment",
|
||||
60000,
|
||||
// Fresh --await-enrollment installs have no cached remote config yet, so
|
||||
// there is no admin downgrade override to honour on this path.
|
||||
false,
|
||||
10000).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Same contract as the startup call site: the updater swallows its own errors;
|
||||
// anything escaping must not break bootstrap — the current binary continues.
|
||||
logger.Warning($"Await-enrollment: self-update retry failed unexpectedly: {ex.Message}. Continuing with current version.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -135,7 +135,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
uploadBatchSize: uploadBatchSize,
|
||||
// Live read: remote config is merged into agentConfig mid-session, so the
|
||||
// trace-event gate must follow it rather than snapshot at construction.
|
||||
traceEventsEnabled: () => agentConfig.SendTraceEvents))
|
||||
traceEventsEnabled: () => agentConfig.SendTraceEvents,
|
||||
espContinueAnywayObservationEnabled: () => agentConfig.EnableEspContinueAnywayObservation))
|
||||
{
|
||||
using (var shutdown = new ManualResetEventSlim(false))
|
||||
using (var shutdownComplete = new ManualResetEventSlim(false))
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// Continue-Anyway observation mode (tenant c9787ba2, session 53d1e9f6, 2026-08-08).
|
||||
/// A Device-phase ESP terminal failure (AccountSetup never entered) on a
|
||||
/// Continue-Anyway-enabled profile is a hard fail by default; with the tenant opt-in
|
||||
/// (<c>EspContinueAnywayObservationEnabled</c> observation) it is defanged into a 60-min
|
||||
/// observation advisory instead. Coverage:
|
||||
/// <list type="bullet">
|
||||
/// <item>Defang + 60-min AdvisoryCompletion arming (vs the classic 30-min window).</item>
|
||||
/// <item>Opt-out / ContinueAnyway-off / self-deploying-scenario regressions still hard-fail.</item>
|
||||
/// <item>Real-user desktop + Hello-disabled completes eagerly through Finalizing; the
|
||||
/// terminal enrollment_complete carries the espSoftFailure marker.</item>
|
||||
/// <item>Window expiry without desktop un-defangs to esp_terminal_failure.</item>
|
||||
/// <item>Deadline fire with desktop but unknown Hello promotes to AwaitingHello.</item>
|
||||
/// <item>Category recovery ("Try again") resolves the advisory (classic 4910a5a5 hook).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public sealed class ContinueAnywayObservationTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 7, 15, 16, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null,
|
||||
string sourceOrigin = "test")
|
||||
{
|
||||
return new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: sourceOrigin,
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
|
||||
payload: payload);
|
||||
}
|
||||
|
||||
private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) =>
|
||||
MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
|
||||
|
||||
private static DecisionEffect SingleTimelineEffect(DecisionStep step, string eventType) =>
|
||||
step.Effects.Single(e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||
&& e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == eventType);
|
||||
|
||||
private static ActiveDeadline? FindDeadline(DecisionState state, string name) =>
|
||||
state.Deadlines.FirstOrDefault(d => d.Name == name);
|
||||
|
||||
/// <summary>
|
||||
/// Replays the session-53d1e9f6 shape up to (but excluding) the ESP terminal failure:
|
||||
/// Continue-Anyway profile with the tenant observation opt-in stamped into the
|
||||
/// EspConfigDetected payload, Hello policy disabled, DeviceSetup entered —
|
||||
/// AccountSetup never reached (the 30-min Device-ESP wall kills it first).
|
||||
/// </summary>
|
||||
private static DecisionState SetupObservationEligibleSession(
|
||||
DecisionEngine engine,
|
||||
bool observationOptIn = true,
|
||||
bool continueAnyway = true,
|
||||
bool helloPolicyDisabled = true)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-53d1e9f6", "tenant-c9787ba2", T0);
|
||||
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
var espConfig = new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.SkipUserEsp] = "false",
|
||||
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||
[SignalPayloadKeys.EspSyncFailureTimeoutMinutes] = "30",
|
||||
[SignalPayloadKeys.EspAllowContinueAnyway] = continueAnyway ? "true" : "false",
|
||||
};
|
||||
if (observationOptIn)
|
||||
{
|
||||
espConfig[SignalPayloadKeys.EspContinueAnywayObservationEnabled] = "true";
|
||||
}
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1), espConfig)).NewState;
|
||||
if (helloPolicyDisabled)
|
||||
{
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
8, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
|
||||
}
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||
return state;
|
||||
}
|
||||
|
||||
private static DecisionStep ApplyDeviceSetupTerminalFailure(DecisionEngine engine, DecisionState state, long ordinal = 50) =>
|
||||
engine.Reduce(state, MakeSignal(
|
||||
ordinal, DecisionSignalKind.EspTerminalFailure, T0.AddMinutes(38),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["failureType"] = "Provisioning_DeviceSetup_Certificates_Failed",
|
||||
["failedSubcategory"] = "Certificates",
|
||||
["category"] = "DeviceSetup",
|
||||
}));
|
||||
|
||||
// ============================================================ defang path ====
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupFailure_WithOptIn_DefangsToObservationAdvisory()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
Assert.Null(state.AccountSetupEnteredUtc);
|
||||
|
||||
var preStage = state.Stage;
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
// No terminal — stage unchanged, advisory anchored, no LastFailureTrigger.
|
||||
Assert.Equal(preStage, step.NewState.Stage);
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotNull(step.NewState.EspAdvisoryFailureRecordedUtc);
|
||||
Assert.Equal("DeviceSetup", step.NewState.EspAdvisoryFailureCategory!.Value);
|
||||
Assert.Null(step.NewState.LastFailureTrigger);
|
||||
|
||||
var advisory = SingleTimelineEffect(step, "esp_failure_advisory");
|
||||
Assert.Equal("esp_failure_defanged_continueanyway_observation", advisory.Parameters!["advisoryReason"]);
|
||||
Assert.Equal("Warning", advisory.Parameters!["severity"]);
|
||||
Assert.Equal("true", advisory.Parameters!["mayHaveContinuedAnyway"]);
|
||||
|
||||
Assert.DoesNotContain(step.Effects, e =>
|
||||
e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == "enrollment_failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupFailure_WithOptIn_Arms60MinObservationWindow()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
var deadline = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(deadline);
|
||||
// 60-min observation window (user decision 2026-08-08) — NOT the classic 30-min
|
||||
// advisory-completion window.
|
||||
Assert.Equal(T0.AddMinutes(38).AddMinutes(60), deadline!.DueAtUtc);
|
||||
}
|
||||
|
||||
// ==================================================== negative / regression ====
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupFailure_WithoutOptIn_StillHardFails()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine, observationOptIn: false);
|
||||
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
|
||||
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
|
||||
SingleTimelineEffect(step, "enrollment_failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupFailure_WithOptIn_ButContinueAnywayDisabled_StillHardFails()
|
||||
{
|
||||
// The observation only makes sense when the ESP profile actually offers the
|
||||
// "Continue anyway" button — without it the user cannot reach the desktop.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine, continueAnyway: false);
|
||||
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
|
||||
SingleTimelineEffect(step, "enrollment_failed");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeviceSetupFailure_WithOptIn_OnSelfDeployingProfile_StillHardFails()
|
||||
{
|
||||
// Scope gate: self-deploying/kiosk flows have no interactive user who could press
|
||||
// "Continue anyway" — observation would only ever expire. Immediate fail is truthful.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.EnrollmentFactsObserved, T0.AddMinutes(3),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.IsSelfDeployingProfile] = "true" })).NewState;
|
||||
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Null(step.NewState.EspAdvisoryFailureRecordedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClassicAdvisory_WithAccountSetupEntered_KeepsClassicReasonAndWindow()
|
||||
{
|
||||
// Regression: the opt-in must not change the classic post-AccountSetup advisory —
|
||||
// same advisoryReason literal, same 30-min window.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
Assert.NotNull(state.AccountSetupEnteredUtc);
|
||||
|
||||
var step = ApplyDeviceSetupTerminalFailure(engine, state);
|
||||
|
||||
var advisory = SingleTimelineEffect(step, "esp_failure_advisory");
|
||||
Assert.Equal("esp_failure_defanged_continueanyway_with_accountsetup", advisory.Parameters!["advisoryReason"]);
|
||||
var deadline = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.Equal(T0.AddMinutes(38).AddMinutes(30), deadline!.DueAtUtc);
|
||||
}
|
||||
|
||||
// ========================================== real end: desktop completion ====
|
||||
|
||||
[Fact]
|
||||
public void ObservationAdvisory_ThenRealUserDesktop_CompletesThroughFinalizing()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
|
||||
// User pressed "Continue anyway" → signed in → DAD-validated real-user desktop.
|
||||
var step = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42)));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Contains(":ContinueAnywayObservation", step.Transition.Trigger);
|
||||
// Hello disabled → synthetic skip recorded by the fast-path.
|
||||
Assert.NotNull(step.NewState.HelloResolvedUtc);
|
||||
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.FinalizingGrace));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservationCompletion_EnrollmentComplete_CarriesEspSoftFailureMarker()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
|
||||
Assert.Equal(SessionStage.Finalizing, state.Stage);
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(42.1), DeadlineNames.FinalizingGrace));
|
||||
|
||||
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome);
|
||||
|
||||
var complete = SingleTimelineEffect(step, "enrollment_complete");
|
||||
var payload = Assert.IsType<Dictionary<string, object>>(complete.TypedPayload);
|
||||
Assert.Equal("true", payload["espSoftFailure"]);
|
||||
Assert.Equal("continue_anyway_observation", payload["completionSource"]);
|
||||
Assert.Equal("DeviceSetup", payload["espSoftFailureCategory"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CleanCompletion_EnrollmentComplete_OmitsEspSoftFailureMarker()
|
||||
{
|
||||
// A session without any advisory must not carry the marker — regression guard for
|
||||
// the BuildEnrollmentCompleteEffect change.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
30, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(20))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
40, DecisionSignalKind.DesktopArrived, T0.AddMinutes(21))).NewState;
|
||||
Assert.Equal(SessionStage.Finalizing, state.Stage);
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(50, T0.AddMinutes(21.1), DeadlineNames.FinalizingGrace));
|
||||
|
||||
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
|
||||
var complete = SingleTimelineEffect(step, "enrollment_complete");
|
||||
var payload = Assert.IsType<Dictionary<string, object>>(complete.TypedPayload);
|
||||
Assert.False(payload.ContainsKey("espSoftFailure"));
|
||||
Assert.False(payload.ContainsKey("completionSource"));
|
||||
}
|
||||
|
||||
// =============================================== window expiry / deadline ====
|
||||
|
||||
[Fact]
|
||||
public void ObservationWindow_ExpiresWithoutDesktop_UnDefangsToEspTerminalFailure()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
Assert.Null(state.DesktopArrivedUtc);
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(60, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
|
||||
// The failure cause IS the original ESP failure — same reason literal, likely-stuck
|
||||
// promotion stays armed via LastFailureTrigger=EspTerminalFailure.
|
||||
Assert.Equal(nameof(DecisionSignalKind.EspTerminalFailure), step.NewState.LastFailureTrigger!.Value);
|
||||
|
||||
var failed = SingleTimelineEffect(step, "enrollment_failed");
|
||||
Assert.Equal("esp_terminal_failure", failed.Parameters!["reason"]);
|
||||
Assert.Equal(
|
||||
"continue_anyway_observation_window_expired_without_completion_evidence",
|
||||
failed.Parameters!["advisoryReason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservationWindow_FiresWithDesktopAndDisabledHello_CompletesInsteadOfFailing()
|
||||
{
|
||||
// Ordering variant: desktop arrived but (hypothetically) no eager completion ran —
|
||||
// the deadline-fire conjunction must accept desktop + hello for the observation
|
||||
// variant without the (structurally impossible) IME user-session gate.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine, helloPolicyDisabled: false);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
// Hello policy lands AFTER the failure; desktop arrives; no eager completion since
|
||||
// the fast-path needs the policy fact at desktop-arrival time.
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
|
||||
Assert.NotEqual(SessionStage.Finalizing, state.Stage);
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
65, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(43),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.FinalizingGrace));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObservationWindow_FiresWithDesktopButUnknownHello_PromotesToAwaitingHello()
|
||||
{
|
||||
// Hello policy never observed (no PassportForWork value) — the hello prerequisite is
|
||||
// structurally unresolvable. The never-observed promote must apply to the observation
|
||||
// variant instead of failing a desktop-proven session.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine, helloPolicyDisabled: false);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.DesktopArrived, T0.AddMinutes(42))).NewState;
|
||||
Assert.Null(state.HelloPolicyEnabled);
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(98), DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
Assert.NotNull(FindDeadline(step.NewState, DeadlineNames.HelloSafety));
|
||||
}
|
||||
|
||||
// ======================================================= category recovery ====
|
||||
|
||||
[Fact]
|
||||
public void ObservationAdvisory_DeviceSetupRecovery_ResolvesAdvisory()
|
||||
{
|
||||
// "Try again" path: the ESP re-runs the device phase and DeviceSetup resolves to
|
||||
// success — the 4910a5a5 recovery hook must match the observation advisory too.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupObservationEligibleSession(engine);
|
||||
state = ApplyDeviceSetupTerminalFailure(engine, state).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.DeviceSetupProvisioningComplete, T0.AddMinutes(55)));
|
||||
|
||||
Assert.NotNull(step.NewState.EspAdvisoryFailureResolvedUtc);
|
||||
Assert.Contains(step.Effects, e =>
|
||||
e.Parameters != null
|
||||
&& e.Parameters.TryGetValue("eventType", out var et)
|
||||
&& et == "esp_failure_advisory_resolved");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Same compile context as the product project: with the shared framework referenced,
|
||||
NuGet prunes framework-provided transitive packages (e.g. the vulnerable
|
||||
DataProtection/Cryptography.Xml 10.0.0 pulled in via Microsoft.Identity.Web),
|
||||
which would otherwise surface NU1903/NU1904 advisories here. -->
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
|
||||
@@ -47,7 +47,7 @@ public class CriticalTableBackupServiceTests
|
||||
|
||||
Assert.Equal(BackupOutcome.Success, result.Outcome);
|
||||
Assert.True(store.ManifestWritten, "manifest must be written LAST");
|
||||
Assert.Equal(17, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups)
|
||||
Assert.Equal(18, result.Manifest.Tables.Count); // critical-tables catalog count (incl. DelegatedAdmins + ConfigurationBackups + SessionAnnotations)
|
||||
Assert.All(result.Manifest.Tables, t =>
|
||||
Assert.True(t.Status == TableBackupStatus.Ok || t.Status == TableBackupStatus.Empty));
|
||||
}
|
||||
|
||||
@@ -75,9 +75,9 @@ public class DeletionManifestBuilderTests
|
||||
Assert.Equal(2, c["classifierVerdictsByIdLevel"]);
|
||||
Assert.Equal(11, c["signalsByKind"]);
|
||||
|
||||
// 16 cascade tables (incl. SessionTimeBreakdowns, F1 PR2) + tombstone = 17 steps;
|
||||
// no inventory steps when no contributions row.
|
||||
Assert.Equal(17, manifest.Steps.Count);
|
||||
// 19 cascade tables (incl. SessionTimeBreakdowns F1 PR2 + 3 SessionAnnotations lanes)
|
||||
// + tombstone = 20 steps; no inventory steps when no contributions row.
|
||||
Assert.Equal(20, manifest.Steps.Count);
|
||||
Assert.Equal(DeletionStepClass.Final, manifest.Steps.Last().Class);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,8 @@ public class DeletionManifestBuilderTests
|
||||
.WithDeadEndsByReason(1, propValue: "deadend_marker")
|
||||
.WithClassifierVerdictsByIdLevel(1, propValue: "classifier_marker")
|
||||
.WithSignalsByKind(1, propValue: "signalkind_marker")
|
||||
.WithSessionTimeBreakdown(propValue: "breakdown_marker"));
|
||||
.WithSessionTimeBreakdown(propValue: "breakdown_marker")
|
||||
.WithSessionAnnotations(propValue: "annotation_marker"));
|
||||
|
||||
var manifest = await NewBuilder(reader).BuildAsync(
|
||||
TenantId, SessionId, "admin_delete",
|
||||
@@ -147,8 +148,9 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
var nonTombstoneSteps = manifest.Steps.Where(s => s.Class != DeletionStepClass.Final).ToList();
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 16 cascade-table steps.
|
||||
Assert.Equal(16, nonTombstoneSteps.Count);
|
||||
// Without a contributions row, neither inventory step is emitted → exactly 19 cascade-table steps
|
||||
// (16 + the 3 SessionAnnotations lane steps).
|
||||
Assert.Equal(19, nonTombstoneSteps.Count);
|
||||
foreach (var step in nonTombstoneSteps)
|
||||
{
|
||||
Assert.Equal(0, step.RowCount);
|
||||
@@ -177,8 +179,8 @@ public class DeletionManifestBuilderTests
|
||||
new DeletionActor { Type = "admin", Actor = "alice@example.com" },
|
||||
new DeletionRetentionContext { TenantRetentionDays = 90 });
|
||||
|
||||
// Expect: 16 cascade + 2 inventory + 1 tombstone = 19 steps.
|
||||
Assert.Equal(19, manifest.Steps.Count);
|
||||
// Expect: 19 cascade + 2 inventory + 1 tombstone = 22 steps.
|
||||
Assert.Equal(22, manifest.Steps.Count);
|
||||
|
||||
var aggregate = manifest.Steps.Single(s => s.Class == DeletionStepClass.Aggregate);
|
||||
Assert.Equal(DeletionStepNames.SoftwareInventoryDecrement, aggregate.Step);
|
||||
@@ -229,7 +231,7 @@ public class DeletionManifestBuilderTests
|
||||
[Fact]
|
||||
public async Task Build_omits_inventory_steps_when_no_side_row()
|
||||
{
|
||||
// The default reader returns no inventory row → 15 cascade + 1 tombstone, no inventory steps.
|
||||
// The default reader returns no inventory row → 19 cascade + 1 tombstone, no inventory steps.
|
||||
var reader = NewReader(seed => seed.WithSessionsRow().WithSessionsIndexRow());
|
||||
|
||||
var manifest = await NewBuilder(reader).BuildAsync(
|
||||
@@ -644,6 +646,7 @@ public class DeletionManifestBuilderTests
|
||||
private TableEntity? _eventSessionIndex;
|
||||
private TableEntity? _sessionTimeBreakdown;
|
||||
private TableEntity? _sessionInventoryContributions;
|
||||
private readonly Dictionary<string, TableEntity> _sessionAnnotations = new(StringComparer.Ordinal);
|
||||
private string _sessionsIndexRowKey = "INDEX_" + SessionId;
|
||||
|
||||
public ReaderSeed WithSessionsRow(string? diagnosticsBlobName = null)
|
||||
@@ -774,6 +777,23 @@ public class DeletionManifestBuilderTests
|
||||
public ReaderSeed WithClassifierVerdictsByIdLevel(int n, string propValue = "cls") => SeedDiscriminatorPkProp(Constants.TableNames.ClassifierVerdictsByIdLevel, n, propValue, "EspApps_High");
|
||||
public ReaderSeed WithSignalsByKind(int n, string propValue = "sk") => SeedDiscriminatorPkProp(Constants.TableNames.SignalsByKind, n, propValue, "EspPhaseChanged");
|
||||
|
||||
public ReaderSeed WithSessionAnnotations(string propValue = "anno")
|
||||
{
|
||||
// One row per lane — the builder emits one exact-RK step per lane.
|
||||
foreach (var lane in AutopilotMonitor.Shared.Models.AnnotationLanes.All)
|
||||
{
|
||||
var e = new TableEntity(TenantId, $"{SessionId}_{lane}")
|
||||
{
|
||||
["Marker"] = propValue,
|
||||
["SessionId"] = SessionId,
|
||||
["Lane"] = lane,
|
||||
};
|
||||
e.ETag = new ETag("0xANNO" + lane);
|
||||
_sessionAnnotations[lane] = e;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public ReaderSeed WithSessionInventoryContributions(TableEntity row)
|
||||
{
|
||||
_sessionInventoryContributions = row;
|
||||
@@ -813,6 +833,11 @@ public class DeletionManifestBuilderTests
|
||||
.ReturnsAsync(_sessionTimeBreakdown);
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionInventoryContributions, TenantId, SessionId, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(_sessionInventoryContributions);
|
||||
foreach (var (lane, row) in _sessionAnnotations)
|
||||
{
|
||||
reader.Setup(r => r.GetEntityOrNullAsync(Constants.TableNames.SessionAnnotations, TenantId, $"{SessionId}_{lane}", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(row);
|
||||
}
|
||||
|
||||
// QueryAsync: per-table seeded list, otherwise empty.
|
||||
reader.Setup(r => r.QueryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the claim precedence behind gather-rule Author stamping
|
||||
/// (<see cref="TenantHelper.GetUserDisplayName(ClaimsPrincipal)"/>): display name
|
||||
/// first, then UPN/email/preferred_username; null for anonymous/app-only callers
|
||||
/// so CreateGatherRule falls back to the model default "Autopilot Monitor".
|
||||
/// </summary>
|
||||
public class GatherRuleAuthorStampingTests
|
||||
{
|
||||
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
|
||||
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
|
||||
|
||||
[Fact]
|
||||
public void DisplayName_wins_over_upn()
|
||||
{
|
||||
var user = Principal(("name", "Alice Admin"), ("upn", "alice@contoso.com"));
|
||||
Assert.Equal("Alice Admin", TenantHelper.GetUserDisplayName(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Upn_used_when_no_name_claim()
|
||||
{
|
||||
var user = Principal(("upn", "alice@contoso.com"), ("preferred_username", "alice@contoso.onmicrosoft.com"));
|
||||
Assert.Equal("alice@contoso.com", TenantHelper.GetUserDisplayName(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PreferredUsername_is_the_last_fallback()
|
||||
{
|
||||
var user = Principal(("preferred_username", "alice@contoso.com"));
|
||||
Assert.Equal("alice@contoso.com", TenantHelper.GetUserDisplayName(user));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_for_principal_without_identifying_claims()
|
||||
{
|
||||
Assert.Null(TenantHelper.GetUserDisplayName(Principal(("tid", "a1b2c3d4-e5f6-7890-abcd-ef1234567890"))));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_for_unauthenticated_or_missing_principal()
|
||||
{
|
||||
Assert.Null(TenantHelper.GetUserDisplayName((ClaimsPrincipal?)null));
|
||||
// ClaimsIdentity without an authentication type => IsAuthenticated == false.
|
||||
Assert.Null(TenantHelper.GetUserDisplayName(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim("name", "Ghost") }))));
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ public class GatherRuleUpdatePartialMergeTests
|
||||
Enabled = true,
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
Author = "Alice Admin",
|
||||
CreatedAt = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc),
|
||||
ActiveFromPhase = "AccountSetup",
|
||||
EmitMode = "on_change",
|
||||
@@ -77,6 +78,7 @@ public class GatherRuleUpdatePartialMergeTests
|
||||
Assert.Equal("AccountSetup", written.ActiveFromPhase);
|
||||
Assert.Equal("on_change", written.EmitMode);
|
||||
Assert.Equal(existing.CreatedAt, written.CreatedAt);
|
||||
Assert.Equal("Alice Admin", written.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -126,6 +128,33 @@ public class GatherRuleUpdatePartialMergeTests
|
||||
Assert.Equal(existing.CreatedAt, written.CreatedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task FullPayload_preserves_original_Author_even_when_payload_carries_one()
|
||||
{
|
||||
var existing = ExistingCustomRule();
|
||||
var (service, _, stored) = BuildService(existing);
|
||||
|
||||
var fullPayload = new GatherRule
|
||||
{
|
||||
RuleId = RuleId,
|
||||
Title = "Collect BIOS Config v2",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\RealmJoin\\Custom\\BIOS2",
|
||||
Trigger = "interval",
|
||||
OutputEventType = "gather_bios_config",
|
||||
Enabled = true,
|
||||
// A hand-crafted PUT could try to rewrite attribution — Author is
|
||||
// stamped at create time and immutable afterwards.
|
||||
Author = "Spoofed Author",
|
||||
};
|
||||
|
||||
var ok = await service.UpdateRuleAsync(TenantId, fullPayload);
|
||||
|
||||
Assert.True(ok);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Alice Admin", written.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuiltInRule_toggle_still_goes_through_RuleState_path()
|
||||
{
|
||||
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Endpoint-level tests proving <see cref="GatherRulesFunction"/> stamps the rule
|
||||
/// Author from the caller's JWT and never from the request body — through the real
|
||||
/// request pipeline (body deserialization → ValidateScopeAndEmitMode → stamp →
|
||||
/// service). Complements <see cref="GatherRuleAuthorStampingTests"/> (claim
|
||||
/// precedence only) and GatherRuleUpdatePartialMergeTests (service-level
|
||||
/// preservation): those pin the pieces, this pins the wiring.
|
||||
/// Unlike most function tests in this project, it fakes HttpRequestData — the
|
||||
/// stamp lives IN the endpoint method, so no smaller seam can prove it.
|
||||
/// </summary>
|
||||
public class GatherRulesFunctionAuthorEndpointTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string RuleId = "GATHER-CUSTOM-777";
|
||||
|
||||
// ── request/response fakes ──────────────────────────────────────────────
|
||||
|
||||
private sealed class FakeHttpResponseData : HttpResponseData
|
||||
{
|
||||
public FakeHttpResponseData(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
|
||||
private static FunctionContext BuildContext(ClaimsPrincipal? principal)
|
||||
{
|
||||
// WriteAsJsonAsync resolves the serializer from InstanceServices.
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object>();
|
||||
if (principal != null)
|
||||
{
|
||||
items["ClaimsPrincipal"] = principal;
|
||||
}
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
return context.Object;
|
||||
}
|
||||
|
||||
private static HttpRequestData BuildRequest(ClaimsPrincipal? principal, object body)
|
||||
{
|
||||
var context = BuildContext(principal);
|
||||
var req = new Mock<HttpRequestData>(context);
|
||||
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
|
||||
req.SetupGet(r => r.Body).Returns(
|
||||
new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body))));
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context));
|
||||
return req.Object;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
|
||||
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
|
||||
|
||||
private static ClaimsPrincipal AlicePrincipal()
|
||||
=> Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com"));
|
||||
|
||||
// ── function under test with a captured repository ─────────────────────
|
||||
|
||||
private static (GatherRulesFunction function, List<GatherRule> stored) BuildFunction(
|
||||
GatherRule? existingTenantRule = null)
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
var stored = new List<GatherRule>();
|
||||
|
||||
repo.Setup(r => r.GatherRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>());
|
||||
repo.Setup(r => r.GetGatherRulesAsync(TenantId)).ReturnsAsync(
|
||||
existingTenantRule == null ? new List<GatherRule>() : new List<GatherRule> { existingTenantRule });
|
||||
repo.Setup(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), TenantId))
|
||||
.Callback<GatherRule, string>((rule, _) => stored.Add(rule))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
return (new GatherRulesFunction(NullLogger<GatherRulesFunction>.Instance, service), stored);
|
||||
}
|
||||
|
||||
private static GatherRule SpoofedPayload() => new()
|
||||
{
|
||||
RuleId = RuleId,
|
||||
Title = "Collect BIOS Config",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\RealmJoin\\Custom\\BIOS",
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_bios_config",
|
||||
Enabled = true,
|
||||
Author = "Spoofed Author",
|
||||
};
|
||||
|
||||
// ── CreateRule ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRule_stamps_author_from_jwt_not_from_body()
|
||||
{
|
||||
var (function, stored) = BuildFunction();
|
||||
var req = BuildRequest(AlicePrincipal(), SpoofedPayload());
|
||||
|
||||
var response = await function.CreateRule(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Alice Admin", written.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateRule_falls_back_to_product_name_without_identifying_claims()
|
||||
{
|
||||
// App-only-shaped token: authenticated with a tenant but no user display claims.
|
||||
var (function, stored) = BuildFunction();
|
||||
var req = BuildRequest(Principal(("tid", TenantId)), SpoofedPayload());
|
||||
|
||||
var response = await function.CreateRule(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Autopilot Monitor", written.Author);
|
||||
}
|
||||
|
||||
// ── UpdateRule ──────────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRule_upsert_of_unknown_rule_stamps_author_from_jwt()
|
||||
{
|
||||
// Full-payload PUT for a ruleId with no existing tenant row upserts a new row —
|
||||
// without the endpoint stamp the payload's author would be stored verbatim.
|
||||
var (function, stored) = BuildFunction(existingTenantRule: null);
|
||||
var req = BuildRequest(AlicePrincipal(), SpoofedPayload());
|
||||
|
||||
var response = await function.UpdateRule(req, RuleId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Alice Admin", written.Author);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateRule_true_update_keeps_original_author_over_editor_and_body()
|
||||
{
|
||||
var existing = new GatherRule
|
||||
{
|
||||
RuleId = RuleId,
|
||||
Title = "Collect BIOS Config",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\RealmJoin\\Custom\\BIOS",
|
||||
Trigger = "startup",
|
||||
OutputEventType = "gather_bios_config",
|
||||
IsBuiltIn = false,
|
||||
IsCommunity = false,
|
||||
Author = "Original Creator",
|
||||
};
|
||||
var (function, stored) = BuildFunction(existing);
|
||||
// Bob edits Alice's rule; neither Bob nor the spoofed body author may win.
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("name", "Bob Editor")), SpoofedPayload());
|
||||
|
||||
var response = await function.UpdateRule(req, RuleId);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(stored);
|
||||
Assert.Equal("Original Creator", written.Author);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Functions.Functions.Annotations;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Core.Serialization;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Newtonsoft.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the session-annotation authorization surface: the per-lane write matrix
|
||||
/// (<see cref="UpsertSessionAnnotationFunction.IsLaneWritableByCaller"/>), the GA-lane
|
||||
/// read filter (<see cref="GetSessionAnnotationsFunction.FilterLanesForCaller"/>), and —
|
||||
/// endpoint-level, through the real request pipeline — that author identity is stamped
|
||||
/// from the JWT and never from the body (same contract as the gather-rule PUT-upserts).
|
||||
/// </summary>
|
||||
public class SessionAnnotationFunctionsTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
// ── per-lane write matrix ───────────────────────────────────────────────
|
||||
// Callers reaching the function already passed TenantAdminOrOperator (tenant
|
||||
// Admin/Operator, or GA via bypass) — the matrix re-gates per lane.
|
||||
|
||||
[Theory]
|
||||
// operator lane: Operator or Tenant Admin; GA (cross-tenant: not tenant admin) may not
|
||||
[InlineData(AnnotationLanes.Operator, "Operator", false, false, true)]
|
||||
[InlineData(AnnotationLanes.Operator, "Admin", true, false, true)]
|
||||
[InlineData(AnnotationLanes.Operator, "GlobalAdmin", false, true, false)]
|
||||
[InlineData(AnnotationLanes.Operator, "Viewer", false, false, false)]
|
||||
// tenantadmin lane: Tenant Admin only
|
||||
[InlineData(AnnotationLanes.TenantAdmin, "Admin", true, false, true)]
|
||||
[InlineData(AnnotationLanes.TenantAdmin, "Operator", false, false, false)]
|
||||
[InlineData(AnnotationLanes.TenantAdmin, "GlobalAdmin", false, true, false)]
|
||||
// globaladmin lane: Global Admin only
|
||||
[InlineData(AnnotationLanes.GlobalAdmin, "GlobalAdmin", false, true, true)]
|
||||
[InlineData(AnnotationLanes.GlobalAdmin, "Admin", true, false, false)]
|
||||
[InlineData(AnnotationLanes.GlobalAdmin, "Operator", false, false, false)]
|
||||
// unknown lane: never writable
|
||||
[InlineData("someotherlane", "GlobalAdmin", true, true, false)]
|
||||
public void IsLaneWritableByCaller_matrix(
|
||||
string lane, string userRole, bool isTenantAdmin, bool isGlobalAdmin, bool expected)
|
||||
{
|
||||
Assert.Equal(expected,
|
||||
UpsertSessionAnnotationFunction.IsLaneWritableByCaller(lane, userRole, isTenantAdmin, isGlobalAdmin));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsLaneWritableByCaller_ga_who_is_also_own_tenant_admin_may_write_tenant_lanes()
|
||||
{
|
||||
// A GA annotating a session of their OWN tenant (IsTenantAdmin=true there) may use
|
||||
// the tenant lanes — the GA-only restriction applies to foreign tenants, where
|
||||
// IsTenantAdmin is false.
|
||||
Assert.True(UpsertSessionAnnotationFunction.IsLaneWritableByCaller(
|
||||
AnnotationLanes.TenantAdmin, "GlobalAdmin", isTenantAdmin: true, isGlobalAdmin: true));
|
||||
}
|
||||
|
||||
// ── GA-lane read filter ─────────────────────────────────────────────────
|
||||
|
||||
private static List<SessionAnnotation> AllLanes() => new()
|
||||
{
|
||||
new SessionAnnotation { Lane = AnnotationLanes.Operator, SessionId = SessionId },
|
||||
new SessionAnnotation { Lane = AnnotationLanes.TenantAdmin, SessionId = SessionId },
|
||||
new SessionAnnotation { Lane = AnnotationLanes.GlobalAdmin, SessionId = SessionId },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void FilterLanesForCaller_hides_globaladmin_lane_without_global_scope()
|
||||
{
|
||||
var visible = GetSessionAnnotationsFunction.FilterLanesForCaller(AllLanes(), hasGlobalScope: false);
|
||||
|
||||
Assert.Equal(2, visible.Count);
|
||||
Assert.DoesNotContain(visible, a => a.Lane == AnnotationLanes.GlobalAdmin);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilterLanesForCaller_returns_all_lanes_for_global_scope()
|
||||
{
|
||||
var visible = GetSessionAnnotationsFunction.FilterLanesForCaller(AllLanes(), hasGlobalScope: true);
|
||||
|
||||
Assert.Equal(3, visible.Count);
|
||||
}
|
||||
|
||||
// ── global list: GA-lane exclusion follows global scope ─────────────────
|
||||
// The route is GlobalReadOrAdmin, but the delegated ("MSP") read rescue admits a
|
||||
// delegated caller on its managed ?tenantId= path — the platform-internal
|
||||
// globaladmin lane must be excluded server-side for anyone without global scope.
|
||||
|
||||
[Theory]
|
||||
[InlineData(true, false)]
|
||||
[InlineData(false, true)]
|
||||
public async Task GlobalList_derives_globaladmin_lane_exclusion_from_global_scope(
|
||||
bool hasGlobalScope, bool expectedExclude)
|
||||
{
|
||||
var annotationRepo = new Mock<ISessionAnnotationRepository>(MockBehavior.Loose);
|
||||
bool? capturedExclude = null;
|
||||
annotationRepo
|
||||
.Setup(r => r.QueryPageAsync(
|
||||
It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(), It.IsAny<string?>(),
|
||||
It.IsAny<DateTime?>(), It.IsAny<DateTime?>(), It.IsAny<int>(), It.IsAny<string?>(),
|
||||
It.IsAny<bool>()))
|
||||
.Callback<string?, string?, string?, string?, DateTime?, DateTime?, int, string?, bool>(
|
||||
(_, _, _, _, _, _, _, _, exclude) => capturedExclude = exclude)
|
||||
.ReturnsAsync((new List<SessionAnnotation>(), (string?)null));
|
||||
|
||||
var function = new ListSessionAnnotationsFunction(
|
||||
NullLogger<ListSessionAnnotationsFunction>.Instance, annotationRepo.Object);
|
||||
|
||||
var requestCtx = new RequestContext
|
||||
{
|
||||
TenantId = TenantId,
|
||||
TargetTenantId = TenantId,
|
||||
UserPrincipalName = "reader@contoso.com",
|
||||
IsGlobalAdmin = hasGlobalScope,
|
||||
};
|
||||
var req = BuildRequest(Principal(("tid", TenantId)), requestCtx, new { });
|
||||
Mock.Get(req).SetupGet(r => r.Url)
|
||||
.Returns(new Uri("https://localhost/api/global/session-annotations"));
|
||||
|
||||
var res = await function.Run(req);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, res.StatusCode);
|
||||
Assert.Equal(expectedExclude, capturedExclude);
|
||||
}
|
||||
|
||||
// ── endpoint-level tests (fake HttpRequestData, same harness as
|
||||
// GatherRulesFunctionAuthorEndpointTests) ──────────────────────────────
|
||||
|
||||
private sealed class FakeHttpResponseData : HttpResponseData
|
||||
{
|
||||
public FakeHttpResponseData(FunctionContext context) : base(context) { }
|
||||
public override HttpStatusCode StatusCode { get; set; } = HttpStatusCode.OK;
|
||||
public override HttpHeadersCollection Headers { get; set; } = new();
|
||||
public override Stream Body { get; set; } = new MemoryStream();
|
||||
public override HttpCookies Cookies { get; } = new Mock<HttpCookies>().Object;
|
||||
}
|
||||
|
||||
private static FunctionContext BuildContext(ClaimsPrincipal? principal, RequestContext requestContext)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddOptions();
|
||||
services.Configure<WorkerOptions>(o => o.Serializer = new JsonObjectSerializer());
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
var items = new Dictionary<object, object> { ["RequestContext"] = requestContext };
|
||||
if (principal != null)
|
||||
{
|
||||
items["ClaimsPrincipal"] = principal;
|
||||
}
|
||||
|
||||
var context = new Mock<FunctionContext>();
|
||||
context.SetupGet(c => c.Items).Returns(items);
|
||||
context.SetupGet(c => c.InstanceServices).Returns(provider);
|
||||
return context.Object;
|
||||
}
|
||||
|
||||
private static HttpRequestData BuildRequest(
|
||||
ClaimsPrincipal? principal, RequestContext requestContext, object body)
|
||||
{
|
||||
var context = BuildContext(principal, requestContext);
|
||||
var req = new Mock<HttpRequestData>(context);
|
||||
req.SetupGet(r => r.Headers).Returns(new HttpHeadersCollection());
|
||||
req.SetupGet(r => r.Body).Returns(
|
||||
new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(body))));
|
||||
req.Setup(r => r.CreateResponse()).Returns(() => new FakeHttpResponseData(context));
|
||||
return req.Object;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims)
|
||||
=> new(new ClaimsIdentity(claims.Select(c => new Claim(c.Type, c.Value)), "TestAuth"));
|
||||
|
||||
private static RequestContext TenantAdminContext() => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
TargetTenantId = TenantId,
|
||||
UserPrincipalName = "alice@contoso.com",
|
||||
IsTenantAdmin = true,
|
||||
UserRole = Constants.TenantRoles.Admin,
|
||||
};
|
||||
|
||||
private sealed class Harness
|
||||
{
|
||||
public UpsertSessionAnnotationFunction Function = default!;
|
||||
public Mock<ISessionAnnotationRepository> AnnotationRepo = default!;
|
||||
public List<SessionAnnotation> Stored = default!;
|
||||
}
|
||||
|
||||
private static Harness BuildFunction(
|
||||
SessionAnnotation? existing = null, List<RuleResult>? ruleResults = null)
|
||||
{
|
||||
var annotationRepo = new Mock<ISessionAnnotationRepository>(MockBehavior.Loose);
|
||||
var stored = new List<SessionAnnotation>();
|
||||
annotationRepo.Setup(r => r.GetAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(existing);
|
||||
annotationRepo.Setup(r => r.UpsertAsync(It.IsAny<SessionAnnotation>()))
|
||||
.Callback<SessionAnnotation>(stored.Add)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var sessionRepo = new Mock<ISessionRepository>(MockBehavior.Loose);
|
||||
sessionRepo.Setup(r => r.GetSessionAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(new SessionSummary { SessionId = SessionId, TenantId = TenantId });
|
||||
sessionRepo.Setup(r => r.FindSessionTenantIdAsync(SessionId)).ReturnsAsync(TenantId);
|
||||
|
||||
var ruleRepo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(ruleResults ?? new List<RuleResult>());
|
||||
|
||||
var maintenanceRepo = new Mock<IMaintenanceRepository>(MockBehavior.Loose);
|
||||
|
||||
return new Harness
|
||||
{
|
||||
Function = new UpsertSessionAnnotationFunction(
|
||||
NullLogger<UpsertSessionAnnotationFunction>.Instance,
|
||||
annotationRepo.Object, sessionRepo.Object, ruleRepo.Object, maintenanceRepo.Object),
|
||||
AnnotationRepo = annotationRepo,
|
||||
Stored = stored,
|
||||
};
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_stamps_author_from_jwt_not_from_body()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
// Body tries to smuggle author fields — the function must never read them.
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new
|
||||
{
|
||||
verdict = AnnotationVerdicts.RootCauseConfirmed,
|
||||
note = "checked on device",
|
||||
authorUpn = "spoofed@evil.example",
|
||||
authorDisplayName = "Spoofed Author",
|
||||
createdByUpn = "spoofed@evil.example",
|
||||
});
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(h.Stored);
|
||||
Assert.Equal("Alice Admin", written.AuthorDisplayName);
|
||||
Assert.Equal("alice@contoso.com", written.AuthorUpn);
|
||||
Assert.Equal("alice@contoso.com", written.CreatedByUpn);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_true_update_preserves_original_creator_and_createdAt()
|
||||
{
|
||||
var createdAt = new DateTime(2026, 7, 1, 8, 0, 0, DateTimeKind.Utc);
|
||||
var h = BuildFunction(existing: new SessionAnnotation
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
Lane = AnnotationLanes.TenantAdmin,
|
||||
CreatedByUpn = "bob@contoso.com",
|
||||
CreatedAtUtc = createdAt,
|
||||
});
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new { verdict = AnnotationVerdicts.AnalysisWrong });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(h.Stored);
|
||||
Assert.Equal("bob@contoso.com", written.CreatedByUpn); // first writer survives
|
||||
Assert.Equal(createdAt, written.CreatedAtUtc);
|
||||
Assert.Equal("alice@contoso.com", written.AuthorUpn); // last editor stamped
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_snapshots_fired_rule_ids()
|
||||
{
|
||||
var h = BuildFunction(ruleResults: new List<RuleResult>
|
||||
{
|
||||
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-ESP-001" },
|
||||
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-ESP-001" }, // duplicate collapses
|
||||
new RuleResult { SessionId = SessionId, RuleId = "ANALYZE-CORR-003" },
|
||||
});
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
|
||||
|
||||
await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
var written = Assert.Single(h.Stored);
|
||||
Assert.Equal(new[] { "ANALYZE-ESP-001", "ANALYZE-CORR-003" }, written.RuleIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_empty_body_clears_the_lane()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("name", "Alice Admin"), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new { verdict = (string?)null, note = "" });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
Assert.Empty(h.Stored);
|
||||
h.AnnotationRepo.Verify(
|
||||
r => r.DeleteAsync(TenantId, SessionId, AnnotationLanes.TenantAdmin), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_unknown_verdict_is_rejected()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new { verdict = "totally_made_up" });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(h.Stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_note_over_cap_is_rejected()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("upn", "alice@contoso.com")),
|
||||
TenantAdminContext(),
|
||||
new { note = new string('x', SessionAnnotation.MaxNoteLength + 1) });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
Assert.Empty(h.Stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_forbids_lane_outside_caller_matrix()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
var operatorContext = new RequestContext
|
||||
{
|
||||
TenantId = TenantId,
|
||||
TargetTenantId = TenantId,
|
||||
UserPrincipalName = "op@contoso.com",
|
||||
IsTenantAdmin = false,
|
||||
UserRole = Constants.TenantRoles.Operator,
|
||||
};
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", TenantId), ("upn", "op@contoso.com")),
|
||||
operatorContext,
|
||||
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Empty(h.Stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_ga_with_own_tenant_admin_role_cannot_write_foreign_tenant_lanes()
|
||||
{
|
||||
// The session belongs to TenantId (foreign); the GA is Admin of their OWN tenant.
|
||||
// Tenant-role lanes bind to the caller's own tenant, so this must 403 — otherwise a
|
||||
// GA's home-tenant admin role would leak into every other tenant's lanes.
|
||||
var h = BuildFunction();
|
||||
var gaContext = new RequestContext
|
||||
{
|
||||
TenantId = "99999999-9999-9999-9999-999999999999",
|
||||
TargetTenantId = "99999999-9999-9999-9999-999999999999",
|
||||
UserPrincipalName = "ga@fabrikam.com",
|
||||
IsGlobalAdmin = true,
|
||||
IsTenantAdmin = true, // admin of the HOME tenant, not of TenantId
|
||||
UserRole = Constants.TenantRoles.Admin,
|
||||
};
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", gaContext.TenantId), ("upn", "ga@fabrikam.com")),
|
||||
gaContext,
|
||||
new { verdict = AnnotationVerdicts.RootCauseConfirmed });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.TenantAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||
Assert.Empty(h.Stored);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Upsert_ga_writes_globaladmin_lane_on_foreign_tenant_session()
|
||||
{
|
||||
var h = BuildFunction();
|
||||
var gaContext = new RequestContext
|
||||
{
|
||||
TenantId = "99999999-9999-9999-9999-999999999999", // GA's home tenant
|
||||
TargetTenantId = "99999999-9999-9999-9999-999999999999",
|
||||
UserPrincipalName = "ga@fabrikam.com",
|
||||
IsGlobalAdmin = true,
|
||||
UserRole = "GlobalAdmin",
|
||||
};
|
||||
var req = BuildRequest(
|
||||
Principal(("tid", gaContext.TenantId), ("name", "Gerd Admin"), ("upn", "ga@fabrikam.com")),
|
||||
gaContext,
|
||||
new { verdict = AnnotationVerdicts.DifferentProblem, note = "actually a network issue" });
|
||||
|
||||
var response = await h.Function.Run(req, SessionId, AnnotationLanes.GlobalAdmin);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var written = Assert.Single(h.Stored);
|
||||
// Tenant resolved from the session index, NOT the GA's own tenant.
|
||||
Assert.Equal(TenantId, written.TenantId);
|
||||
Assert.Equal(AnnotationLanes.GlobalAdmin, written.Lane);
|
||||
}
|
||||
}
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Data.Tables;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Round-trip pins for the SessionAnnotations Store/Map pair (memory:
|
||||
/// feedback_table_storage_serialization — every new field in <see cref="SessionAnnotation"/>
|
||||
/// MUST be exercised in Store+Map so silent drops surface here), plus the RowKey and
|
||||
/// OData-filter builders.
|
||||
/// </summary>
|
||||
public class TableSessionAnnotationRepositoryTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string SessionId = "22222222-2222-2222-2222-222222222222";
|
||||
|
||||
private static SessionAnnotation FullAnnotation() => new()
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
Lane = AnnotationLanes.TenantAdmin,
|
||||
Verdict = AnnotationVerdicts.RootCauseConfirmed,
|
||||
Note = "Root cause matches what we saw on the device.",
|
||||
AuthorUpn = "alice@contoso.com",
|
||||
AuthorDisplayName = "Alice Admin",
|
||||
CreatedByUpn = "bob@contoso.com",
|
||||
CreatedAtUtc = new DateTime(2026, 8, 1, 10, 30, 0, DateTimeKind.Utc),
|
||||
UpdatedAtUtc = new DateTime(2026, 8, 11, 9, 15, 0, DateTimeKind.Utc),
|
||||
RuleIds = new List<string> { "ANALYZE-ESP-001", "ANALYZE-CORR-003" },
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_all_fields_survive_store_and_map()
|
||||
{
|
||||
var original = FullAnnotation();
|
||||
|
||||
var mapped = TableSessionAnnotationRepository.MapAnnotation(
|
||||
TableSessionAnnotationRepository.StoreAnnotation(original));
|
||||
|
||||
Assert.Equal(original.TenantId, mapped.TenantId);
|
||||
Assert.Equal(original.SessionId, mapped.SessionId);
|
||||
Assert.Equal(original.Lane, mapped.Lane);
|
||||
Assert.Equal(original.Verdict, mapped.Verdict);
|
||||
Assert.Equal(original.Note, mapped.Note);
|
||||
Assert.Equal(original.AuthorUpn, mapped.AuthorUpn);
|
||||
Assert.Equal(original.AuthorDisplayName, mapped.AuthorDisplayName);
|
||||
Assert.Equal(original.CreatedByUpn, mapped.CreatedByUpn);
|
||||
Assert.Equal(original.CreatedAtUtc, mapped.CreatedAtUtc);
|
||||
Assert.Equal(original.UpdatedAtUtc, mapped.UpdatedAtUtc);
|
||||
Assert.Equal(original.RuleIds, mapped.RuleIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_null_verdict_and_note_stay_null()
|
||||
{
|
||||
var original = FullAnnotation();
|
||||
original.Verdict = null;
|
||||
original.Note = null;
|
||||
original.RuleIds = new List<string>();
|
||||
|
||||
var mapped = TableSessionAnnotationRepository.MapAnnotation(
|
||||
TableSessionAnnotationRepository.StoreAnnotation(original));
|
||||
|
||||
Assert.Null(mapped.Verdict);
|
||||
Assert.Null(mapped.Note);
|
||||
Assert.Empty(mapped.RuleIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Store_uses_tenant_pk_and_sessionId_lane_rk()
|
||||
{
|
||||
var entity = TableSessionAnnotationRepository.StoreAnnotation(FullAnnotation());
|
||||
|
||||
Assert.Equal(TenantId, entity.PartitionKey);
|
||||
Assert.Equal($"{SessionId}_{AnnotationLanes.TenantAdmin}", entity.RowKey);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("{\"truncated\":")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
public void Map_corrupt_or_missing_ruleIds_degrades_to_empty_list(string? corruptJson)
|
||||
{
|
||||
var entity = TableSessionAnnotationRepository.StoreAnnotation(FullAnnotation());
|
||||
entity["RuleIdsJson"] = corruptJson;
|
||||
|
||||
var mapped = TableSessionAnnotationRepository.MapAnnotation(entity);
|
||||
|
||||
Assert.Empty(mapped.RuleIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_tolerates_minimal_legacy_entity()
|
||||
{
|
||||
// A row carrying only PK/RK (e.g. hand-repaired) must map without throwing.
|
||||
var entity = new TableEntity(TenantId, $"{SessionId}_{AnnotationLanes.Operator}");
|
||||
|
||||
var mapped = TableSessionAnnotationRepository.MapAnnotation(entity);
|
||||
|
||||
Assert.Equal(TenantId, mapped.TenantId);
|
||||
Assert.Equal(string.Empty, mapped.SessionId);
|
||||
Assert.Null(mapped.Verdict);
|
||||
Assert.Null(mapped.Note);
|
||||
Assert.Empty(mapped.RuleIds);
|
||||
}
|
||||
|
||||
// ── query filter builder ────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryFilter_no_filters_returns_null()
|
||||
{
|
||||
Assert.Null(TableSessionAnnotationRepository.BuildQueryFilter(null, null, null, null, null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryFilter_combines_all_clauses_with_and()
|
||||
{
|
||||
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
|
||||
TenantId, AnnotationLanes.GlobalAdmin, AnnotationVerdicts.AnalysisWrong,
|
||||
new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
new DateTime(2026, 8, 11, 0, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
Assert.Equal(
|
||||
$"PartitionKey eq '{TenantId}'" +
|
||||
" and Lane eq 'globaladmin'" +
|
||||
" and Verdict eq 'analysis_wrong'" +
|
||||
" and UpdatedAtUtc ge datetime'2026-08-01T00:00:00Z'" +
|
||||
" and UpdatedAtUtc lt datetime'2026-08-11T00:00:00Z'",
|
||||
filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryFilter_escapes_single_quotes()
|
||||
{
|
||||
var filter = TableSessionAnnotationRepository.BuildQueryFilter("ten'ant", null, null, null, null);
|
||||
|
||||
Assert.Equal("PartitionKey eq 'ten''ant'", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryFilter_excludes_globaladmin_lane_for_tenant_scope()
|
||||
{
|
||||
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
|
||||
TenantId, null, null, null, null, excludeGlobalAdminLane: true);
|
||||
|
||||
Assert.Equal($"PartitionKey eq '{TenantId}' and Lane ne 'globaladmin'", filter);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildQueryFilter_explicit_globaladmin_lane_with_exclusion_contradicts_to_empty()
|
||||
{
|
||||
// A tenant caller explicitly asking for lane=globaladmin gets a self-contradicting
|
||||
// filter (eq + ne on the same value) — structurally an empty result, never a leak.
|
||||
var filter = TableSessionAnnotationRepository.BuildQueryFilter(
|
||||
TenantId, AnnotationLanes.GlobalAdmin, null, null, null, excludeGlobalAdminLane: true);
|
||||
|
||||
Assert.Contains("Lane eq 'globaladmin'", filter);
|
||||
Assert.Contains("Lane ne 'globaladmin'", filter);
|
||||
}
|
||||
}
|
||||
@@ -55,12 +55,6 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<!-- Security pins: Microsoft.Identity.Web 4.3.0 pulls these transitively at 10.0.0, which carries
|
||||
known advisories (GHSA-9mv3-2cwr-p262 DataProtection; GHSA-37gx-xxp4-5rgx and siblings on
|
||||
Cryptography.Xml). Force the patched 10.0.10 servicing build. Remove once Identity.Web ships
|
||||
a dependency floor past the fix. -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.10" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
|
||||
<PackageReference Include="Resend" Version="0.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace AutopilotMonitor.Functions.DataAccess
|
||||
services.AddSingleton<IAdminRepository, TableAdminRepository>();
|
||||
services.AddSingleton<IConfigRepository, TableConfigRepository>();
|
||||
services.AddSingleton<IFeedbackRepository, TableFeedbackRepository>();
|
||||
services.AddSingleton<ISessionAnnotationRepository, TableSessionAnnotationRepository>();
|
||||
services.AddSingleton<IBootstrapRepository, TableBootstrapRepository>();
|
||||
services.AddSingleton<INotificationRepository, TableNotificationRepository>();
|
||||
services.AddSingleton<ITenantNotificationRepository, TableTenantNotificationRepository>();
|
||||
|
||||
+2
@@ -623,6 +623,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "NtpServer", config.NtpServer },
|
||||
{ "EnableImeMatchLog", config.EnableImeMatchLog },
|
||||
{ "EnableGatherRuleDebugLog", config.EnableGatherRuleDebugLog },
|
||||
{ "EnableEspContinueAnywayObservation", config.EnableEspContinueAnywayObservation },
|
||||
{ "LogLevel", config.LogLevel },
|
||||
{ "MaxBatchSize", config.MaxBatchSize },
|
||||
{ "DiagnosticsBlobSasUrl", config.DiagnosticsBlobSasUrl },
|
||||
@@ -729,6 +730,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
NtpServer = string.IsNullOrWhiteSpace(entity.GetString("NtpServer")) ? "time.windows.com" : entity.GetString("NtpServer"),
|
||||
EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"),
|
||||
EnableGatherRuleDebugLog = entity.GetBoolean("EnableGatherRuleDebugLog"),
|
||||
EnableEspContinueAnywayObservation = entity.GetBoolean("EnableEspContinueAnywayObservation"),
|
||||
LogLevel = entity.GetString("LogLevel"),
|
||||
MaxBatchSize = entity.GetInt32("MaxBatchSize"),
|
||||
DiagnosticsBlobSasUrl = entity.GetString("DiagnosticsBlobSasUrl"),
|
||||
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Table-storage implementation of <see cref="ISessionAnnotationRepository"/>.
|
||||
/// Backed by the dedicated <c>SessionAnnotations</c> table:
|
||||
/// PK = tenantId, RK = <c>{sessionId}_{lane}</c>.
|
||||
/// </summary>
|
||||
public class TableSessionAnnotationRepository : ISessionAnnotationRepository
|
||||
{
|
||||
// Client-side ruleId filtering can shrink an Azure page below pageSize; the
|
||||
// back-fill loop keeps fetching until enough matches accumulate, capped here so
|
||||
// a pathological filter can never turn one request into an unbounded scan.
|
||||
internal const int MaxBackfillRoundTrips = 10;
|
||||
|
||||
private readonly TableClient _tableClient;
|
||||
private readonly ILogger<TableSessionAnnotationRepository> _logger;
|
||||
|
||||
public TableSessionAnnotationRepository(
|
||||
TableStorageService storage, ILogger<TableSessionAnnotationRepository> logger)
|
||||
{
|
||||
_tableClient = storage.GetTableClient(Constants.TableNames.SessionAnnotations);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<SessionAnnotation?> GetAsync(string tenantId, string sessionId, string lane)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entity = await _tableClient.GetEntityAsync<TableEntity>(
|
||||
tenantId, BuildRowKey(sessionId, lane));
|
||||
return MapAnnotation(entity.Value);
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error loading annotation {SessionId}/{Lane} for tenant {TenantId}",
|
||||
sessionId, lane, tenantId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<SessionAnnotation>> GetForSessionAsync(string tenantId, string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// '~' (0x7E) sorts after '_' (0x5F) and every lane letter: upper-bounds all
|
||||
// "{sessionId}_{lane}" keys without matching a longer sessionId prefix.
|
||||
var filter =
|
||||
$"PartitionKey eq '{Escape(tenantId)}'" +
|
||||
$" and RowKey ge '{Escape(sessionId)}_'" +
|
||||
$" and RowKey lt '{Escape(sessionId)}_~'";
|
||||
var results = new List<SessionAnnotation>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
results.Add(MapAnnotation(entity));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error loading annotations for session {SessionId}, tenant {TenantId}",
|
||||
sessionId, tenantId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task UpsertAsync(SessionAnnotation annotation)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _tableClient.UpsertEntityAsync(StoreAnnotation(annotation), TableUpdateMode.Replace);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error saving annotation {SessionId}/{Lane} for tenant {TenantId}",
|
||||
annotation.SessionId, annotation.Lane, annotation.TenantId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string tenantId, string sessionId, string lane)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _tableClient.DeleteEntityAsync(tenantId, BuildRowKey(sessionId, lane));
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Missing row is a no-op — clearing an annotation twice must not fail.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error deleting annotation {SessionId}/{Lane} for tenant {TenantId}",
|
||||
sessionId, lane, tenantId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(List<SessionAnnotation> Items, string? NextRawToken)> QueryPageAsync(
|
||||
string? tenantId,
|
||||
string? lane,
|
||||
string? verdict,
|
||||
string? ruleId,
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
int pageSize,
|
||||
string? continuation,
|
||||
bool excludeGlobalAdminLane = false)
|
||||
{
|
||||
var filter = BuildQueryFilter(tenantId, lane, verdict, dateFrom, dateTo, excludeGlobalAdminLane);
|
||||
var items = new List<SessionAnnotation>();
|
||||
var token = continuation;
|
||||
|
||||
// ruleId cannot be pushed into OData (substring match on a JSON column), so it
|
||||
// filters client-side. Back-fill whole Azure pages until pageSize matches
|
||||
// accumulate or the scan is exhausted, so filtered-out rows never consume page
|
||||
// budget (lesson: filter-after-pagination empties the list).
|
||||
for (var round = 0; round < MaxBackfillRoundTrips; round++)
|
||||
{
|
||||
var (page, nextToken) = await AzureTablesPaginator.FetchPageAsync<TableEntity>(
|
||||
_tableClient, filter, pageSize, token);
|
||||
|
||||
foreach (var entity in page)
|
||||
{
|
||||
var annotation = MapAnnotation(entity);
|
||||
if (ruleId != null &&
|
||||
!annotation.RuleIds.Contains(ruleId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
items.Add(annotation);
|
||||
}
|
||||
|
||||
token = nextToken;
|
||||
if (token == null || items.Count >= pageSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (items, token);
|
||||
}
|
||||
|
||||
internal static string BuildRowKey(string sessionId, string lane) => $"{sessionId}_{lane}";
|
||||
|
||||
internal static string? BuildQueryFilter(
|
||||
string? tenantId, string? lane, string? verdict, DateTime? dateFrom, DateTime? dateTo,
|
||||
bool excludeGlobalAdminLane = false)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
void Append(string clause)
|
||||
{
|
||||
if (sb.Length > 0) sb.Append(" and ");
|
||||
sb.Append(clause);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(tenantId)) Append($"PartitionKey eq '{Escape(tenantId!)}'");
|
||||
if (!string.IsNullOrEmpty(lane)) Append($"Lane eq '{Escape(lane!)}'");
|
||||
// Server-side, so hidden rows never consume page budget; combined with an explicit
|
||||
// lane=globaladmin filter this contradicts to an empty result — fail-safe by shape.
|
||||
if (excludeGlobalAdminLane) Append($"Lane ne '{AnnotationLanes.GlobalAdmin}'");
|
||||
if (!string.IsNullOrEmpty(verdict)) Append($"Verdict eq '{Escape(verdict!)}'");
|
||||
if (dateFrom.HasValue) Append($"UpdatedAtUtc ge datetime'{dateFrom.Value.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}'");
|
||||
if (dateTo.HasValue) Append($"UpdatedAtUtc lt datetime'{dateTo.Value.ToUniversalTime():yyyy-MM-ddTHH:mm:ssZ}'");
|
||||
|
||||
return sb.Length == 0 ? null : sb.ToString();
|
||||
}
|
||||
|
||||
private static string Escape(string value) => value.Replace("'", "''");
|
||||
|
||||
// ── Store / Map (memory: feedback_table_storage_serialization) ─────────
|
||||
// internal static: pinned by round-trip unit tests — every property must
|
||||
// survive Store→Map, so any new field lands in BOTH methods plus the test.
|
||||
|
||||
internal static TableEntity StoreAnnotation(SessionAnnotation a) =>
|
||||
new(a.TenantId, BuildRowKey(a.SessionId, a.Lane))
|
||||
{
|
||||
["SessionId"] = a.SessionId,
|
||||
["Lane"] = a.Lane,
|
||||
["Verdict"] = a.Verdict,
|
||||
["Note"] = a.Note,
|
||||
["AuthorUpn"] = a.AuthorUpn,
|
||||
["AuthorDisplayName"] = a.AuthorDisplayName,
|
||||
["CreatedByUpn"] = a.CreatedByUpn,
|
||||
["CreatedAtUtc"] = EnsureUtc(a.CreatedAtUtc),
|
||||
["UpdatedAtUtc"] = EnsureUtc(a.UpdatedAtUtc),
|
||||
["RuleIdsJson"] = JsonSerializer.Serialize(a.RuleIds),
|
||||
};
|
||||
|
||||
internal static SessionAnnotation MapAnnotation(TableEntity e) => new()
|
||||
{
|
||||
TenantId = e.PartitionKey,
|
||||
SessionId = e.GetString("SessionId") ?? string.Empty,
|
||||
Lane = e.GetString("Lane") ?? string.Empty,
|
||||
Verdict = e.GetString("Verdict"),
|
||||
Note = e.GetString("Note"),
|
||||
AuthorUpn = e.GetString("AuthorUpn") ?? string.Empty,
|
||||
AuthorDisplayName = e.GetString("AuthorDisplayName") ?? string.Empty,
|
||||
CreatedByUpn = e.GetString("CreatedByUpn") ?? string.Empty,
|
||||
CreatedAtUtc = e.GetDateTime("CreatedAtUtc") ?? DateTime.MinValue,
|
||||
UpdatedAtUtc = e.GetDateTime("UpdatedAtUtc") ?? DateTime.MinValue,
|
||||
RuleIds = DeserializeRuleIds(e.GetString("RuleIdsJson")),
|
||||
};
|
||||
|
||||
private static List<string> DeserializeRuleIds(string? json)
|
||||
{
|
||||
if (string.IsNullOrEmpty(json)) return new List<string>();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(json!) ?? new List<string>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new List<string>(); // corrupt/truncated column — degrade to empty, never throw on read
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime EnsureUtc(DateTime value) =>
|
||||
value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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
|
||||
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the annotations (all lanes visible to the caller) for one session.
|
||||
/// The <c>globaladmin</c> lane is platform-internal and filtered out for callers
|
||||
/// without global scope.
|
||||
/// </summary>
|
||||
public class GetSessionAnnotationsFunction
|
||||
{
|
||||
private readonly ILogger<GetSessionAnnotationsFunction> _logger;
|
||||
private readonly ISessionAnnotationRepository _annotationRepo;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
|
||||
public GetSessionAnnotationsFunction(
|
||||
ILogger<GetSessionAnnotationsFunction> logger,
|
||||
ISessionAnnotationRepository annotationRepo,
|
||||
ISessionRepository sessionRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_annotationRepo = annotationRepo;
|
||||
_sessionRepo = sessionRepo;
|
||||
}
|
||||
|
||||
[Function("GetSessionAnnotations")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/{sessionId}/annotations")] HttpRequestData req,
|
||||
string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var effectiveTenantId = requestCtx.TargetTenantId;
|
||||
|
||||
// Global-scope (GA or read-only GlobalReader) cross-tenant fallback: resolve
|
||||
// the actual tenant upfront so the read works cross-tenant.
|
||||
if (requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null
|
||||
&& !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
|
||||
var annotations = await _annotationRepo.GetForSessionAsync(effectiveTenantId, sessionId);
|
||||
var visible = FilterLanesForCaller(annotations, requestCtx.HasGlobalScope);
|
||||
|
||||
return await req.OkAsync(new
|
||||
{
|
||||
success = true,
|
||||
sessionId,
|
||||
tenantId = effectiveTenantId,
|
||||
annotations = visible.Select(AnnotationWire.ToWire).ToList(),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching annotations for session {SessionId}", sessionId);
|
||||
var errorResponse = req.CreateResponse(System.Net.HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The globaladmin lane is a platform-internal quality label — callers without
|
||||
/// global scope (tenant members and delegated admins) never see it.
|
||||
/// </summary>
|
||||
internal static List<SessionAnnotation> FilterLanesForCaller(
|
||||
List<SessionAnnotation> annotations, bool hasGlobalScope)
|
||||
{
|
||||
if (hasGlobalScope) return annotations;
|
||||
return annotations
|
||||
.Where(a => !string.Equals(a.Lane, AnnotationLanes.GlobalAdmin, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explicit camelCase wire shape shared by the annotation endpoints so the JSON
|
||||
/// contract does not depend on serializer naming configuration.
|
||||
/// </summary>
|
||||
internal static class AnnotationWire
|
||||
{
|
||||
internal static object ToWire(SessionAnnotation a) => new
|
||||
{
|
||||
lane = a.Lane,
|
||||
verdict = a.Verdict,
|
||||
note = a.Note,
|
||||
authorUpn = a.AuthorUpn,
|
||||
authorDisplayName = a.AuthorDisplayName,
|
||||
createdByUpn = a.CreatedByUpn,
|
||||
createdAtUtc = a.CreatedAtUtc,
|
||||
updatedAtUtc = a.UpdatedAtUtc,
|
||||
ruleIds = a.RuleIds,
|
||||
};
|
||||
|
||||
internal static object ToWireWithScope(SessionAnnotation a) => new
|
||||
{
|
||||
tenantId = a.TenantId,
|
||||
sessionId = a.SessionId,
|
||||
lane = a.Lane,
|
||||
verdict = a.Verdict,
|
||||
note = a.Note,
|
||||
authorUpn = a.AuthorUpn,
|
||||
authorDisplayName = a.AuthorDisplayName,
|
||||
createdByUpn = a.CreatedByUpn,
|
||||
createdAtUtc = a.CreatedAtUtc,
|
||||
updatedAtUtc = a.UpdatedAtUtc,
|
||||
ruleIds = a.RuleIds,
|
||||
};
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
{
|
||||
/// <summary>
|
||||
/// Cross-tenant evaluation stream over all annotation lanes (the flywheel read:
|
||||
/// verdicts per rule, false-positive rates, labeled sessions). Platform scope for the
|
||||
/// aggregate; a delegated ("MSP") reader reaches it only on its managed ?tenantId= path
|
||||
/// and never sees the platform-internal globaladmin lane.
|
||||
/// </summary>
|
||||
public class ListSessionAnnotationsFunction
|
||||
{
|
||||
private readonly ILogger<ListSessionAnnotationsFunction> _logger;
|
||||
private readonly ISessionAnnotationRepository _annotationRepo;
|
||||
|
||||
public ListSessionAnnotationsFunction(
|
||||
ILogger<ListSessionAnnotationsFunction> logger,
|
||||
ISessionAnnotationRepository annotationRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_annotationRepo = annotationRepo;
|
||||
}
|
||||
|
||||
[Function("ListSessionAnnotations")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/session-annotations")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + GlobalReadOrAdmin authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var parsed = SessionAnnotationsPagination.ParseQuery(query);
|
||||
if (parsed.Error != null)
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = parsed.Error });
|
||||
return bad;
|
||||
}
|
||||
|
||||
string? azureToken = null;
|
||||
if (parsed.Continuation != null)
|
||||
{
|
||||
if (!SessionAnnotationsPagination.TryAcceptContinuation(
|
||||
parsed, callerTenantId, out azureToken, out var rejectReason))
|
||||
{
|
||||
_logger.LogWarning("ListSessionAnnotations: continuation rejected ({Reason})", rejectReason);
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
|
||||
});
|
||||
return bad;
|
||||
}
|
||||
}
|
||||
|
||||
// Not only GA/Reader reach this route: the delegated ("MSP") read rescue admits a
|
||||
// delegated caller on its managed ?tenantId= path. The platform-internal globaladmin
|
||||
// lane stays global-scope-only, so it is excluded for everyone else.
|
||||
var (items, nextRawToken) = await _annotationRepo.QueryPageAsync(
|
||||
parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId,
|
||||
parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken,
|
||||
excludeGlobalAdminLane: !requestCtx.HasGlobalScope);
|
||||
|
||||
string? nextLink = null;
|
||||
if (!string.IsNullOrEmpty(nextRawToken))
|
||||
{
|
||||
var wireToken = SessionAnnotationsPagination.EncodeContinuation(
|
||||
parsed, callerTenantId, nextRawToken!);
|
||||
nextLink = SessionAnnotationsPagination.BuildNextLink(parsed, wireToken);
|
||||
}
|
||||
|
||||
return await req.OkAsync(new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
annotations = items.Select(AnnotationWire.ToWireWithScope).ToList(),
|
||||
nextLink,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error listing session annotations");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Pagination;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
{
|
||||
/// <summary>
|
||||
/// Tenant-scoped annotation list — backs the portal's Annotations overview page
|
||||
/// ("which sessions has my team already judged?"). Same filter surface as the global
|
||||
/// evaluation stream, always bound to the middleware-validated target tenant. The
|
||||
/// platform-internal globaladmin lane is excluded in the OData query for callers
|
||||
/// without global scope, so hidden rows never consume page budget.
|
||||
/// </summary>
|
||||
public class ListTenantSessionAnnotationsFunction
|
||||
{
|
||||
private readonly ILogger<ListTenantSessionAnnotationsFunction> _logger;
|
||||
private readonly ISessionAnnotationRepository _annotationRepo;
|
||||
|
||||
public ListTenantSessionAnnotationsFunction(
|
||||
ILogger<ListTenantSessionAnnotationsFunction> logger,
|
||||
ISessionAnnotationRepository annotationRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_annotationRepo = annotationRepo;
|
||||
}
|
||||
|
||||
// Route lives under the /api/sessions prefix so the platform cert-exclusion list
|
||||
// (Azure-portal-only config) needs no new entry — same convention as
|
||||
// /api/diagnostics/files. Three segments on purpose: a two-segment literal
|
||||
// ("sessions/annotations") would be eaten by the sibling "sessions/{sessionId}"
|
||||
// template (see the /api/stats/sessions incident note in lib/api.ts).
|
||||
[Function("ListTenantSessionAnnotations")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "sessions/annotations/list")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var callerTenantId = TenantHelper.GetTenantId(req);
|
||||
|
||||
var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty);
|
||||
var parsedRaw = SessionAnnotationsPagination.ParseQuery(query);
|
||||
if (parsedRaw.Error != null)
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { success = false, message = parsedRaw.Error });
|
||||
return bad;
|
||||
}
|
||||
|
||||
// The tenant filter is never caller-chosen here: TargetTenantId is the
|
||||
// middleware-validated scope (own tenant; the ?tenantId drill-in is validated
|
||||
// there too). A body/query value can never widen it.
|
||||
var parsed = new SessionAnnotationsPagination.Parsed
|
||||
{
|
||||
FilterTenantId = requestCtx.TargetTenantId,
|
||||
Lane = parsedRaw.Lane,
|
||||
Verdict = parsedRaw.Verdict,
|
||||
RuleId = parsedRaw.RuleId,
|
||||
DateFrom = parsedRaw.DateFrom,
|
||||
DateTo = parsedRaw.DateTo,
|
||||
PageSize = parsedRaw.PageSize,
|
||||
Continuation = parsedRaw.Continuation,
|
||||
};
|
||||
|
||||
string? azureToken = null;
|
||||
if (parsed.Continuation != null)
|
||||
{
|
||||
if (!SessionAnnotationsPagination.TryAcceptContinuation(
|
||||
parsed, callerTenantId, out azureToken, out var rejectReason))
|
||||
{
|
||||
_logger.LogWarning("ListTenantSessionAnnotations: continuation rejected ({Reason})", rejectReason);
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = $"Invalid continuation token ({rejectReason}). Restart pagination from the first page.",
|
||||
});
|
||||
return bad;
|
||||
}
|
||||
}
|
||||
|
||||
var (items, nextRawToken) = await _annotationRepo.QueryPageAsync(
|
||||
parsed.FilterTenantId, parsed.Lane, parsed.Verdict, parsed.RuleId,
|
||||
parsed.DateFrom, parsed.DateTo, parsed.PageSize, azureToken,
|
||||
excludeGlobalAdminLane: !requestCtx.HasGlobalScope);
|
||||
|
||||
string? nextLink = null;
|
||||
if (!string.IsNullOrEmpty(nextRawToken))
|
||||
{
|
||||
var wireToken = SessionAnnotationsPagination.EncodeContinuation(
|
||||
parsed, callerTenantId, nextRawToken!);
|
||||
nextLink = SessionAnnotationsPagination.BuildNextLink(
|
||||
parsed, wireToken, SessionAnnotationsPagination.TenantBasePath);
|
||||
}
|
||||
|
||||
return await req.OkAsync(new
|
||||
{
|
||||
success = true,
|
||||
count = items.Count,
|
||||
annotations = items.Select(AnnotationWire.ToWireWithScope).ToList(),
|
||||
nextLink,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error listing tenant session annotations");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Annotations
|
||||
{
|
||||
/// <summary>
|
||||
/// Upserts (or clears) the annotation for one session + lane.
|
||||
/// Policy tier is TenantAdminOrOperator; the per-lane write matrix is re-gated here
|
||||
/// via <see cref="IsLaneWritableByCaller"/> (catalog multi-kind rule). Author fields
|
||||
/// are stamped from the JWT — a body-supplied author never wins.
|
||||
/// </summary>
|
||||
public class UpsertSessionAnnotationFunction
|
||||
{
|
||||
private readonly ILogger<UpsertSessionAnnotationFunction> _logger;
|
||||
private readonly ISessionAnnotationRepository _annotationRepo;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
|
||||
public UpsertSessionAnnotationFunction(
|
||||
ILogger<UpsertSessionAnnotationFunction> logger,
|
||||
ISessionAnnotationRepository annotationRepo,
|
||||
ISessionRepository sessionRepo,
|
||||
IRuleRepository ruleRepo,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_annotationRepo = annotationRepo;
|
||||
_sessionRepo = sessionRepo;
|
||||
_ruleRepo = ruleRepo;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
}
|
||||
|
||||
[Function("UpsertSessionAnnotation")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "sessions/{sessionId}/annotations/{lane}")] HttpRequestData req,
|
||||
string sessionId,
|
||||
string lane)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Authentication + TenantAdminOrOperator authorization enforced by
|
||||
// PolicyEnforcementMiddleware; the lane matrix is re-gated below.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var userIdentifier = TenantHelper.GetUserIdentifier(req);
|
||||
|
||||
var normalizedLane = lane?.ToLowerInvariant() ?? string.Empty;
|
||||
if (!AnnotationLanes.All.Contains(normalizedLane))
|
||||
{
|
||||
return await BadRequestAsync(req,
|
||||
$"lane must be one of: {string.Join(", ", AnnotationLanes.All)}");
|
||||
}
|
||||
|
||||
// Tenant identity: non-GA callers are pinned to their middleware-validated
|
||||
// tenant (never a body value — prevents horizontal escalation). Global
|
||||
// Admins may annotate foreign-tenant sessions (the GA labeling flow), so
|
||||
// resolve the session's actual tenant like the other session reads do.
|
||||
// Resolution happens BEFORE the lane gate: tenant-role lanes must bind to
|
||||
// the caller's OWN tenant, so a GA who is also an own-tenant admin cannot
|
||||
// write another tenant's operator/tenantadmin lanes.
|
||||
var effectiveTenantId = requestCtx.TargetTenantId;
|
||||
if (requestCtx.IsGlobalAdmin)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null
|
||||
&& !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
|
||||
var isOwnTenant = string.Equals(effectiveTenantId, requestCtx.TenantId, StringComparison.OrdinalIgnoreCase);
|
||||
if (!IsLaneWritableByCaller(
|
||||
normalizedLane, isOwnTenant ? requestCtx.UserRole : null,
|
||||
requestCtx.IsTenantAdmin && isOwnTenant, requestCtx.IsGlobalAdmin))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"UpsertSessionAnnotation: BLOCKED lane={Lane} for user={User} role={Role} ownTenant={OwnTenant}",
|
||||
normalizedLane, userIdentifier, requestCtx.UserRole, isOwnTenant);
|
||||
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbidden.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = $"Your role does not permit writing the '{normalizedLane}' annotation lane.",
|
||||
});
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
// Annotations must not create junk rows for sessions that don't exist.
|
||||
var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId);
|
||||
if (session == null)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new { success = false, message = "Session not found." });
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var body = await req.ReadAsStringAsync() ?? string.Empty;
|
||||
JObject json;
|
||||
try
|
||||
{
|
||||
json = JObject.Parse(body);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return await BadRequestAsync(req, "Invalid JSON body.");
|
||||
}
|
||||
|
||||
var verdict = ReadOptionalString(json, "verdict")?.ToLowerInvariant();
|
||||
var note = ReadOptionalString(json, "note");
|
||||
|
||||
if (verdict != null && !AnnotationVerdicts.All.Contains(verdict))
|
||||
{
|
||||
return await BadRequestAsync(req,
|
||||
$"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}");
|
||||
}
|
||||
if (note != null && note.Length > SessionAnnotation.MaxNoteLength)
|
||||
{
|
||||
return await BadRequestAsync(req,
|
||||
$"note must be at most {SessionAnnotation.MaxNoteLength} characters.");
|
||||
}
|
||||
|
||||
// Anti-spoof: author identity comes from the JWT, never from the body
|
||||
// (same stamp as the rule PUT-upserts).
|
||||
var authorDisplayName = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
|
||||
|
||||
// Both fields empty = clear the lane.
|
||||
if (verdict == null && note == null)
|
||||
{
|
||||
await _annotationRepo.DeleteAsync(effectiveTenantId, sessionId, normalizedLane);
|
||||
await LogAuditAsync(requestCtx.IsGlobalAdmin, effectiveTenantId, "DELETE",
|
||||
sessionId, normalizedLane, userIdentifier, verdict: null);
|
||||
_logger.LogInformation(
|
||||
"Annotation cleared for session {SessionId} lane {Lane} by {User}",
|
||||
sessionId, normalizedLane, userIdentifier);
|
||||
return await req.OkAsync(new { success = true, deleted = true });
|
||||
}
|
||||
|
||||
var existing = await _annotationRepo.GetAsync(effectiveTenantId, sessionId, normalizedLane);
|
||||
var now = DateTime.UtcNow;
|
||||
var annotation = new SessionAnnotation
|
||||
{
|
||||
TenantId = effectiveTenantId,
|
||||
SessionId = sessionId,
|
||||
Lane = normalizedLane,
|
||||
Verdict = verdict,
|
||||
Note = note,
|
||||
AuthorUpn = userIdentifier,
|
||||
AuthorDisplayName = authorDisplayName,
|
||||
CreatedByUpn = existing?.CreatedByUpn ?? userIdentifier,
|
||||
CreatedAtUtc = existing?.CreatedAtUtc ?? now,
|
||||
UpdatedAtUtc = now,
|
||||
RuleIds = await SnapshotRuleIdsAsync(effectiveTenantId, sessionId),
|
||||
};
|
||||
|
||||
await _annotationRepo.UpsertAsync(annotation);
|
||||
await LogAuditAsync(requestCtx.IsGlobalAdmin, effectiveTenantId, "UPDATE",
|
||||
sessionId, normalizedLane, userIdentifier, verdict);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Annotation saved for session {SessionId} lane {Lane} by {User} (verdict={Verdict})",
|
||||
sessionId, normalizedLane, userIdentifier, verdict ?? "none");
|
||||
|
||||
return await req.OkAsync(new
|
||||
{
|
||||
success = true,
|
||||
annotation = AnnotationWire.ToWire(annotation),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving annotation for session {SessionId} lane {Lane}", sessionId, lane);
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error." });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-lane write matrix (policy tier already limited callers to tenant
|
||||
/// Admin/Operator or GA):
|
||||
/// operator → Operator or Tenant Admin (admins supervise operator notes)
|
||||
/// tenantadmin → Tenant Admin only
|
||||
/// globaladmin → Global Admin only (GA writes ONLY this lane — clean attribution;
|
||||
/// a GA who is also an own-tenant admin still passes IsTenantAdmin
|
||||
/// for that tenant's lanes)
|
||||
/// </summary>
|
||||
internal static bool IsLaneWritableByCaller(
|
||||
string lane, string? userRole, bool isTenantAdmin, bool isGlobalAdmin)
|
||||
{
|
||||
return lane switch
|
||||
{
|
||||
AnnotationLanes.GlobalAdmin => isGlobalAdmin,
|
||||
AnnotationLanes.TenantAdmin => isTenantAdmin,
|
||||
AnnotationLanes.Operator => isTenantAdmin
|
||||
|| string.Equals(userRole, Constants.TenantRoles.Operator, StringComparison.OrdinalIgnoreCase),
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the rule ids that fired for this session, denormalized onto the
|
||||
/// annotation so rule-quality evaluation needs no join. Fail-soft: an empty list
|
||||
/// never blocks the write.
|
||||
/// </summary>
|
||||
private async Task<List<string>> SnapshotRuleIdsAsync(string tenantId, string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var results = await _ruleRepo.GetRuleResultsAsync(tenantId, sessionId);
|
||||
return results
|
||||
.Select(r => r.RuleId)
|
||||
.Where(id => !string.IsNullOrEmpty(id))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Annotation rule-id snapshot failed for session {SessionId}; storing empty list", sessionId);
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
// Audit entries land in the tenant-visible audit log; GA writes are skipped so
|
||||
// platform-internal labeling activity does not surface there (same convention as
|
||||
// session-report submissions by GAs).
|
||||
private async Task LogAuditAsync(
|
||||
bool isGlobalAdmin, string tenantId, string action,
|
||||
string sessionId, string lane, string userIdentifier, string? verdict)
|
||||
{
|
||||
if (isGlobalAdmin) return;
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
tenantId,
|
||||
action,
|
||||
"SessionAnnotation",
|
||||
$"{sessionId}/{lane}",
|
||||
userIdentifier,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "Action", "UpsertSessionAnnotation" },
|
||||
{ "SessionId", sessionId },
|
||||
{ "Lane", lane },
|
||||
{ "Verdict", verdict ?? string.Empty },
|
||||
});
|
||||
}
|
||||
|
||||
private static string? ReadOptionalString(JObject json, string property)
|
||||
{
|
||||
var token = json[property];
|
||||
if (token == null || token.Type == JTokenType.Null) return null;
|
||||
var value = token.ToString().Trim();
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
|
||||
{
|
||||
var response = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await response.WriteAsJsonAsync(new { success = false, message });
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,13 +274,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new AgentConfigResponse
|
||||
{
|
||||
ConfigVersion = 36, // EnableGatherRuleDebugLog (local gather-rule evaluation trace)
|
||||
ConfigVersion = 37, // EnableEspContinueAnywayObservation (Continue-Anyway observation mode)
|
||||
UploadIntervalSeconds = 10,
|
||||
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
|
||||
KeepLogFile = tenantConfig.KeepLogFile ?? false,
|
||||
EnableGeoLocation = tenantConfig.EnableGeoLocation ?? true,
|
||||
EnableImeMatchLog = tenantConfig.EnableImeMatchLog ?? false,
|
||||
EnableGatherRuleDebugLog = tenantConfig.EnableGatherRuleDebugLog ?? false,
|
||||
EnableEspContinueAnywayObservation = tenantConfig.EnableEspContinueAnywayObservation ?? false,
|
||||
MaxAuthFailures = tenantConfig.MaxAuthFailures ?? 5,
|
||||
AuthFailureTimeoutMinutes = tenantConfig.AuthFailureTimeoutMinutes ?? 0,
|
||||
LogLevel = tenantConfig.LogLevel ?? "Info",
|
||||
|
||||
+4
@@ -119,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 ||
|
||||
@@ -133,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;
|
||||
|
||||
@@ -72,6 +72,10 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Author is stamped from the creator's token, never from the payload
|
||||
// (anti-spoof), and stays immutable through every later update.
|
||||
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
|
||||
|
||||
try
|
||||
{
|
||||
var success = await _ruleService.CreateRuleAsync(tenantId, rule);
|
||||
@@ -124,6 +128,11 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Same anti-spoof stamp as CreateRule: on a true update the service replaces
|
||||
// this with the original author (immutable attribution), but a full-payload PUT
|
||||
// that upserts a rule with no existing row must not store the payload's author.
|
||||
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
|
||||
|
||||
try
|
||||
{
|
||||
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -110,6 +111,11 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
if (scopeError != null)
|
||||
return await BadRequestAsync(req, scopeError);
|
||||
|
||||
// Same anti-spoof stamp as the tenant route: true updates keep the original
|
||||
// author (service-level preservation); a PUT-upsert with no existing row
|
||||
// stores the editing admin, never the payload's author.
|
||||
rule.Author = TenantHelper.GetUserDisplayName(req) ?? "Autopilot Monitor";
|
||||
|
||||
_logger.LogInformation("Global admin updating gather rule {RuleId} for tenant {TenantId}", ruleId, tenantId);
|
||||
|
||||
var success = await _gatherRuleService.UpdateRuleAsync(tenantId, rule);
|
||||
|
||||
@@ -19,27 +19,33 @@ public static class TenantHelper
|
||||
/// <param name="req">The HTTP request</param>
|
||||
/// <returns>The tenant ID from the JWT token</returns>
|
||||
/// <exception cref="UnauthorizedAccessException">Thrown when user is not authenticated or tenant ID is missing</exception>
|
||||
public static string GetTenantId(HttpRequestData req)
|
||||
/// <summary>
|
||||
/// Resolves the authenticated ClaimsPrincipal for the request, or null.
|
||||
/// Azure Functions Isolated Worker: FunctionContext.Items first (set by
|
||||
/// AuthenticationMiddleware, more reliable than httpContext.User), then
|
||||
/// the HTTP context as fallback.
|
||||
/// </summary>
|
||||
private static ClaimsPrincipal? GetAuthenticatedPrincipal(HttpRequestData req)
|
||||
{
|
||||
// Azure Functions Isolated Worker: Try FunctionContext.Items first
|
||||
// This is set by AuthenticationMiddleware and is more reliable than httpContext.User
|
||||
ClaimsPrincipal? user = null;
|
||||
|
||||
if (req.FunctionContext.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
|
||||
&& principalObj is ClaimsPrincipal principal)
|
||||
{
|
||||
user = principal;
|
||||
return principal;
|
||||
}
|
||||
else
|
||||
|
||||
var httpContext = req.FunctionContext.GetHttpContext();
|
||||
if (httpContext?.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
// Fallback to HTTP context (may not work reliably in isolated worker)
|
||||
var httpContext = req.FunctionContext.GetHttpContext();
|
||||
if (httpContext?.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
user = httpContext.User;
|
||||
}
|
||||
return httpContext.User;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string GetTenantId(HttpRequestData req)
|
||||
{
|
||||
var user = GetAuthenticatedPrincipal(req);
|
||||
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
throw new UnauthorizedAccessException("User is not authenticated. JWT token required.");
|
||||
@@ -67,23 +73,7 @@ public static class TenantHelper
|
||||
/// <returns>User email or name, or "Anonymous" if not authenticated</returns>
|
||||
public static string GetUserIdentifier(HttpRequestData req)
|
||||
{
|
||||
// Azure Functions Isolated Worker: Try FunctionContext.Items first
|
||||
ClaimsPrincipal? user = null;
|
||||
|
||||
if (req.FunctionContext.Items.TryGetValue("ClaimsPrincipal", out var principalObj)
|
||||
&& principalObj is ClaimsPrincipal principal)
|
||||
{
|
||||
user = principal;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback to HTTP context
|
||||
var httpContext = req.FunctionContext.GetHttpContext();
|
||||
if (httpContext?.User?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
user = httpContext.User;
|
||||
}
|
||||
}
|
||||
var user = GetAuthenticatedPrincipal(req);
|
||||
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
@@ -100,4 +90,32 @@ public static class TenantHelper
|
||||
user.FindFirst("name")?.Value ??
|
||||
"Unknown";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the caller's human-readable name for attribution (e.g. rule Author):
|
||||
/// display name first, then UPN/email/preferred_username. Returns null when
|
||||
/// the request carries no user identity (anonymous or app-only token) so the
|
||||
/// caller can choose its own fallback.
|
||||
/// </summary>
|
||||
public static string? GetUserDisplayName(HttpRequestData req)
|
||||
=> GetUserDisplayName(GetAuthenticatedPrincipal(req));
|
||||
|
||||
/// <summary>
|
||||
/// Claim-precedence core of <see cref="GetUserDisplayName(HttpRequestData)"/>,
|
||||
/// separated so the precedence is unit-testable without an HttpRequestData.
|
||||
/// </summary>
|
||||
public static string? GetUserDisplayName(ClaimsPrincipal? user)
|
||||
{
|
||||
if (user?.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return user.FindFirst("name")?.Value ??
|
||||
user.FindFirst(ClaimTypes.Name)?.Value ??
|
||||
user.FindFirst("upn")?.Value ??
|
||||
user.FindFirst(ClaimTypes.Upn)?.Value ??
|
||||
user.FindFirst(ClaimTypes.Email)?.Value ??
|
||||
user.FindFirst("preferred_username")?.Value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Pagination;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Pagination
|
||||
{
|
||||
/// <summary>
|
||||
/// Pure helpers for the <c>GET /api/global/session-annotations</c> pagination
|
||||
/// surface. GA-only at the policy layer; every optional filter is a server-side
|
||||
/// <em>filter</em>, not an authorization scope, so the continuation token binds
|
||||
/// the caller's identity + the whole active filter set (a token issued for one
|
||||
/// filter view can't be replayed against another).
|
||||
/// </summary>
|
||||
public static class SessionAnnotationsPagination
|
||||
{
|
||||
public const int DefaultPageSize = 200;
|
||||
public const int MaxPageSize = 1000;
|
||||
|
||||
public static string Fingerprint(
|
||||
string callerTenantId, string? filterTenantId, string? lane, string? verdict,
|
||||
string? ruleId, DateTime? dateFrom, DateTime? dateTo) =>
|
||||
ContinuationToken.ComputeFingerprint(new[]
|
||||
{
|
||||
new KeyValuePair<string, string?>("scope", "session-annotations"),
|
||||
new KeyValuePair<string, string?>("tenantId", callerTenantId),
|
||||
new KeyValuePair<string, string?>("filterTenantId", filterTenantId),
|
||||
new KeyValuePair<string, string?>("lane", lane),
|
||||
new KeyValuePair<string, string?>("verdict", verdict),
|
||||
new KeyValuePair<string, string?>("ruleId", ruleId),
|
||||
new KeyValuePair<string, string?>("dateFrom", dateFrom?.ToString("o", CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("dateTo", dateTo?.ToString("o", CultureInfo.InvariantCulture)),
|
||||
});
|
||||
|
||||
public sealed class Parsed
|
||||
{
|
||||
public string? FilterTenantId { get; init; }
|
||||
public string? Lane { get; init; }
|
||||
public string? Verdict { get; init; }
|
||||
public string? RuleId { get; init; }
|
||||
public DateTime? DateFrom { get; init; }
|
||||
public DateTime? DateTo { get; init; }
|
||||
public int PageSize { get; init; } = DefaultPageSize;
|
||||
public string? Continuation { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public static Parsed ParseQuery(NameValueCollection? query)
|
||||
{
|
||||
var pageSize = DefaultPageSize;
|
||||
var pageSizeRaw = query?["pageSize"];
|
||||
if (!string.IsNullOrEmpty(pageSizeRaw))
|
||||
{
|
||||
if (!int.TryParse(pageSizeRaw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n))
|
||||
return new Parsed { Error = "pageSize must be an integer" };
|
||||
if (n < 1 || n > MaxPageSize)
|
||||
return new Parsed { Error = $"pageSize must be between 1 and {MaxPageSize}" };
|
||||
pageSize = n;
|
||||
}
|
||||
|
||||
var lane = Normalize(query?["lane"]);
|
||||
if (lane != null && !AnnotationLanes.All.Contains(lane))
|
||||
return new Parsed { Error = $"lane must be one of: {string.Join(", ", AnnotationLanes.All)}" };
|
||||
|
||||
var verdict = Normalize(query?["verdict"]);
|
||||
if (verdict != null && !AnnotationVerdicts.All.Contains(verdict))
|
||||
return new Parsed { Error = $"verdict must be one of: {string.Join(", ", AnnotationVerdicts.All)}" };
|
||||
|
||||
if (!TryParseDate(query?["dateFrom"], out var dateFrom))
|
||||
return new Parsed { Error = "dateFrom must be an ISO 8601 date" };
|
||||
if (!TryParseDate(query?["dateTo"], out var dateTo))
|
||||
return new Parsed { Error = "dateTo must be an ISO 8601 date" };
|
||||
|
||||
return new Parsed
|
||||
{
|
||||
FilterTenantId = NullIfEmpty(query?["tenantId"]),
|
||||
Lane = lane,
|
||||
Verdict = verdict,
|
||||
RuleId = NullIfEmpty(query?["ruleId"]),
|
||||
DateFrom = dateFrom,
|
||||
DateTo = dateTo,
|
||||
PageSize = pageSize,
|
||||
Continuation = NullIfEmpty(query?["continuation"]),
|
||||
};
|
||||
}
|
||||
|
||||
public static bool TryAcceptContinuation(
|
||||
Parsed parsed,
|
||||
string callerTenantId,
|
||||
out string azureToken,
|
||||
out string? rejectReason)
|
||||
{
|
||||
var fp = Fingerprint(
|
||||
callerTenantId, parsed.FilterTenantId, parsed.Lane, parsed.Verdict,
|
||||
parsed.RuleId, parsed.DateFrom, parsed.DateTo);
|
||||
return ContinuationToken.TryDecode(parsed.Continuation!, callerTenantId, fp, out azureToken, out rejectReason);
|
||||
}
|
||||
|
||||
public static string EncodeContinuation(Parsed parsed, string callerTenantId, string rawAzureToken)
|
||||
{
|
||||
var fp = Fingerprint(
|
||||
callerTenantId, parsed.FilterTenantId, parsed.Lane, parsed.Verdict,
|
||||
parsed.RuleId, parsed.DateFrom, parsed.DateTo);
|
||||
return ContinuationToken.Encode(rawAzureToken, callerTenantId, fp);
|
||||
}
|
||||
|
||||
public const string GlobalBasePath = "/api/global/session-annotations";
|
||||
// Under /api/sessions so the platform client-cert exclusion prefix covers it.
|
||||
public const string TenantBasePath = "/api/sessions/annotations/list";
|
||||
|
||||
public static string BuildNextLink(Parsed parsed, string wireContinuation, string basePath = GlobalBasePath)
|
||||
{
|
||||
var sb = new StringBuilder(basePath);
|
||||
sb.Append("?pageSize=").Append(parsed.PageSize.ToString(CultureInfo.InvariantCulture));
|
||||
sb.Append("&continuation=").Append(Uri.EscapeDataString(wireContinuation));
|
||||
AppendIfSet(sb, "tenantId", parsed.FilterTenantId);
|
||||
AppendIfSet(sb, "lane", parsed.Lane);
|
||||
AppendIfSet(sb, "verdict", parsed.Verdict);
|
||||
AppendIfSet(sb, "ruleId", parsed.RuleId);
|
||||
AppendIfSet(sb, "dateFrom", parsed.DateFrom?.ToString("o", CultureInfo.InvariantCulture));
|
||||
AppendIfSet(sb, "dateTo", parsed.DateTo?.ToString("o", CultureInfo.InvariantCulture));
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendIfSet(StringBuilder sb, string name, string? value)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(value))
|
||||
sb.Append('&').Append(name).Append('=').Append(Uri.EscapeDataString(value!));
|
||||
}
|
||||
|
||||
private static bool TryParseDate(string? raw, out DateTime? value)
|
||||
{
|
||||
value = null;
|
||||
if (string.IsNullOrEmpty(raw)) return true;
|
||||
if (!DateTime.TryParse(
|
||||
raw, CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsed))
|
||||
return false;
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? Normalize(string? raw) =>
|
||||
string.IsNullOrEmpty(raw) ? null : raw.ToLowerInvariant();
|
||||
|
||||
private static string? NullIfEmpty(string? raw) =>
|
||||
string.IsNullOrEmpty(raw) ? null : raw;
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,14 @@ public static class EndpointAccessPolicyCatalog
|
||||
// sessions/{sessionId}/signals + /decision-graph live in the GlobalAdminOnly block below
|
||||
// (Inspector v1 — Plan §M6). Lift back to MemberRead+QueryParam at the v2 adminMode lift.
|
||||
new("GET", "sessions/{sessionId}/analysis", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
// Member tier: annotation reads are safe for every tenant member because the handler
|
||||
// filters the platform-internal globaladmin lane for callers without global scope
|
||||
// (GetSessionAnnotationsFunction.FilterLanesForCaller; the tenant list excludes the
|
||||
// lane server-side in the OData query). The list route deliberately lives under the
|
||||
// /api/sessions client-cert exclusion prefix, three segments deep (a two-segment
|
||||
// literal would be eaten by the sessions/{sessionId} template).
|
||||
new("GET", "sessions/{sessionId}/annotations", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("GET", "sessions/annotations/list", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("GET", "sessions/{sessionId}/vulnerability-report", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("GET", "sessions/{sessionId}/time-attribution", EndpointPolicy.MemberRead, TenantScoping.QueryParam),
|
||||
new("GET", "metrics/app", EndpointPolicy.MemberRead),
|
||||
@@ -297,6 +305,10 @@ public static class EndpointAccessPolicyCatalog
|
||||
// the portal); the function re-gates the admin-only action types (terminate_session,
|
||||
// rotate_config) on RequestContext.IsTenantAdmin || IsGlobalAdmin.
|
||||
new("POST", "sessions/{sessionId}/actions", EndpointPolicy.TenantAdminOrOperator),
|
||||
// Operator tier so troubleshooting staff can write the operator annotation lane; the
|
||||
// function re-gates the per-lane matrix (tenantadmin lane → IsTenantAdmin, globaladmin
|
||||
// lane → IsGlobalAdmin) via UpsertSessionAnnotationFunction.IsLaneWritableByCaller.
|
||||
new("PUT", "sessions/{sessionId}/annotations/{lane}", EndpointPolicy.TenantAdminOrOperator, TenantScoping.QueryParam),
|
||||
new("POST", "sessions/{sessionId}/report", EndpointPolicy.TenantAdminOrGA),
|
||||
// Operator tier: submitting diagnostic files to the Autopilot Monitor team is a
|
||||
// troubleshooting action, not a config change — a support-driven Operator may use it.
|
||||
@@ -449,6 +461,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "global/session-reports", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/session-reports/download-url", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("PATCH", "global/session-reports/{reportId}/note", EndpointPolicy.GlobalAdminOnly),
|
||||
// Flywheel evaluation stream: all annotation lanes across tenants (verdict/rule filters).
|
||||
new("GET", "global/session-annotations", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/rules/gather", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
new("GET", "global/rules/analyze", EndpointPolicy.GlobalReadOrAdmin, TenantScoping.QueryParam),
|
||||
// Cross-tenant rule WRITES (edit/toggle/delete an existing rule in a foreign tenant). GlobalAdminOnly
|
||||
|
||||
+17
-1
@@ -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);
|
||||
|
||||
+17
-6
@@ -12,6 +12,7 @@ using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services.Tables;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using AutopilotMonitor.Shared.Models.Deletion;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -117,17 +118,25 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
// ---- Step 16: F1 time-attribution breakdown (PK=tenant, RK=sessionId; PR2). ----
|
||||
await AddPkRkExactStepAsync(manifest, order: 16, table: Constants.TableNames.SessionTimeBreakdowns, partitionKey: tenantId, rowKey: sessionId, cancellationToken);
|
||||
|
||||
// ---- Steps 17 + 18: SoftwareInventory side-row (omit both for pre-side-row sessions). ----
|
||||
// ---- Steps 17-19: session annotations (PK=tenant, RK="{sessionId}_{lane}"). ----
|
||||
// Lanes are a closed set of 3, so exact-RK steps cover every possible row.
|
||||
var annotationOrder = 17;
|
||||
foreach (var lane in AnnotationLanes.All)
|
||||
{
|
||||
await AddPkRkExactStepAsync(manifest, order: annotationOrder++, table: Constants.TableNames.SessionAnnotations, partitionKey: tenantId, rowKey: $"{sessionId}_{lane}", cancellationToken);
|
||||
}
|
||||
|
||||
// ---- Steps 20 + 21: SoftwareInventory side-row (omit both for pre-side-row sessions). ----
|
||||
var contributionsRow = await _reader.GetEntityOrNullAsync(
|
||||
Constants.TableNames.SessionInventoryContributions, tenantId, sessionId, cancellationToken);
|
||||
if (contributionsRow != null)
|
||||
{
|
||||
AddSoftwareInventoryDecrementStep(manifest, order: 17, contributionsRow);
|
||||
AddContributionsRowStep(manifest, order: 18, contributionsRow);
|
||||
AddSoftwareInventoryDecrementStep(manifest, order: 20, contributionsRow);
|
||||
AddContributionsRowStep(manifest, order: 21, contributionsRow);
|
||||
}
|
||||
|
||||
// ---- Step 19: Tombstone (SessionsIndex first, then Sessions). ----
|
||||
AddTombstoneStep(manifest, order: 19, sessionsIndexRow, sessionRow);
|
||||
// ---- Step 22: Tombstone (SessionsIndex first, then Sessions). ----
|
||||
AddTombstoneStep(manifest, order: 22, sessionsIndexRow, sessionRow);
|
||||
|
||||
// PreflightCounts derive from each step's RowCount, plus the AGGREGATE decrements length.
|
||||
manifest.PreflightCounts = ComputePreflightCounts(manifest);
|
||||
@@ -433,7 +442,9 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
continue;
|
||||
}
|
||||
if (string.IsNullOrEmpty(step.Table)) continue;
|
||||
counts[CamelCase(step.Table!)] = step.RowCount;
|
||||
// Sum, don't overwrite: SessionAnnotations emits one step per lane on the same table.
|
||||
var key = CamelCase(step.Table!);
|
||||
counts[key] = counts.TryGetValue(key, out var existing) ? existing + step.RowCount : step.RowCount;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
+15
-2
@@ -391,11 +391,24 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
if (c.CompletionEvent != null)
|
||||
{
|
||||
// Soft-failure marker from the V2 engine's enrollment_complete audit payload:
|
||||
// the session completed while an ESP-failure advisory was still unresolved
|
||||
// (Continue-Anyway observation / classic advisory defang). Stamped on the
|
||||
// Sessions row so the UI can render the amber "completed with issues" badge.
|
||||
var espSoftFailure = string.Equals(
|
||||
c.CompletionEvent.Data?.ContainsKey("espSoftFailure") == true
|
||||
? c.CompletionEvent.Data["espSoftFailure"]?.ToString() : null,
|
||||
"true", StringComparison.OrdinalIgnoreCase);
|
||||
var completionSource = c.CompletionEvent.Data?.ContainsKey("completionSource") == true
|
||||
? c.CompletionEvent.Data["completionSource"]?.ToString() : null;
|
||||
|
||||
statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||||
request.TenantId, request.SessionId, SessionStatus.Succeeded, c.CompletionEvent.Phase,
|
||||
completedAt: c.CompletionEvent.Timestamp,
|
||||
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp);
|
||||
_logger.LogInformation("{SessionPrefix} Status: Succeeded (transitioned={Transitioned})", sessionPrefix, statusTransitioned);
|
||||
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
|
||||
espSoftFailure: espSoftFailure, completionSource: completionSource);
|
||||
_logger.LogInformation("{SessionPrefix} Status: Succeeded (transitioned={Transitioned}, espSoftFailure={EspSoftFailure})",
|
||||
sessionPrefix, statusTransitioned, espSoftFailure);
|
||||
}
|
||||
else if (c.FailureEvent != null)
|
||||
{
|
||||
|
||||
@@ -214,7 +214,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
rule.IsBuiltIn = false;
|
||||
rule.IsCommunity = false;
|
||||
if (existing != null)
|
||||
{
|
||||
rule.CreatedAt = existing.CreatedAt;
|
||||
// Author is stamped from the creator's token at create time and is
|
||||
// immutable afterwards — updates (incl. cross-tenant GA edits) keep it.
|
||||
rule.Author = existing.Author;
|
||||
}
|
||||
rule.UpdatedAt = DateTime.UtcNow;
|
||||
return await _ruleRepo.StoreGatherRuleAsync(rule, tenantId);
|
||||
}
|
||||
|
||||
+4
@@ -89,6 +89,10 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
// save), so the wipe cannot re-create a snapshot afterwards. The admin-config
|
||||
// "GlobalConfig" partition never matches a tenant GUID and survives untouched.
|
||||
Constants.TableNames.ConfigurationBackups,
|
||||
// Session annotations (PK=tenantId). Deliberately WIPED — unlike Feedback, these
|
||||
// are customer-entered notes tied to the tenant's sessions, not product feedback.
|
||||
// Rows also die per-session via the deletion manifest; this covers offboarding.
|
||||
Constants.TableNames.SessionAnnotations,
|
||||
};
|
||||
|
||||
// Plan §6.4 — composite-PK "{tenantId}_..." wipes (Variant A range).
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly SessionReportDiagnosticsArchiveCopier _diagnosticsArchiveCopier;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
private readonly ISessionAnnotationRepository _annotationRepo;
|
||||
private const string ContainerName = "session-reports";
|
||||
|
||||
public SessionReportService(
|
||||
@@ -38,6 +39,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
SessionReportDiagnosticsArchiveCopier diagnosticsArchiveCopier,
|
||||
TenantConfigurationService configService,
|
||||
IRuleRepository ruleRepo,
|
||||
ISessionAnnotationRepository annotationRepo,
|
||||
ILogger<SessionReportService> logger)
|
||||
{
|
||||
_notificationRepo = notificationRepo;
|
||||
@@ -47,6 +49,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_diagnosticsArchiveCopier = diagnosticsArchiveCopier;
|
||||
_configService = configService;
|
||||
_ruleRepo = ruleRepo;
|
||||
_annotationRepo = annotationRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -95,6 +98,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// have changed by the time an operator investigates). Fail-soft; never blocks.
|
||||
var tenantContext = await CollectTenantContextAsync(request.TenantId, reportId);
|
||||
|
||||
// Human annotations at report time — the submitter's own verdict/notes are exactly
|
||||
// the context an investigating operator wants first, and the snapshot survives the
|
||||
// session's retention/delete. ALL lanes are included: the report blob is readable
|
||||
// only through the GA-only session-reports routes, so the platform-internal
|
||||
// globaladmin lane never reaches tenant callers this way. Fail-soft; never blocks.
|
||||
var annotations = await CollectAnnotationsAsync(request.TenantId, request.SessionId, reportId);
|
||||
|
||||
// Decode attachments up front so report-metadata.json can record their outcome
|
||||
// instead of silently dropping invalid payloads.
|
||||
var (screenshotBytes, screenshotStatus) = DecodeAttachment(request.ScreenshotBase64, reportId, "screenshot");
|
||||
@@ -128,6 +138,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
AddTextEntry(archive, "timeline.txt", request.TimelineExportTxt);
|
||||
}
|
||||
|
||||
// Human annotations snapshot (all lanes) — omitted when none exist.
|
||||
if (annotations is { Count: > 0 })
|
||||
{
|
||||
AddJsonEntry(archive, "annotations.json", annotations);
|
||||
}
|
||||
|
||||
// Report metadata
|
||||
AddJsonEntry(archive, "report-metadata.json", new
|
||||
{
|
||||
@@ -157,6 +173,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
screenshot = screenshotStatus,
|
||||
agentLog = agentLogStatus
|
||||
},
|
||||
// Count only — the rows themselves live in annotations.json. 0 with a
|
||||
// missing annotations.json ⇒ either none existed or the read failed soft.
|
||||
annotationCount = annotations?.Count ?? 0,
|
||||
tenantContext
|
||||
});
|
||||
|
||||
@@ -360,6 +379,35 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the session's human annotations (all lanes) for annotations.json.
|
||||
/// Fail-soft: a failed read yields null and never blocks the report.
|
||||
/// </summary>
|
||||
private async Task<List<object>?> CollectAnnotationsAsync(string tenantId, string sessionId, string reportId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rows = await _annotationRepo.GetForSessionAsync(tenantId, sessionId);
|
||||
return rows.Select(a => (object)new
|
||||
{
|
||||
lane = a.Lane,
|
||||
verdict = a.Verdict,
|
||||
note = a.Note,
|
||||
authorUpn = a.AuthorUpn,
|
||||
authorDisplayName = a.AuthorDisplayName,
|
||||
createdByUpn = a.CreatedByUpn,
|
||||
createdAtUtc = a.CreatedAtUtc,
|
||||
updatedAtUtc = a.UpdatedAtUtc,
|
||||
ruleIds = a.RuleIds,
|
||||
}).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Report {ReportId}: annotations snapshot failed — omitted from package", reportId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decodes an optional base64 attachment. Returns the bytes plus a status string
|
||||
/// ("none" | "included" | "invalid-base64") that is persisted in report-metadata.json —
|
||||
|
||||
@@ -175,6 +175,15 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (!string.IsNullOrEmpty(reconcileReason))
|
||||
indexEntity["ReconcileReason"] = reconcileReason;
|
||||
|
||||
// Soft-failure marker — rendered as the amber "completed with issues" badge in the
|
||||
// index-served session list, so it must survive a StartedAt-shift full upsert too.
|
||||
var espSoftFailure = sessionEntity.GetBoolean("EspSoftFailure");
|
||||
if (espSoftFailure == true)
|
||||
indexEntity["EspSoftFailure"] = true;
|
||||
var completionSource = sessionEntity.GetString("CompletionSource");
|
||||
if (!string.IsNullOrEmpty(completionSource))
|
||||
indexEntity["CompletionSource"] = completionSource;
|
||||
|
||||
// Device-validation path recorded at registration (AutopilotV1 / CorporateIdentifier /
|
||||
// DeviceAssociation / Bootstrap) — index-served session reads must carry it too.
|
||||
var validatedBy = sessionEntity.GetString("ValidatedBy");
|
||||
@@ -647,6 +656,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// fresh-read window isn't reverted by the Replace below (see guard).
|
||||
"Status", "CurrentPhase", "CompletedAt", "FailureReason", "ReconcileReason",
|
||||
"FailureSnapshotJson", "FailureSource", "AdminMarkedAction", "DurationSeconds",
|
||||
"EspSoftFailure", "CompletionSource",
|
||||
});
|
||||
freshEtag = freshResponse.Value.ETag;
|
||||
freshEntity = freshResponse.Value;
|
||||
@@ -686,7 +696,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|| freshStatus == SessionStatus.Incomplete.ToString())
|
||||
{
|
||||
entity["Status"] = freshStatus;
|
||||
foreach (var col in new[] { "CurrentPhase", "CompletedAt", "FailureReason", "ReconcileReason", "FailureSnapshotJson", "FailureSource", "AdminMarkedAction", "DurationSeconds" })
|
||||
foreach (var col in new[] { "CurrentPhase", "CompletedAt", "FailureReason", "ReconcileReason", "FailureSnapshotJson", "FailureSource", "AdminMarkedAction", "DurationSeconds", "EspSoftFailure", "CompletionSource" })
|
||||
{
|
||||
if (freshEntity!.TryGetValue(col, out var val) && val is not null)
|
||||
entity[col] = val;
|
||||
@@ -1590,7 +1600,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
: null;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null, DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false, string? failureSource = null, string? adminMarkedAction = null, string? failureSnapshotJson = null, bool allowTerminalReclassification = false)
|
||||
public async Task<bool> UpdateSessionStatusAsync(string tenantId, string sessionId, SessionStatus status, EnrollmentPhase? currentPhase = null, string? failureReason = null, DateTime? completedAt = null, DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null, bool? isUserDriven = null, DateTime? resumedAt = null, DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false, string? failureSource = null, string? adminMarkedAction = null, string? failureSnapshotJson = null, bool allowTerminalReclassification = false, bool espSoftFailure = false, string? completionSource = null)
|
||||
{
|
||||
SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId));
|
||||
SecurityValidator.EnsureValidGuid(sessionId, nameof(sessionId));
|
||||
@@ -1701,6 +1711,18 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var reconcileReason = ComputeReconcileReason(existingStatusStr, failureReason, adminMarkedAction);
|
||||
if (reconcileReason != null)
|
||||
update["ReconcileReason"] = reconcileReason;
|
||||
|
||||
// Soft-failure marker (Continue-Anyway observation / advisory-defang):
|
||||
// the session succeeded, but the ESP demonstrably gave up on at least
|
||||
// one blocking item first. Rendered as the amber "completed with
|
||||
// issues" badge. Only ever stamped alongside a Succeeded transition —
|
||||
// absence means a clean success.
|
||||
if (espSoftFailure)
|
||||
{
|
||||
update["EspSoftFailure"] = true;
|
||||
if (!string.IsNullOrEmpty(completionSource))
|
||||
update["CompletionSource"] = completionSource;
|
||||
}
|
||||
}
|
||||
else if (status == SessionStatus.Failed)
|
||||
{
|
||||
@@ -1973,6 +1995,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var forceReconcileReason = ComputeReconcileReason(freshStatusStr, failureReason, adminMarkedAction);
|
||||
if (forceReconcileReason != null)
|
||||
forceUpdate["ReconcileReason"] = forceReconcileReason;
|
||||
// Mirror the normal path's soft-failure marker.
|
||||
if (espSoftFailure)
|
||||
{
|
||||
forceUpdate["EspSoftFailure"] = true;
|
||||
if (!string.IsNullOrEmpty(completionSource))
|
||||
forceUpdate["CompletionSource"] = completionSource;
|
||||
}
|
||||
}
|
||||
else if (status == SessionStatus.Failed)
|
||||
{
|
||||
@@ -2928,6 +2957,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
FailureReason = entity.GetString("FailureReason") ?? string.Empty,
|
||||
FailureSource = entity.GetString("FailureSource") ?? string.Empty,
|
||||
ReconcileReason = entity.GetString("ReconcileReason") ?? string.Empty,
|
||||
EspSoftFailure = entity.GetBoolean("EspSoftFailure") ?? false,
|
||||
CompletionSource = entity.GetString("CompletionSource") ?? string.Empty,
|
||||
AdminMarkedAction = entity.GetString("AdminMarkedAction"),
|
||||
ValidatedBy = entity.GetString("ValidatedBy") ?? string.Empty,
|
||||
PendingActionsJson = entity.GetString("PendingActionsJson") ?? string.Empty,
|
||||
|
||||
@@ -132,6 +132,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
"Disabled", "DisabledReason", "DisabledUntil",
|
||||
"AllowInsecureAgentRequests", "BootstrapTokenEnabled",
|
||||
"UnrestrictedModeEnabled", "EntraAppRolesEnabled",
|
||||
"EnableEspContinueAnywayObservation",
|
||||
"CustomRateLimitRequestsPerMinute", "CustomUserRateLimitRequestsPerMinute",
|
||||
"ValidateDeviceAssociation", "MaxNdjsonPayloadSizeMB",
|
||||
};
|
||||
|
||||
+4
-4
@@ -24,7 +24,7 @@
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.1",
|
||||
"tsx": "^4.23.1",
|
||||
"tsx": "^4.23.11",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
@@ -3823,9 +3823,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
|
||||
"integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
|
||||
"version": "4.23.11",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.11.tgz",
|
||||
"integrity": "sha512-Ry2oTEUnhBdeEdWIztY8kf3/nBGnPnjMLVGL0YfdRXMORuPER5NlKmayqxtxRxwB1xBN+RivRaJfe7PM1rtiyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.20.1",
|
||||
"tsx": "^4.23.1",
|
||||
"tsx": "^4.23.11",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
|
||||
@@ -113,6 +113,65 @@ describe('gather guardrails (agent matching semantics)', () => {
|
||||
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects the registry-hive store path (System32\\config) as hard-blocked', () => {
|
||||
const bad = validGather();
|
||||
bad.collectorType = 'file';
|
||||
bad.target = 'C:\\Windows\\System32\\config\\SAM';
|
||||
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
|
||||
});
|
||||
|
||||
it('validates json/xml targets with file-path semantics', () => {
|
||||
const okJson = validGather();
|
||||
okJson.collectorType = 'json';
|
||||
okJson.target = 'C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\state.json';
|
||||
expect(validateRuleDraft(okJson).valid).toBe(true);
|
||||
|
||||
const blockedJson = validGather();
|
||||
blockedJson.collectorType = 'json';
|
||||
blockedJson.target = 'C:\\Users\\admin\\AppData\\Local\\app\\state.json';
|
||||
expect(errors(validateRuleDraft(blockedJson).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
|
||||
|
||||
const badXml = validGather();
|
||||
badXml.collectorType = 'xml';
|
||||
badXml.target = 'D:\\NotAllowed\\config.xml';
|
||||
expect(errors(validateRuleDraft(badXml).findings).some((m) => m.includes('allowed file prefix'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects hard-blocked command patterns and over-length commands', () => {
|
||||
const blocked = validGather();
|
||||
blocked.collectorType = 'command_allowlisted';
|
||||
blocked.target = 'Get-Tpm; Invoke-WebRequest -Uri https://exfil.example';
|
||||
expect(errors(validateRuleDraft(blocked).findings).some((m) => m.includes('hard-blocked pattern'))).toBe(true);
|
||||
|
||||
const long = validGather();
|
||||
long.collectorType = 'command_allowlisted';
|
||||
long.target = 'Get-Something ' + 'a'.repeat(2100);
|
||||
expect(errors(validateRuleDraft(long).findings).some((m) => m.includes('character hard limit'))).toBe(true);
|
||||
});
|
||||
|
||||
it('still accepts allowlisted commands that contain no blocked pattern', () => {
|
||||
const ok = validGather();
|
||||
ok.collectorType = 'command_allowlisted';
|
||||
// must not be caught by the "certutil -urlcache" pattern
|
||||
ok.target = 'certutil -store My';
|
||||
expect(validateRuleDraft(ok).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects activePhases + activeFromPhase set together (backend 400 parity)', () => {
|
||||
const bad = validGather();
|
||||
bad.activePhases = ['DeviceSetup'];
|
||||
bad.activeFromPhase = 'AccountSetup';
|
||||
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('mutually exclusive'))).toBe(true);
|
||||
|
||||
const okPhases = validGather();
|
||||
okPhases.activePhases = ['DeviceSetup'];
|
||||
expect(validateRuleDraft(okPhases).valid).toBe(true);
|
||||
|
||||
const okFrom = validGather();
|
||||
okFrom.activeFromPhase = 'AccountSetup';
|
||||
expect(validateRuleDraft(okFrom).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an allow-listed file target and rejects others', () => {
|
||||
const ok = validGather();
|
||||
ok.collectorType = 'file';
|
||||
@@ -137,6 +196,37 @@ describe('gather guardrails (agent matching semantics)', () => {
|
||||
expect(validateRuleDraft(spoof).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('WMI: property projection of an allowed class is admitted', () => {
|
||||
const single = validGather();
|
||||
single.collectorType = 'wmi';
|
||||
single.target = 'SELECT BatteryStatus FROM Win32_Battery';
|
||||
expect(validateRuleDraft(single).valid).toBe(true);
|
||||
|
||||
const multi = validGather();
|
||||
multi.collectorType = 'wmi';
|
||||
multi.target = 'SELECT BatteryStatus, EstimatedChargeRemaining FROM Win32_Battery WHERE BatteryStatus = 1';
|
||||
expect(validateRuleDraft(multi).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('WMI: projection of a disallowed class and star-in-list are rejected', () => {
|
||||
const badClass = validGather();
|
||||
badClass.collectorType = 'wmi';
|
||||
badClass.target = 'SELECT Name FROM Win32_Process';
|
||||
expect(validateRuleDraft(badClass).valid).toBe(false);
|
||||
|
||||
const starMix = validGather();
|
||||
starMix.collectorType = 'wmi';
|
||||
starMix.target = 'SELECT *, Name FROM Win32_BIOS';
|
||||
expect(validateRuleDraft(starMix).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('WMI: leading whitespace is trimmed like the agent does (no false positive)', () => {
|
||||
const padded = validGather();
|
||||
padded.collectorType = 'wmi';
|
||||
padded.target = ' SELECT * FROM Win32_BIOS';
|
||||
expect(validateRuleDraft(padded).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('commands match exactly — extra arguments are rejected', () => {
|
||||
const ok = validGather();
|
||||
ok.collectorType = 'command_allowlisted';
|
||||
|
||||
@@ -108,6 +108,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
// This is the source of truth. A new tool MUST be added here, or the GA test
|
||||
// fails — that failure is the deliberate prompt to decide its role placement.
|
||||
const GA_FULL = [
|
||||
'annotate_session',
|
||||
'get_api_usage',
|
||||
'get_app_install_metrics',
|
||||
'get_audit_logs',
|
||||
@@ -136,6 +137,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'get_usage_metrics',
|
||||
'get_vulnerability_summary',
|
||||
'list_blocked_devices',
|
||||
'list_session_annotations',
|
||||
'list_session_reports',
|
||||
'list_tables',
|
||||
'list_tenant_config_backups',
|
||||
@@ -178,17 +180,23 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'update_tenant_config',
|
||||
];
|
||||
|
||||
// Session-annotation write surface: strictGa only — a read-only Global Reader
|
||||
// reads annotations (list_session_annotations) but never writes the GA lane.
|
||||
const ANNOTATION_WRITE_GA_STRICT = ['annotate_session'];
|
||||
|
||||
// Platform-only tools: a non-platform caller (tenant or delegated) gets no
|
||||
// cross-fleet aggregate surface at all. Superset of RAW_GA_STRICT and
|
||||
// CONFIG_WRITE_GA_STRICT (those are also platform-only) plus the curated
|
||||
// cross-tenant aggregates.
|
||||
const PLATFORM_ONLY = [
|
||||
'annotate_session',
|
||||
'get_api_usage',
|
||||
'get_ops_events',
|
||||
'get_platform_metrics',
|
||||
'get_tenant_config',
|
||||
'get_tenant_config_schema',
|
||||
'list_blocked_devices',
|
||||
'list_session_annotations',
|
||||
'list_session_reports',
|
||||
'list_tables',
|
||||
'list_tenant_config_backups',
|
||||
@@ -212,9 +220,9 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
expect(registeredToolNames(true, true)).toEqual(GA_FULL);
|
||||
});
|
||||
|
||||
it('Global Reader = GA minus the secret-bearing raw tools and the config-write surface (strictGa split)', () => {
|
||||
it('Global Reader = GA minus the secret-bearing raw tools, the config-write surface and the annotation write (strictGa split)', () => {
|
||||
expect(registeredToolNames(true, false)).toEqual(
|
||||
without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT]));
|
||||
without(GA_FULL, [...RAW_GA_STRICT, ...CONFIG_WRITE_GA_STRICT, ...ANNOTATION_WRITE_GA_STRICT]));
|
||||
});
|
||||
|
||||
it('tenant user = GA minus all platform-only tools', () => {
|
||||
@@ -232,6 +240,7 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
for (const [label, list] of [
|
||||
['RAW_GA_STRICT', RAW_GA_STRICT],
|
||||
['CONFIG_WRITE_GA_STRICT', CONFIG_WRITE_GA_STRICT],
|
||||
['ANNOTATION_WRITE_GA_STRICT', ANNOTATION_WRITE_GA_STRICT],
|
||||
['PLATFORM_ONLY', PLATFORM_ONLY],
|
||||
['DELEGATED_HIDDEN', DELEGATED_HIDDEN],
|
||||
['DELEGATED_ADDED', DELEGATED_ADDED],
|
||||
@@ -252,4 +261,9 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
const leaked = CONFIG_WRITE_GA_STRICT.filter((n) => !PLATFORM_ONLY.includes(n));
|
||||
expect(leaked, 'a config-write GA-strict tool is not also marked platform-only').toEqual([]);
|
||||
});
|
||||
|
||||
it('the annotation write tool is a subset of the platform-only tools', () => {
|
||||
const leaked = ANNOTATION_WRITE_GA_STRICT.filter((n) => !PLATFORM_ONLY.includes(n));
|
||||
expect(leaked, 'the annotation write tool is not also marked platform-only').toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,6 +277,7 @@ export const DEVICE_PROPERTIES_CATALOG = {
|
||||
'autopilot_profile_missing.profileAvailable': { type: 'string', description: 'Always "0" — Windows recorded that no Autopilot profile was available during OOBE' },
|
||||
'autopilot_profile_missing.zeroTouchTenantDomain': { type: 'string', description: 'CloudAssignedTenantDomain from the cached ZeroTouchConfig (empty when no profile was downloaded)' },
|
||||
'autopilot_profile_missing.likelyCauses': { type: 'string', description: 'Comma-separated candidate causes (profile_not_assigned, profile_assignment_not_propagated) — the device itself passed backend device-registration validation, so the gap is the profile assignment, not the registration' },
|
||||
'autopilot_profile_missing.isCloudPc': { type: 'boolean', description: 'true when the device is a Windows 365 Cloud PC — there the missing profile is expected (Cloud PCs are provisioned by the Windows 365 service, not Autopilot) and the event is emitted as Info instead of Warning' },
|
||||
},
|
||||
hardware_spec: {
|
||||
'hardware_spec.cpuName': { type: 'string', description: 'CPU model name (e.g. "Intel Core i7-1265U")' },
|
||||
|
||||
@@ -70,6 +70,14 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
file:
|
||||
'Check file/directory existence, optionally read content. target = path under an allow-listed ' +
|
||||
'prefix. parameters: readContent ("true"). C:\\Users is blocked.',
|
||||
json:
|
||||
'Query a JSON file with a JSONPath expression. target = file path under an allow-listed ' +
|
||||
'prefix (same file guardrails as collectorType "file", 200 KB cap). parameters: jsonpath ' +
|
||||
'(REQUIRED, e.g. "$.status.lastSync"), maxResults (default 20, max 100).',
|
||||
xml:
|
||||
'Query an XML file with an XPath expression. target = file path under an allow-listed ' +
|
||||
'prefix (same file guardrails as collectorType "file", 200 KB cap). parameters: xpath ' +
|
||||
'(REQUIRED), namespaces (optional "prefix=uri;prefix2=uri2"), maxResults (default 20, max 100).',
|
||||
command_allowlisted:
|
||||
'Run one of the exactly allow-listed commands (see rule_guardrails allowedCommands). ' +
|
||||
'Matching is EXACT (trimmed, case-insensitive) — no arguments can be added or removed.',
|
||||
@@ -247,7 +255,7 @@ export const RULE_AUTHORING_GUIDE = {
|
||||
analyzeCategories: ['network', 'identity', 'enrollment', 'apps', 'esp', 'device', 'security'],
|
||||
analyzeSeverities: ['info', 'warning', 'high', 'critical'],
|
||||
gatherOutputSeverities: ['Info', 'Warning', 'Error', 'Critical'],
|
||||
gatherCollectorTypes: ['registry', 'eventlog', 'wmi', 'file', 'command_allowlisted', 'logparser'],
|
||||
gatherCollectorTypes: ['registry', 'eventlog', 'wmi', 'file', 'json', 'xml', 'command_allowlisted', 'logparser'],
|
||||
gatherTriggers: ['startup', 'phase_change', 'phase_exit', 'interval', 'on_event'],
|
||||
analyzeConditionSources: ['event_type', 'event_data', 'event_data_array', 'phase_duration', 'event_count', 'app_install_duration', 'event_correlation'],
|
||||
},
|
||||
|
||||
@@ -98,6 +98,8 @@ export const GATHER_RULE_SCHEMA: Record<string, unknown> = {
|
||||
"eventlog",
|
||||
"wmi",
|
||||
"file",
|
||||
"json",
|
||||
"xml",
|
||||
"command_allowlisted",
|
||||
"logparser"
|
||||
]
|
||||
@@ -1000,6 +1002,27 @@ export const RULE_GUARDRAILS = {
|
||||
"C:\\Install\\Log"
|
||||
],
|
||||
"blockedFilePrefixes": [
|
||||
"C:\\Users"
|
||||
]
|
||||
"C:\\Users",
|
||||
"C:\\Windows\\System32\\config"
|
||||
],
|
||||
"blockedCommandPatterns": [
|
||||
"Invoke-WebRequest",
|
||||
"Invoke-RestMethod",
|
||||
"Start-BitsTransfer",
|
||||
"wget",
|
||||
"curl",
|
||||
"certutil -urlcache",
|
||||
"New-LocalUser",
|
||||
"Add-LocalGroupMember",
|
||||
"net user",
|
||||
"net localgroup",
|
||||
"bcdedit",
|
||||
"bcdboot",
|
||||
"schtasks /create",
|
||||
"Register-ScheduledTask",
|
||||
"Remove-Item -Recurse",
|
||||
"Format-Volume",
|
||||
"Set-ExecutionPolicy"
|
||||
],
|
||||
"maxCommandLength": 2000
|
||||
} as const;
|
||||
|
||||
@@ -67,10 +67,32 @@ const flatRegistryPrefixes = RULE_GUARDRAILS.registryPrefixes.flatMap((g) => [..
|
||||
const flatCommands = RULE_GUARDRAILS.allowedCommands.flatMap((g) => [...g.commands]);
|
||||
const flatEventLogChannels = RULE_GUARDRAILS.eventLogChannels.flatMap((g) => [...g.channels]);
|
||||
|
||||
/** Hard blocks enforced in agent CODE (not liftable via guardrails.json). Keep in
|
||||
* sync with GatherRuleGuards.cs — additions there are fail-safe (agent still blocks),
|
||||
// Canonical WMI allowlist entry: exactly "SELECT * FROM <class>". Such entries
|
||||
// contribute their class name; any other entry (none today) stays on the legacy
|
||||
// literal-prefix path. Mirrors GatherRuleGuards.cs.
|
||||
const CANONICAL_WMI_ENTRY_REGEX = /^SELECT\s+\*\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)$/i;
|
||||
|
||||
// WQL shape "SELECT <* | property-list> FROM <class>", class token bounded by
|
||||
// whitespace or end of string (trailing WHERE permitted, Win32_BIOSX cannot
|
||||
// pass as Win32_BIOS).
|
||||
const WMI_QUERY_REGEX =
|
||||
/^SELECT\s+(?:\*|[A-Za-z_][A-Za-z0-9_]*(?:\s*,\s*[A-Za-z_][A-Za-z0-9_]*)*)\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*)(?=\s|$)/i;
|
||||
|
||||
const allowedWmiClasses = new Set<string>();
|
||||
const residualWmiPrefixes: string[] = [];
|
||||
for (const entry of RULE_GUARDRAILS.wmiQueryPrefixes) {
|
||||
const m = CANONICAL_WMI_ENTRY_REGEX.exec(entry.trim());
|
||||
if (m) allowedWmiClasses.add(m[1].toLowerCase());
|
||||
else residualWmiPrefixes.push(entry);
|
||||
}
|
||||
|
||||
/** Hard blocks enforced in agent CODE (not liftable via guardrails.json — the JSON
|
||||
* mirrors them for display and pre-flight; an agent-side parity test pins the two
|
||||
* equal). Additions in GatherRuleGuards.cs are fail-safe: the agent still blocks,
|
||||
* missing ones here just cost a pre-flight warning. */
|
||||
const HARD_BLOCKED_PATH_PREFIXES = ['C:\\Users', 'C:\\Windows\\System32\\config'];
|
||||
const HARD_BLOCKED_PATH_PREFIXES: readonly string[] = RULE_GUARDRAILS.blockedFilePrefixes;
|
||||
const HARD_BLOCKED_COMMAND_PATTERNS: readonly string[] = RULE_GUARDRAILS.blockedCommandPatterns;
|
||||
const MAX_COMMAND_LENGTH: number = RULE_GUARDRAILS.maxCommandLength;
|
||||
|
||||
/** Segment-bounded prefix match: target equals the prefix or continues with '\'. */
|
||||
function pathPrefixAllowed(target: string, prefixes: readonly string[]): boolean {
|
||||
@@ -102,6 +124,8 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
|
||||
break;
|
||||
}
|
||||
case 'file':
|
||||
case 'json':
|
||||
case 'xml':
|
||||
case 'logparser': {
|
||||
const blocked = HARD_BLOCKED_PATH_PREFIXES.find((p) => pathPrefixAllowed(target, [p]));
|
||||
if (blocked) {
|
||||
@@ -121,19 +145,46 @@ function checkGatherTarget(collectorType: string, target: string): ValidationFin
|
||||
break;
|
||||
}
|
||||
case 'wmi': {
|
||||
const t = target.toLowerCase();
|
||||
const ok = RULE_GUARDRAILS.wmiQueryPrefixes.some((p) => {
|
||||
const pl = p.toLowerCase();
|
||||
// Whitespace-bounded: the query is the prefix or continues with whitespace
|
||||
// (mirrors the agent's boundary check, prevents Win32_BIOSX spoofing).
|
||||
return t === pl || (t.startsWith(pl) && /\s/.test(t.charAt(pl.length)));
|
||||
});
|
||||
// Trim like the agent does — an untrimmed target must not be a false positive.
|
||||
const t = target.trim().toLowerCase();
|
||||
const m = WMI_QUERY_REGEX.exec(t);
|
||||
let ok = m !== null && allowedWmiClasses.has(m[1]);
|
||||
if (!ok) {
|
||||
findings.push({ level: 'error', message: `guardrails: WMI query "${target}" does not start with any allowed query prefix (rule_guardrails → wmiQueryPrefixes).` });
|
||||
// Non-canonical allowlist entries keep the legacy literal-prefix match.
|
||||
ok = residualWmiPrefixes.some((p) => {
|
||||
const pl = p.toLowerCase();
|
||||
return t === pl || (t.startsWith(pl) && /\s/.test(t.charAt(pl.length)));
|
||||
});
|
||||
}
|
||||
if (!ok) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message:
|
||||
`guardrails: WMI query "${target}" is not allowed — use "SELECT <properties or *> FROM <class>" against a class from rule_guardrails → wmiQueryPrefixes. ` +
|
||||
'Allowing additional classes requires a contribution to rules/guardrails.json in the product repository.',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'command_allowlisted': {
|
||||
// Hard guards enforced in agent CODE even in unrestricted mode (mirrors
|
||||
// GatherRuleGuards.IsCommandAllowed order: length cap → blocked patterns → allowlist).
|
||||
if (target.length > MAX_COMMAND_LENGTH) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message: `guardrails: command exceeds the ${MAX_COMMAND_LENGTH}-character hard limit — the agent always refuses this, even in unrestricted mode.`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
const targetLower = target.toLowerCase();
|
||||
const blockedPattern = HARD_BLOCKED_COMMAND_PATTERNS.find((p) => targetLower.includes(p.toLowerCase()));
|
||||
if (blockedPattern) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message: `guardrails: command contains hard-blocked pattern "${blockedPattern}" — the agent always refuses this, even in unrestricted mode. It cannot be allow-listed.`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
const t = target.trim().toLowerCase();
|
||||
if (!flatCommands.some((c) => c.trim().toLowerCase() === t)) {
|
||||
findings.push({
|
||||
@@ -335,6 +386,18 @@ function lintGatherRule(rule: Record<string, unknown>): ValidationFinding[] {
|
||||
const findings: ValidationFinding[] = [];
|
||||
const trigger = rule.trigger as string | undefined;
|
||||
|
||||
// Mirrors GatherRulesFunction.ValidateScopeAndEmitMode: both scope fields set
|
||||
// is a 400 at create/update time (the schema enums cover the token values, but
|
||||
// cannot express this exclusivity).
|
||||
const hasActivePhases = Array.isArray(rule.activePhases) && rule.activePhases.length > 0;
|
||||
const hasFromPhase = typeof rule.activeFromPhase === 'string' && rule.activeFromPhase.length > 0;
|
||||
if (hasActivePhases && hasFromPhase) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message: 'activePhases and activeFromPhase are mutually exclusive — set only one (the backend rejects rules with both, HTTP 400).',
|
||||
});
|
||||
}
|
||||
|
||||
if (trigger === 'interval' && typeof rule.intervalSeconds !== 'number') {
|
||||
findings.push({ level: 'error', message: 'trigger "interval" requires intervalSeconds.' });
|
||||
}
|
||||
|
||||
@@ -785,6 +785,91 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
})
|
||||
);
|
||||
|
||||
// Tool 18b: list_session_annotations — platform scope (GA + Global Reader).
|
||||
if (ga) server.registerTool(
|
||||
'list_session_annotations',
|
||||
{
|
||||
title: 'List Session Annotations',
|
||||
description:
|
||||
'List human session annotations across tenants — the rule-quality evaluation stream. Each annotation is a ' +
|
||||
'per-session, per-lane row (lane = operator | tenantadmin | globaladmin) carrying a structured verdict ' +
|
||||
'(root_cause_confirmed | analysis_wrong | different_problem | inconclusive), an optional free-text note, the ' +
|
||||
'author, and a snapshot of the rule ids that had fired for the session at write time (ruleIds). ' +
|
||||
'Evaluate rule quality by filtering: verdict + ruleId → confirmed vs false-positive rate per rule; ' +
|
||||
'lane=globaladmin → the platform team\'s own labels. Platform scope only. ' +
|
||||
'Pagination: when "nextLink" is present, pass the whole string back as "continuation"; stop when absent.',
|
||||
inputSchema: {
|
||||
tenantId: TenantGuidSchema.optional().describe('Optional — filter to a single tenant. Omit for cross-tenant view.'),
|
||||
lane: z.enum(['operator', 'tenantadmin', 'globaladmin']).optional()
|
||||
.describe('Optional — filter to one annotation lane.'),
|
||||
verdict: z.enum(['root_cause_confirmed', 'analysis_wrong', 'different_problem', 'inconclusive']).optional()
|
||||
.describe('Optional — filter to one structured verdict.'),
|
||||
ruleId: z.string().optional()
|
||||
.describe('Optional — only annotations whose rule-id snapshot contains this rule (e.g. "ANALYZE-ESP-001").'),
|
||||
dateFrom: z.string().optional().describe('ISO 8601 UTC timestamp — inclusive lower bound on the last-updated time.'),
|
||||
dateTo: z.string().optional().describe('ISO 8601 UTC timestamp — exclusive upper bound on the last-updated time.'),
|
||||
pageSize: z.coerce.number().int().min(1).max(1000).optional().default(200)
|
||||
.describe('Page size (1-1000, default 200). Returns this many annotations per call; follow nextLink to fetch more.'),
|
||||
continuation: z.string().optional()
|
||||
.describe('Either the opaque "continuation" value from a prior response or the full nextLink path — both are accepted; the latter is preferred so all filters round-trip correctly.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('list_session_annotations', args, async () => {
|
||||
try {
|
||||
const { tenantId, lane, verdict, ruleId, dateFrom, dateTo, pageSize, continuation } = args;
|
||||
const path = followNextLink(
|
||||
'/api/global/session-annotations',
|
||||
{ tenantId, lane, verdict, ruleId, dateFrom, dateTo, pageSize },
|
||||
continuation,
|
||||
);
|
||||
const data = await apiFetch(path);
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.adminStream);
|
||||
} catch (error: unknown) {
|
||||
return toolError('list_session_annotations', args, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Tool 18c: annotate_session — real Global Admin only (write).
|
||||
if (strictGa) server.registerTool(
|
||||
'annotate_session',
|
||||
{
|
||||
title: 'Annotate Session',
|
||||
description:
|
||||
'Write the platform team\'s annotation on an enrollment session — a structured verdict about the analysis ' +
|
||||
'plus an optional note. Global Admin only; always writes the platform-internal "globaladmin" lane (never a ' +
|
||||
'tenant\'s own lanes), which tenant users cannot see. Use after debugging a session to label it: verdict ' +
|
||||
'root_cause_confirmed | analysis_wrong | different_problem | inconclusive. The backend stamps the author from ' +
|
||||
'your identity and snapshots the currently-fired rule ids onto the annotation for later rule-quality ' +
|
||||
'evaluation (list_session_annotations). Passing BOTH verdict and note as null clears the annotation. ' +
|
||||
'Re-annotating overwrites the previous verdict/note (first author + creation time are preserved).',
|
||||
inputSchema: {
|
||||
sessionId: SessionIdSchema.describe('Session UUID to annotate.'),
|
||||
tenantId: TenantGuidSchema.optional()
|
||||
.describe('Optional — the session\'s tenant. If omitted, auto-resolved from the session.'),
|
||||
verdict: z.enum(['root_cause_confirmed', 'analysis_wrong', 'different_problem', 'inconclusive']).nullable().optional()
|
||||
.describe('Structured verdict, or null. At least one of verdict/note must be non-null unless clearing.'),
|
||||
note: z.string().max(4096).nullable().optional()
|
||||
.describe('Free-text note (max 4096 chars), or null.'),
|
||||
},
|
||||
annotations: MUTATING,
|
||||
},
|
||||
async (args) => withToolTelemetry('annotate_session', args, async () => {
|
||||
try {
|
||||
const { sessionId, tenantId, verdict, note } = args;
|
||||
const query = tenantId ? `?tenantId=${encodeURIComponent(tenantId)}` : '';
|
||||
const data = await apiFetch(`/api/sessions/${sessionId}/annotations/globaladmin${query}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ verdict: verdict ?? null, note: note ?? null }),
|
||||
});
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
return toolError('annotate_session', args, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// ── Raw Data Tools ────────────────────────────────────────────────────
|
||||
|
||||
// Tool 18: query_raw_events
|
||||
|
||||
@@ -468,10 +468,13 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
const q = buildQuery({ tenantId } as Record<string, string | undefined>);
|
||||
const fetchOpts = { signal: AbortSignal.timeout(90_000) };
|
||||
|
||||
const [sessionData, eventsData, analysisData] = await Promise.all([
|
||||
const [sessionData, eventsData, analysisData, annotationsData] = await Promise.all([
|
||||
apiFetch(`/api/sessions/${sessionId}${q}`, fetchOpts) as Promise<Record<string, unknown>>,
|
||||
apiFetch(`/api/sessions/${sessionId}/events${q}`, fetchOpts) as Promise<{ events?: Array<Record<string, unknown>>; count?: number }>,
|
||||
apiFetch(`/api/sessions/${sessionId}/analysis${q}`, fetchOpts).catch(() => null) as Promise<Record<string, unknown> | null>,
|
||||
// Human annotations (verdict + note per lane). Backend filters the platform-internal
|
||||
// globaladmin lane for non-global callers — pass-through, no re-shaping needed.
|
||||
apiFetch(`/api/sessions/${sessionId}/annotations${q}`, fetchOpts).catch(() => null) as Promise<{ annotations?: Array<Record<string, unknown>> } | null>,
|
||||
]);
|
||||
|
||||
const s = (sessionData.session ?? sessionData) as Record<string, unknown>;
|
||||
@@ -611,10 +614,17 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
};
|
||||
}
|
||||
|
||||
// Null when no lanes are annotated (or the read failed) — omitting keeps old output stable.
|
||||
const annotations =
|
||||
annotationsData?.annotations && annotationsData.annotations.length > 0
|
||||
? annotationsData.annotations
|
||||
: null;
|
||||
|
||||
const result = {
|
||||
overview,
|
||||
keyEvents: mappedEvents,
|
||||
analysis,
|
||||
annotations,
|
||||
stats: {
|
||||
totalEvents: allEvents.length,
|
||||
keyEventsTotal: sortedKey.length,
|
||||
|
||||
@@ -111,6 +111,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
public const string EspAllowContinueAnyway = "espAllowContinueAnyway";
|
||||
|
||||
/// <summary>
|
||||
/// On <c>EspConfigDetected</c>: "true" when the tenant opted into the
|
||||
/// Continue-Anyway observation mode (operator-set
|
||||
/// <c>EnableEspContinueAnywayObservation</c> tenant setting, stamped into the signal
|
||||
/// payload by the agent from RemoteConfig — NOT an ESP registry fact). Only stamped
|
||||
/// when enabled; missing → fact stays unset and a Device-phase ESP terminal failure
|
||||
/// keeps the immediate hard-fail semantics.
|
||||
/// </summary>
|
||||
public const string EspContinueAnywayObservationEnabled = "espContinueAnywayObservationEnabled";
|
||||
|
||||
/// <summary>On <c>HelloPolicyDetected</c>: "true" / "false". PR4 (882fef64 debrief).</summary>
|
||||
public const string HelloEnabled = "helloEnabled";
|
||||
|
||||
|
||||
@@ -565,13 +565,23 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// and surfaces hello_resolution via completion_waiting; the wait is bounded by an
|
||||
// already-armed HelloSafety, the tracker's Hello completion timer, or the
|
||||
// AdvisoryCompletion backstop.
|
||||
// Continue-Anyway observation completion (2026-08-08): while a Device-phase
|
||||
// observation advisory is live (see IsContinueAnywayObservationActive — ESP
|
||||
// failed in DeviceSetup, tenant opted into observation, AccountSetup never
|
||||
// entered), the strong post-AccountSetup gate is unsatisfiable by construction.
|
||||
// The DAD-validated real-user desktop IS the observation's completion evidence:
|
||||
// the user pressed "Continue anyway" and reached a working desktop. Complete
|
||||
// eagerly here instead of waiting out the 60-min window so the session end
|
||||
// reflects the real end.
|
||||
var observationCompletion = IsContinueAnywayObservationActive(state);
|
||||
|
||||
if (!helloAlreadyResolved
|
||||
&& HelloPolicyDisabledWithoutWizard(state.HelloPolicyEnabled, state.HelloWizardStartedUtc)
|
||||
// desktopArrivedInFlight: the desktop fact was just recorded on the builder
|
||||
// (line above) and is invisible to the pre-mutation state — arm C's "desktop
|
||||
// arrives last" ordering (session a4537c36) must count it. Arms A/B ignore
|
||||
// the flag entirely.
|
||||
&& ShouldTransitionToAwaitingHello(state, desktopArrivedInFlight: true))
|
||||
&& (ShouldTransitionToAwaitingHello(state, desktopArrivedInFlight: true) || observationCompletion))
|
||||
{
|
||||
SynthesizeHelloSkipped(builder, signal);
|
||||
|
||||
@@ -600,12 +610,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
|
||||
// Completion gates (ARCH-F1): synthetic Hello + Desktop are recorded; complete
|
||||
// through Finalizing when all gates are open, else defer until a gate releases.
|
||||
// The observation trigger label keeps the two doors apart on the audit trail —
|
||||
// the observation path deliberately bypassed the post-AccountSetup gate.
|
||||
return CompleteThroughFinalizingOrDefer(
|
||||
state: state,
|
||||
signal: signal,
|
||||
preparedBuilder: builder,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath",
|
||||
trigger: ShouldTransitionToAwaitingHello(state, desktopArrivedInFlight: true)
|
||||
? nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath"
|
||||
: nameof(DecisionSignalKind.DesktopArrived) + ":ContinueAnywayObservation",
|
||||
leadingEffects: extraEffects);
|
||||
}
|
||||
|
||||
@@ -1029,16 +1043,37 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </param>
|
||||
internal static DecisionEffect BuildEnrollmentCompleteEffect(DecisionState state, string completionTrigger)
|
||||
{
|
||||
var trail = DecisionAuditTrailBuilder.Build(
|
||||
postState: state,
|
||||
decidedStage: state.Stage,
|
||||
trigger: completionTrigger);
|
||||
|
||||
// Soft-failure honesty (2026-08-08): a session completing while an ESP-failure
|
||||
// advisory is still unresolved reached the desktop, but the ESP demonstrably gave
|
||||
// up on at least one blocking item first (Continue-Anyway observation, or the
|
||||
// classic post-AccountSetup defang). Stamp that on the terminal event so the
|
||||
// backend can mark the session "completed with issues" instead of presenting a
|
||||
// clean success. A RESOLVED advisory (category re-ran to success after
|
||||
// "Try again") is a genuine recovery and stays unstamped.
|
||||
if (state.EspAdvisoryFailureRecordedUtc != null && state.EspAdvisoryFailureResolvedUtc == null)
|
||||
{
|
||||
trail["espSoftFailure"] = "true";
|
||||
trail["completionSource"] = state.AccountSetupEnteredUtc == null
|
||||
? "continue_anyway_observation"
|
||||
: "continue_anyway_post_accountsetup";
|
||||
if (state.EspAdvisoryFailureCategory != null)
|
||||
{
|
||||
trail["espSoftFailureCategory"] = state.EspAdvisoryFailureCategory.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return new DecisionEffect(
|
||||
kind: DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = SharedConstants.EventTypes.EnrollmentComplete,
|
||||
},
|
||||
typedPayload: DecisionAuditTrailBuilder.Build(
|
||||
postState: state,
|
||||
decidedStage: state.Stage,
|
||||
trigger: completionTrigger));
|
||||
typedPayload: trail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,20 +237,61 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
var observations = state.ScenarioObservations;
|
||||
var continueAnywayEnabled = observations?.EspAllowContinueAnyway?.Value == true;
|
||||
var advisoryEligible = continueAnywayEnabled && state.AccountSetupEnteredUtc != null;
|
||||
|
||||
// Continue-Anyway observation mode (tenant c9787ba2, session 53d1e9f6, 2026-08-08):
|
||||
// a Device-phase ESP terminal failure (AccountSetup never entered) is a hard fail
|
||||
// by default — the classic advisory gate above requires AccountSetupEnteredUtc.
|
||||
// Fleets held at the ESP timeout wall by one slow blocking app then show 100%
|
||||
// Failed although the profile allows "Continue anyway" and users demonstrably
|
||||
// reach the desktop. When the tenant explicitly opted in
|
||||
// (EnableEspContinueAnywayObservation → EspContinueAnywayObservationEnabled
|
||||
// observation), defang this case too: arm a 60-min observation window instead of
|
||||
// failing immediately. The DAD-validated real-user desktop + the Hello gate then
|
||||
// detect the true end (see HandleDesktopArrivedV1's observation completion and
|
||||
// the observation branch in HandleAdvisoryCompletionDeadlineFired); an expired
|
||||
// window un-defangs to the original esp_terminal_failure. Scope: Classic /
|
||||
// Unknown user-driven flows only — WhiteGlove / SelfDeploying / DevicePreparation
|
||||
// have no Continue-Anyway user desktop to observe.
|
||||
var observationEligible = !advisoryEligible
|
||||
&& continueAnywayEnabled
|
||||
&& observations?.EspContinueAnywayObservationEnabled?.Value == true
|
||||
&& state.AccountSetupEnteredUtc == null
|
||||
&& IsObservationEligibleScenario(state);
|
||||
|
||||
var parameters = BuildEspFailureParameters(
|
||||
signal: signal,
|
||||
reason: reason,
|
||||
advisoryPath: advisoryEligible,
|
||||
advisoryPath: advisoryEligible || observationEligible,
|
||||
observations: observations);
|
||||
|
||||
if (advisoryEligible)
|
||||
if (advisoryEligible || observationEligible)
|
||||
{
|
||||
return BuildAdvisoryStep(state, signal, nextStep, reason, parameters);
|
||||
if (observationEligible)
|
||||
{
|
||||
parameters["advisoryReason"] = "esp_failure_defanged_continueanyway_observation";
|
||||
}
|
||||
return BuildAdvisoryStep(state, signal, nextStep, reason, parameters, observationVariant: observationEligible);
|
||||
}
|
||||
|
||||
return BuildFailedStep(state, signal, nextStep, reason, parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scenario gate for the Continue-Anyway observation defang: only Classic (or
|
||||
/// still-unclassified) non-pre-provisioning flows qualify. WhiteGlove technician
|
||||
/// sessions, SelfDeploying/kiosk profiles and DevicePreparation have no interactive
|
||||
/// user who could press "Continue anyway" and produce a real-user desktop, so the
|
||||
/// observation window could only ever expire — the immediate hard fail is more
|
||||
/// truthful there.
|
||||
/// </summary>
|
||||
private static bool IsObservationEligibleScenario(DecisionState state)
|
||||
{
|
||||
var profile = state.ScenarioProfile;
|
||||
return (profile.Mode == EnrollmentMode.Unknown || profile.Mode == EnrollmentMode.Classic)
|
||||
&& profile.PreProvisioningSide == PreProvisioningSide.None
|
||||
&& state.ScenarioObservations?.RegistrySelfDeployingProfile?.Value != true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the advisory-enrichment step that runs when an EspTerminalFailure duplicate
|
||||
/// arrives AFTER an advisory was already recorded AND the duplicate carries new
|
||||
@@ -322,6 +363,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
private static readonly TimeSpan s_advisoryCompletionWindow = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// Observation window for the Continue-Anyway observation defang (Device-phase ESP
|
||||
/// terminal failure, tenant opt-in — see <c>HandleEspTerminalFailureV1</c>). Longer
|
||||
/// than <see cref="s_advisoryCompletionWindow"/> because the user must first notice
|
||||
/// the failure screen, press "Continue anyway", sign in and reach the desktop —
|
||||
/// or press "Try again" and let the ESP re-run the whole device phase. Fixed 60 min
|
||||
/// (user decision 2026-08-08): well under the agent max lifetime, and every observed
|
||||
/// session still gets a terminal verdict when the window expires.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan s_observationCompletionWindow = TimeSpan.FromMinutes(60);
|
||||
|
||||
/// <summary>
|
||||
/// Arming-time baseline keys carried on the AdvisoryCompletion deadline's
|
||||
/// <see cref="ActiveDeadline.FiresPayload"/> (session 1924092e enforcement-progress
|
||||
@@ -343,10 +395,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// <see cref="HasEnforcementProgressSinceArming"/> can tell an idle dead-end from a
|
||||
/// still-progressing enrollment at fire time.
|
||||
/// </summary>
|
||||
internal static ActiveDeadline BuildAdvisoryCompletionDeadline(DecisionState state, DecisionSignal signal) =>
|
||||
internal static ActiveDeadline BuildAdvisoryCompletionDeadline(DecisionState state, DecisionSignal signal, TimeSpan? window = null) =>
|
||||
new ActiveDeadline(
|
||||
name: DeadlineNames.AdvisoryCompletion,
|
||||
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(s_advisoryCompletionWindow),
|
||||
dueAtUtc: EffectiveDeadlineBase(state, signal).Add(window ?? s_advisoryCompletionWindow),
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
@@ -358,6 +410,23 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
.ToString(CultureInfo.InvariantCulture),
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// True while a Continue-Anyway observation advisory is live: an ESP-failure advisory
|
||||
/// is recorded, not resolved, and AccountSetup was never entered. Only the
|
||||
/// observation arming site in <see cref="HandleEspTerminalFailureV1"/> can produce
|
||||
/// this shape — the classic advisory requires <see cref="DecisionState.AccountSetupEnteredUtc"/>
|
||||
/// at arm time and neither fact is ever retracted — so no separate opt-in re-check is
|
||||
/// needed. Consumed by the observation completion path in
|
||||
/// <c>HandleDesktopArrivedV1</c> and the observation branches in
|
||||
/// <see cref="HandleAdvisoryCompletionDeadlineFired"/>. Deliberately flips false when
|
||||
/// AccountSetup IS entered later (user pressed "Try again" and the ESP re-ran into
|
||||
/// the user phase) — from that point the classic advisory semantics apply.
|
||||
/// </summary>
|
||||
internal static bool IsContinueAnywayObservationActive(DecisionState state) =>
|
||||
state.EspAdvisoryFailureRecordedUtc != null
|
||||
&& state.EspAdvisoryFailureResolvedUtc == null
|
||||
&& state.AccountSetupEnteredUtc == null;
|
||||
|
||||
/// <summary>True when <see cref="DeadlineNames.AdvisoryCompletion"/> is armed in <paramref name="state"/>.</summary>
|
||||
internal static bool HasAdvisoryCompletionDeadline(DecisionState state)
|
||||
=> FindAdvisoryCompletionDeadline(state) != null;
|
||||
@@ -417,7 +486,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
DecisionSignal signal,
|
||||
int nextStep,
|
||||
string reason,
|
||||
Dictionary<string, string> parameters)
|
||||
Dictionary<string, string> parameters,
|
||||
bool observationVariant = false)
|
||||
{
|
||||
// Session 4910a5a5: remember WHICH category failed so the recovery hook
|
||||
// (TryResolveAdvisoryOnCategoryRecovery) can later match a ProvisioningComplete
|
||||
@@ -443,7 +513,13 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// session either way (see HandleAdvisoryCompletionDeadlineFired). AddDeadline
|
||||
// replaces by name, so an earlier esp_exiting arm is intentionally re-based on the
|
||||
// (later) failure instant — the freshest dead-end signal owns the window.
|
||||
var advisoryCompletion = BuildAdvisoryCompletionDeadline(state, signal);
|
||||
//
|
||||
// Observation variant (2026-08-08): the Device-phase Continue-Anyway observation
|
||||
// uses the longer 60-min window — the user must traverse failure screen →
|
||||
// Continue anyway → sign-in → desktop (or a full device-phase re-run via
|
||||
// "Try again") before completion evidence can exist.
|
||||
var advisoryCompletion = BuildAdvisoryCompletionDeadline(
|
||||
state, signal, observationVariant ? s_observationCompletionWindow : (TimeSpan?)null);
|
||||
builder.AddDeadline(advisoryCompletion);
|
||||
|
||||
var newState = builder.Build();
|
||||
@@ -636,7 +712,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// Shared with ShouldTransitionToAwaitingHello's proactive arm C (session a4537c36).
|
||||
var imeUserSessionGenuine = IsImeUserSessionGenuine(state);
|
||||
|
||||
if (desktopArrived && helloSatisfied && imeUserSessionGenuine)
|
||||
// Continue-Anyway observation variant (2026-08-08): a Device-phase advisory —
|
||||
// AccountSetup never entered — cannot satisfy the IME user-session gate by
|
||||
// construction (the gate anchors on AccountSetupEnteredUtc). The DAD-validated
|
||||
// real-user desktop plus the Hello gate ARE the completion evidence there: the
|
||||
// user pressed "Continue anyway" and reached a working desktop. Only the
|
||||
// observation arming site can produce this shape (the classic advisory requires
|
||||
// AccountSetupEnteredUtc at arm time and the fact is never retracted), so the
|
||||
// predicate needs no separate opt-in re-check.
|
||||
var isObservationAdvisory = IsContinueAnywayObservationActive(state);
|
||||
|
||||
if (desktopArrived && helloSatisfied && (imeUserSessionGenuine || isObservationAdvisory))
|
||||
{
|
||||
if (state.HelloResolvedUtc == null)
|
||||
{
|
||||
@@ -669,7 +755,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// the Hello wizard right now. Promote to AwaitingHello and let a real
|
||||
// HelloResolved — or the HelloSafety timeout — decide instead.
|
||||
if (desktopArrived
|
||||
&& imeUserSessionGenuine
|
||||
&& (imeUserSessionGenuine || isObservationAdvisory)
|
||||
&& state.HelloResolvedUtc == null
|
||||
&& state.HelloPolicyEnabled?.Value == false
|
||||
&& state.HelloWizardStartedUtc != null)
|
||||
@@ -722,7 +808,13 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// — the failure DID un-happen, so a live AccountSetup must not be failed on the
|
||||
// stale advisory). Convergent either way: each re-arm renews the baselines, so a
|
||||
// session that stalls after the recovery still fails one window later.
|
||||
if ((!hasAdvisoryAnchor || advisoryResolved) && HasEnforcementProgressSinceArming(state, armedDeadline!))
|
||||
// The observation variant participates too (2026-08-08): its whole point is to
|
||||
// keep watching while there is evidence of life — a user who pressed "Try again"
|
||||
// late in the window keeps apps reaching terminal states, and failing mid-retry
|
||||
// would be exactly the false positive the guard exists to prevent. Convergent:
|
||||
// each re-arm (30 min) demands NEW progress.
|
||||
if ((!hasAdvisoryAnchor || advisoryResolved || isObservationAdvisory)
|
||||
&& HasEnforcementProgressSinceArming(state, armedDeadline!))
|
||||
{
|
||||
var rearmedDeadline = BuildAdvisoryCompletionDeadline(state, signal);
|
||||
builder.AddDeadline(rearmedDeadline);
|
||||
@@ -778,7 +870,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// through Finalizing. Deliberately does NOT require the IME user-session gate —
|
||||
// the FP sessions never saw an IME user-session completion (that gate guards
|
||||
// completion evidence, not user presence; presence is the DAD-validated desktop).
|
||||
if ((!hasAdvisoryAnchor || advisoryResolved)
|
||||
if ((!hasAdvisoryAnchor || advisoryResolved || isObservationAdvisory)
|
||||
&& desktopArrived
|
||||
&& state.HelloResolvedUtc == null
|
||||
&& state.HelloPolicyEnabled == null
|
||||
@@ -861,7 +953,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
reason: reason,
|
||||
advisoryPath: false,
|
||||
observations: state.ScenarioObservations);
|
||||
parameters["advisoryReason"] = "advisory_completion_window_expired_without_completion_evidence";
|
||||
// Observation variant: the 60-min Continue-Anyway observation produced no
|
||||
// real-user desktop — either nobody pressed the button or the device truly
|
||||
// is stuck at the failure screen. The un-defang is identical (the failure
|
||||
// cause IS the ESP failure), only the advisoryReason tells the two windows
|
||||
// apart on the timeline.
|
||||
parameters["advisoryReason"] = isObservationAdvisory
|
||||
? "continue_anyway_observation_window_expired_without_completion_evidence"
|
||||
: "advisory_completion_window_expired_without_completion_evidence";
|
||||
builder.WithLastFailureTrigger(nameof(DecisionSignalKind.EspTerminalFailure), signal.SessionSignalOrdinal);
|
||||
}
|
||||
else
|
||||
|
||||
@@ -68,6 +68,18 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// </summary>
|
||||
public SignalFact<bool>? EspAllowContinueAnyway { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant-config opt-in from <see cref="Signals.DecisionSignalKind.EspConfigDetected"/>
|
||||
/// (payload <c>espContinueAnywayObservationEnabled</c>, stamped by the agent from
|
||||
/// <c>RemoteConfig.EnableEspContinueAnywayObservation</c> — an operator-set tenant
|
||||
/// setting, not an ESP registry fact). When <c>true</c> AND the ESP profile allows
|
||||
/// "Continue anyway", a Device-phase ESP terminal failure (AccountSetup never entered)
|
||||
/// is defanged into an observation advisory instead of failing the session immediately
|
||||
/// — see <c>HandleEspTerminalFailureV1</c>. Default (absent/false) keeps the immediate
|
||||
/// hard-fail semantics. Set-once.
|
||||
/// </summary>
|
||||
public SignalFact<bool>? EspContinueAnywayObservationEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw registry fact from <see cref="Signals.DecisionSignalKind.EnrollmentFactsObserved"/>
|
||||
/// (payload <c>isSelfDeployingProfile</c>, from <c>CloudAssignedOobeConfig</c> bits
|
||||
@@ -126,6 +138,11 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
? this
|
||||
: this with { EspAllowContinueAnyway = new SignalFact<bool>(value, sourceSignalOrdinal) };
|
||||
|
||||
public EnrollmentScenarioObservations WithEspContinueAnywayObservationEnabled(bool value, long sourceSignalOrdinal) =>
|
||||
EspContinueAnywayObservationEnabled != null
|
||||
? this
|
||||
: this with { EspContinueAnywayObservationEnabled = new SignalFact<bool>(value, sourceSignalOrdinal) };
|
||||
|
||||
public EnrollmentScenarioObservations WithRegistrySelfDeployingProfile(bool value, long sourceSignalOrdinal) =>
|
||||
RegistrySelfDeployingProfile != null
|
||||
? this
|
||||
|
||||
@@ -226,6 +226,11 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
{
|
||||
newObservations = newObservations.WithEspAllowContinueAnyway(allowContinue, signal.SessionSignalOrdinal);
|
||||
}
|
||||
if (signal.Payload.TryGetValue(SignalPayloadKeys.EspContinueAnywayObservationEnabled, out var rawObservation)
|
||||
&& bool.TryParse(rawObservation, out var observationEnabled))
|
||||
{
|
||||
newObservations = newObservations.WithEspContinueAnywayObservationEnabled(observationEnabled, signal.SessionSignalOrdinal);
|
||||
}
|
||||
}
|
||||
|
||||
var newProfile = currentProfile;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.0</TargetFramework>
|
||||
<!-- netstandard2.0 for the net48 agent; net10.0 so nullable-enabled backend consumers
|
||||
compile against the real BCL nullability attributes instead of the internal
|
||||
netstandard2.0 polyfill (NullableAttributes.cs), which would otherwise collide
|
||||
with System.Runtime's public copy through InternalsVisibleTo (CS0433). -->
|
||||
<TargetFrameworks>netstandard2.0;net10.0</TargetFrameworks>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||
<PackageReference Include="System.Text.Json" Version="9.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1106,6 +1106,16 @@ namespace AutopilotMonitor.Shared
|
||||
// for the watchdog (5min staleness threshold for Running, 60min for Queued).
|
||||
public const string BackupJobs = "BackupJobs";
|
||||
|
||||
// Human-entered per-session verdict + note, role-separated into lanes
|
||||
// (operator / tenantadmin / globaladmin — the globaladmin lane is
|
||||
// platform-internal and filtered out for tenant callers).
|
||||
// - PartitionKey = tenantId
|
||||
// - RowKey = "{sessionId}_{lane}"
|
||||
// One row per session + lane, edited in place. Wiped on tenant offboarding,
|
||||
// cascade-deleted with the session, and in the critical-backup set because
|
||||
// annotations are hand-labeled rule-quality data.
|
||||
public const string SessionAnnotations = "SessionAnnotations";
|
||||
|
||||
/// <summary>
|
||||
/// Returns all table names for initialization
|
||||
/// </summary>
|
||||
@@ -1174,6 +1184,7 @@ namespace AutopilotMonitor.Shared
|
||||
ScriptNameCache,
|
||||
BackupJobs,
|
||||
ConfigurationBackups,
|
||||
SessionAnnotations,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1366,6 +1377,7 @@ namespace AutopilotMonitor.Shared
|
||||
TableNames.PreviewConfig,
|
||||
TableNames.PreviewWhitelist,
|
||||
TableNames.RuleStates,
|
||||
TableNames.SessionAnnotations,
|
||||
TableNames.TenantAdmins,
|
||||
TableNames.TenantConfiguration,
|
||||
TableNames.TenantOffboardingCustomsArchive,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Shared.DataAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Storage for <see cref="SessionAnnotation"/> rows. Backed by the dedicated
|
||||
/// <c>SessionAnnotations</c> table (PK = tenantId, RK = <c>{sessionId}_{lane}</c>).
|
||||
/// Rows are wiped on tenant offboarding and cascade-deleted with their session;
|
||||
/// the table is in the critical-backup set because annotations are hand-labeled data.
|
||||
/// </summary>
|
||||
public interface ISessionAnnotationRepository
|
||||
{
|
||||
/// <summary>Returns the annotation for one session + lane, or null.</summary>
|
||||
Task<SessionAnnotation?> GetAsync(string tenantId, string sessionId, string lane);
|
||||
|
||||
/// <summary>Returns all lanes present for one session (0–3 rows).</summary>
|
||||
Task<List<SessionAnnotation>> GetForSessionAsync(string tenantId, string sessionId);
|
||||
|
||||
/// <summary>Creates or replaces the row for the annotation's session + lane.</summary>
|
||||
Task UpsertAsync(SessionAnnotation annotation);
|
||||
|
||||
/// <summary>Deletes the row for one session + lane. Missing row is a no-op.</summary>
|
||||
Task DeleteAsync(string tenantId, string sessionId, string lane);
|
||||
|
||||
/// <summary>
|
||||
/// Global evaluation query (cross-tenant filtered table scan; annotation volume is
|
||||
/// human-entered and small). All filters optional. <paramref name="ruleId"/> is
|
||||
/// matched client-side against <see cref="SessionAnnotation.RuleIds"/>, so this
|
||||
/// method back-fills short pages by looping the Azure continuation until
|
||||
/// <paramref name="pageSize"/> matches accumulate or the scan is exhausted
|
||||
/// (bounded round-trips) — a filtered-out row never consumes page budget.
|
||||
/// </summary>
|
||||
/// <returns>The page items plus the raw Azure continuation token for the next page (null when done).</returns>
|
||||
/// <param name="excludeGlobalAdminLane">
|
||||
/// True for callers without global scope: adds a server-side <c>Lane ne 'globaladmin'</c>
|
||||
/// clause so the platform-internal lane never consumes page budget (and an explicit
|
||||
/// <paramref name="lane"/>=globaladmin filter simply yields an empty page).
|
||||
/// </param>
|
||||
Task<(List<SessionAnnotation> Items, string? NextRawToken)> QueryPageAsync(
|
||||
string? tenantId,
|
||||
string? lane,
|
||||
string? verdict,
|
||||
string? ruleId,
|
||||
DateTime? dateFrom,
|
||||
DateTime? dateTo,
|
||||
int pageSize,
|
||||
string? continuation,
|
||||
bool excludeGlobalAdminLane = false);
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,8 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
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);
|
||||
/// <summary>
|
||||
/// Increments per-session counters via read-modify-write. Returns the post-merge session
|
||||
/// snapshot (the RMW read with the applied increments) so hot-path callers can skip a
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -10,10 +11,10 @@ namespace AutopilotMonitor.Shared.Logging
|
||||
public class CmTraceLogEntry
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string Component { get; set; }
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string Component { get; set; } = string.Empty;
|
||||
public int Type { get; set; } // 1=Info, 2=Warning, 3=Error
|
||||
public string Thread { get; set; }
|
||||
public string Thread { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -42,11 +43,12 @@ namespace AutopilotMonitor.Shared.Logging
|
||||
/// Attempts to parse a single line of CMTrace-formatted log.
|
||||
/// Returns true if parsing succeeded, false if the line doesn't match the format.
|
||||
/// </summary>
|
||||
public static bool TryParseLine(string line, out CmTraceLogEntry entry)
|
||||
public static bool TryParseLine(string? line, [NotNullWhen(true)] out CmTraceLogEntry? entry)
|
||||
{
|
||||
entry = null;
|
||||
|
||||
if (string.IsNullOrEmpty(line) || !line.StartsWith("<![LOG["))
|
||||
// netstandard2.0's IsNullOrEmpty lacks [NotNullWhen(false)] — assert non-null after it.
|
||||
if (string.IsNullOrEmpty(line) || !line!.StartsWith("<![LOG["))
|
||||
return false;
|
||||
|
||||
var match = CmTraceRegex.Match(line);
|
||||
|
||||
@@ -46,6 +46,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool EnableGatherRuleDebugLog { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Continue-Anyway observation mode (operator-set tenant opt-in): a Device-phase ESP
|
||||
/// terminal failure on a Continue-Anyway-enabled profile is observed for up to 60
|
||||
/// minutes instead of failing immediately — the real-user desktop then completes the
|
||||
/// session with an esp-soft-failure marker. Default false = immediate hard fail.
|
||||
/// </summary>
|
||||
public bool EnableEspContinueAnywayObservation { get; set; } = false;
|
||||
|
||||
public CollectorConfiguration Collectors { get; set; } = default!;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -49,7 +49,7 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// a security matter, or a change that needs an administrator's attention. Editable by
|
||||
/// the tenant's own admins under Settings → Tenant → Contact.
|
||||
/// <para>
|
||||
/// Seeded once at onboarding from the Private Preview notification address if one was
|
||||
/// Seeded once at onboarding from the tenant's notification address if one was
|
||||
/// given, and never re-synced afterwards: from that point the value belongs to the
|
||||
/// tenant, and a later edit must not be overwritten by the onboarding source.
|
||||
/// </para>
|
||||
@@ -382,6 +382,17 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool? EnableGatherRuleDebugLog { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Continue-Anyway observation mode: when true AND the ESP profile allows
|
||||
/// "Continue anyway", a Device-phase ESP terminal failure (AccountSetup never
|
||||
/// entered) does not fail the session immediately — the agent keeps monitoring for
|
||||
/// up to 60 minutes and completes with an esp-soft-failure marker once the
|
||||
/// DAD-validated real-user desktop (plus the Hello gate) proves the user continued;
|
||||
/// an expired window fails with the original esp_terminal_failure. Operator-set only
|
||||
/// (not exposed in the tenant admin UI). null = use agent default (false).
|
||||
/// </summary>
|
||||
public bool? EnableEspContinueAnywayObservation { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Log verbosity level override for this tenant's agents.
|
||||
/// null = use agent default ("Info"). Values: "Info", "Debug", "Verbose", "Trace".
|
||||
|
||||
@@ -131,6 +131,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
Status = s.Status,
|
||||
FailureReason = s.FailureReason,
|
||||
FailureSource = s.FailureSource,
|
||||
EspSoftFailure = s.EspSoftFailure,
|
||||
CompletionSource = s.CompletionSource,
|
||||
EventCount = s.EventCount,
|
||||
DurationSeconds = s.DurationSeconds,
|
||||
EnrollmentType = s.EnrollmentType,
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-entered per-session verdict + free-text note, role-separated into lanes
|
||||
/// (one row per session + lane, editable in place). Stored in the
|
||||
/// <c>SessionAnnotations</c> table: PK = tenantId, RK = <c>{sessionId}_{lane}</c>.
|
||||
/// The <c>globaladmin</c> lane is platform-internal and never returned to tenant
|
||||
/// callers. Author fields are stamped server-side from the JWT, never from the body.
|
||||
/// </summary>
|
||||
public class SessionAnnotation
|
||||
{
|
||||
/// <summary>Maximum length of <see cref="Note"/> (matches the feedback comment cap).</summary>
|
||||
public const int MaxNoteLength = 4096;
|
||||
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>One of <see cref="AnnotationLanes.All"/>.</summary>
|
||||
public string Lane { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>One of <see cref="AnnotationVerdicts.All"/>, or null (note-only annotation).</summary>
|
||||
public string? Verdict { get; set; }
|
||||
|
||||
/// <summary>Free text, ≤ <see cref="MaxNoteLength"/>, or null (verdict-only annotation).</summary>
|
||||
public string? Note { get; set; }
|
||||
|
||||
/// <summary>UPN of the last editor. Server-stamped.</summary>
|
||||
public string AuthorUpn { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Display name of the last editor. Server-stamped.</summary>
|
||||
public string AuthorDisplayName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UPN of the first writer. Immutable across edits.</summary>
|
||||
public string CreatedByUpn { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
public DateTime UpdatedAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the rule ids that had fired for the session at write time
|
||||
/// (denormalized so rule-quality evaluation needs no join against RuleResults).
|
||||
/// </summary>
|
||||
public List<string> RuleIds { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Lane discriminators — the RowKey suffix and the write-permission axis.</summary>
|
||||
public static class AnnotationLanes
|
||||
{
|
||||
public const string Operator = "operator";
|
||||
public const string TenantAdmin = "tenantadmin";
|
||||
public const string GlobalAdmin = "globaladmin";
|
||||
|
||||
public static readonly string[] All = { Operator, TenantAdmin, GlobalAdmin };
|
||||
}
|
||||
|
||||
/// <summary>Structured verdict vocabulary. A verdict is always optional.</summary>
|
||||
public static class AnnotationVerdicts
|
||||
{
|
||||
public const string RootCauseConfirmed = "root_cause_confirmed";
|
||||
public const string AnalysisWrong = "analysis_wrong";
|
||||
public const string DifferentProblem = "different_problem";
|
||||
public const string Inconclusive = "inconclusive";
|
||||
|
||||
public static readonly string[] All =
|
||||
{
|
||||
RootCauseConfirmed, AnalysisWrong, DifferentProblem, Inconclusive,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,26 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public string ReconcileReason { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// True only on a Succeeded session that completed while an ESP-failure advisory was
|
||||
/// still unresolved: the ESP demonstrably gave up on at least one blocking item
|
||||
/// (typically the 30-min timeout wall) and the user most likely pressed
|
||||
/// "Continue anyway" — the device reached a working desktop, but not everything the
|
||||
/// ESP tracked finished inside its window. Rendered as the amber "completed with
|
||||
/// issues" badge. False/absent = clean success.
|
||||
/// </summary>
|
||||
public bool EspSoftFailure { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How a soft-failure completion was detected. Values:
|
||||
/// - "continue_anyway_observation": Device-phase ESP failure, tenant opted into the
|
||||
/// Continue-Anyway observation window, real-user desktop arrived inside it
|
||||
/// - "continue_anyway_post_accountsetup": classic advisory defang (AccountSetup had
|
||||
/// already been entered when the ESP failure fired)
|
||||
/// Empty on clean successes.
|
||||
/// </summary>
|
||||
public string CompletionSource { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Non-null only when an administrator explicitly flipped the session via the portal
|
||||
/// (MarkSessionSucceeded / MarkSessionFailed). Values: <c>null</c> (default, agent-driven),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// netstandard2.0 ships none of the nullable-flow attributes, so shared Try-pattern APIs
|
||||
// could not tell nullable-enabled consumers (backend net10, agent test projects) that an
|
||||
// out param is non-null on success. Internal polyfill — the consuming compiler matches
|
||||
// these attributes by full name from metadata, so accessibility does not matter.
|
||||
#if NETSTANDARD2_0
|
||||
namespace System.Diagnostics.CodeAnalysis
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Parameter, Inherited = false)]
|
||||
internal sealed class NotNullWhenAttribute : Attribute
|
||||
{
|
||||
public NotNullWhenAttribute(bool returnValue) => ReturnValue = returnValue;
|
||||
|
||||
public bool ReturnValue { get; }
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user