mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d86ae18a5e | |||
| 1c5f69a9fe | |||
| 17affb6dfd | |||
| acf4d0eeaa | |||
| 4c1889d011 | |||
| da4de79a9d | |||
| 6f3f35e1cd | |||
| ca22be5cd0 | |||
| c421b81751 | |||
| 7636a00e24 | |||
| 90294455e0 | |||
| 4def73f193 | |||
| 6bab2d3db9 | |||
| 78b8dd0f1b | |||
| 14acfd5c59 | |||
| 22d4c6c677 | |||
| 9fce499f4c | |||
| 8c7a6410d2 | |||
| c158768343 | |||
| 75197a4184 | |||
| 179a441e64 | |||
| acc95471a6 | |||
| 29ced81d6e | |||
| 4966d23a46 | |||
| 59ba8301a8 | |||
| b142fa0994 | |||
| afa8750cbb | |||
| 3c1229a301 | |||
| b4e409783e |
+1
-1
Submodule .claude/commands updated: 53953bd30b...0766ebfef6
@@ -82,7 +82,7 @@ jobs:
|
||||
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
|
||||
# the cap to match; never raise it.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 115
|
||||
run: npm run lint -- --max-warnings 99
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
name: Pester (Bootstrap scripts)
|
||||
|
||||
# Runs the Pester suite for the Intune bootstrap scripts under Windows PowerShell 5.1
|
||||
# (the runtime IME uses to execute platform scripts) so guard/relax logic changes are
|
||||
# verified without resetting a device. Like ci.yml it reports rather than blocks.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['scripts/Bootstrap/**']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['scripts/Bootstrap/**']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pester-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pester:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
|
||||
- name: Run Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
$c = New-PesterConfiguration
|
||||
$c.Run.Path = 'scripts/Bootstrap'
|
||||
$c.Run.Exit = $true
|
||||
$c.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $c
|
||||
|
||||
- name: Guard scripts stay pure ASCII
|
||||
shell: powershell
|
||||
run: |
|
||||
$failed = $false
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||
for ($i = 0; $i -lt $bytes.Length; $i++) {
|
||||
if ($bytes[$i] -gt 127) {
|
||||
Write-Host "::error file=scripts/Bootstrap/$($_.Name)::Non-ASCII byte 0x$('{0:X2}' -f $bytes[$i]) at offset $i. PS 5.1 (IME) reads BOM-less files as ANSI and corrupts multi-byte chars."
|
||||
$failed = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($failed) { exit 1 }
|
||||
Write-Host 'All bootstrap scripts are pure ASCII.'
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
type: Concept
|
||||
title: W365 Cloud PC Device Validation — Cert-CN-Bound Fallback Validator
|
||||
description: Why Windows 365 Cloud PCs can never pass the Autopilot serial lookup, and how the CloudPc validator admits exactly the tenant's service-provisioned Cloud PCs by binding the MDM certificate's Subject CN (= Intune device id) to the Graph virtualEndpoint/cloudPCs inventory.
|
||||
resource: src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs
|
||||
tags:
|
||||
- backend
|
||||
- security
|
||||
- device-validation
|
||||
- windows365
|
||||
- cloudpc
|
||||
- graph
|
||||
timestamp: 2026-08-06T00:00:00+02:00
|
||||
---
|
||||
|
||||
# W365 Cloud PC Device Validation
|
||||
|
||||
Windows 365 Cloud PCs are provisioned headless by the Windows 365 service and are
|
||||
**structurally never Autopilot-registered** — the serial lookup against
|
||||
`windowsAutopilotDeviceIdentities` (and the Corporate Identifier fallback, whose
|
||||
serials nobody can pre-import for VMs minted at provisioning time) always misses.
|
||||
Before this validator, a Cloud PC agent died in the 403 "Device not registered"
|
||||
even though its mTLS cert was perfectly valid, so Cloud PC first-connect
|
||||
enrollment (Account Setup, RealmJoin, Hello) could not be monitored.
|
||||
|
||||
# Schema — the validator chain
|
||||
|
||||
`SecurityValidator.ValidateRequestAsync` stage 4 runs the same chain for every
|
||||
device; there is **no device-type routing** and the local W365 markers
|
||||
(Windows365 registry key, CloudManagedDesktopExtension service) are never
|
||||
consulted for auth — they gain an attacker nothing:
|
||||
|
||||
1. **Autopilot serial lookup** (`ValidateAutopilotDevice`) — unchanged primary.
|
||||
2. **Corporate Identifier** (`ValidateCorporateIdentifier`) — unchanged fallback.
|
||||
3. **CloudPc** (`ValidateCloudPcDevice`, default off) — only reached when 1+2
|
||||
missed. Extracts the Intune device id from the **chain-validated** client
|
||||
certificate's Subject CN (`TryGetIntuneDeviceIdFromCertSubject`: CN present
|
||||
AND canonical GUID, else definitive reject) and requires a `cloudPC` object
|
||||
with `managedDeviceId eq <CN>` in the claimed tenant
|
||||
(`GET /v1.0/deviceManagement/virtualEndpoint/cloudPCs?$filter=…`).
|
||||
`ValidatedBy = ValidatorType.CloudPc (5)`.
|
||||
|
||||
Only machines actually provisioned by the Windows 365 service have a `cloudPC`
|
||||
object — a regular Intune-enrolled non-Autopilot device still ends in the 403,
|
||||
so the "Autopilot devices only" contract of stages 1–2 is not widened. The
|
||||
tenant hard gate accepts `ValidateCloudPcDevice` as a third satisfying flag.
|
||||
|
||||
## Why the cert CN, not headers or local markers
|
||||
|
||||
Field-verified 2026-08-06 on a real W365 Enterprise Cloud PC (gktatooine.net):
|
||||
|
||||
- MDM client cert Subject `CN=07623d56-…` == Intune `managedDevice.id` ==
|
||||
`cloudPC.managedDeviceId`. The CN cannot be forged without the Intune MDM
|
||||
Device CA (chain pinned to embedded roots, `CustomRootTrust`).
|
||||
- `$filter=managedDeviceId eq '…'` is supported server-side on **v1.0** — no
|
||||
paging fallback needed.
|
||||
- `managedDevice.serialNumber` matches local `Win32_BIOS.SerialNumber` exactly;
|
||||
the "Cloud PC Enterprise …" model string exists **only** in Graph, never in
|
||||
local WMI (local WMI reports Manufacturer='Microsoft Corporation',
|
||||
Model='Virtual Machine').
|
||||
|
||||
Threat model: a valid Intune cert from a *different* tenant fails (its CN does
|
||||
not exist in the claimed tenant's Cloud PC inventory — this is a *tenant
|
||||
binding* the serial-based validators do not have); spoofed serial headers and
|
||||
faked local W365 markers are irrelevant (never consulted); a rogue enrolled VM
|
||||
with doctored SMBIOS cannot mint a `cloudPC` object (service-side state, not
|
||||
device-reported inventory — the reason the model-string gate was rejected).
|
||||
Retired/deprovisioned Cloud PCs disappear from the inventory and their
|
||||
validation dies with them (compensates the absent cert revocation check).
|
||||
Residual: a compromised *legitimate* Cloud PC of the tenant — same trust level
|
||||
as any tenant device today, rate-limited by cert thumbprint.
|
||||
|
||||
## Graph permission plumbing
|
||||
|
||||
- Optional add-on permission `CloudPC.Read.All` — NOT part of the default
|
||||
consent set. Feature `W365CloudPcValidation` in `GraphFeatureCatalog` →
|
||||
surfaces automatically in the "Optional Graph capabilities" UI (the feature
|
||||
table and grant command are backend-driven) and in
|
||||
`Grant-AutopilotMonitorAddOn.ps1` (`-Features W365CloudPcValidation`).
|
||||
`GraphFeatureCatalogSyncTests` pins C# catalog ↔ grant script (previously
|
||||
comment-only lock-step).
|
||||
- A Graph **403** (permission not granted) is a definitive negative (cached
|
||||
5 min), NOT transient — a 503 Retry-After loop cannot fix a missing grant.
|
||||
The error message names the add-on feature.
|
||||
- Same resilience contract as `AutopilotDeviceValidator`: 30/5 min
|
||||
positive/negative cache, 2 attempts, transient → agent-facing 503.
|
||||
|
||||
## Bootstrap script interplay (v2.3-dev.3)
|
||||
|
||||
The Cloud-PC relax in `Install-AutopilotMonitor-dev.ps1` additionally exempts
|
||||
**guard 4** (12 h uptime window): a Cloud PC runs headless for days before the
|
||||
user's first connect, and the monitorable phase (Account Setup) only starts at
|
||||
that first connect — the single-fresh-profile window (< 15 min) is the time
|
||||
anchor there, not boot uptime. The OOBE-restore trigger keeps the uptime guard.
|
||||
|
||||
## Agent-side IsCloudPc flag + decision-engine expectation
|
||||
|
||||
Same change set, agent side. `CloudPcDetector` (V2.Core) re-uses the bootstrap's
|
||||
field-verified marker AND — `HKLM\SOFTWARE\Microsoft\Windows365` key **plus**
|
||||
installed `CloudManagedDesktopExtension` service (probed via its
|
||||
`HKLM\SYSTEM\CurrentControlSet\Services` key; the agent deliberately has no
|
||||
`System.ServiceProcess` dependency). SKIP-safe: any error resolves `false`.
|
||||
|
||||
Three independent transport rails, never an auth input:
|
||||
|
||||
- **Session metadata** — `SessionRegistration.IsCloudPc` →
|
||||
Sessions table (sticky-true OR across re-registrations, like
|
||||
`IsSelfDeployingProfile`) → SessionsIndex full mirror → `SessionSummary` →
|
||||
search filter `isCloudPc` (portal, `/api/search/sessions`, `/api/raw/sessions`,
|
||||
MCP `search_sessions`/`query_raw_sessions`). Agent-reported and unverified —
|
||||
can legitimately disagree with the server-derived `ValidatedBy = CloudPc`
|
||||
(e.g. Cloud PC admitted via a stage that ran earlier).
|
||||
- **Distress channel** — `DistressReport.IsCloudPc` (captured once at
|
||||
`DistressReporter` construction, `BackendClientFactory`) rides every pre-auth
|
||||
distress report → `DistressReportEntry.IsCloudPc` → the "Devices Not
|
||||
Registered" report aggregates it **sticky-true** per serial
|
||||
(`GetDeviceNotRegisteredFunction`) and the portal insight renders a Cloud PC
|
||||
badge plus a hint pointing at the Cloud PC validation toggle + the
|
||||
`W365CloudPcValidation` add-on. Same UNVERIFIED contract as all distress
|
||||
fields; rows from older agents read false. Fits the existing 1536-byte
|
||||
payload cap without a bump.
|
||||
- **Decision engine** — the marker rides the `EnrollmentFactsObserved` payload
|
||||
(`isCloudPc`), is recorded set-once for BOTH values in
|
||||
`EnrollmentScenarioObservations.CloudPc`, and annotates the scenario profile
|
||||
reason `cloud_pc_first_connect:no_device_esp_expected`. That is the
|
||||
engine-recorded expectation "no Device ESP phase will appear": Device-ESP ran
|
||||
headless at provisioning, the session starts at Account Setup. Deliberately
|
||||
reason+observation only — no completion arm depends on DeviceSetup, `Mode`
|
||||
is still classified by AccountSetup/IME signals (Classic), and `EspConfig`
|
||||
keeps the *configured* FirstSync semantics. The positive marker surfaces as
|
||||
`cloud_pc_marker` in the terminal audit-trail signal census.
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/Backend/AutopilotMonitor.Functions/Security/CloudPcDeviceValidator.cs`
|
||||
- `src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs` — stage 4 chain, `TryGetIntuneDeviceIdFromCertSubject`
|
||||
- `src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs` — `W365CloudPcValidation`
|
||||
- `scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1` — customer-side grant
|
||||
- `scripts/Bootstrap/Install-AutopilotMonitor-dev.ps1` — Cloud-PC relax + guard-4 exemption
|
||||
- `src/Backend/AutopilotMonitor.Functions.Tests/CloudPcDeviceValidatorTests.cs`, `SecurityValidatorTests.cs`, `GraphFeatureCatalogSyncTests.cs`
|
||||
- `src/Agent/AutopilotMonitor.Agent.V2.Core/Security/CloudPcDetector.cs` — agent-side marker AND
|
||||
- `src/Shared/AutopilotMonitor.DecisionCore/State/EnrollmentScenarioObservations.cs` — `CloudPc` fact; reason annotation in `EnrollmentScenarioProfileUpdater`
|
||||
@@ -29,6 +29,7 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
|
||||
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
|
||||
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
|
||||
* [Rule Authoring Surface](rules/rule-authoring-surface.md) - How customer AIs author rules against the real contract: the baked MCP resources (guide/schemas/guardrails via combine.js), the MCP-local validate_rule pre-flight, and the side-effect-free POST rules/analyze/dryrun whose trace twin shares the production evaluators (parity + side-effect-freedom pinned by tests).
|
||||
|
||||
# Backend
|
||||
|
||||
@@ -38,11 +39,13 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [Enrollment Time Attribution](backend/time-attribution.md) - How a terminal session's authoritative wall clock (DurationSeconds, WhiteGlove pause excluded) is partitioned into named segments with an explicit unattributed remainder, ESP-blocking apps as positive evidence with "up to" what-if bounds, reboot spans from event gaps, and the breakdown/aggregate tables the sweep maintains.
|
||||
* [Rule Regression Radar](backend/rule-regression-radar.md) - How analyze-rule hit-rate regressions are detected (7-day window ≥2× the 28-day baseline with disjoint Wilson intervals), which suppressions keep the radar quiet (grace period, edit-in-window, kill switch), how alert episodes live in the ruleregression tracker keyspace with re-arm semantics, and the on-fire dimension correlation with its "correlated, not necessarily causal" wording contract.
|
||||
* [Tenant Activation & the Once-Only Welcome Mail](backend/tenant-activation-welcome-mail.md) - How a signup becomes an activated tenant (manual approve or the auto-approve queue), and the write-then-read + conditional-marker arbitration that sends the welcome mail exactly once even though activation and the user-typed notification address race each other.
|
||||
* [W365 Cloud PC Device Validation](backend/cloudpc-device-validation.md) - Why Cloud PCs can never pass the Autopilot serial lookup, and the cert-CN-bound CloudPc fallback validator: Subject CN (= Intune device id) matched against the tenant's Graph virtualEndpoint/cloudPCs inventory — admits exactly the service-provisioned Cloud PCs, nothing else.
|
||||
* [ServerActions & On-Demand Diagnostics](backend/server-actions-on-demand-diagnostics.md) - The queue-on-session, deliver-on-ingest action channel, the TenantAdminOrOperator tier with in-function per-type re-gating, the portal's Collect Logs flow (quick-config orders rotate_config before request_diagnostics), and the hosted-prefix sweep in the offboarding cascade.
|
||||
|
||||
# MCP Server
|
||||
|
||||
* [MCP Docs Corpus](mcp/docs-corpus.md) - How the published customer documentation becomes the `search_docs` corpus: CRLF-safe heading chunking, build-time embedding as a third precomputed section, and why a docs edit needs an MCP redeploy.
|
||||
* [MCP Tool Telemetry](mcp/tool-telemetry.md) - The three telemetry layers for judging tool quality: backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
|
||||
|
||||
# Web Portal
|
||||
|
||||
|
||||
+21
@@ -1,5 +1,26 @@
|
||||
# Log
|
||||
|
||||
## 2026-08-07 (later)
|
||||
|
||||
* **Update**: `backend/cloudpc-device-validation.md` — third `IsCloudPc` transport rail: the pre-auth distress channel. `DistressReport.IsCloudPc` (CloudPcDetector verdict captured at `DistressReporter` construction) → `DistressReportEntry`/table column → "Devices Not Registered" aggregation (sticky-true per serial, same OR semantics as the Sessions merge) → portal Cloud PC badge + actionable hint ("enable Windows 365 Cloud PC Validation + W365CloudPcValidation add-on"). Motivated by the first W365 field run: the rejected Cloud PC showed up as anonymous "Microsoft Corporation / Virtual Machine" with no clue that the fix is the Cloud PC validator, not Autopilot registration. UNVERIFIED like all distress fields; ≤18 bytes, fits the 1536-byte cap.
|
||||
|
||||
## 2026-08-07
|
||||
|
||||
* **Update**: `backend/cloudpc-device-validation.md` — the "deliberately out of scope" follow-up landed in the same PR: agent-side `IsCloudPc` flag (`CloudPcDetector`, bootstrap-verified marker AND: Windows365 key + CloudManagedDesktopExtension service key, SKIP-safe) rides two rails — `SessionRegistration` → Sessions/Index (sticky-true) → `SessionSummary` + `isCloudPc` search filter (portal/raw/MCP), and the `EnrollmentFactsObserved` payload → `EnrollmentScenarioObservations.CloudPc` (set-once, both values) with profile reason `cloud_pc_first_connect:no_device_esp_expected` + `cloud_pc_marker` in the audit-trail census. Reason+observation only: no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-classified, EspConfig keeps configured-FirstSync semantics. Never an auth input.
|
||||
|
||||
## 2026-08-06
|
||||
|
||||
* **Creation**: Added `backend/cloudpc-device-validation.md` — the CloudPc fallback validator (`ValidatorType.CloudPc = 5`, tenant flag `ValidateCloudPcDevice`): W365 Cloud PCs are structurally never Autopilot-registered, so stage 4 gains a third stage that binds the chain-validated MDM cert's Subject CN (= Intune device id, field-verified on a real Cloud PC) to the tenant's `virtualEndpoint/cloudPCs` inventory — admits exactly the service-provisioned Cloud PCs, no device-type routing, local W365 markers never consulted for auth. New optional Graph add-on `W365CloudPcValidation` → `CloudPC.Read.All` (catalog + grant script now pinned by `GraphFeatureCatalogSyncTests`; Graph 403 = definitive "grant missing", not transient). Bootstrap dev script v2.3-dev.3 exempts guard 4 (uptime) while the Cloud-PC relax is active — the fresh-profile window is the time anchor at first connect, not boot uptime. Follow-up deliberately out of scope: agent-side `IsCloudPc` classification + decision-engine "no Device ESP" expectation.
|
||||
|
||||
## 2026-08-05 (later)
|
||||
|
||||
* **Update**: `rules/rule-authoring-surface.md` — new section `POST /api/rules/gather/test-pattern`: the logparser pattern tester (agent-exact .NET matching: no IgnoreCase, first-match-per-line, cmtrace vs text mode, verbatim format=text hint), motivated by a field incident where a PHP-verified regex behaved differently under .NET. `CmTraceLogParser` moved Agent → `AutopilotMonitor.Shared.Logging` so agent and backend share the ONE parsing implementation (timestamp caveat: AssumeLocal = server TZ on backend, endpoint reports no timestamps). MCP: new `test_log_pattern` tool, logparser lint in `validate_rule`, guide gains the case-sensitivity/engine-mismatch pitfalls.
|
||||
|
||||
## 2026-08-05
|
||||
|
||||
* **Creation**: Added `mcp/tool-telemetry.md` — the three MCP tool-telemetry layers (App Insights `McpToolName`, per-user usage table, `MCP_TOOL_LOGGING` stderr lines) and the new quality signals: soft-error capture (`toolError` returns instead of throwing, so backend telemetry counted those calls as successes), `resultChars`/`overCap` vs. the inline-size hint, bounded args summaries, caller scope, `search_zero_hit` on the three search tools (unmet-demand signal), and `tool_call_rejected` for JSON-RPC-level Zod/unknown-tool rejections sniffed at the `/mcp` route (previously invisible everywhere). Bicep default for `toolLoggingEnabled` flipped to `true` (documented desired state; live flag set via `az containerapp update`).
|
||||
* **Creation**: Added `rules/rule-authoring-surface.md` — the AI-assisted rule-authoring stack: three deterministic MCP resources (`rule_authoring_guide` hand-written, `rule_schemas` + `rule_guardrails` generated into `rule-authoring.generated.ts` by a new combine.js emitter, drift-guarded twice), the MCP-local `validate_rule` (ajv 2020-12 against the single-rule $defs, guardrail pre-flight mirroring GatherRuleGuards matching semantics incl. hard blocks, semantic lint for schema-valid-but-dead rules), and the side-effect-free `POST /api/rules/analyze/dryrun` (TenantAdminOrGlobalReader; `RuleEngine.DryRun.cs` trace twin sharing the production evaluators, all-conditions trace with per-condition evidence + matchingEventCount, parity and side-effect-freedom pinned by RuleEngineDryRunTests). MCP `test_analyze_rule` wraps the endpoint with `{{token}}`-interpolated previews; `author-rule` prompt drives the draft → validate → dry-run loop. Deliberately absent: gather-rule remote execution, rule writes via MCP.
|
||||
|
||||
## 2026-08-04
|
||||
|
||||
* **Update**: `rules/gather-rule-phase-scoping.md` — new section "Which phase feeds the triggers": `GatherRuleExecutorHost` now drives `phase_change`/`phase_exit`/`on_event` gather triggers (and the phase-scope gates) from the post-reduce `TimelineEventStream` (every emitted event's `(eventType, phase)` published by `EventTimelineEmitter`) instead of the pre-reduce `SignalIngress.SignalPosted` payloads. Trigger: session `32312a32` (RealmJoin tenant rsneuffen.de) — a Phase-Start rule on FinalizingSetup fired at the raw ESP-exit `EspPhaseChanged(FinalizingSetup)` signal, 7 minutes before the engine's RealmJoin-gated `phase_transition(FinalizingSetup)`, reading `HKLM\SOFTWARE\RealmJoin\Custom\BIOS` before the RealmJoin package created it. Phase triggers now fire exactly when the timeline shows the phase; `on_event` additionally sees engine-emitted event types (`enrollment_complete`, `realmjoin_resolved`, …) for the first time in V2, restoring V1 semantics and the documented end-of-enrollment pattern. Other raw-signal hosts (DeviceInfo, keep-awake, PPKG) deliberately unchanged.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
type: concept
|
||||
title: MCP Tool Telemetry
|
||||
description: The three telemetry layers for judging MCP tool quality — backend App Insights via X-MCP-Tool-Name, the per-user usage table, and the MCP_TOOL_LOGGING stderr lines that carry the signals the backend cannot see (soft errors, result size vs. cap, zero-hit searches, Zod rejections).
|
||||
resource: src/McpServer/autopilot-monitor-mcp/src/telemetry.ts
|
||||
tags: [mcp, telemetry, observability, tool-quality]
|
||||
timestamp: 2026-08-05
|
||||
---
|
||||
|
||||
# Schema
|
||||
|
||||
Three layers answer "are the MCP tools doing their job, and are tools missing?":
|
||||
|
||||
1. **Backend App Insights** — every tool handler runs inside `withToolTelemetry`
|
||||
(`src/telemetry.ts`), which propagates the tool name via AsyncLocalStorage so
|
||||
`apiFetch` stamps `X-MCP-Tool-Name` on each backend request.
|
||||
`RequestTelemetryMiddleware` copies it into the request telemetry as
|
||||
`McpToolName`: per-tool call volume, latency percentiles and HTTP failure
|
||||
rates by KQL. Blind spot: tools that never call the backend, and handler-level
|
||||
soft errors (see layer 3).
|
||||
2. **Usage table** — `McpQuotaEnforcementMiddleware.TrackUsage` counts per
|
||||
user/day under the key `toolname:endpoint`; surfaced by the `get_api_usage`
|
||||
tool. Counts only, every call counts as success.
|
||||
3. **stderr JSON lines** (`MCP_TOOL_LOGGING=true`, queryable via Container App
|
||||
Logs) — the layer that carries what the other two structurally cannot:
|
||||
- `tool_call`: duration, `isError` **including soft errors** (handlers never
|
||||
throw — `toolError` *returns* `isError: true`, so backend-only telemetry
|
||||
records those calls as successes), `resultChars` + `overCap` (result
|
||||
exceeds the tool's `anthropic/maxResultSizeChars` hint → the host truncates
|
||||
it; a frequently-overCap tool needs tighter defaults or projections), a
|
||||
size-bounded args summary, and the caller scope (ga/delegated/tenant).
|
||||
This is the ONLY telemetry for backend-free tools: `search_docs`,
|
||||
`validate_rule`, `get_resource`, the local ranking of `search_knowledge`.
|
||||
- `search_zero_hit`: query text (capped) whenever `search_events` /
|
||||
`search_knowledge` / `search_docs` return zero results — the most direct
|
||||
"missing docs / missing knowledge / missing tool?" demand signal.
|
||||
- `tool_call_rejected`: rejections the SDK answers *before* any handler runs
|
||||
(Zod argument validation, unknown/disabled tool). Two non-obvious facts,
|
||||
both found by live verification: SDK 1.30 wraps these McpErrors as a
|
||||
*successful* JSON-RPC response whose result is `{ isError: true, text:
|
||||
"MCP error <code>: …" }` — a JSON-RPC error envelope never exists for
|
||||
them — and the Hono bridge (`getRequestListener`) may stream the body as
|
||||
`res.write` chunks with a bare `res.end()`. The sniffer in the `/mcp`
|
||||
route therefore wraps both `write` and `end`, reassembles up to 10 KB and
|
||||
matches both shapes (`attachToolCallRejectionSniffer` in `telemetry.ts`).
|
||||
Handler soft errors never carry the `MCP error` prefix, so they are not
|
||||
double-logged. This is the strongest "the schema or description confuses
|
||||
the model" signal and was previously invisible everywhere.
|
||||
|
||||
The env flag defaults to `true` in `infra/mcp-server.bicep` (documented desired
|
||||
state — the template is not routinely deployed; the live flag is set with
|
||||
`az containerapp update --set-env-vars MCP_TOOL_LOGGING=true`).
|
||||
|
||||
# Examples
|
||||
|
||||
Count zero-hit queries to find unmet demand (Log Analytics):
|
||||
|
||||
```kusto
|
||||
ContainerAppConsoleLogs_CL
|
||||
| where Log_s has '"type":"search_zero_hit"'
|
||||
| extend d = parse_json(Log_s)
|
||||
| summarize count() by tostring(d.tool), tostring(d.query)
|
||||
```
|
||||
|
||||
Per-tool soft-error and truncation rates:
|
||||
|
||||
```kusto
|
||||
ContainerAppConsoleLogs_CL
|
||||
| where Log_s has '"type":"tool_call"'
|
||||
| extend d = parse_json(Log_s)
|
||||
| summarize calls = count(), errors = countif(d.isError == true),
|
||||
overCap = countif(d.overCap == true), p95ms = percentile(tolong(d.durationMs), 95)
|
||||
by tool = tostring(d.tool)
|
||||
```
|
||||
|
||||
# Citations
|
||||
|
||||
- `src/McpServer/autopilot-monitor-mcp/src/telemetry.ts` — wrapper + log emitters
|
||||
- `src/McpServer/autopilot-monitor-mcp/src/index.ts` — `/mcp` rejection sniffer
|
||||
- `src/Backend/AutopilotMonitor.Functions/Middleware/RequestTelemetryMiddleware.cs` — `McpToolName` stamping
|
||||
- `src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs` — usage-table counting
|
||||
@@ -0,0 +1,152 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Rule Authoring Surface — MCP Resources, validate_rule, and the Analyze Dry-Run
|
||||
description: >-
|
||||
How customer AIs author gather/analyze rules against the real contract: the baked
|
||||
authoring resources (guide, schemas, guardrails) generated by combine.js, the
|
||||
MCP-local validate_rule pre-flight, and the side-effect-free backend dry-run that
|
||||
traces a draft analyze rule against one session's events.
|
||||
resource: src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts
|
||||
tags: [rules, mcp, analyze-rules, gather-rules, dry-run, authoring]
|
||||
timestamp: 2026-08-05
|
||||
---
|
||||
|
||||
# Concept
|
||||
|
||||
Customers author rules with an AI connected to the MCP server. Retrieval
|
||||
(`search_docs`) is deliberately NOT the knowledge source for authoring: chunked
|
||||
top-K retrieval can silently omit an operator, a guardrail prefix, or an enum,
|
||||
and the AI then produces a plausible-but-dead rule. The authoring surface is
|
||||
therefore **deterministic**: three `get_resource` entries carry the complete
|
||||
contract, a local validator rejects what production would reject or silently
|
||||
ignore, and a backend dry-run evaluates a draft against real session events with
|
||||
a full trace.
|
||||
|
||||
The intended loop: draft → `validate_rule` → `test_analyze_rule` against a real
|
||||
session (one that should fire, one that should not) → iterate → create in the
|
||||
portal. The `author-rule` MCP prompt walks a client through exactly this order.
|
||||
|
||||
# Schema
|
||||
|
||||
## Resources (deterministic authoring context)
|
||||
|
||||
| Resource | Content | Source |
|
||||
| --- | --- | --- |
|
||||
| `rule_authoring_guide` | Hand-written complete authoring knowledge: collector types, triggers, condition sources, operators, confidence model incl. the exact factor DSL, `{{token}}` interpolation order, guardrail matching semantics, pitfalls | `src/rule-authoring-guide.ts` |
|
||||
| `rule_schemas` | Both JSON Schemas (2020-12) verbatim | generated |
|
||||
| `rule_guardrails` | `rules/guardrails.json` verbatim | generated |
|
||||
|
||||
Generation: `node rules/scripts/combine.js` bakes schemas + guardrails into
|
||||
`src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts` (same
|
||||
pattern as the web's `utils/guardrails.generated.ts`; the tsconfig `rootDir` and
|
||||
the Docker build's exclusion of `rules/schema` rule out importing the JSON
|
||||
directly). Drift is closed twice: CI's `guardrails-in-sync` job re-runs
|
||||
combine.js and fails on any diff, and `rule-authoring-drift.test.ts` pins
|
||||
generated == repo source plus guide enums == schema enums.
|
||||
|
||||
## validate_rule (MCP-local)
|
||||
|
||||
`src/rule-validation.ts`, no backend call. Three layers:
|
||||
|
||||
1. **Schema** — ajv (draft 2020-12) against the baked schemas, validating the
|
||||
single-rule `$defs` entry directly (the top-level oneOf wrapper would double
|
||||
every error).
|
||||
2. **Guardrails** — mirrors the AGENT's matching semantics from
|
||||
`GatherRuleGuards.cs` (see [gather-rule-guardrails.md](gather-rule-guardrails.md)):
|
||||
hive-stripped segment-bounded registry/file prefixes, whitespace-bounded WMI
|
||||
prefixes, EXACT command matching, `/`-bounded event-log channels, and the
|
||||
code-enforced hard blocks (`Security` etc. channels, `C:\Users`,
|
||||
`C:\Windows\System32\config`).
|
||||
3. **Semantic lint** — schema-valid-but-dead rules: unreachable
|
||||
confidenceThreshold, factor conditions outside the three parseable shapes
|
||||
(`exists`, `count >= N`, `phase_duration > N`), unknown event types (warning;
|
||||
`gather_*` softened to info), unanchored `not_regex` allow-lists,
|
||||
unresolvable `{{token}}`s, trigger cross-fields, outputEventType
|
||||
convention/collision checks.
|
||||
|
||||
The validator is advisory pre-flight only — the agent re-enforces guardrails
|
||||
on-device and the backend re-validates structure. It must never be stricter
|
||||
than production.
|
||||
|
||||
## POST /api/rules/analyze/dryrun (backend)
|
||||
|
||||
`DryRunAnalyzeRuleFunction` → `RuleEngine.DryRunRuleAsync`. Body
|
||||
`{ sessionId, rule }` (draft `AnalyzeRule`, 1 MB cap). Policy tier
|
||||
`TenantAdminOrGlobalReader` — a POST by shape but a pure read in effect, so the
|
||||
read-only Global Reader is admitted; platform-scope callers reach any tenant's
|
||||
session via `FindSessionTenantIdAsync` (same pattern as `GetRuleResultsFunction`).
|
||||
|
||||
`RuleEngine.DryRun.cs` is a partial-class trace twin of `EvaluateRule`: it
|
||||
shares the exact evaluators (`EvaluatePrecondition` / `EvaluateCondition` /
|
||||
`EvaluateConfidenceFactor`) but evaluates **all** conditions instead of breaking
|
||||
on the first failed required one — the full picture is the point of a dry run.
|
||||
Verdicts: `fired`, `skipped_by_precondition`, `required_condition_not_met`,
|
||||
`no_conditions_matched`, `below_confidence_threshold`, `no_events`. Each
|
||||
condition entry carries `matched`, the production evidence dictionary (or the
|
||||
evaluator's reason string) and `matchingEventCount` — how many session events
|
||||
even have that eventType, the first thing to check on an unexpected miss.
|
||||
|
||||
Invariants, pinned by `RuleEngineDryRunTests`:
|
||||
|
||||
* **Parity**: verdict `fired` ⇔ `EvaluateRule` produces a result, with identical
|
||||
confidence and evidence keys. Factor evaluation stays sequential against the
|
||||
accumulating `matchedConditions` (a factor can observe earlier `factor_*`
|
||||
entries) — reordering would change results.
|
||||
* **Side-effect freedom**: exactly one strict event read; never a RuleResult
|
||||
write, never `TryMarkSessionFailedFromRuleAsync`, never a notification.
|
||||
`wouldMarkSessionAsFailed` is reported, not acted on.
|
||||
|
||||
`test_analyze_rule` (MCP) wraps the endpoint and adds the interpolated
|
||||
explanation/remediation preview via the existing `interpolate-rule-template.ts`
|
||||
against the returned `matchedConditions`.
|
||||
|
||||
## POST /api/rules/gather/test-pattern (backend)
|
||||
|
||||
The logparser counterpart to the analyze dry-run, added after a field incident:
|
||||
a customer verified a logparser regex with a PHP tester and the agent's .NET
|
||||
engine matched differently. `TestLogPatternFunction` evaluates a pattern against
|
||||
pasted sample lines with the AGENT's exact matching semantics:
|
||||
|
||||
* Regex construction identical to `LogParserCollector`: `Compiled`, 1 s timeout,
|
||||
**no IgnoreCase** — logparser matching is case-sensitive (unlike analyze-rule
|
||||
regex conditions, which run IgnoreCase).
|
||||
* `format` behaves exactly like the rule parameter: `cmtrace` (default) parses
|
||||
each line via the shared `CmTraceLogParser` and matches against the parsed
|
||||
MESSAGE only; `text` matches the raw line. First match per line
|
||||
(`Regex.Match`), group `"0"` excluded, unsuccessful groups omitted — the
|
||||
returned `groups` are exactly the event-data fields the device would emit.
|
||||
* Mirrors the agent's diagnostics: per-line outcomes
|
||||
(`matched | no_match | parse_failed | regex_timeout`), the all-lines-parse-fail
|
||||
hint ("set parameter format=text") verbatim, and a case-sensitivity note on
|
||||
zero matches.
|
||||
* Pure compute on the request body — no tenant data touched. Same policy tier
|
||||
as the dry-run (`TenantAdminOrGlobalReader`); caps: 200 lines, 8 KB/line,
|
||||
1 MB body.
|
||||
|
||||
Parity is structural, not duplicated: `CmTraceLogParser` moved from the agent
|
||||
(`Monitoring/Enrollment/Ime`) to **Shared** (`AutopilotMonitor.Shared.Logging`)
|
||||
so agent and backend run the SAME parsing implementation. Timestamp caveat:
|
||||
bias-less CMTrace lines parse with `AssumeLocal`, which on the backend is the
|
||||
server's timezone — the endpoint therefore reports match/parse results, never
|
||||
timestamps. `test_log_pattern` (MCP) wraps the endpoint; `validate_rule`
|
||||
additionally lints logparser parameters (missing/non-compiling pattern, unknown
|
||||
format value) and its `nextStep` routes logparser drafts to `test_log_pattern`.
|
||||
|
||||
## What is deliberately absent
|
||||
|
||||
* **No gather-rule remote execution** — gather rules run as SYSTEM on devices;
|
||||
session dry-run is analyze-only. Logparser authors get the pattern tester
|
||||
above; all gather authors get guardrail pre-flight plus the on-device debug
|
||||
log ([gather-rule-debug-log](../agent/gather-rule-debug-log.md)).
|
||||
* **No rule write/deploy via MCP** — creating rules stays in the portal.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/TestLogPatternFunction.cs` — logparser pattern tester
|
||||
* `src/Shared/AutopilotMonitor.Shared/Logging/CmTraceLogParser.cs` — shared CMTrace parser (agent + backend)
|
||||
* `src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.DryRun.cs` — trace twin + DTOs
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/DryRunAnalyzeRuleFunction.cs` — endpoint + draft validation
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/rule-validation.ts` — validator
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/tools/rules.ts` — validate_rule / test_analyze_rule
|
||||
* `src/McpServer/autopilot-monitor-mcp/src/rule-authoring-guide.ts` — guide resource
|
||||
* `rules/scripts/combine.js` — MCP module generation
|
||||
@@ -80,8 +80,8 @@ param mcpPublicUrl string = ''
|
||||
@description('Base64-encoded HMAC key (>=32 bytes after decode) for signing the OAuth proxy state. Naming and format match the backend\'s PaginationTokenSigningKey for consistency. Empty = generate per-instance random key on startup (acceptable for single-replica deployments since state has 10 min lifetime). Generate with PowerShell: [Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))')
|
||||
param oAuthStateSigningKey string = ''
|
||||
|
||||
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs)')
|
||||
param toolLoggingEnabled bool = false
|
||||
@description('Enable structured tool-call logging to stderr (queryable via Container App Logs). Volume is one small JSON line per tool call - negligible at current usage.')
|
||||
param toolLoggingEnabled bool = true
|
||||
|
||||
// --- Azure Container Registry ---
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-SEC-008",
|
||||
"title": "Secure Boot is disabled",
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "note",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"description": "Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "secureboot_disabled",
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiSecureBootEnabled",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "physical_device",
|
||||
"source": "event_data",
|
||||
"eventType": "hardware_spec",
|
||||
"dataField": "isVirtualMachine",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "physical_device", "condition": "exists", "weight": 20 }
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Enable Secure Boot in UEFI firmware",
|
||||
"steps": [
|
||||
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
|
||||
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
|
||||
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
|
||||
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
|
||||
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Check compliance impact",
|
||||
"steps": [
|
||||
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
|
||||
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Re-enable Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
|
||||
}
|
||||
],
|
||||
"tags": ["security", "secureboot", "firmware", "uefi", "compliance"]
|
||||
}
|
||||
+87
@@ -2687,6 +2687,93 @@
|
||||
"truncated",
|
||||
"coverage"
|
||||
]
|
||||
},
|
||||
{
|
||||
"ruleId": "ANALYZE-SEC-008",
|
||||
"title": "Secure Boot is disabled",
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
"baseConfidence": 70,
|
||||
"preconditions": [
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "note",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"description": "Skip when the agent could not find the SecureBoot state registry key (note field set) — that indicates a legacy BIOS / non-UEFI device where Secure Boot is unsupported rather than disabled, so a warning would not be actionable."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "secureboot_disabled",
|
||||
"source": "event_data",
|
||||
"eventType": "secureboot_status",
|
||||
"dataField": "uefiSecureBootEnabled",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "physical_device",
|
||||
"source": "event_data",
|
||||
"eventType": "hardware_spec",
|
||||
"dataField": "isVirtualMachine",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "physical_device",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 60,
|
||||
"explanation": "This device enrolled with **Secure Boot disabled**.\n\nThe firmware reports UEFI mode, but Secure Boot is turned off (`HKLM\\SYSTEM\\CurrentControlSet\\Control\\SecureBoot\\State\\UEFISecureBootEnabled = 0`). Without Secure Boot the boot chain is not verified, which:\n\n- Leaves the device exposed to bootkits and unsigned boot-time code\n- Blocks or weakens Virtualization-based Security (VBS), Credential Guard and Device Health Attestation\n- Typically makes the device **non-compliant** if an Intune compliance policy requires Secure Boot — Conditional Access may then block the user right after enrollment\n\nSecure Boot is usually disabled deliberately in firmware setup (e.g. for imaging, dual-boot or legacy CSM mode) and simply never re-enabled before the device was handed to Autopilot.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Enable Secure Boot in UEFI firmware",
|
||||
"steps": [
|
||||
"Reboot into the UEFI firmware setup (typically F2/F10/F12/Del during boot, or Shift+Restart -> Troubleshoot -> UEFI Firmware Settings)",
|
||||
"Disable Legacy/CSM boot mode if it is active — Secure Boot requires native UEFI boot",
|
||||
"Enable Secure Boot and ensure the Microsoft UEFI CA keys are selected (factory default key set)",
|
||||
"Save, reboot and verify in Windows: msinfo32 -> 'Secure Boot State' = On, or PowerShell Confirm-SecureBootUEFI returns True",
|
||||
"For Hyper-V test VMs: use a Generation 2 VM and enable Secure Boot in the VM's Security settings (template 'Microsoft Windows')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Check compliance impact",
|
||||
"steps": [
|
||||
"Review Intune compliance policies assigned to this device for a 'Require Secure Boot' setting",
|
||||
"If the device stays non-compliant, Conditional Access policies may block sign-in — fix firmware settings before user handover"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/design/device-experiences/oem-secure-boot"
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Learn: Re-enable Secure Boot",
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
"firmware",
|
||||
"uefi",
|
||||
"compliance"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -161,3 +161,45 @@ ${flatArray(guardrails.blockedEventLogChannels)}
|
||||
} else {
|
||||
console.warn('guardrails.json not found — skipping guardrails generation');
|
||||
}
|
||||
|
||||
// ── MCP rule-authoring module generation ────────────────────────────────────
|
||||
// The MCP server exposes the rule schemas + guardrails as get_resource content
|
||||
// so a customer's AI can author rules against the real contract instead of
|
||||
// retrieval fragments. The server's tsconfig (rootDir: src, no resolveJsonModule)
|
||||
// and the Docker build (rules/schema not copied) rule out importing the JSON
|
||||
// directly — so we bake it into a TS module, exactly like the web mirror above.
|
||||
// CI's guardrails-in-sync job re-runs this script and fails on any diff, which
|
||||
// makes drift between rules/* and this generated module impossible to merge.
|
||||
|
||||
const mcpAuthoringOutput = path.resolve(
|
||||
rulesRoot, '..', 'src', 'McpServer', 'autopilot-monitor-mcp', 'src', 'rule-authoring.generated.ts'
|
||||
);
|
||||
|
||||
{
|
||||
const guardrails = JSON.parse(fs.readFileSync(guardrailsPath, 'utf8'));
|
||||
const gatherSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'gather-rule.schema.json'), 'utf8'));
|
||||
const analyzeSchema = JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', 'analyze-rule.schema.json'), 'utf8'));
|
||||
|
||||
const ts = `/**
|
||||
* AUTO-GENERATED from rules/guardrails.json + rules/schema/*.schema.json — DO NOT EDIT.
|
||||
* Run: node rules/scripts/combine.js
|
||||
*
|
||||
* Consumed by the MCP server's rule-authoring surface (get_resource +
|
||||
* validate_rule): the JSON Schemas are the validation contract, the guardrails
|
||||
* are the agent-side collection allowlists. Single source of truth is rules/;
|
||||
* the CI guardrails-in-sync job guards this file against drift.
|
||||
*/
|
||||
|
||||
/** JSON Schema (2020-12) for gather rules — rules/schema/gather-rule.schema.json verbatim. */
|
||||
export const GATHER_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(gatherSchema, null, 2)};
|
||||
|
||||
/** JSON Schema (2020-12) for analyze rules — rules/schema/analyze-rule.schema.json verbatim. */
|
||||
export const ANALYZE_RULE_SCHEMA: Record<string, unknown> = ${JSON.stringify(analyzeSchema, null, 2)};
|
||||
|
||||
/** Gather-rule collection guardrails — rules/guardrails.json verbatim. */
|
||||
export const RULE_GUARDRAILS = ${JSON.stringify(guardrails, null, 2)} as const;
|
||||
`;
|
||||
|
||||
fs.writeFileSync(mcpAuthoringOutput, ts, 'utf8');
|
||||
console.log('rule-authoring.generated.ts: schemas + guardrails baked for MCP server');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
# Pester tests (Pester 5+) for the bootstrap guard/relax logic in
|
||||
# Install-AutopilotMonitor-dev.ps1 (v2.3-dev).
|
||||
#
|
||||
# The script under test is dot-sourced; its entry guard prevents the bootstrap
|
||||
# from executing, so loading it here is side-effect free. External state (WMI,
|
||||
# registry, filesystem, WinRT) is mocked; profile directories are real folders
|
||||
# under Pester's $TestDrive with a manipulated CreationTimeUtc.
|
||||
#
|
||||
# Run under Windows PowerShell 5.1 to match the IME runtime:
|
||||
# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }"
|
||||
#
|
||||
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
BeforeAll {
|
||||
. (Join-Path $PSScriptRoot 'Install-AutopilotMonitor-dev.ps1')
|
||||
|
||||
function New-FakeProfile {
|
||||
param([string]$Name, [double]$AgeMinutes)
|
||||
$p = Join-Path $TestDrive $Name
|
||||
New-Item -ItemType Directory -Path $p -Force | Out-Null
|
||||
(Get-Item $p).CreationTimeUtc = (Get-Date).ToUniversalTime().AddMinutes(-$AgeMinutes)
|
||||
return $p
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Test-IsCloudPc' {
|
||||
# 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).
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ Manufacturer = 'Microsoft Corporation'; Model = 'Virtual Machine' } }
|
||||
}
|
||||
|
||||
It 'detects a Cloud PC when both markers are present' {
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not detect with the registry key alone (e.g. W365-Boot physical client)' {
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { $null }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not detect with the service alone' {
|
||||
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not detect a plain device (no markers)' {
|
||||
Mock Test-Path { $false } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { $null }
|
||||
Test-IsCloudPc | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'still evaluates markers when the WMI identity query fails' {
|
||||
Mock Get-CimInstance { throw 'RPC server unavailable' }
|
||||
Mock Test-Path { $true } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\Microsoft\Windows365' }
|
||||
Mock Get-Service { [pscustomobject]@{ Name = 'CloudManagedDesktopExtension'; Status = 'Running' } }
|
||||
Test-IsCloudPc | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-OobeState' {
|
||||
It 'returns a non-empty state string and never throws' {
|
||||
Get-OobeState | Should -Not -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RealUserProfilePaths' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
}
|
||||
|
||||
It 'merges WMI and filesystem views, drops special/system profiles, dedupes' {
|
||||
Mock Get-CimInstance {
|
||||
@(
|
||||
[pscustomobject]@{ Special = $false; LocalPath = 'C:\Users\LeonGottschalk' }
|
||||
[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\Public' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\defaultuser0' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\WDAGUtilityAccount' }
|
||||
)
|
||||
}
|
||||
$result = @(Get-RealUserProfilePaths)
|
||||
$result | Should -Be @('C:\Users\LeonGottschalk')
|
||||
}
|
||||
|
||||
It 'falls back to the filesystem view when the WMI query fails' {
|
||||
Mock Get-CimInstance { throw 'WMI broken' }
|
||||
Mock Get-ChildItem {
|
||||
@(
|
||||
[pscustomobject]@{ FullName = 'C:\Users\SomeUser' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\Default' }
|
||||
)
|
||||
}
|
||||
$result = @(Get-RealUserProfilePaths)
|
||||
$result | Should -Be @('C:\Users\SomeUser')
|
||||
}
|
||||
|
||||
It 'returns an empty result when only special/system profiles exist' {
|
||||
Mock Get-CimInstance { @() }
|
||||
Mock Get-ChildItem {
|
||||
@(
|
||||
[pscustomobject]@{ FullName = 'C:\Users\Public' }
|
||||
[pscustomobject]@{ FullName = 'C:\Users\defaultuser1' }
|
||||
)
|
||||
}
|
||||
@(Get-RealUserProfilePaths).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-RelaxDecision' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
# Defaults: physical device, OOBE long done. Individual tests override.
|
||||
Mock Get-OobeState { 'Completed' }
|
||||
Mock Test-IsCloudPc { $false }
|
||||
}
|
||||
|
||||
Context 'Windows 365 Cloud PC' {
|
||||
BeforeEach {
|
||||
Mock Test-IsCloudPc { $true }
|
||||
}
|
||||
|
||||
It 'relaxes for a single fresh profile although OOBE is Completed (W365 field case)' {
|
||||
$p = New-FakeProfile 'LeonGottschalk' 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
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'does not relax for two profiles' {
|
||||
$p1 = New-FakeProfile 'UserA' 2
|
||||
$p2 = New-FakeProfile 'UserB' 400
|
||||
(Get-RelaxDecision -ProfilePaths @($p1, $p2) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'relaxes even when the WinRT OOBE API is unavailable (old build)' {
|
||||
Mock Get-OobeState { 'Unavailable' }
|
||||
$p = New-FakeProfile 'CpcUser' 1
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'does not relax for a profile with a future creation time (clock skew)' {
|
||||
$p = New-FakeProfile 'SkewUser' -10
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
}
|
||||
|
||||
Context 'OOBE InProgress (Windows Backup for Organizations, v2.2 behaviour)' {
|
||||
It 'relaxes for a single fresh profile during OOBE' {
|
||||
Mock Get-OobeState { 'InProgress' }
|
||||
$p = New-FakeProfile 'RestoreUser' 3
|
||||
$d = Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15
|
||||
$d.Active | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Context 'no trigger' {
|
||||
It 'does not relax on a physical device with a fresh profile and OOBE Completed' {
|
||||
$p = New-FakeProfile 'SomeUser' 2
|
||||
(Get-RelaxDecision -ProfilePaths @($p) -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'stays inactive with no profiles at all' {
|
||||
(Get-RelaxDecision -ProfilePaths @() -OobeProfileMaxAgeMinutes 15).Active | Should -BeFalse
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Get-BootstrapDecision' {
|
||||
BeforeEach {
|
||||
Mock Write-Log { }
|
||||
# Baseline: clean device during Device ESP -- no marker, no profiles, no
|
||||
# logged-on user, 1h uptime, agent not installed. Tests override selectively.
|
||||
Mock Get-ItemProperty { $null }
|
||||
Mock Get-RealUserProfilePaths { @() }
|
||||
Mock Get-OobeState { 'Completed' }
|
||||
Mock Test-IsCloudPc { $false }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-1) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$script:cleanAgentBin = Join-Path $TestDrive 'AgentBin'
|
||||
}
|
||||
|
||||
It 'installs on a clean ESP device' {
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeTrue
|
||||
$d.ReasonCode | Should -BeNullOrEmpty
|
||||
$d.RelaxActive | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'skips when the Deployed marker exists (guard 1)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'AlreadyDeployed'
|
||||
}
|
||||
|
||||
It 'skips a productive device with an old profile (guard 2)' {
|
||||
$script:fakeProfile = New-FakeProfile 'OldUser' 5000
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'ProfilesFound'
|
||||
}
|
||||
|
||||
It 'installs on a freshly provisioned Cloud PC with one fresh profile (W365 field case)' {
|
||||
$script:fakeProfile = New-FakeProfile 'LeonGottschalk' 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*' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeTrue
|
||||
$d.RelaxActive | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'skips when a real user has logged on and no relax applies (guard 3)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'AzureAD\SomeUser' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'LastLoggedOnUser'
|
||||
}
|
||||
|
||||
It 'does not skip for the defaultuser0 OOBE account (guard 3)' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ LastLoggedOnUser = 'defaultuser0' } } -ParameterFilter { $Path -like '*LogonUI*' }
|
||||
(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin).Install | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'skips when uptime exceeds the bootstrap window (guard 4)' {
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
|
||||
It 'installs on a Cloud PC despite uptime over the window when the relax is active (guard 4 exemption, v2.3-dev.3)' {
|
||||
# W365 reality: provisioned days ago, running headless ever since; the user's
|
||||
# first connect just created the profile. Uptime is meaningless here.
|
||||
$script:fakeProfile = New-FakeProfile 'FirstConnectUser' 0.6
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Test-IsCloudPc { $true }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeTrue
|
||||
$d.RelaxActive | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'still skips an over-uptime Cloud PC when the relax is NOT active (no fresh profile)' {
|
||||
Mock Test-IsCloudPc { $true }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-90) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
|
||||
It 'keeps guard 4 for the OOBE-restore relax (physical device, uptime is meaningful)' {
|
||||
$script:fakeProfile = New-FakeProfile 'RestoreUser' 3
|
||||
Mock Get-RealUserProfilePaths { @($script:fakeProfile) }
|
||||
Mock Get-OobeState { 'InProgress' }
|
||||
Mock Get-CimInstance { [pscustomobject]@{ LastBootUpTime = (Get-Date).AddHours(-20) } } -ParameterFilter { $ClassName -eq 'Win32_OperatingSystem' }
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $script:cleanAgentBin
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'UptimeExceeded'
|
||||
}
|
||||
|
||||
It 'skips when the agent binary is already present (guard 5)' {
|
||||
$binPath = Join-Path $TestDrive 'AgentBinExisting'
|
||||
New-Item -ItemType Directory -Path $binPath -Force | Out-Null
|
||||
Set-Content -Path (Join-Path $binPath 'AutopilotMonitor.Agent.exe') -Value 'stub'
|
||||
$d = Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath $binPath
|
||||
$d.Install | Should -BeFalse
|
||||
$d.ReasonCode | Should -Be 'AgentPresent'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'logging purity' {
|
||||
# Regression guard: Write-Log runs inside functions that RETURN decision
|
||||
# objects. If it ever emits to the output stream again (Write-Output), the
|
||||
# log lines leak into the return values and turn them into arrays. This
|
||||
# test deliberately does NOT mock Write-Log.
|
||||
It 'Get-BootstrapDecision returns exactly one object with real logging' {
|
||||
Mock Get-ItemProperty { [pscustomobject]@{ Deployed = '2026-08-01T10:00:00Z' } } -ParameterFilter { $Path -eq 'HKLM:\SOFTWARE\AutopilotMonitor' }
|
||||
# Plain local assignment: PowerShell's dynamic scoping makes Write-Log
|
||||
# (called from Get-BootstrapDecision) resolve this $LogFile instead of
|
||||
# the ProgramData path defined by the dot-sourced script.
|
||||
$LogFile = Join-Path $TestDrive 'bootstrap_test.log'
|
||||
$d = @(Get-BootstrapDecision -MaxBootstrapWindowHours 12 -OobeProfileMaxAgeMinutes 15 -AgentBinPath (Join-Path $TestDrive 'NoAgent'))
|
||||
$d.Count | Should -Be 1
|
||||
$d[0].ReasonCode | Should -Be 'AlreadyDeployed'
|
||||
Get-Content $LogFile -Raw | Should -Match 'SKIP: Agent was previously deployed'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
<#
|
||||
.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
|
||||
}
|
||||
@@ -23,7 +23,11 @@
|
||||
One or more high-level feature identifiers. The script translates each to the underlying
|
||||
Graph permissions. Use this when you don't want to know permission strings.
|
||||
Available features:
|
||||
- ScriptDisplayNames -> DeviceManagementScripts.Read.All
|
||||
- ScriptDisplayNames -> DeviceManagementScripts.Read.All
|
||||
- W365CloudPcValidation -> CloudPC.Read.All
|
||||
(validates Windows 365 Cloud PCs against the tenant's Cloud PC inventory --
|
||||
Cloud PCs are never Autopilot-registered, so this enables agent monitoring
|
||||
of Cloud PC first-connect enrollment)
|
||||
|
||||
.PARAMETER Permissions
|
||||
Microsoft Graph application permission names to grant directly. Use when the admin UI
|
||||
@@ -76,7 +80,7 @@ param(
|
||||
[string]$ClientId,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ByFeatures')]
|
||||
[ValidateSet('ScriptDisplayNames')]
|
||||
[ValidateSet('ScriptDisplayNames', 'W365CloudPcValidation')]
|
||||
[string[]]$Features,
|
||||
|
||||
[Parameter(Mandatory = $true, ParameterSetName = 'ByPermissions')]
|
||||
@@ -97,7 +101,8 @@ $ErrorActionPreference = 'Stop'
|
||||
# ---- Feature -> Graph permission catalog -----------------------------------
|
||||
# Keep in lock-step with src/Shared/AutopilotMonitor.Shared/Models/Graph/GraphFeatureCatalog.cs
|
||||
$FeatureCatalog = @{
|
||||
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
|
||||
'ScriptDisplayNames' = @('DeviceManagementScripts.Read.All')
|
||||
'W365CloudPcValidation' = @('CloudPC.Read.All')
|
||||
}
|
||||
|
||||
# Translate Features -> Permissions when caller used the high-level form.
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
using System;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||
|
||||
+5
@@ -66,6 +66,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
|
||||
// same facts signal (safe default "false" off-device).
|
||||
var selfDeploying = payload["isSelfDeployingProfile"];
|
||||
Assert.True(selfDeploying == "true" || selfDeploying == "false");
|
||||
// W365 — the Cloud PC marker fact rides the same signal (safe default "false"
|
||||
// off-device; a build host is not a Cloud PC).
|
||||
Assert.Contains("isCloudPc", (IDictionary<string, string>)payload);
|
||||
var cloudPc = payload["isCloudPc"];
|
||||
Assert.True(cloudPc == "true" || cloudPc == "false");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.Agent.V2.Core.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Pins the marker-AND contract of <see cref="CloudPcDetector.ResolveIsCloudPc"/>: BOTH
|
||||
/// the Windows365 registry key and the CloudManagedDesktopExtension service key must be
|
||||
/// present — each alone has plausible look-alikes (W365-Boot physical clients carry
|
||||
/// Windows365 policy state; the MCMD agent family served other managed-desktop
|
||||
/// offerings). Mirrors the bootstrap script's field-verified <c>Test-IsCloudPc</c>.
|
||||
/// </summary>
|
||||
public sealed class CloudPcDetectorTests
|
||||
{
|
||||
private static Func<string, bool> Probe(params string[] existingKeys)
|
||||
{
|
||||
var set = new HashSet<string>(existingKeys, StringComparer.OrdinalIgnoreCase);
|
||||
return path => set.Contains(path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Both_markers_present_resolves_true()
|
||||
{
|
||||
Assert.True(CloudPcDetector.ResolveIsCloudPc(Probe(
|
||||
CloudPcDetector.Windows365MarkerKey,
|
||||
CloudPcDetector.CmdeServiceKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Windows365_key_alone_resolves_false()
|
||||
{
|
||||
// W365-Boot physical client shape: policy state without the on-device MCMD agent.
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.Windows365MarkerKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cmde_service_alone_resolves_false()
|
||||
{
|
||||
// MCMD agent family also served other managed-desktop offerings.
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe(CloudPcDetector.CmdeServiceKey)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void No_markers_resolves_false()
|
||||
{
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(Probe()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_probe_resolves_false()
|
||||
{
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throwing_probe_degrades_to_false()
|
||||
{
|
||||
// SKIP-safe contract: a broken registry read must never classify, let alone throw.
|
||||
Assert.False(CloudPcDetector.ResolveIsCloudPc(_ => throw new UnauthorizedAccessException("registry denied")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DetectIsCloudPc_never_throws_on_build_host()
|
||||
{
|
||||
// Real-registry shell: exercises the exception-swallowing wrapper end to end.
|
||||
// A build host is not a Cloud PC, but the contract under test is "no throw",
|
||||
// not the detected value.
|
||||
var ex = Record.Exception(() => CloudPcDetector.DetectIsCloudPc());
|
||||
Assert.Null(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
@@ -137,6 +137,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Security
|
||||
Assert.Equal(Environment.MachineName, captured.DeviceName);
|
||||
Assert.False(string.IsNullOrEmpty(captured.EnrollmentType));
|
||||
Assert.True(captured.IsUserDriven);
|
||||
// W365 marker rides the registration body. A build host is not a Cloud PC
|
||||
// (marker-AND: Windows365 key + CloudManagedDesktopExtension service), so the
|
||||
// detector's SKIP-safe default is what must land here.
|
||||
Assert.False(captured.IsCloudPc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+1
@@ -7,6 +7,7 @@ using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Win32;
|
||||
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
|
||||
@@ -56,6 +56,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
private DateTime? _lastReportTime;
|
||||
private int _reportCount;
|
||||
|
||||
// Local W365 marker verdict, captured once at construction (same UNVERIFIED contract
|
||||
// as the hardware fields). Optional ctor arg so test fakes stay source-compatible.
|
||||
private readonly bool _isCloudPc;
|
||||
|
||||
public DistressReporter(
|
||||
string baseUrl,
|
||||
string tenantId,
|
||||
@@ -64,7 +68,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
string serialNumber,
|
||||
string agentVersion,
|
||||
AgentLogger logger,
|
||||
X509Certificate2 clientCertificate = null)
|
||||
X509Certificate2 clientCertificate = null,
|
||||
bool isCloudPc = false)
|
||||
{
|
||||
_tenantId = tenantId;
|
||||
_manufacturer = manufacturer;
|
||||
@@ -72,6 +77,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
_serialNumber = serialNumber;
|
||||
_agentVersion = agentVersion;
|
||||
_logger = logger;
|
||||
_isCloudPc = isCloudPc;
|
||||
|
||||
// Capture cert context. Each field is independently nullable so the backend can tell
|
||||
// "agent had no cert at all" (NoCertInStore + all-null fields) from "agent had a cert
|
||||
@@ -169,6 +175,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Transport
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Message = Truncate(message, 256),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
IsCloudPc = _isCloudPc,
|
||||
CertSourceState = _capturedCertState,
|
||||
CertThumbprint = _capturedCertThumbprint,
|
||||
CertSubject = Truncate(_capturedCertSubject, 96),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Detects whether the agent runs on a Windows 365 Cloud PC, using the same marker
|
||||
/// evidence the bootstrap script's <c>Test-IsCloudPc</c> settled on after the first
|
||||
/// W365 field test (2026-08-06) disproved WMI-model matching (a Cloud PC reports only
|
||||
/// Manufacturer='Microsoft Corporation' + Model='Virtual Machine' locally — identical
|
||||
/// to any Hyper-V VM):
|
||||
/// <list type="bullet">
|
||||
/// <item><c>HKLM\SOFTWARE\Microsoft\Windows365</c> exists, AND</item>
|
||||
/// <item>the <c>CloudManagedDesktopExtension</c> service (Microsoft Cloud Managed
|
||||
/// Desktop Extension, the W365 management agent that runs ON Cloud PCs) is
|
||||
/// installed — probed via its <c>HKLM\SYSTEM\CurrentControlSet\Services</c> key,
|
||||
/// so the agent needs no System.ServiceProcess dependency.</item>
|
||||
/// </list>
|
||||
/// BOTH markers are required (AND): each alone has plausible look-alikes (W365-Boot
|
||||
/// physical clients may carry Windows365 policy state; the MCMD agent family also
|
||||
/// served other managed-desktop offerings).
|
||||
/// <para>
|
||||
/// Display/context metadata only — the flag rides on <c>SessionRegistration</c> and the
|
||||
/// <c>EnrollmentFactsObserved</c> signal. It is NEVER an auth input: backend device
|
||||
/// validation trusts exclusively the cert-CN-bound Graph lookup
|
||||
/// (<c>CloudPcDeviceValidator</c>), not this locally-derived value.
|
||||
/// Exception-swallowing, false on any error — mirrors the SKIP-safe bootstrap contract
|
||||
/// and the <see cref="EnrollmentRegistryDetector"/> degradation rules.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class CloudPcDetector
|
||||
{
|
||||
internal const string Windows365MarkerKey = @"SOFTWARE\Microsoft\Windows365";
|
||||
internal const string CmdeServiceKey = @"SYSTEM\CurrentControlSet\Services\CloudManagedDesktopExtension";
|
||||
|
||||
public static bool DetectIsCloudPc()
|
||||
{
|
||||
try
|
||||
{
|
||||
return ResolveIsCloudPc(SubKeyExists);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure decision logic for <see cref="DetectIsCloudPc"/>. Exposed internally so the
|
||||
/// marker-AND semantics can be exercised without a real registry (same seam contract
|
||||
/// as <see cref="EnrollmentRegistryDetector.ResolveHybridJoinFromValues"/>).
|
||||
/// <paramref name="subKeyExists"/> receives an HKLM-relative subkey path and reports
|
||||
/// whether it exists; a throwing probe degrades to <c>false</c>.
|
||||
/// </summary>
|
||||
internal static bool ResolveIsCloudPc(Func<string, bool> subKeyExists)
|
||||
{
|
||||
if (subKeyExists == null) return false;
|
||||
|
||||
try
|
||||
{
|
||||
return subKeyExists(Windows365MarkerKey) && subKeyExists(CmdeServiceKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SubKeyExists(string subKeyPath)
|
||||
{
|
||||
using (var key = Registry.LocalMachine.OpenSubKey(subKeyPath))
|
||||
{
|
||||
return key != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
|
||||
EnrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType(),
|
||||
IsHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin(),
|
||||
IsSelfDeployingProfile = EnrollmentRegistryDetector.DetectSelfDeployingProfile(),
|
||||
IsCloudPc = CloudPcDetector.DetectIsCloudPc(),
|
||||
// Deliberately stays true even for self-deploying profiles: the WhiteGlove
|
||||
// Part1/Part2 merge paths and existing dashboards key on IsUserDriven;
|
||||
// IsSelfDeployingProfile is the additive classification signal.
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
@@ -102,7 +102,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
serialNumber: hardware.SerialNumber,
|
||||
agentVersion: agentVersion,
|
||||
logger: logger,
|
||||
clientCertificate: clientCertificate);
|
||||
clientCertificate: clientCertificate,
|
||||
isCloudPc: CloudPcDetector.DetectIsCloudPc());
|
||||
|
||||
var emergencyReporter = new EmergencyReporter(
|
||||
apiClient: backendApiClient,
|
||||
|
||||
@@ -309,12 +309,14 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
var enrollmentType = EnrollmentRegistryDetector.DetectEnrollmentType();
|
||||
var isHybridJoin = EnrollmentRegistryDetector.DetectHybridJoin();
|
||||
var isSelfDeploying = EnrollmentRegistryDetector.DetectSelfDeployingProfile();
|
||||
var isCloudPc = CloudPcDetector.DetectIsCloudPc();
|
||||
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["enrollmentType"] = enrollmentType,
|
||||
["isHybridJoin"] = isHybridJoin ? "true" : "false",
|
||||
["isSelfDeployingProfile"] = isSelfDeploying ? "true" : "false",
|
||||
["isCloudPc"] = isCloudPc ? "true" : "false",
|
||||
};
|
||||
|
||||
var evidence = new Evidence(
|
||||
@@ -329,7 +331,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
evidence: evidence,
|
||||
payload: payload);
|
||||
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}).");
|
||||
logger.Debug($"EnrollmentFactsObserved signal posted (enrollmentType={enrollmentType}, isHybridJoin={isHybridJoin}, isSelfDeployingProfile={isSelfDeploying}, isCloudPc={isCloudPc}).");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests
|
||||
{
|
||||
/// <summary>
|
||||
/// W365 — reducer-level coverage for the Cloud PC marker fact riding
|
||||
/// <see cref="DecisionSignalKind.EnrollmentFactsObserved"/>: the observation records BOTH
|
||||
/// values set-once (mirror of the registry self-deploying fact), the profile reason
|
||||
/// carries the "no Device-ESP phase expected" expectation, and the signal census folds the
|
||||
/// positive marker into terminal audit trails. The engine deliberately has no DeviceSetup
|
||||
/// arrival requirement, so no behavioural gate is added — the observation is the
|
||||
/// engine-side context that a session starting at Account Setup is the expected shape.
|
||||
/// </summary>
|
||||
public sealed class CloudPcObservationTests
|
||||
{
|
||||
private static readonly DateTime T0 = new DateTime(2026, 8, 7, 10, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
long ordinal,
|
||||
DecisionSignalKind kind,
|
||||
DateTime occurredAtUtc,
|
||||
IReadOnlyDictionary<string, string>? payload = null) =>
|
||||
new DecisionSignal(
|
||||
sessionSignalOrdinal: ordinal,
|
||||
sessionTraceOrdinal: ordinal,
|
||||
kind: kind,
|
||||
kindSchemaVersion: 1,
|
||||
occurredAtUtc: occurredAtUtc,
|
||||
sourceOrigin: "test",
|
||||
evidence: new Evidence(EvidenceKind.Synthetic, $"t-{kind}-{ordinal}", $"synthetic {kind}"),
|
||||
payload: payload);
|
||||
|
||||
private static DecisionState ReduceFacts(DecisionEngine engine, DecisionState state, long ordinal, string isCloudPc)
|
||||
=> engine.Reduce(state, MakeSignal(ordinal, DecisionSignalKind.EnrollmentFactsObserved, T0.AddSeconds(ordinal),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsCloudPc] = isCloudPc,
|
||||
})).NewState;
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_true_recordsObservation_andExpectationReason()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = engine.Reduce(
|
||||
DecisionState.CreateInitial("cpc-1", "t", T0),
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
state = ReduceFacts(engine, state, 1, "true");
|
||||
|
||||
Assert.NotNull(state.ScenarioObservations.CloudPc);
|
||||
Assert.True(state.ScenarioObservations.CloudPc!.Value);
|
||||
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
|
||||
Assert.Equal(
|
||||
EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason,
|
||||
state.ScenarioProfile.Reason);
|
||||
// Stage-agnostic fact signal — the reducer must not move the session anywhere.
|
||||
Assert.Equal(SessionStage.SessionStarted, state.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_false_recordsObservation_setOnce()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = engine.Reduce(
|
||||
DecisionState.CreateInitial("cpc-2", "t", T0),
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
state = ReduceFacts(engine, state, 1, "false");
|
||||
Assert.NotNull(state.ScenarioObservations.CloudPc);
|
||||
Assert.False(state.ScenarioObservations.CloudPc!.Value);
|
||||
|
||||
// Set-once: a later contradicting re-read must not overwrite the first sighting
|
||||
// (same contract as RegistrySelfDeployingProfile — replay-deterministic evidence).
|
||||
state = ReduceFacts(engine, state, 2, "true");
|
||||
Assert.False(state.ScenarioObservations.CloudPc!.Value);
|
||||
Assert.Equal(1, state.ScenarioObservations.CloudPc!.SourceSignalOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Census_foldsPositiveMarker_only()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var initial = engine.Reduce(
|
||||
DecisionState.CreateInitial("cpc-3", "t", T0),
|
||||
MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState;
|
||||
|
||||
var cloudPcState = ReduceFacts(engine, initial, 1, "true");
|
||||
var census = DecisionStateSignalCensus.Build(cloudPcState);
|
||||
Assert.Contains("cloud_pc_marker", census.SignalsSeen);
|
||||
Assert.True(census.SignalEvidence.ContainsKey("cloudPcMarker"));
|
||||
|
||||
var physicalState = ReduceFacts(engine, initial, 1, "false");
|
||||
var physicalCensus = DecisionStateSignalCensus.Build(physicalState);
|
||||
Assert.DoesNotContain("cloud_pc_marker", physicalCensus.SignalsSeen);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -490,7 +490,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
.WithShellCoreWhiteGloveSuccessSeen(sourceSignalOrdinal: 3)
|
||||
.WithSkipUserEsp(value: true, sourceSignalOrdinal: 4)
|
||||
.WithEspSyncFailureTimeoutMinutes(value: 90, sourceSignalOrdinal: 5)
|
||||
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6))
|
||||
.WithRegistrySelfDeployingProfile(value: false, sourceSignalOrdinal: 6)
|
||||
.WithCloudPc(value: true, sourceSignalOrdinal: 7))
|
||||
.Build();
|
||||
|
||||
var json = StateSerializer.Serialize(state);
|
||||
@@ -504,6 +505,8 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.Equal(5, o.EspSyncFailureTimeoutMinutes!.SourceSignalOrdinal);
|
||||
Assert.False(o.RegistrySelfDeployingProfile!.Value);
|
||||
Assert.Equal(6, o.RegistrySelfDeployingProfile!.SourceSignalOrdinal);
|
||||
Assert.True(o.CloudPc!.Value);
|
||||
Assert.Equal(7, o.CloudPc!.SourceSignalOrdinal);
|
||||
|
||||
// Never-observed facts must rehydrate as null (null ≠ veto — session 62e603c9).
|
||||
Assert.Null(o.WhiteGloveSealingPatternSeen);
|
||||
|
||||
+93
@@ -515,6 +515,99 @@ namespace AutopilotMonitor.DecisionCore.Tests.State
|
||||
|
||||
// ================================================================== helpers
|
||||
|
||||
// ------------------------------------------------ isCloudPc annotation (W365)
|
||||
// Cloud PC first connect: Device-ESP already ran headless at provisioning, so the
|
||||
// profile records the "no Device-ESP phase expected" expectation as the reason.
|
||||
// Reason-only by design — Mode is classified by AccountSetup/IME signals (Classic),
|
||||
// and EspConfig keeps the CONFIGURED FirstSync semantics.
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPc_annotatesReason_keepsModeUnknown()
|
||||
{
|
||||
var signal = MakeSignal(
|
||||
DecisionSignalKind.EnrollmentFactsObserved,
|
||||
ordinal: 0,
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsHybridJoin] = "false",
|
||||
[SignalPayloadKeys.IsCloudPc] = "true",
|
||||
});
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, signal);
|
||||
|
||||
Assert.Equal(EnrollmentMode.Unknown, profile.Mode);
|
||||
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
|
||||
Assert.Equal(EnrollmentJoinMode.AzureAdJoin, profile.JoinMode);
|
||||
Assert.Equal(EspConfig.Unknown, profile.EspConfig);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPc_repeatPost_isIdempotent()
|
||||
{
|
||||
Dictionary<string, string> Facts() => new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsHybridJoin] = "false",
|
||||
[SignalPayloadKeys.IsCloudPc] = "true",
|
||||
};
|
||||
var first = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 0, payload: Facts());
|
||||
var second = MakeSignal(DecisionSignalKind.EnrollmentFactsObserved, ordinal: 9, payload: Facts());
|
||||
|
||||
var p1 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, first);
|
||||
var p2 = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(p1, second);
|
||||
|
||||
// The repeat must be a full no-op: the Cloud PC annotation already owns the
|
||||
// reason slot, and the cloudPcAnnotates gate keeps the generic v1 annotation
|
||||
// from re-claiming it (which would bump EvidenceOrdinal every post).
|
||||
Assert.Same(p1, p2);
|
||||
Assert.Equal(0, p2.EvidenceOrdinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPcFalse_keepsGenericV1Reason()
|
||||
{
|
||||
var signal = MakeSignal(
|
||||
DecisionSignalKind.EnrollmentFactsObserved,
|
||||
ordinal: 0,
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsCloudPc] = "false",
|
||||
});
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, signal);
|
||||
|
||||
Assert.Equal("enrollment_facts_observed:v1", profile.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EnrollmentFactsObserved_cloudPc_doesNotDisturbSelfDeployingSeed()
|
||||
{
|
||||
// Defensive combination (not expected in the field — Cloud PCs are never
|
||||
// Autopilot-registered): the deterministic self-deploying Mode seed must
|
||||
// survive; only the reason slot goes to the more specific annotation.
|
||||
var signal = MakeSignal(
|
||||
DecisionSignalKind.EnrollmentFactsObserved,
|
||||
ordinal: 0,
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.EnrollmentType] = "v1",
|
||||
[SignalPayloadKeys.IsSelfDeployingProfile] = "true",
|
||||
[SignalPayloadKeys.IsCloudPc] = "true",
|
||||
});
|
||||
|
||||
var profile = EnrollmentScenarioProfileUpdater.ApplyEnrollmentFactsObserved(
|
||||
EnrollmentScenarioProfile.Empty, signal);
|
||||
|
||||
Assert.Equal(EnrollmentMode.SelfDeploying, profile.Mode);
|
||||
Assert.Equal(ProfileConfidence.High, profile.Confidence);
|
||||
Assert.Equal(EnrollmentScenarioProfileUpdater.CloudPcNoDeviceEspExpectedReason, profile.Reason);
|
||||
}
|
||||
|
||||
private static DecisionSignal MakeSignal(
|
||||
DecisionSignalKind kind,
|
||||
long ordinal,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="CloudPcDeviceValidator"/> — focused on pure-function pieces:
|
||||
/// JSON-to-DTO mapping (incl. exact-match guard on managedDeviceId) and cache key shape.
|
||||
/// The HTTP/cache/retry resilience is mirrored 1:1 from <c>AutopilotDeviceValidator</c>;
|
||||
/// behavioural drift would surface here. Response shapes match a real
|
||||
/// virtualEndpoint/cloudPCs payload (field-captured 2026-08-06 on a W365 tenant).
|
||||
/// </summary>
|
||||
public class CloudPcDeviceValidatorTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private const string DeviceId = "07623d56-1e77-4948-bff5-5bdac8167560";
|
||||
|
||||
// -- BuildCacheKey --
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_StableShape()
|
||||
{
|
||||
var key = CloudPcDeviceValidator.BuildCacheKey(TenantId, DeviceId);
|
||||
Assert.Equal($"cloudpc-device-validation:{TenantId}:{DeviceId}", key);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_DistinctFromOtherValidatorKeys()
|
||||
{
|
||||
// The validators must NOT share cache keys; otherwise a positive hit in one
|
||||
// would incorrectly satisfy a lookup in another.
|
||||
var key = CloudPcDeviceValidator.BuildCacheKey(TenantId, DeviceId);
|
||||
Assert.StartsWith("cloudpc-device-validation:", key);
|
||||
Assert.DoesNotContain("autopilot", key);
|
||||
Assert.DoesNotContain("device-association", key);
|
||||
}
|
||||
|
||||
// -- ParseCloudPcResponse: empty / missing / malformed --
|
||||
|
||||
[Fact]
|
||||
public void Parse_EmptyValueArray_NotFound()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse("{\"value\":[]}", DeviceId);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
Assert.Equal(DeviceId, result.IntuneDeviceId);
|
||||
Assert.Contains("not a Windows 365 Cloud PC", result.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MissingValueProperty_NotFound()
|
||||
{
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse("{}", DeviceId);
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MalformedJson_NotFound_NotTransient()
|
||||
{
|
||||
// Defensive: bad payload is treated as "not a Cloud PC", not as a transient
|
||||
// — transient handling is only triggered by HTTP-level failures.
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse("this is not json", DeviceId);
|
||||
Assert.False(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
}
|
||||
|
||||
// -- ParseCloudPcResponse: exact-match guard --
|
||||
|
||||
[Fact]
|
||||
public void Parse_DifferentManagedDeviceId_NotFound()
|
||||
{
|
||||
// Guards against widened filter semantics: even if Graph returns Cloud PCs whose
|
||||
// managedDeviceId does not exactly match the queried id, we must reject.
|
||||
var body = "{\"value\":[{\"id\":\"423a1fb4-6033-4b69-a31f-e7f3f54194dc\",\"managedDeviceId\":\"99999999-9999-9999-9999-999999999999\"}]}";
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
|
||||
Assert.False(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_ExactMatch_PopulatesAllFields()
|
||||
{
|
||||
var body = $@"{{
|
||||
""value"":[{{
|
||||
""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",
|
||||
""displayName"":""GKT-CloudPC - Obi-Wan Kenobi"",
|
||||
""managedDeviceId"":""{DeviceId}"",
|
||||
""managedDeviceName"":""CPC-cloud-AY5HT"",
|
||||
""userPrincipalName"":""cloudadmin@gktatooine.net"",
|
||||
""servicePlanName"":""Cloud PC Enterprise 2vCPU/4GB/128GB""
|
||||
}}]
|
||||
}}";
|
||||
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
|
||||
|
||||
Assert.True(result.IsValid);
|
||||
Assert.False(result.IsTransient);
|
||||
Assert.Equal(DeviceId, result.IntuneDeviceId);
|
||||
Assert.Equal("423a1fb4-6033-4b69-a31f-e7f3f54194dc", result.CloudPcId);
|
||||
Assert.Equal("CPC-cloud-AY5HT", result.ManagedDeviceName);
|
||||
Assert.Equal("Cloud PC Enterprise 2vCPU/4GB/128GB", result.ServicePlanName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_CaseInsensitiveIdMatch()
|
||||
{
|
||||
var body = $@"{{""value"":[{{""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",""managedDeviceId"":""{DeviceId.ToUpperInvariant()}""}}]}}";
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_TrimsWhitespace_OnGraphIdBeforeMatching()
|
||||
{
|
||||
var body = $@"{{""value"":[{{""id"":""423a1fb4-6033-4b69-a31f-e7f3f54194dc"",""managedDeviceId"":"" {DeviceId} ""}}]}}";
|
||||
var result = CloudPcDeviceValidator.ParseCloudPcResponse(body, DeviceId);
|
||||
Assert.True(result.IsValid);
|
||||
}
|
||||
}
|
||||
@@ -259,20 +259,21 @@ public class ContinuationTokenTests
|
||||
public void TryDecode_rejects_token_signed_with_a_different_key()
|
||||
{
|
||||
// Simulate key rotation: token issued under key A, validated under key B.
|
||||
// (We toggle the test key around the encode/decode pair.)
|
||||
// The scoped (AsyncLocal) override keeps the swap confined to this test's
|
||||
// execution context — mutating the process-wide test key here raced the
|
||||
// concurrently running pagination test classes.
|
||||
var keyA = new byte[32]; for (int i = 0; i < keyA.Length; i++) keyA[i] = (byte)(i * 3 + 1);
|
||||
var keyB = new byte[32]; for (int i = 0; i < keyB.Length; i++) keyB[i] = (byte)(i * 5 + 7);
|
||||
|
||||
var fp = ContinuationToken.ComputeFingerprint(FilterArgsA());
|
||||
|
||||
ContinuationToken.SetSigningKeyForTesting(keyA);
|
||||
ContinuationToken.SetScopedSigningKeyForTesting(keyA);
|
||||
var encoded = ContinuationToken.Encode(AzureToken, TenantA, fp);
|
||||
|
||||
ContinuationToken.SetSigningKeyForTesting(keyB);
|
||||
ContinuationToken.SetScopedSigningKeyForTesting(keyB);
|
||||
var ok = ContinuationToken.TryDecode(encoded, TenantA, fp, out _, out var reason);
|
||||
|
||||
// Restore the test default before leaving so subsequent tests aren't affected.
|
||||
TestSetup.Initialize();
|
||||
ContinuationToken.SetScopedSigningKeyForTesting(null);
|
||||
|
||||
Assert.False(ok);
|
||||
Assert.Equal("bad_signature", reason);
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
public class GatherRuleUpdatePartialMergeTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string RuleId = "GATHER-CUST-001";
|
||||
private const string RuleId = "GATHER-CUSTOM-001";
|
||||
|
||||
private static GatherRule ExistingCustomRule() => new()
|
||||
{
|
||||
|
||||
+39
-1
@@ -164,12 +164,49 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
Assert.Equal(3, (int)item.attemptCount);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Cloud PC marker aggregation
|
||||
// =========================================================================
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_IsCloudPc_StickyTrueAcrossGroup()
|
||||
{
|
||||
// One old agent (no field → false) + one new agent reporting the W365 marker:
|
||||
// the aggregate must say Cloud PC even though the MOST RECENT report is false —
|
||||
// same OR semantics as the Sessions-table IsCloudPc merge.
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0, isCloudPc: true),
|
||||
MakeEntry("DeviceNotRegistered", "Microsoft Corporation", "Virtual Machine", "SN1", T0.AddMinutes(5), isCloudPc: false),
|
||||
};
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.True((bool)item.isCloudPc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAggregatedResult_IsCloudPc_FalseWhenNoReportCarriesMarker()
|
||||
{
|
||||
var reports = new List<DistressReportEntry>
|
||||
{
|
||||
MakeEntry("DeviceNotRegistered", "Lenovo", "ThinkPad T14", "SN1", T0),
|
||||
};
|
||||
|
||||
var (aggregated, _) = GetDeviceNotRegisteredFunction.BuildAggregatedResult(reports);
|
||||
|
||||
var item = ToDynamic(aggregated[0]);
|
||||
Assert.False((bool)item.isCloudPc);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Helpers
|
||||
// =========================================================================
|
||||
|
||||
private static DistressReportEntry MakeEntry(
|
||||
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt) =>
|
||||
string errorType, string? manufacturer, string? model, string? serial, DateTime ingestedAt,
|
||||
bool isCloudPc = false) =>
|
||||
new()
|
||||
{
|
||||
TenantId = "test-tenant",
|
||||
@@ -179,6 +216,7 @@ public class GetDeviceNotRegisteredFunctionTests
|
||||
SerialNumber = serial,
|
||||
IngestedAt = ingestedAt,
|
||||
AgentTimestamp = ingestedAt,
|
||||
IsCloudPc = isCloudPc,
|
||||
};
|
||||
|
||||
private static dynamic ToDynamic(object obj)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Models.Graph;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Guard: the customer-side grant script (scripts/CustomerSetup/Grant-AutopilotMonitorAddOn.ps1)
|
||||
/// duplicates the feature→permission catalog from <see cref="GraphFeatureCatalog"/> — in its
|
||||
/// <c>$FeatureCatalog</c> hashtable AND in the <c>[ValidateSet(...)]</c> of <c>-Features</c>.
|
||||
/// The sync was previously comment-only ("Keep in lock-step with …") and had no enforcement;
|
||||
/// this test fails the build when either side drifts.
|
||||
/// </summary>
|
||||
public class GraphFeatureCatalogSyncTests
|
||||
{
|
||||
[Fact]
|
||||
public void GrantScript_FeatureCatalog_MatchesSharedCatalog()
|
||||
{
|
||||
var script = ReadGrantScript();
|
||||
|
||||
// Parse: $FeatureCatalog = @{ 'Name' = @('Perm', ...) ... }
|
||||
var blockMatch = Regex.Match(script, @"\$FeatureCatalog\s*=\s*@\{(?<body>[^}]*)\}", RegexOptions.Singleline);
|
||||
Assert.True(blockMatch.Success, "Could not locate the $FeatureCatalog hashtable in the grant script.");
|
||||
|
||||
var scriptCatalog = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (Match entry in Regex.Matches(blockMatch.Groups["body"].Value, @"'(?<feature>[^']+)'\s*=\s*@\((?<perms>[^)]*)\)"))
|
||||
{
|
||||
var perms = Regex.Matches(entry.Groups["perms"].Value, @"'(?<p>[^']+)'")
|
||||
.Select(m => m.Groups["p"].Value)
|
||||
.ToArray();
|
||||
scriptCatalog[entry.Groups["feature"].Value] = perms;
|
||||
}
|
||||
|
||||
Assert.Equal(
|
||||
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
|
||||
scriptCatalog.Keys.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
foreach (var feature in GraphFeatureCatalog.Features)
|
||||
{
|
||||
Assert.Equal(
|
||||
GraphFeatureCatalog.RequiredPermissions(feature).OrderBy(p => p, StringComparer.OrdinalIgnoreCase),
|
||||
scriptCatalog[feature].OrderBy(p => p, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GrantScript_ValidateSet_MatchesSharedCatalog()
|
||||
{
|
||||
var script = ReadGrantScript();
|
||||
|
||||
var vsMatch = Regex.Match(script, @"\[ValidateSet\((?<body>[^\)]*)\)\]");
|
||||
Assert.True(vsMatch.Success, "Could not locate the [ValidateSet(...)] of -Features in the grant script.");
|
||||
|
||||
var scriptFeatures = Regex.Matches(vsMatch.Groups["body"].Value, @"'(?<f>[^']+)'")
|
||||
.Select(m => m.Groups["f"].Value)
|
||||
.ToArray();
|
||||
|
||||
Assert.Equal(
|
||||
GraphFeatureCatalog.Features.OrderBy(f => f, StringComparer.OrdinalIgnoreCase),
|
||||
scriptFeatures.OrderBy(f => f, StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static string ReadGrantScript()
|
||||
{
|
||||
var path = Path.Combine(FindRepoRoot(), "scripts", "CustomerSetup", "Grant-AutopilotMonitorAddOn.ps1");
|
||||
Assert.True(File.Exists(path), $"Grant script not found at {path}");
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
|
||||
private static string FindRepoRoot()
|
||||
{
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AutopilotMonitor.sln")))
|
||||
{
|
||||
dir = dir.Parent;
|
||||
}
|
||||
Assert.NotNull(dir);
|
||||
return dir!.FullName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the dry-run (rule-authoring) evaluation path:
|
||||
/// 1. The trace evaluates EVERY condition (no early break) so authors see the full picture.
|
||||
/// 2. The verdict/confidence is outcome-equivalent to the production EvaluateRule path
|
||||
/// (parity tests via AnalyzeSessionAsync).
|
||||
/// 3. The dry-run is side-effect free: only the strict event read touches storage — never
|
||||
/// UpdateSessionStatusAsync, never a rule-result write.
|
||||
/// Plus the draft validation surface of <see cref="DryRunAnalyzeRuleFunction"/>.
|
||||
/// </summary>
|
||||
public class RuleEngineDryRunTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
// ===== Trace semantics =====
|
||||
|
||||
[Fact]
|
||||
public async Task Fired_FullTrace_WithFactorAndInterpolationEvidence()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.ConfidenceFactors = new List<ConfidenceFactor>
|
||||
{
|
||||
new() { Signal = "app_install_failed", Condition = "count >= 2", Weight = 15 },
|
||||
};
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
AppFailedEvent("app-1", "0x80070005"),
|
||||
AppFailedEvent("app-1", "0x80070005"),
|
||||
};
|
||||
|
||||
var (dry, sessionRepo, ruleRepo) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.Fired, dry.Verdict);
|
||||
Assert.Equal(2, dry.EventCount);
|
||||
Assert.Single(dry.Conditions);
|
||||
Assert.True(dry.Conditions[0].Matched);
|
||||
Assert.Equal(2, dry.Conditions[0].MatchingEventCount);
|
||||
Assert.IsType<Dictionary<string, object>>(dry.Conditions[0].Evidence);
|
||||
|
||||
Assert.Single(dry.ConfidenceFactors);
|
||||
Assert.True(dry.ConfidenceFactors[0].Matched);
|
||||
Assert.Equal(50 + 15, dry.FinalConfidence);
|
||||
|
||||
// Evidence map matches what production would persist — including the factor marker —
|
||||
// so clients can preview {{token}} interpolation against it.
|
||||
Assert.NotNull(dry.MatchedConditions);
|
||||
Assert.True(dry.MatchedConditions!.ContainsKey("app_failure"));
|
||||
Assert.True(dry.MatchedConditions.ContainsKey("factor_app_install_failed"));
|
||||
|
||||
// Side-effect freedom: exactly one strict event read, nothing else.
|
||||
sessionRepo.Verify(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>()), Times.Once);
|
||||
sessionRepo.VerifyNoOtherCalls();
|
||||
ruleRepo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequiredConditionNotMet_StillEvaluatesAllConditions()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions.Add(new RuleCondition
|
||||
{
|
||||
Signal = "os_edition", Source = "event_data", EventType = "os_info",
|
||||
DataField = "edition", Operator = "equals", Value = "Enterprise", Required = false,
|
||||
});
|
||||
var events = new List<EnrollmentEvent> { OsInfoEvent("Enterprise") }; // no app_install_failed
|
||||
|
||||
var (dry, _, _) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.RequiredConditionNotMet, dry.Verdict);
|
||||
// Production breaks on the first failed required condition; the dry-run keeps going —
|
||||
// the second (optional) condition must appear in the trace, matched.
|
||||
Assert.Equal(2, dry.Conditions.Count);
|
||||
Assert.False(dry.Conditions[0].Matched);
|
||||
Assert.Equal("no matching events", dry.Conditions[0].Evidence);
|
||||
Assert.Equal(0, dry.Conditions[0].MatchingEventCount);
|
||||
Assert.True(dry.Conditions[1].Matched);
|
||||
|
||||
Assert.Null(dry.FinalConfidence);
|
||||
Assert.Empty(dry.ConfidenceFactors); // factor stage never reached, mirrors production
|
||||
Assert.False(dry.WouldMarkSessionAsFailed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreconditionFails_VerdictSkipped_ConditionsStillTraced()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Preconditions = new List<RulePrecondition>
|
||||
{
|
||||
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" },
|
||||
};
|
||||
var events = new List<EnrollmentEvent>
|
||||
{
|
||||
HardwareSpecEvent(isVirtualMachine: "true"),
|
||||
AppFailedEvent("app-1", "0x1"),
|
||||
};
|
||||
|
||||
var (dry, _, _) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.SkippedByPrecondition, dry.Verdict);
|
||||
Assert.Single(dry.Preconditions);
|
||||
Assert.False(dry.Preconditions[0].Passed);
|
||||
// Author still sees that the condition WOULD have matched.
|
||||
Assert.Single(dry.Conditions);
|
||||
Assert.True(dry.Conditions[0].Matched);
|
||||
Assert.Null(dry.FinalConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BelowThreshold_ReportsFinalConfidence()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.BaseConfidence = 30;
|
||||
rule.ConfidenceThreshold = 60;
|
||||
var events = new List<EnrollmentEvent> { AppFailedEvent("app-1", "0x1") };
|
||||
|
||||
var (dry, _, _) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.BelowConfidenceThreshold, dry.Verdict);
|
||||
Assert.Equal(30, dry.FinalConfidence);
|
||||
Assert.Equal(60, dry.ConfidenceThreshold);
|
||||
Assert.False(dry.WouldMarkSessionAsFailed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoEvents_ShortCircuits()
|
||||
{
|
||||
var (dry, _, _) = await DryRunAsync(MakeRule(), new List<EnrollmentEvent>());
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.NoEvents, dry.Verdict);
|
||||
Assert.Equal(0, dry.EventCount);
|
||||
Assert.Empty(dry.Conditions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AllOptionalNoneMatch_VerdictNoConditionsMatched()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions[0].Required = false;
|
||||
var events = new List<EnrollmentEvent> { OsInfoEvent("Pro") };
|
||||
|
||||
var (dry, _, _) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.NoConditionsMatched, dry.Verdict);
|
||||
Assert.Null(dry.FinalConfidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Fired_WithMarkSessionAsFailed_OnlyReportsFlag()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.MarkSessionAsFailedDefault = true;
|
||||
var events = new List<EnrollmentEvent> { AppFailedEvent("app-1", "0x1") };
|
||||
|
||||
var (dry, sessionRepo, _) = await DryRunAsync(rule, events);
|
||||
|
||||
Assert.Equal(RuleDryRunVerdict.Fired, dry.Verdict);
|
||||
Assert.True(dry.WouldMarkSessionAsFailed);
|
||||
// The flag is REPORTED, never acted upon: no session read, no status write.
|
||||
sessionRepo.Verify(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>()), Times.Once);
|
||||
sessionRepo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
// ===== Parity with the production path =====
|
||||
|
||||
public static TheoryData<string> ParityScenarios => new() { "fires", "required_miss", "below_threshold", "precondition_skip" };
|
||||
|
||||
/// <summary>
|
||||
/// The dry-run verdict must be outcome-equivalent to EvaluateRule (via AnalyzeSessionAsync):
|
||||
/// fired ⇔ a RuleResult is produced, and on fire the confidence and evidence keys are identical.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(ParityScenarios))]
|
||||
public async Task DryRunVerdict_MatchesProductionOutcome(string scenario)
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.ConfidenceFactors = new List<ConfidenceFactor>
|
||||
{
|
||||
new() { Signal = "app_install_failed", Condition = "count >= 2", Weight = 15 },
|
||||
};
|
||||
List<EnrollmentEvent> events = scenario switch
|
||||
{
|
||||
"fires" => new() { AppFailedEvent("a", "0x1"), AppFailedEvent("a", "0x1") },
|
||||
"required_miss" => new() { OsInfoEvent("Pro") },
|
||||
"below_threshold" => new() { AppFailedEvent("a", "0x1") },
|
||||
"precondition_skip" => new() { HardwareSpecEvent("true"), AppFailedEvent("a", "0x1") },
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(scenario)),
|
||||
};
|
||||
if (scenario == "below_threshold") { rule.BaseConfidence = 30; rule.ConfidenceThreshold = 60; }
|
||||
if (scenario == "precondition_skip")
|
||||
{
|
||||
rule.Preconditions = new List<RulePrecondition>
|
||||
{
|
||||
new() { Source = "event_data", EventType = "hardware_spec", DataField = "isVirtualMachine", Operator = "equals", Value = "false" },
|
||||
};
|
||||
}
|
||||
|
||||
var production = await RunProductionAsync(rule, events);
|
||||
var (dry, _, _) = await DryRunAsync(rule, events);
|
||||
|
||||
var productionFired = production.Results.Count == 1;
|
||||
Assert.Equal(productionFired, dry.Verdict == RuleDryRunVerdict.Fired);
|
||||
|
||||
if (productionFired)
|
||||
{
|
||||
var result = production.Results[0];
|
||||
Assert.Equal(result.ConfidenceScore, dry.FinalConfidence);
|
||||
Assert.Equal(
|
||||
result.MatchedConditions.Keys.OrderBy(k => k),
|
||||
dry.MatchedConditions!.Keys.OrderBy(k => k));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Draft validation (DryRunAnalyzeRuleFunction.ValidateDraftRule) =====
|
||||
|
||||
[Fact]
|
||||
public void Validate_ValidRule_NoErrors()
|
||||
{
|
||||
Assert.Empty(DryRunAnalyzeRuleFunction.ValidateDraftRule(MakeRule()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NullRule_Error()
|
||||
{
|
||||
Assert.Contains("rule is required", DryRunAnalyzeRuleFunction.ValidateDraftRule(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_NoConditions_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions.Clear();
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("at least one condition"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Event_Type")] // wrong case — the evaluator switch is case-sensitive
|
||||
[InlineData("eventtype")]
|
||||
[InlineData("")]
|
||||
public void Validate_UnknownOrMissingSource_Error(string source)
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions[0].Source = source;
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("source"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_UnknownOperator_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions[0].Operator = "starts_with";
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("unknown operator 'starts_with'"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CorrelationWithoutJoinField_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions[0].Source = "event_correlation";
|
||||
rule.Conditions[0].CorrelateEventType = "app_install_completed";
|
||||
rule.Conditions[0].JoinField = "";
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("joinField"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_InvalidRegex_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions[0].Operator = "regex";
|
||||
rule.Conditions[0].Value = "([unclosed";
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("not a valid regex"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("count > 3")] // wrong comparator
|
||||
[InlineData("Count >= 3")] // wrong case
|
||||
[InlineData("phase_duration >= 3")] // wrong comparator
|
||||
[InlineData("always")]
|
||||
public void Validate_UnsupportedFactorCondition_Error(string condition)
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.ConfidenceFactors = new List<ConfidenceFactor> { new() { Signal = "x", Condition = condition, Weight = 10 } };
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("not evaluable"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("exists")]
|
||||
[InlineData("count >= 3")]
|
||||
[InlineData("count >=3")]
|
||||
[InlineData("phase_duration > 300")]
|
||||
public void Validate_SupportedFactorCondition_NoError(string condition)
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.ConfidenceFactors = new List<ConfidenceFactor> { new() { Signal = "x", Condition = condition, Weight = 10 } };
|
||||
Assert.Empty(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DuplicateSignals_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.Conditions.Add(new RuleCondition
|
||||
{
|
||||
Signal = "app_failure", Source = "event_type", EventType = "os_info", Operator = "exists", Required = false,
|
||||
});
|
||||
Assert.Contains(DryRunAnalyzeRuleFunction.ValidateDraftRule(rule), e => e.Contains("duplicate signal"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ConfidenceOutOfRange_Error()
|
||||
{
|
||||
var rule = MakeRule();
|
||||
rule.BaseConfidence = 120;
|
||||
rule.ConfidenceThreshold = -5;
|
||||
var errors = DryRunAnalyzeRuleFunction.ValidateDraftRule(rule);
|
||||
Assert.Contains(errors, e => e.Contains("baseConfidence"));
|
||||
Assert.Contains(errors, e => e.Contains("confidenceThreshold"));
|
||||
}
|
||||
|
||||
// ===== Helpers =====
|
||||
|
||||
/// <summary>Simple single-condition rule: required event_type match on app_install_failed,
|
||||
/// base 50 / threshold 40 — fires whenever the event exists.</summary>
|
||||
private static AnalyzeRule MakeRule() => new()
|
||||
{
|
||||
RuleId = "ANALYZE-TEST-001",
|
||||
Title = "Test rule",
|
||||
Severity = "warning",
|
||||
Category = "apps",
|
||||
// Drafts behave like tenant custom rules; also keeps the parity path clear of the
|
||||
// live-catalog sunset filter in GetAllRulesForTenantAsync (built-in ids only).
|
||||
IsBuiltIn = false,
|
||||
BaseConfidence = 50,
|
||||
ConfidenceThreshold = 40,
|
||||
Conditions = new List<RuleCondition>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Signal = "app_failure", Source = "event_type", EventType = "app_install_failed",
|
||||
Operator = "exists", Value = "", Required = true,
|
||||
},
|
||||
},
|
||||
Explanation = "App {{appId}} failed with {{errorCode}}.",
|
||||
};
|
||||
|
||||
private static EnrollmentEvent AppFailedEvent(string appId, string errorCode) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "app_install_failed",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 1,
|
||||
Data = new Dictionary<string, object> { ["appId"] = appId, ["errorCode"] = errorCode },
|
||||
};
|
||||
|
||||
private static EnrollmentEvent OsInfoEvent(string edition) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "os_info",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 2,
|
||||
Data = new Dictionary<string, object> { ["edition"] = edition },
|
||||
};
|
||||
|
||||
private static EnrollmentEvent HardwareSpecEvent(string isVirtualMachine) => new()
|
||||
{
|
||||
EventId = Guid.NewGuid().ToString(),
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
EventType = "hardware_spec",
|
||||
Timestamp = DateTime.UtcNow,
|
||||
Sequence = 3,
|
||||
Data = new Dictionary<string, object> { ["isVirtualMachine"] = isVirtualMachine },
|
||||
};
|
||||
|
||||
private static (RuleEngine engine, Mock<ISessionRepository> sessionRepo, Mock<IRuleRepository> ruleRepo) MakeEngine(List<EnrollmentEvent> events)
|
||||
{
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleEngineDryRunTests>.Instance);
|
||||
return (engine, sessionRepo, ruleRepo);
|
||||
}
|
||||
|
||||
private static async Task<(RuleDryRun dry, Mock<ISessionRepository> sessionRepo, Mock<IRuleRepository> ruleRepo)> DryRunAsync(
|
||||
AnalyzeRule rule, List<EnrollmentEvent> events)
|
||||
{
|
||||
var (engine, sessionRepo, ruleRepo) = MakeEngine(events);
|
||||
var dry = await engine.DryRunRuleAsync(TenantId, SessionId, rule);
|
||||
return (dry, sessionRepo, ruleRepo);
|
||||
}
|
||||
|
||||
private static async Task<AnalysisOutcome> RunProductionAsync(AnalyzeRule rule, List<EnrollmentEvent> events)
|
||||
{
|
||||
var ruleRepo = new Mock<IRuleRepository>();
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync("global")).ReturnsAsync(new List<AnalyzeRule>());
|
||||
ruleRepo.Setup(r => r.GetAnalyzeRulesAsync(TenantId)).ReturnsAsync(new List<AnalyzeRule> { rule });
|
||||
ruleRepo.Setup(r => r.GetRuleStatesAsync(It.IsAny<string>())).ReturnsAsync(new Dictionary<string, RuleState>());
|
||||
ruleRepo.Setup(r => r.GetRuleResultsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(new List<RuleResult>());
|
||||
var sessionRepo = new Mock<ISessionRepository>();
|
||||
sessionRepo.Setup(s => s.GetSessionEventsStrictAsync(TenantId, SessionId, It.IsAny<int>())).ReturnsAsync(events);
|
||||
var ruleService = new AnalyzeRuleService(ruleRepo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
var engine = new RuleEngine(ruleService, ruleRepo.Object, sessionRepo.Object, NullLogger<RuleEngineDryRunTests>.Instance);
|
||||
return await engine.AnalyzeSessionAsync(TenantId, SessionId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the reserved built-in rule-ID namespace: tenant custom rules must never
|
||||
/// occupy (ANALYZE|GATHER)-<CATEGORY>-<NUMBER> — a gap or retired ID there can be
|
||||
/// re-shipped as a built-in later, which would silently shadow the tenant's copy at
|
||||
/// merge time. The CUSTOM category ("ANALYZE-CUSTOM-001") is the sanctioned tenant
|
||||
/// namespace; the platform never ships built-ins in a category named CUSTOM.
|
||||
/// Enforced on BOTH the create path and the custom-branch of the update path
|
||||
/// (updates upsert, so a PUT would otherwise bypass the create check).
|
||||
/// </summary>
|
||||
public class RuleIdReservationTests
|
||||
{
|
||||
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
[Theory]
|
||||
[InlineData("ANALYZE-SEC-002")] // retired gap — the incident that motivated the policy
|
||||
[InlineData("ANALYZE-APP-999")] // unused number in an existing category
|
||||
[InlineData("GATHER-DEVICE-006")]
|
||||
[InlineData("analyze-sec-002")] // case variants are equally confusing → reserved
|
||||
[InlineData("Gather-Net-001")]
|
||||
[InlineData("ANALYZE-NEWCAT-001")] // future built-in categories are covered too
|
||||
public void Reserved_builtin_ids_are_detected(string ruleId)
|
||||
=> Assert.True(RuleIdPolicy.IsReservedBuiltInId(ruleId));
|
||||
|
||||
[Theory]
|
||||
[InlineData("ANALYZE-CUSTOM-001")] // sanctioned tenant namespace (portal suggestion)
|
||||
[InlineData("GATHER-CUSTOM-042")]
|
||||
[InlineData("ANALYZE-ID-001-CUSTOM")] // template copies end in -CUSTOM, not a number
|
||||
[InlineData("CONTOSO-WIFI-001")] // organization prefix
|
||||
[InlineData("ANALYZE-SIDECAR-PROVIDER")] // no trailing number (existing prod custom rule)
|
||||
[InlineData("dcu-restart-required")] // freeform (existing prod custom rule)
|
||||
[InlineData("")]
|
||||
public void Custom_schemes_are_allowed(string ruleId)
|
||||
=> Assert.False(RuleIdPolicy.IsReservedBuiltInId(ruleId));
|
||||
|
||||
// ---- create path -------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_create_rejects_reserved_id_before_touching_storage()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.CreateRuleAsync(TenantId, new AnalyzeRule { RuleId = "ANALYZE-SEC-002" }));
|
||||
|
||||
Assert.Contains("reserved", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
repo.VerifyNoOtherCalls(); // rejected before any storage lookup
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_create_rejects_reserved_id_before_touching_storage()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.CreateRuleAsync(TenantId, new GatherRule { RuleId = "GATHER-NET-099" }));
|
||||
|
||||
Assert.Contains("reserved", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_create_still_accepts_custom_namespace()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
repo.Setup(r => r.AnalyzeRuleExistsAsync(It.IsAny<string>(), It.IsAny<string>())).ReturnsAsync(false);
|
||||
repo.Setup(r => r.StoreAnalyzeRuleAsync(It.IsAny<AnalyzeRule>(), TenantId)).ReturnsAsync(true);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
Assert.True(await service.CreateRuleAsync(TenantId, new AnalyzeRule { RuleId = "ANALYZE-CUSTOM-001" }));
|
||||
repo.Verify(r => r.StoreAnalyzeRuleAsync(It.Is<AnalyzeRule>(x => x.RuleId == "ANALYZE-CUSTOM-001"), TenantId), Times.Once);
|
||||
}
|
||||
|
||||
// ---- update path (upserts — must not be a bypass) ----------------------
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_update_custom_branch_rejects_reserved_id()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Strict);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
// IsBuiltIn=false routes into the custom-rule upsert branch.
|
||||
var rule = new AnalyzeRule { RuleId = "ANALYZE-SEC-002", IsBuiltIn = false, IsCommunity = false };
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateRuleAsync(TenantId, rule));
|
||||
repo.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Analyze_update_builtin_state_toggle_is_unaffected()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
repo.Setup(r => r.StoreRuleStateAsync(TenantId, "ANALYZE-SEC-001", It.IsAny<RuleState>())).ReturnsAsync(true);
|
||||
var service = new AnalyzeRuleService(repo.Object, NullLogger<AnalyzeRuleService>.Instance);
|
||||
|
||||
// Disabling a real built-in stays possible — that flow stores per-tenant state only.
|
||||
var rule = new AnalyzeRule { RuleId = "ANALYZE-SEC-001", IsBuiltIn = true, Enabled = false };
|
||||
|
||||
Assert.True(await service.UpdateRuleAsync(TenantId, rule));
|
||||
repo.Verify(r => r.StoreRuleStateAsync(TenantId, "ANALYZE-SEC-001", It.IsAny<RuleState>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_update_custom_branch_rejects_reserved_id_when_no_global_row_exists()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
// Retired scenario: the ID is reserved but no global row exists anymore.
|
||||
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>());
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
|
||||
var rule = new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-DEVICE-099",
|
||||
Title = "squat",
|
||||
CollectorType = "registry",
|
||||
Target = "HKLM\\SOFTWARE\\Microsoft\\Provisioning",
|
||||
};
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => service.UpdateRuleAsync(TenantId, rule));
|
||||
repo.Verify(r => r.StoreGatherRuleAsync(It.IsAny<GatherRule>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Gather_update_builtin_state_toggle_is_unaffected()
|
||||
{
|
||||
var repo = new Mock<IRuleRepository>(MockBehavior.Loose);
|
||||
repo.Setup(r => r.GetGatherRulesAsync("global")).ReturnsAsync(new List<GatherRule>
|
||||
{
|
||||
new() { RuleId = "GATHER-DEVICE-001", IsBuiltIn = true },
|
||||
});
|
||||
repo.Setup(r => r.StoreRuleStateAsync(TenantId, "GATHER-DEVICE-001", It.IsAny<RuleState>())).ReturnsAsync(true);
|
||||
var service = new GatherRuleService(repo.Object, NullLogger<GatherRuleService>.Instance);
|
||||
|
||||
var rule = new GatherRule { RuleId = "GATHER-DEVICE-001", Enabled = false };
|
||||
|
||||
Assert.True(await service.UpdateRuleAsync(TenantId, rule));
|
||||
repo.Verify(r => r.StoreRuleStateAsync(TenantId, "GATHER-DEVICE-001", It.IsAny<RuleState>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -90,4 +90,55 @@ public class SecurityValidatorTests
|
||||
{
|
||||
Assert.False(SecurityValidator.IsValidGuid(payload));
|
||||
}
|
||||
|
||||
// --- Intune device id extraction from the MDM client certificate subject ---
|
||||
// Intune MDM Device CA certs carry the Intune managedDevice id as the Subject CN
|
||||
// (field-verified 2026-08-06 on a W365 Cloud PC). This is the identity anchor for the
|
||||
// CloudPc validator, so the parse must be strict: CN present AND a canonical GUID.
|
||||
|
||||
[Fact]
|
||||
public void TryGetIntuneDeviceId_PlainCnGuid_Extracts()
|
||||
{
|
||||
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
|
||||
"CN=07623d56-1e77-4948-bff5-5bdac8167560", out var id));
|
||||
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetIntuneDeviceId_UppercaseGuid_NormalizedToLower()
|
||||
{
|
||||
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
|
||||
"CN=07623D56-1E77-4948-BFF5-5BDAC8167560", out var id));
|
||||
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetIntuneDeviceId_MultiRdnSubject_FindsCn()
|
||||
{
|
||||
Assert.True(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
|
||||
"O=Contoso, CN=07623d56-1e77-4948-bff5-5bdac8167560, C=DE", out var id));
|
||||
Assert.Equal("07623d56-1e77-4948-bff5-5bdac8167560", id);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("O=Contoso, C=DE")] // no CN at all
|
||||
[InlineData("CN=not-a-guid")] // CN present but not a GUID
|
||||
[InlineData("CN=hostname.contoso.com")] // typical non-Intune device cert
|
||||
[InlineData("CN=' or '1'='1")] // injection payload never survives
|
||||
public void TryGetIntuneDeviceId_InvalidSubjects_ReturnFalse(string? subject)
|
||||
{
|
||||
Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(subject, out var id));
|
||||
Assert.Null(id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetIntuneDeviceId_NonGuidFirstCn_IsDefinitive()
|
||||
{
|
||||
// A first CN that is not a GUID means "not an Intune MDM device cert shape" — a
|
||||
// later GUID-valued CN must NOT resurrect the parse (no CN-shopping).
|
||||
Assert.False(SecurityValidator.TryGetIntuneDeviceIdFromCertSubject(
|
||||
"CN=hostname, CN=07623d56-1e77-4948-bff5-5bdac8167560", out _));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ public class SessionIndexProjectionTests
|
||||
["ExcessiveEventsAutoActioned"] = true,
|
||||
// Self-deploying/kiosk profile marker (session 320b3bf7) — search-filterable.
|
||||
["IsSelfDeployingProfile"] = true,
|
||||
// W365 Cloud PC marker (agent-detected, sticky-true) — search-filterable.
|
||||
["IsCloudPc"] = true,
|
||||
// Device-validation path recorded at registration — shown on the session detail page.
|
||||
["ValidatedBy"] = "AutopilotV1",
|
||||
// Agent→backend HTTP latency projection (UpdateSessionNetworkLatencyAsync merges these).
|
||||
@@ -72,6 +74,7 @@ public class SessionIndexProjectionTests
|
||||
Assert.True(idx.GetBoolean("ExcessiveEventsAlerted"));
|
||||
Assert.True(idx.GetBoolean("ExcessiveEventsAutoActioned"));
|
||||
Assert.True(idx.GetBoolean("IsSelfDeployingProfile"));
|
||||
Assert.True(idx.GetBoolean("IsCloudPc"));
|
||||
Assert.Equal("AutopilotV1", idx.GetString("ValidatedBy"));
|
||||
Assert.Equal(513.5, idx.GetDouble("AvgApiLatencyMs"));
|
||||
Assert.Equal(100, idx.GetInt32("ApiRequestCount"));
|
||||
@@ -103,6 +106,7 @@ public class SessionIndexProjectionTests
|
||||
Assert.Equal(0, idx.GetInt32("RebootCount"));
|
||||
Assert.False(idx.GetBoolean("ExcessiveEventsAlerted"));
|
||||
Assert.False(idx.GetBoolean("IsSelfDeployingProfile"));
|
||||
Assert.False(idx.GetBoolean("IsCloudPc"));
|
||||
Assert.Equal(string.Empty, idx.GetString("ImeAgentVersion"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,16 @@ public class SessionStatusTransitionTests
|
||||
Assert.True(TableStorageService.IsTerminalTransitionAllowed("Succeeded", incoming));
|
||||
}
|
||||
|
||||
// ---- Pending is idempotent: the agent emits whiteglove_complete twice (raw Shell-Core
|
||||
// event + engine timeline event) — the second Pending write must not count as a
|
||||
// transition, it fired duplicate Pre-Provisioning Completed webhooks ----
|
||||
[Fact]
|
||||
public void Pending_over_Pending_is_refused()
|
||||
{
|
||||
Assert.False(TableStorageService.IsTerminalTransitionAllowed("Pending", SessionStatus.Pending));
|
||||
Assert.True(TableStorageService.IsTerminalTransitionAllowed("InProgress", SessionStatus.Pending));
|
||||
}
|
||||
|
||||
// ================= ComputeReconcileReason =================
|
||||
// The reconcile hygiene clears FailureReason/FailureSnapshotJson on every Succeeded write;
|
||||
// ReconcileReason is what keeps a backend-declared success explainable (session 294ab5b4).
|
||||
|
||||
+63
@@ -168,6 +168,69 @@ public class StoreSessionReregistrationPreserveTests
|
||||
Assert.True(harness.Written!.GetBoolean("IsSelfDeployingProfile"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_cloudPc_isStickyTrue_acrossReregistration()
|
||||
{
|
||||
// W365 marker mirrors the self-deploying sticky-true contract: once a boot observed
|
||||
// the Cloud PC markers, a later re-registration whose registry/service probe degraded
|
||||
// (registration.IsCloudPc=false) must NOT clear the fact.
|
||||
var existing = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "InProgress",
|
||||
["IsCloudPc"] = true,
|
||||
};
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var registration = new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
SerialNumber = "SN-1",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Model-X",
|
||||
DeviceName = "PC-1",
|
||||
IsCloudPc = false, // fresh marker read degraded
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StoreSessionAsync_cloudPc_upgradesFalseToTrue_onReregistration()
|
||||
{
|
||||
var existing = new TableEntity(TenantId, SessionId)
|
||||
{
|
||||
["StartedAt"] = new DateTimeOffset(new DateTime(2026, 1, 1, 8, 0, 0, DateTimeKind.Utc)),
|
||||
["Status"] = "InProgress",
|
||||
["IsCloudPc"] = false,
|
||||
};
|
||||
existing.ETag = new ETag("0xEXISTING");
|
||||
var harness = new Harness(existing);
|
||||
|
||||
var registration = new SessionRegistration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
SessionId = SessionId,
|
||||
StartedAt = new DateTime(2026, 1, 1, 9, 0, 0, DateTimeKind.Utc),
|
||||
SerialNumber = "SN-1",
|
||||
Manufacturer = "Contoso",
|
||||
Model = "Model-X",
|
||||
DeviceName = "PC-1",
|
||||
IsCloudPc = true,
|
||||
};
|
||||
|
||||
var ok = await harness.Sut.StoreSessionAsync(registration);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.True(harness.Written!.GetBoolean("IsCloudPc"));
|
||||
}
|
||||
|
||||
// ============================================================ Harness ====
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Functions.Rules;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the agent-parity semantics of the logparser pattern test endpoint: case-sensitive
|
||||
/// matching (the field incident that motivated the endpoint: a pattern verified with a PHP
|
||||
/// regex tester behaved differently under .NET), first-match-per-line, group extraction
|
||||
/// identical to what LogParserCollector puts into the emitted event data, CMTrace parsing
|
||||
/// via the SHARED CmTraceLogParser, and the format=text hint.
|
||||
/// </summary>
|
||||
public class TestLogPatternFunctionTests
|
||||
{
|
||||
private static Regex AgentRegex(string pattern)
|
||||
=> new(pattern, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
|
||||
private const string CmTraceLine =
|
||||
"<![LOG[Installation failed for app Ivanti Secure Access Client with error 0x87D1041C]LOG]!>" +
|
||||
"<time=\"07:48:35.1783405\" date=\"8-4-2026\" component=\"AppWorkload\" context=\"\" type=\"3\" thread=\"42\" file=\"\">";
|
||||
|
||||
// ===== text mode =====
|
||||
|
||||
[Fact]
|
||||
public void TextMode_NamedGroups_LandInGroups()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex(@"(?<level>ERROR|FATAL)\s+(?<component>\w+):\s+(?<message>.+)"),
|
||||
isTextMode: true,
|
||||
new[] { "ERROR Installer: disk full", "INFO Installer: all good" });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
Assert.Equal("matched", result.Lines[0].Outcome);
|
||||
Assert.Equal("ERROR", result.Lines[0].Groups!["level"]);
|
||||
Assert.Equal("Installer", result.Lines[0].Groups!["component"]);
|
||||
Assert.Equal("disk full", result.Lines[0].Groups!["message"]);
|
||||
Assert.Equal("no_match", result.Lines[1].Outcome);
|
||||
}
|
||||
|
||||
/// <summary>The motivating field case: logparser matching has NO IgnoreCase — a pattern
|
||||
/// that matched in a case-insensitive tester silently never fires on the device.</summary>
|
||||
[Fact]
|
||||
public void TextMode_IsCaseSensitive_AndSaysSoInTheNote()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: true, new[] { "error: something broke" });
|
||||
|
||||
Assert.Equal(0, result.MatchCount);
|
||||
Assert.Contains(result.Notes, n => n.Contains("case-SENSITIVE"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TextMode_UnsuccessfulOptionalGroup_IsOmitted()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex(@"ERROR(?<code>\s0x[0-9A-F]+)?"), isTextMode: true, new[] { "ERROR without code" });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
Assert.False(result.Lines[0].Groups!.ContainsKey("code"));
|
||||
}
|
||||
|
||||
// ===== cmtrace mode =====
|
||||
|
||||
[Fact]
|
||||
public void CmTraceMode_MatchesAgainstParsedMessage_AndReportsComponentAndType()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex(@"error (?<errorCode>0x[0-9A-Fa-f]+)"), isTextMode: false, new[] { CmTraceLine });
|
||||
|
||||
Assert.Equal(1, result.MatchCount);
|
||||
var row = result.Lines[0];
|
||||
Assert.Equal("matched", row.Outcome);
|
||||
Assert.Equal("0x87D1041C", row.Groups!["errorCode"]);
|
||||
Assert.Equal("AppWorkload", row.Component);
|
||||
Assert.Equal(3, row.CmTraceType);
|
||||
Assert.Contains("Installation failed", row.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CmTraceMode_RegexDoesNotSeeTheEnvelope()
|
||||
{
|
||||
// "component=" only exists in the CMTrace envelope, never in the message the
|
||||
// agent matches against — a pattern targeting it must NOT match.
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("component="), isTextMode: false, new[] { CmTraceLine });
|
||||
|
||||
Assert.Equal(0, result.MatchCount);
|
||||
Assert.Equal("no_match", result.Lines[0].Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CmTraceMode_PlainTextLines_ParseFail_WithFormatHint()
|
||||
{
|
||||
var result = TestLogPatternFunction.EvaluatePattern(
|
||||
AgentRegex("ERROR"), isTextMode: false, new[] { "ERROR plain line", "another plain line" });
|
||||
|
||||
Assert.Equal(2, result.ParseFailureCount);
|
||||
Assert.All(result.Lines, l => Assert.Equal("parse_failed", l.Outcome));
|
||||
Assert.Contains(result.Notes, n => n.Contains("format=text"));
|
||||
}
|
||||
|
||||
// ===== request validation =====
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingPatternAndLines()
|
||||
{
|
||||
Assert.Contains("pattern", TestLogPatternFunction.ValidateRequest(
|
||||
new TestLogPatternFunction.TestLogPatternRequest { SampleLines = new List<string> { "x" } })!);
|
||||
Assert.Contains("sampleLines", TestLogPatternFunction.ValidateRequest(
|
||||
new TestLogPatternFunction.TestLogPatternRequest { Pattern = "a" })!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsTooManyLines_AndBadFormat()
|
||||
{
|
||||
var tooMany = new TestLogPatternFunction.TestLogPatternRequest
|
||||
{
|
||||
Pattern = "a",
|
||||
SampleLines = Enumerable.Repeat("line", TestLogPatternFunction.MaxSampleLines + 1).ToList(),
|
||||
};
|
||||
Assert.Contains("at most", TestLogPatternFunction.ValidateRequest(tooMany)!);
|
||||
|
||||
var badFormat = new TestLogPatternFunction.TestLogPatternRequest
|
||||
{
|
||||
Pattern = "a",
|
||||
Format = "json",
|
||||
SampleLines = new List<string> { "x" },
|
||||
};
|
||||
Assert.Contains("format", TestLogPatternFunction.ValidateRequest(badFormat)!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsBothFormatsAndDefault()
|
||||
{
|
||||
foreach (var format in new[] { null, "cmtrace", "text", "TEXT" })
|
||||
{
|
||||
Assert.Null(TestLogPatternFunction.ValidateRequest(new TestLogPatternFunction.TestLogPatternRequest
|
||||
{
|
||||
Pattern = "a",
|
||||
Format = format,
|
||||
SampleLines = new List<string> { "x" },
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -603,6 +603,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "ValidateAutopilotDevice", config.ValidateAutopilotDevice },
|
||||
{ "ValidateCorporateIdentifier", config.ValidateCorporateIdentifier },
|
||||
{ "ValidateDeviceAssociation", config.ValidateDeviceAssociation },
|
||||
{ "ValidateCloudPcDevice", config.ValidateCloudPcDevice },
|
||||
{ "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests },
|
||||
{ "DataRetentionDays", config.DataRetentionDays },
|
||||
{ "SessionTimeoutHours", config.SessionTimeoutHours },
|
||||
@@ -704,6 +705,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
ValidateAutopilotDevice = entity.GetBoolean("ValidateAutopilotDevice") ?? entity.GetBoolean("ValidateSerialNumber") ?? false,
|
||||
ValidateCorporateIdentifier = entity.GetBoolean("ValidateCorporateIdentifier") ?? false,
|
||||
ValidateDeviceAssociation = entity.GetBoolean("ValidateDeviceAssociation") ?? false,
|
||||
ValidateCloudPcDevice = entity.GetBoolean("ValidateCloudPcDevice") ?? false,
|
||||
AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false,
|
||||
DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90,
|
||||
SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5,
|
||||
|
||||
+2
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
["AgentTimestamp"] = entry.AgentTimestamp,
|
||||
["IngestedAt"] = entry.IngestedAt,
|
||||
["SourceIp"] = Truncate(entry.SourceIp, 45), // max IPv6 length
|
||||
["IsCloudPc"] = entry.IsCloudPc,
|
||||
["CertSourceState"] = Truncate(entry.CertSourceState, 32),
|
||||
["CertThumbprint"] = Truncate(entry.CertThumbprint, 40),
|
||||
["CertSubject"] = Truncate(entry.CertSubject, 96),
|
||||
@@ -125,6 +126,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
AgentTimestamp = entity.GetDateTimeOffset("AgentTimestamp")?.UtcDateTime ?? DateTime.MinValue,
|
||||
IngestedAt = entity.GetDateTimeOffset("IngestedAt")?.UtcDateTime ?? DateTime.MinValue,
|
||||
SourceIp = entity.GetString("SourceIp"),
|
||||
IsCloudPc = entity.GetBoolean("IsCloudPc") ?? false,
|
||||
CertSourceState = entity.GetString("CertSourceState"),
|
||||
CertThumbprint = entity.GetString("CertThumbprint"),
|
||||
CertSubject = entity.GetString("CertSubject"),
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
private readonly DeviceAssociationValidator _deviceAssociationValidator;
|
||||
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly KillSwitchEvaluator _killSwitchEvaluator;
|
||||
|
||||
@@ -38,6 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
DeviceAssociationValidator deviceAssociationValidator,
|
||||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
KillSwitchEvaluator killSwitchEvaluator)
|
||||
{
|
||||
@@ -50,6 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
_deviceAssociationValidator = deviceAssociationValidator;
|
||||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_killSwitchEvaluator = killSwitchEvaluator;
|
||||
}
|
||||
@@ -84,7 +87,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
_corporateIdentifierValidator,
|
||||
_logger,
|
||||
bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator
|
||||
deviceAssociationValidator: _deviceAssociationValidator,
|
||||
cloudPcDeviceValidator: _cloudPcDeviceValidator
|
||||
);
|
||||
|
||||
if (errorResponse != null)
|
||||
|
||||
+5
-1
@@ -52,6 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
private readonly DeviceAssociationValidator _deviceAssociationValidator;
|
||||
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly HostedDiagnosticsBlobService _hostedDiagnostics;
|
||||
|
||||
@@ -63,6 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
DeviceAssociationValidator deviceAssociationValidator,
|
||||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
HostedDiagnosticsBlobService hostedDiagnostics)
|
||||
{
|
||||
@@ -73,6 +75,7 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
_deviceAssociationValidator = deviceAssociationValidator;
|
||||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_hostedDiagnostics = hostedDiagnostics;
|
||||
}
|
||||
@@ -123,7 +126,8 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
_corporateIdentifierValidator,
|
||||
_logger,
|
||||
bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator
|
||||
deviceAssociationValidator: _deviceAssociationValidator,
|
||||
cloudPcDeviceValidator: _cloudPcDeviceValidator
|
||||
);
|
||||
|
||||
if (errorResponse != null)
|
||||
|
||||
+3
@@ -125,6 +125,9 @@ public class GetGraphPermissionsStatusFunction
|
||||
["ScriptDisplayNamesGranted"] = GraphFeatureCatalog
|
||||
.IsFeatureGranted(GraphFeatureCatalog.FeatureScriptDisplayNames, snapshot.GrantedRoles)
|
||||
.ToString(CultureInfo.InvariantCulture),
|
||||
["W365CloudPcValidationGranted"] = GraphFeatureCatalog
|
||||
.IsFeatureGranted(GraphFeatureCatalog.FeatureW365CloudPcValidation, snapshot.GrantedRoles)
|
||||
.ToString(CultureInfo.InvariantCulture),
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -49,6 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
private readonly DeviceAssociationValidator _deviceAssociationValidator;
|
||||
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly KillSwitchEvaluator _killSwitchEvaluator;
|
||||
private readonly SessionDeletionGuard _deletionGuard;
|
||||
@@ -66,6 +67,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
DeviceAssociationValidator deviceAssociationValidator,
|
||||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
KillSwitchEvaluator killSwitchEvaluator,
|
||||
SessionDeletionGuard deletionGuard)
|
||||
@@ -82,6 +84,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
_deviceAssociationValidator = deviceAssociationValidator;
|
||||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_killSwitchEvaluator = killSwitchEvaluator;
|
||||
_deletionGuard = deletionGuard;
|
||||
@@ -111,7 +114,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_corporateIdentifierValidator,
|
||||
_logger,
|
||||
bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator);
|
||||
deviceAssociationValidator: _deviceAssociationValidator,
|
||||
cloudPcDeviceValidator: _cloudPcDeviceValidator);
|
||||
|
||||
if (errorResponse != null) return AsOutput(errorResponse);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
private readonly DeviceAssociationValidator _deviceAssociationValidator;
|
||||
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
|
||||
private readonly TelemetryClient _telemetryClient;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
@@ -47,6 +48,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
DeviceAssociationValidator deviceAssociationValidator,
|
||||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||||
TelemetryClient telemetryClient,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
ISessionRepository sessionRepo,
|
||||
@@ -59,6 +61,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
_deviceAssociationValidator = deviceAssociationValidator;
|
||||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||||
_telemetryClient = telemetryClient;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_sessionRepo = sessionRepo;
|
||||
@@ -94,7 +97,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
_corporateIdentifierValidator,
|
||||
_logger,
|
||||
bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator
|
||||
deviceAssociationValidator: _deviceAssociationValidator,
|
||||
cloudPcDeviceValidator: _cloudPcDeviceValidator
|
||||
);
|
||||
|
||||
if (errorResponse != null)
|
||||
|
||||
@@ -44,6 +44,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
// Strict limits for the unauthenticated endpoint.
|
||||
// MaxContentLength bumped from 1024 -> 1536 to accommodate the V2 cert-context fields
|
||||
// (Thumbprint + Subject + Issuer + NotBefore/After + SourceState ~430 bytes worst-case).
|
||||
// The IsCloudPc bool (2026-08) adds ≤ 18 bytes ("\"IsCloudPc\":false,") — well within
|
||||
// the existing headroom, no bump needed.
|
||||
// Rate-limiting (per-IP + per-tenant + global circuit breaker) remains the primary
|
||||
// DoS defense — the size cap is a payload-shape gate, not the throttle.
|
||||
internal const int MaxContentLength = 1536;
|
||||
@@ -204,6 +206,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
AgentTimestamp = report.Timestamp,
|
||||
IngestedAt = DateTime.UtcNow,
|
||||
SourceIp = clientIp,
|
||||
IsCloudPc = report.IsCloudPc,
|
||||
CertSourceState = certSourceState,
|
||||
CertThumbprint = certThumbprint,
|
||||
CertSubject = certSubject,
|
||||
@@ -297,8 +300,8 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
|
||||
// Structured log (Warning, not Critical — data is unverified)
|
||||
_logger.LogWarning(
|
||||
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} http={HttpStatusCode} ver={AgentVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
|
||||
report.ErrorType, tenantId, manufacturer, model, serialNumber,
|
||||
"AgentDistress [{ErrorType}] tenant={TenantId} mfr={Manufacturer} model={Model} sn={SerialNumber} cloudPc={IsCloudPc} http={HttpStatusCode} ver={AgentVersion} certState={CertSourceState} thumbprint={CertThumbprint}: {Message}",
|
||||
report.ErrorType, tenantId, manufacturer, model, serialNumber, report.IsCloudPc,
|
||||
report.HttpStatusCode, agentVersion, certSourceState ?? "n/a", certThumbprint ?? "n/a", message);
|
||||
|
||||
// Custom event for KQL queries:
|
||||
@@ -315,6 +318,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
|
||||
["Message"] = message ?? string.Empty,
|
||||
["AgentTimestamp"] = report.Timestamp.ToString("O"),
|
||||
["SourceIp"] = clientIp,
|
||||
["IsCloudPc"] = report.IsCloudPc.ToString(),
|
||||
["CertSourceState"] = certSourceState ?? string.Empty,
|
||||
["CertThumbprint"] = certThumbprint ?? string.Empty,
|
||||
["CertSubject"] = certSubject ?? string.Empty,
|
||||
|
||||
@@ -92,6 +92,7 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
var isPreProvisioned = query["isPreProvisioned"];
|
||||
var isHybridJoin = query["isHybridJoin"];
|
||||
var isSelfDeployingProfile = query["isSelfDeployingProfile"];
|
||||
var isCloudPc = query["isCloudPc"];
|
||||
var fieldsParam = query["fields"];
|
||||
|
||||
var pagination = SearchSessionsPagination.ParsePagination(query);
|
||||
@@ -128,6 +129,8 @@ namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
filter.IsHybridJoin = hj;
|
||||
if (!string.IsNullOrEmpty(isSelfDeployingProfile) && bool.TryParse(isSelfDeployingProfile, out var sdp))
|
||||
filter.IsSelfDeployingProfile = sdp;
|
||||
if (!string.IsNullOrEmpty(isCloudPc) && bool.TryParse(isCloudPc, out var icp))
|
||||
filter.IsCloudPc = icp;
|
||||
if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
|
||||
if (int.TryParse(query["rebootCountMax"], out var rcMax)) filter.RebootCountMax = rcMax;
|
||||
var connectionType = query["connectionType"];
|
||||
|
||||
+5
-1
@@ -49,7 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
success = true,
|
||||
aggregated,
|
||||
totalRawReports,
|
||||
dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, and model values are self-reported by devices."
|
||||
dataQualityNotice = "This data is from pre-authentication distress reports and is UNVERIFIED. Devices reported here were rejected with HTTP 403 because they were not found in the tenant's Autopilot or Corporate Identifier registry. Serial number, manufacturer, model, and the Cloud PC marker are self-reported by devices."
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -81,6 +81,10 @@ namespace AutopilotMonitor.Functions.Functions.Reports
|
||||
serialNumber = mostRecent.SerialNumber ?? "",
|
||||
manufacturer = mostRecent.Manufacturer ?? "",
|
||||
model = mostRecent.Model ?? "",
|
||||
// Sticky-true across the group (same OR semantics as the Sessions-table
|
||||
// IsCloudPc merge): older agents in the mix report false for the same
|
||||
// device, and a once-seen W365 marker must not flap back to false.
|
||||
isCloudPc = g.Any(r => r.IsCloudPc),
|
||||
attemptCount = g.Count(),
|
||||
firstSeen = g.Min(r => r.IngestedAt),
|
||||
lastSeen = g.Max(r => r.IngestedAt)
|
||||
|
||||
@@ -108,10 +108,19 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
|
||||
rule.RuleId = ruleId;
|
||||
|
||||
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
|
||||
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
|
||||
return response;
|
||||
try
|
||||
{
|
||||
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
|
||||
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
|
||||
return response;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
|
||||
return conflict;
|
||||
}
|
||||
}
|
||||
|
||||
[Function("CreateAnalyzeRuleFromTemplate")]
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
{
|
||||
/// <summary>
|
||||
/// Dry-runs a DRAFT analyze rule against one session's events and returns a full diagnostic
|
||||
/// trace (per-condition matched/evidence, confidence breakdown, verdict) WITHOUT persisting
|
||||
/// anything. Powers the rule-authoring loop in the portal and the MCP `test_analyze_rule` tool.
|
||||
///
|
||||
/// The rule arrives in the request body and never touches storage; the session status is never
|
||||
/// modified (the engine's dry-run path has no MarkSessionAsFailed side effect by construction).
|
||||
/// Global-scope callers may dry-run against any tenant's session — the tenant is resolved from
|
||||
/// the session, same as GetRuleResultsFunction.
|
||||
/// </summary>
|
||||
public class DryRunAnalyzeRuleFunction
|
||||
{
|
||||
private readonly ILogger<DryRunAnalyzeRuleFunction> _logger;
|
||||
private readonly IRuleRepository _ruleRepo;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
private readonly AnalyzeRuleService _analyzeRuleService;
|
||||
|
||||
public DryRunAnalyzeRuleFunction(
|
||||
ILogger<DryRunAnalyzeRuleFunction> logger,
|
||||
IRuleRepository ruleRepo,
|
||||
ISessionRepository sessionRepo,
|
||||
AnalyzeRuleService analyzeRuleService)
|
||||
{
|
||||
_logger = logger;
|
||||
_ruleRepo = ruleRepo;
|
||||
_sessionRepo = sessionRepo;
|
||||
_analyzeRuleService = analyzeRuleService;
|
||||
}
|
||||
|
||||
public sealed class DryRunRequest
|
||||
{
|
||||
public string? SessionId { get; set; }
|
||||
public AnalyzeRule? Rule { get; set; }
|
||||
}
|
||||
|
||||
[Function("DryRunAnalyzeRule")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/analyze/dryrun")] HttpRequestData req)
|
||||
{
|
||||
// Authentication + TenantAdminOrGlobalReader authorization enforced by PolicyEnforcementMiddleware.
|
||||
// Read-only diagnostic, so the read-only Global Reader is admitted alongside Tenant Admins and GAs.
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var effectiveTenantId = requestCtx.TargetTenantId;
|
||||
|
||||
if (req.Headers.TryGetValues("Content-Length", out var clValues)
|
||||
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
|
||||
&& contentLength > 1_048_576) // 1 MB limit
|
||||
{
|
||||
return await BadRequestAsync(req, new[] { "Request body too large" });
|
||||
}
|
||||
|
||||
var body = await new StreamReader(req.Body).ReadToEndAsync();
|
||||
DryRunRequest? request;
|
||||
try
|
||||
{
|
||||
request = JsonConvert.DeserializeObject<DryRunRequest>(body);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return await BadRequestAsync(req, new[] { $"Request body is not valid JSON: {ex.Message}" });
|
||||
}
|
||||
|
||||
if (request == null || string.IsNullOrWhiteSpace(request.SessionId))
|
||||
{
|
||||
return await BadRequestAsync(req, new[] { "sessionId is required" });
|
||||
}
|
||||
|
||||
var errors = ValidateDraftRule(request.Rule);
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return await BadRequestAsync(req, errors);
|
||||
}
|
||||
|
||||
var sessionId = request.SessionId!.Trim();
|
||||
var rule = request.Rule!;
|
||||
|
||||
// Global-scope cross-tenant fallback: resolve the session's actual tenant so a GA /
|
||||
// Global Reader can dry-run against any tenant's session (mirrors GetRuleResultsFunction).
|
||||
if (requestCtx.HasGlobalScope)
|
||||
{
|
||||
var resolvedTenantId = await _sessionRepo.FindSessionTenantIdAsync(sessionId);
|
||||
if (resolvedTenantId != null && !string.Equals(resolvedTenantId, effectiveTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
effectiveTenantId = resolvedTenantId;
|
||||
}
|
||||
}
|
||||
|
||||
var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId);
|
||||
if (session == null)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new { success = false, message = "Session not found", sessionId });
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var ruleEngine = new RuleEngine(_analyzeRuleService, _ruleRepo, _sessionRepo, _logger);
|
||||
var result = await ruleEngine.DryRunRuleAsync(effectiveTenantId, sessionId, rule);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Dry-run of draft rule {RuleId} against session {SessionId}: verdict={Verdict}, confidence={Confidence}",
|
||||
string.IsNullOrEmpty(rule.RuleId) ? "(no id)" : rule.RuleId, sessionId, result.Verdict, result.FinalConfidence);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true, sessionId, result });
|
||||
return response;
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, IReadOnlyList<string> errors)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = errors[0], errors });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// ===== Draft validation =====
|
||||
|
||||
// Exact-match sets mirroring what the RuleEngine actually dispatches on. The evaluator
|
||||
// switch is case-sensitive ("event_type", not "Event_Type"), and an unknown source/operator
|
||||
// silently evaluates to false in production — for a dry-run that silence is exactly the
|
||||
// confusion we want to catch up front.
|
||||
private static readonly HashSet<string> KnownSources = new(StringComparer.Ordinal)
|
||||
{
|
||||
"event_type", "event_data", "event_data_array", "event_count",
|
||||
"phase_duration", "app_install_duration", "event_correlation",
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> KnownOperators = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"equals", "not_equals", "contains", "not_contains", "regex", "not_regex",
|
||||
"gt", "lt", "gte", "lte", "exists", "not_exists",
|
||||
"count_gte", "count_per_group_gte", "in", "not_in",
|
||||
};
|
||||
|
||||
// The three shapes EvaluateConfidenceFactor actually parses. Anything else is silently
|
||||
// false in production ("count > 3", "Count >= 3", "phase_duration>=300", …).
|
||||
private static readonly Regex FactorConditionShape = new(
|
||||
@"^(exists|count >= ?\d+|phase_duration > ?\d+)$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Structural validation of a draft rule before evaluation. Returns human-readable errors
|
||||
/// (empty list = valid). Deliberately limited to what would make the dry-run silently
|
||||
/// meaningless or throw — full authoring lint (schema, guardrails, event-type catalog)
|
||||
/// lives client-side in the MCP `validate_rule` tool.
|
||||
/// </summary>
|
||||
internal static List<string> ValidateDraftRule(AnalyzeRule? rule)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
if (rule == null)
|
||||
{
|
||||
errors.Add("rule is required");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (rule.Conditions == null || rule.Conditions.Count == 0)
|
||||
{
|
||||
errors.Add("rule must contain at least one condition");
|
||||
return errors;
|
||||
}
|
||||
|
||||
if (rule.BaseConfidence < 0 || rule.BaseConfidence > 100)
|
||||
errors.Add($"baseConfidence must be 0-100 (was {rule.BaseConfidence})");
|
||||
if (rule.ConfidenceThreshold < 0 || rule.ConfidenceThreshold > 100)
|
||||
errors.Add($"confidenceThreshold must be 0-100 (was {rule.ConfidenceThreshold})");
|
||||
|
||||
var seenSignals = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var i = 0; i < rule.Conditions.Count; i++)
|
||||
{
|
||||
var c = rule.Conditions[i];
|
||||
var label = $"conditions[{i}]";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(c.Signal))
|
||||
errors.Add($"{label}: signal is required");
|
||||
else if (!seenSignals.Add(c.Signal))
|
||||
errors.Add($"{label}: duplicate signal '{c.Signal}' — signals must be unique (evidence is keyed by signal)");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(c.Source))
|
||||
errors.Add($"{label}: source is required");
|
||||
else if (!KnownSources.Contains(c.Source))
|
||||
errors.Add($"{label}: unknown source '{c.Source}' (must be one of: {string.Join(", ", KnownSources)}; exact lowercase)");
|
||||
|
||||
if (!string.IsNullOrEmpty(c.Operator) && !KnownOperators.Contains(c.Operator))
|
||||
errors.Add($"{label}: unknown operator '{c.Operator}'");
|
||||
|
||||
if (string.Equals(c.Source, "event_correlation", StringComparison.Ordinal))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(c.CorrelateEventType))
|
||||
errors.Add($"{label}: event_correlation requires correlateEventType");
|
||||
if (string.IsNullOrWhiteSpace(c.JoinField))
|
||||
errors.Add($"{label}: event_correlation requires joinField");
|
||||
}
|
||||
|
||||
if ((string.Equals(c.Operator, "regex", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(c.Operator, "not_regex", StringComparison.OrdinalIgnoreCase))
|
||||
&& !string.IsNullOrEmpty(c.Value))
|
||||
{
|
||||
try
|
||||
{
|
||||
_ = new Regex(c.Value, RegexOptions.IgnoreCase, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
errors.Add($"{label}: value is not a valid regex: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = 0; i < (rule.ConfidenceFactors?.Count ?? 0); i++)
|
||||
{
|
||||
var f = rule.ConfidenceFactors![i];
|
||||
var label = $"confidenceFactors[{i}]";
|
||||
if (string.IsNullOrWhiteSpace(f.Signal))
|
||||
errors.Add($"{label}: signal is required");
|
||||
if (string.IsNullOrEmpty(f.Condition) || !FactorConditionShape.IsMatch(f.Condition))
|
||||
errors.Add($"{label}: condition '{f.Condition}' is not evaluable — supported shapes: \"exists\", \"count >= N\", \"phase_duration > N\" (exact spacing)");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,10 +124,19 @@ namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
|
||||
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
|
||||
return response;
|
||||
try
|
||||
{
|
||||
var success = await _ruleService.UpdateRuleAsync(tenantId, rule);
|
||||
var response = req.CreateResponse(success ? HttpStatusCode.OK : HttpStatusCode.InternalServerError);
|
||||
await response.WriteAsJsonAsync(new { success, message = success ? "Rule updated" : "Failed to update rule" });
|
||||
return response;
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new { success = false, message = ex.Message });
|
||||
return conflict;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Shared.Logging;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Rules
|
||||
{
|
||||
/// <summary>
|
||||
/// Tests a logparser gather-rule regex against pasted sample log lines with the AGENT's
|
||||
/// exact matching semantics — the ".NET dry-run" for logparser rules, which cannot be
|
||||
/// dry-run against a session (they execute on devices).
|
||||
///
|
||||
/// Parity is the entire point: authors test regexes in whatever engine their AI client
|
||||
/// has (JS, PHP, Python) and .NET behaves subtly differently (this endpoint exists
|
||||
/// because exactly that happened in the field). Mirrored semantics, keep in lock-step
|
||||
/// with LogParserCollector (agent):
|
||||
/// - Regex options: Compiled, 1s timeout, NO IgnoreCase — logparser matching is
|
||||
/// case-SENSITIVE (unlike analyze-rule regex conditions).
|
||||
/// - First match per line (Regex.Match, not Matches).
|
||||
/// - format "cmtrace" (default): CmTraceLogParser.TryParseLine first (shared code, the
|
||||
/// same implementation the agent runs), regex against the parsed MESSAGE only.
|
||||
/// - format "text": regex against the raw line.
|
||||
/// - Captured data: every named/numbered group except "0", unsuccessful groups omitted.
|
||||
///
|
||||
/// No tenant data is touched; the endpoint is pure compute on the request body.
|
||||
/// </summary>
|
||||
public class TestLogPatternFunction
|
||||
{
|
||||
private readonly ILogger<TestLogPatternFunction> _logger;
|
||||
|
||||
public TestLogPatternFunction(ILogger<TestLogPatternFunction> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public sealed class TestLogPatternRequest
|
||||
{
|
||||
public string? Pattern { get; set; }
|
||||
public string? Format { get; set; }
|
||||
public List<string>? SampleLines { get; set; }
|
||||
}
|
||||
|
||||
internal const int MaxSampleLines = 200;
|
||||
internal const int MaxLineLength = 8192;
|
||||
|
||||
[Function("TestLogPattern")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "rules/gather/test-pattern")] HttpRequestData req)
|
||||
{
|
||||
// Authentication + TenantAdminOrGlobalReader authorization enforced by PolicyEnforcementMiddleware.
|
||||
|
||||
if (req.Headers.TryGetValues("Content-Length", out var clValues)
|
||||
&& long.TryParse(clValues.FirstOrDefault(), out var contentLength)
|
||||
&& contentLength > 1_048_576) // 1 MB limit
|
||||
{
|
||||
return await BadRequestAsync(req, "Request body too large");
|
||||
}
|
||||
|
||||
var body = await new StreamReader(req.Body).ReadToEndAsync();
|
||||
TestLogPatternRequest? request;
|
||||
try
|
||||
{
|
||||
request = JsonConvert.DeserializeObject<TestLogPatternRequest>(body);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return await BadRequestAsync(req, $"Request body is not valid JSON: {ex.Message}");
|
||||
}
|
||||
|
||||
var validationError = ValidateRequest(request);
|
||||
if (validationError != null)
|
||||
{
|
||||
return await BadRequestAsync(req, validationError);
|
||||
}
|
||||
|
||||
var isTextMode = string.Equals(request!.Format, "text", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
Regex pattern;
|
||||
try
|
||||
{
|
||||
// EXACT agent construction (LogParserCollector): Compiled + 1s timeout,
|
||||
// no IgnoreCase — logparser matching is case-sensitive.
|
||||
pattern = new Regex(request.Pattern!, RegexOptions.Compiled, TimeSpan.FromSeconds(1));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return await BadRequestAsync(req, $"Invalid regex pattern (this is the agent's .NET engine speaking): {ex.Message}");
|
||||
}
|
||||
|
||||
var result = EvaluatePattern(pattern, isTextMode, request.SampleLines!);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Log-pattern test: {LineCount} lines, {MatchCount} matched, {ParseFailures} parse failures, mode={Mode}",
|
||||
request.SampleLines!.Count, result.MatchCount, result.ParseFailureCount, isTextMode ? "text" : "cmtrace");
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { success = true, format = isTextMode ? "text" : "cmtrace", result });
|
||||
return response;
|
||||
}
|
||||
|
||||
internal static string? ValidateRequest(TestLogPatternRequest? request)
|
||||
{
|
||||
if (request == null || string.IsNullOrEmpty(request.Pattern))
|
||||
return "pattern is required";
|
||||
if (request.SampleLines == null || request.SampleLines.Count == 0)
|
||||
return "sampleLines is required — paste raw lines from the log file";
|
||||
if (request.SampleLines.Count > MaxSampleLines)
|
||||
return $"too many sampleLines ({request.SampleLines.Count}) — send at most {MaxSampleLines} representative lines";
|
||||
if (request.SampleLines.Any(l => l != null && l.Length > MaxLineLength))
|
||||
return $"a sample line exceeds {MaxLineLength} characters";
|
||||
if (!string.IsNullOrEmpty(request.Format)
|
||||
&& !string.Equals(request.Format, "cmtrace", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(request.Format, "text", StringComparison.OrdinalIgnoreCase))
|
||||
return "format must be \"cmtrace\" (default) or \"text\"";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-line evaluation mirroring LogParserCollector's inner loop. Returns one row per
|
||||
/// sample line so the author sees exactly which lines matched, which failed CMTrace
|
||||
/// parsing, and which data fields the emitted event would carry.
|
||||
/// </summary>
|
||||
internal static LogPatternTestResult EvaluatePattern(Regex pattern, bool isTextMode, IReadOnlyList<string> lines)
|
||||
{
|
||||
var result = new LogPatternTestResult();
|
||||
|
||||
for (var i = 0; i < lines.Count; i++)
|
||||
{
|
||||
var line = lines[i] ?? string.Empty;
|
||||
var row = new LogPatternLineResult { LineNumber = i + 1 };
|
||||
result.Lines.Add(row);
|
||||
|
||||
string textToMatch;
|
||||
if (isTextMode)
|
||||
{
|
||||
textToMatch = line;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!CmTraceLogParser.TryParseLine(line, out var entry))
|
||||
{
|
||||
row.Outcome = "parse_failed";
|
||||
result.ParseFailureCount++;
|
||||
continue;
|
||||
}
|
||||
row.Component = entry.Component;
|
||||
row.CmTraceType = entry.Type;
|
||||
row.Message = Truncate(entry.Message, 500);
|
||||
textToMatch = entry.Message;
|
||||
}
|
||||
|
||||
Match match;
|
||||
try
|
||||
{
|
||||
match = pattern.Match(textToMatch);
|
||||
}
|
||||
catch (RegexMatchTimeoutException)
|
||||
{
|
||||
row.Outcome = "regex_timeout";
|
||||
result.TimeoutCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
row.Outcome = "no_match";
|
||||
continue;
|
||||
}
|
||||
|
||||
row.Outcome = "matched";
|
||||
result.MatchCount++;
|
||||
row.MatchedText = Truncate(match.Value, 500);
|
||||
var groups = new Dictionary<string, string>();
|
||||
foreach (var groupName in pattern.GetGroupNames())
|
||||
{
|
||||
if (groupName == "0") continue;
|
||||
var group = match.Groups[groupName];
|
||||
if (group.Success)
|
||||
groups[groupName] = Truncate(group.Value, 500);
|
||||
}
|
||||
row.Groups = groups;
|
||||
}
|
||||
|
||||
// Mirror the agent's debug-log hint verbatim — the single most common logparser
|
||||
// authoring mistake is running a plain-text log through the cmtrace default.
|
||||
if (!isTextMode && result.Lines.Count > 0 && result.ParseFailureCount == result.Lines.Count)
|
||||
{
|
||||
result.Notes.Add("every line failed CMTrace parsing — if this is a plain-text log, set parameter format=text");
|
||||
}
|
||||
if (result.MatchCount == 0 && result.ParseFailureCount < result.Lines.Count)
|
||||
{
|
||||
result.Notes.Add("no line matched — remember logparser patterns are case-SENSITIVE (unlike analyze-rule regex conditions)");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int maxLength)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || value.Length <= maxLength)
|
||||
return value;
|
||||
return value.Substring(0, maxLength) + "...";
|
||||
}
|
||||
|
||||
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message });
|
||||
return badRequest;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Aggregate outcome of one pattern test. Serialized camelCase to clients.</summary>
|
||||
public sealed class LogPatternTestResult
|
||||
{
|
||||
public int MatchCount { get; set; }
|
||||
public int ParseFailureCount { get; set; }
|
||||
public int TimeoutCount { get; set; }
|
||||
public List<LogPatternLineResult> Lines { get; } = new();
|
||||
public List<string> Notes { get; } = new();
|
||||
}
|
||||
|
||||
public sealed class LogPatternLineResult
|
||||
{
|
||||
public int LineNumber { get; set; }
|
||||
|
||||
/// <summary>matched | no_match | parse_failed | regex_timeout</summary>
|
||||
public string Outcome { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The named/numbered capture groups exactly as they would land in the
|
||||
/// emitted event's data (group "0" excluded, unsuccessful groups omitted).</summary>
|
||||
public Dictionary<string, string>? Groups { get; set; }
|
||||
|
||||
public string? MatchedText { get; set; }
|
||||
|
||||
/// <summary>cmtrace mode only: the parsed component/type/message the regex ran against.</summary>
|
||||
public string? Component { get; set; }
|
||||
public int? CmTraceType { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -30,6 +30,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
private readonly DeviceAssociationValidator _deviceAssociationValidator;
|
||||
private readonly CloudPcDeviceValidator _cloudPcDeviceValidator;
|
||||
private readonly BootstrapSessionService _bootstrapSessionService;
|
||||
private readonly WebhookNotificationService _webhookNotificationService;
|
||||
private readonly SessionDeletionGuard _deletionGuard;
|
||||
@@ -45,6 +46,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
AutopilotDeviceValidator autopilotDeviceValidator,
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
DeviceAssociationValidator deviceAssociationValidator,
|
||||
CloudPcDeviceValidator cloudPcDeviceValidator,
|
||||
BootstrapSessionService bootstrapSessionService,
|
||||
WebhookNotificationService webhookNotificationService,
|
||||
SessionDeletionGuard deletionGuard)
|
||||
@@ -58,6 +60,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
_deviceAssociationValidator = deviceAssociationValidator;
|
||||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_webhookNotificationService = webhookNotificationService;
|
||||
_deletionGuard = deletionGuard;
|
||||
@@ -101,7 +104,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions
|
||||
_logger,
|
||||
registration.SessionId,
|
||||
bootstrapSessionService: _bootstrapSessionService,
|
||||
deviceAssociationValidator: _deviceAssociationValidator
|
||||
deviceAssociationValidator: _deviceAssociationValidator,
|
||||
cloudPcDeviceValidator: _cloudPcDeviceValidator
|
||||
);
|
||||
|
||||
if (errorResponse2 != null)
|
||||
|
||||
@@ -147,6 +147,7 @@ public class SearchSessionsFunction
|
||||
if (bool.TryParse(query["isPreProvisioned"], out var ipp)) filter.IsPreProvisioned = ipp;
|
||||
if (bool.TryParse(query["isHybridJoin"], out var ihj)) filter.IsHybridJoin = ihj;
|
||||
if (bool.TryParse(query["isSelfDeployingProfile"], out var isdp)) filter.IsSelfDeployingProfile = isdp;
|
||||
if (bool.TryParse(query["isCloudPc"], out var icp)) filter.IsCloudPc = icp;
|
||||
if (DateTime.TryParse(query["startedAfter"], out var sa)) filter.StartedAfter = sa;
|
||||
if (DateTime.TryParse(query["startedBefore"], out var sb)) filter.StartedBefore = sb;
|
||||
if (int.TryParse(query["rebootCountMin"], out var rcMin)) filter.RebootCountMin = rcMin;
|
||||
@@ -207,6 +208,7 @@ public class SearchSessionsFunction
|
||||
if (fields.Contains("isUserDriven")) dict["isUserDriven"] = s.IsUserDriven;
|
||||
if (fields.Contains("isHybridJoin")) dict["isHybridJoin"] = s.IsHybridJoin;
|
||||
if (fields.Contains("isSelfDeployingProfile")) dict["isSelfDeployingProfile"] = s.IsSelfDeployingProfile;
|
||||
if (fields.Contains("isCloudPc")) dict["isCloudPc"] = s.IsCloudPc;
|
||||
if (fields.Contains("agentVersion")) dict["agentVersion"] = s.AgentVersion;
|
||||
if (fields.Contains("imeAgentVersion")) dict["imeAgentVersion"] = s.ImeAgentVersion;
|
||||
if (fields.Contains("geoCountry")) dict["geoCountry"] = s.GeoCountry;
|
||||
|
||||
@@ -48,6 +48,7 @@ namespace AutopilotMonitor.Functions.Pagination
|
||||
new KeyValuePair<string, string?>("isPreProvisioned", filter.IsPreProvisioned?.ToString()),
|
||||
new KeyValuePair<string, string?>("isHybridJoin", filter.IsHybridJoin?.ToString()),
|
||||
new KeyValuePair<string, string?>("isSelfDeployingProfile", filter.IsSelfDeployingProfile?.ToString()),
|
||||
new KeyValuePair<string, string?>("isCloudPc", filter.IsCloudPc?.ToString()),
|
||||
new KeyValuePair<string, string?>("geoCountry", filter.GeoCountry),
|
||||
new KeyValuePair<string, string?>("startedAfter", filter.StartedAfter?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)),
|
||||
new KeyValuePair<string, string?>("startedBefore", filter.StartedBefore?.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture)),
|
||||
|
||||
@@ -394,6 +394,7 @@ builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.GraphTokenServ
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.AutopilotDeviceValidator>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CorporateIdentifierValidator>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.DeviceAssociationValidator>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.CloudPcDeviceValidator>();
|
||||
|
||||
// Graph add-on permission detection + script display-name resolution.
|
||||
// Detector parses the `roles` claim of the SP access token; Resolver fetches
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
using AutopilotMonitor.Shared;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates Windows 365 Cloud PCs via the service-side Cloud PC inventory
|
||||
/// (<c>virtualEndpoint/cloudPCs</c>). Cloud PCs are structurally never
|
||||
/// Autopilot-registered, so instead of a serial lookup this validator binds the
|
||||
/// device identity to the MDM client certificate: the caller passes the Intune
|
||||
/// device id extracted from the chain-validated cert's Subject CN, and the device
|
||||
/// is valid only when the tenant has a <c>cloudPC</c> object whose
|
||||
/// <c>managedDeviceId</c> equals that id. Only machines provisioned by the
|
||||
/// Windows 365 service have such an object — no other enrolled device can pass.
|
||||
///
|
||||
/// Built as a near-sibling of <see cref="AutopilotDeviceValidator"/> — same DI
|
||||
/// dependencies, same retry/cache/transient contract:
|
||||
/// - 30 min positive cache, 5 min negative cache (transient failures NOT cached)
|
||||
/// - 2 attempts with 2s back-off between attempts
|
||||
/// - <see cref="CloudPcValidationResult.IsTransient"/> on Graph 5xx, token failures, exceptions
|
||||
/// Requires the optional Graph permission CloudPC.Read.All (a 403 from Graph is
|
||||
/// treated as definitive "not granted", not transient — see TryValidateViaGraphAsync).
|
||||
/// </summary>
|
||||
public class CloudPcDeviceValidator
|
||||
{
|
||||
private static readonly TimeSpan PositiveCacheTtl = TimeSpan.FromMinutes(30);
|
||||
private static readonly TimeSpan NegativeCacheTtl = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly ILogger<CloudPcDeviceValidator> _logger;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly GraphTokenService _graphTokenService;
|
||||
|
||||
public CloudPcDeviceValidator(
|
||||
ILogger<CloudPcDeviceValidator> logger,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IMemoryCache cache,
|
||||
GraphTokenService graphTokenService)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_cache = cache;
|
||||
_graphTokenService = graphTokenService;
|
||||
}
|
||||
|
||||
public async Task<CloudPcValidationResult> ValidateCloudPcAsync(
|
||||
string tenantId,
|
||||
string? intuneDeviceId,
|
||||
string? sessionId = null)
|
||||
{
|
||||
// The id is interpolated into an OData filter — a strict GUID gate is the
|
||||
// injection defense (same rule as SecurityValidator.IsValidGuid everywhere else).
|
||||
if (!SecurityValidator.IsValidGuid(intuneDeviceId))
|
||||
{
|
||||
return new CloudPcValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
ErrorMessage = "Certificate subject does not carry a valid Intune device id"
|
||||
};
|
||||
}
|
||||
|
||||
var normalizedId = intuneDeviceId!.Trim().ToLowerInvariant();
|
||||
var cacheKey = BuildCacheKey(tenantId, normalizedId);
|
||||
|
||||
if (_cache.TryGetValue(cacheKey, out CloudPcValidationResult? cached) && cached != null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
const int maxAttempts = 2;
|
||||
CloudPcValidationResult? lastTransient = null;
|
||||
|
||||
for (int attempt = 1; attempt <= maxAttempts; attempt++)
|
||||
{
|
||||
var result = await TryValidateViaGraphAsync(tenantId, normalizedId, sessionId, cacheKey, attempt);
|
||||
|
||||
if (result.IsValid || !result.IsTransient)
|
||||
return result;
|
||||
|
||||
lastTransient = result;
|
||||
if (attempt < maxAttempts)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Cloud PC validation transient failure for tenant {TenantId}, deviceId {IntuneDeviceId} (attempt {Attempt}/{MaxAttempts}). Retrying...",
|
||||
tenantId, normalizedId, attempt, maxAttempts);
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"Cloud PC validation failed after {MaxAttempts} attempts for tenant {TenantId}, deviceId {IntuneDeviceId}",
|
||||
maxAttempts, tenantId, normalizedId);
|
||||
|
||||
return lastTransient!;
|
||||
}
|
||||
|
||||
private async Task<CloudPcValidationResult> TryValidateViaGraphAsync(
|
||||
string tenantId, string normalizedId, string? sessionId, string cacheKey, int attempt)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tokenResult = await _graphTokenService.GetAccessTokenAsync(tenantId);
|
||||
if (string.IsNullOrEmpty(tokenResult.AccessToken))
|
||||
{
|
||||
return new CloudPcValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
IsTransient = true,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = "Graph access token could not be acquired"
|
||||
};
|
||||
}
|
||||
|
||||
var graphClient = _httpClientFactory.CreateClient();
|
||||
graphClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.AccessToken);
|
||||
|
||||
// eq on managedDeviceId is supported server-side (field-verified 2026-08-06 on a
|
||||
// real W365 tenant). normalizedId passed the strict GUID gate above, so the
|
||||
// filter literal cannot carry injection payloads.
|
||||
var filter = Uri.EscapeDataString($"managedDeviceId eq '{normalizedId}'");
|
||||
var graphUrl = $"{Constants.GraphBaseUrl}/v1.0/deviceManagement/virtualEndpoint/cloudPCs"
|
||||
+ $"?$select=id,displayName,managedDeviceId,managedDeviceName,servicePlanName&$filter={filter}";
|
||||
|
||||
var response = await graphClient.GetAsync(graphUrl);
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Cloud PC validation Graph query failed for tenant {TenantId} (attempt {Attempt}). Status: {StatusCode}. Body: {ResponseBody}",
|
||||
tenantId, attempt, (int)response.StatusCode, responseBody);
|
||||
|
||||
// 403 = CloudPC.Read.All not granted in this tenant. That is a configuration
|
||||
// state, not an outage: retrying cannot fix it, and a 503 Retry-After would
|
||||
// keep every W365 agent in a futile retry loop. Cache it as a definitive
|
||||
// negative (5 min) so a fresh grant is picked up quickly.
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
return CacheAndReturn(cacheKey, new CloudPcValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = "Cloud PC lookup not permitted — grant the optional 'W365CloudPcValidation' Graph feature (CloudPC.Read.All)"
|
||||
}, isPositive: false);
|
||||
}
|
||||
|
||||
return new CloudPcValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
IsTransient = true,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = $"Graph query failed with status {(int)response.StatusCode}"
|
||||
};
|
||||
}
|
||||
|
||||
var result = ParseCloudPcResponse(responseBody, normalizedId);
|
||||
if (result.IsValid)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Cloud PC validation succeeded for tenant {TenantId}, session {SessionId}, deviceId {IntuneDeviceId}, cloudPcId {CloudPcId}, name {ManagedDeviceName}",
|
||||
tenantId, sessionId ?? "<none>", normalizedId,
|
||||
result.CloudPcId ?? "<none>", result.ManagedDeviceName ?? "<none>");
|
||||
}
|
||||
return CacheAndReturn(cacheKey, result, isPositive: result.IsValid);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error during Cloud PC validation for tenant {TenantId}, session {SessionId}, deviceId {IntuneDeviceId} (attempt {Attempt})",
|
||||
tenantId, sessionId ?? "<none>", normalizedId, attempt);
|
||||
|
||||
return new CloudPcValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
IsTransient = true,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = $"Error during Cloud PC validation: {ex.Message}"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure-function: maps a <c>virtualEndpoint/cloudPCs</c> Graph response body to a
|
||||
/// <see cref="CloudPcValidationResult"/>. Performs an exact-match guard on
|
||||
/// <c>managedDeviceId</c> so a widened server-side filter can never produce a
|
||||
/// false positive.
|
||||
/// </summary>
|
||||
internal static CloudPcValidationResult ParseCloudPcResponse(string responseBody, string normalizedId)
|
||||
{
|
||||
var notFound = new CloudPcValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
IntuneDeviceId = normalizedId,
|
||||
ErrorMessage = $"Device '{normalizedId}' is not a Windows 365 Cloud PC in this tenant"
|
||||
};
|
||||
|
||||
JObject? data;
|
||||
try
|
||||
{
|
||||
data = JsonConvert.DeserializeObject<JObject>(responseBody);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var cloudPcs = data?["value"] as JArray;
|
||||
if (cloudPcs == null || cloudPcs.Count == 0)
|
||||
return notFound;
|
||||
|
||||
var match = cloudPcs.FirstOrDefault(c => string.Equals(
|
||||
c?["managedDeviceId"]?.ToString()?.Trim(),
|
||||
normalizedId,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match == null)
|
||||
return notFound;
|
||||
|
||||
return new CloudPcValidationResult
|
||||
{
|
||||
IsValid = true,
|
||||
IntuneDeviceId = normalizedId,
|
||||
CloudPcId = match["id"]?.ToString(),
|
||||
ManagedDeviceName = match["managedDeviceName"]?.ToString(),
|
||||
ServicePlanName = match["servicePlanName"]?.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
internal static string BuildCacheKey(string tenantId, string intuneDeviceId)
|
||||
=> $"cloudpc-device-validation:{tenantId}:{intuneDeviceId}";
|
||||
|
||||
private CloudPcValidationResult CacheAndReturn(
|
||||
string cacheKey,
|
||||
CloudPcValidationResult result,
|
||||
bool isPositive)
|
||||
{
|
||||
var ttl = isPositive ? PositiveCacheTtl : NegativeCacheTtl;
|
||||
_cache.Set(cacheKey, result, new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = ttl
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public class CloudPcValidationResult
|
||||
{
|
||||
public bool IsValid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when the failure is transient (Graph API error, token issue, network timeout).
|
||||
/// Transient failures are NOT cached and should trigger a 503 Retry-After to the agent.
|
||||
/// </summary>
|
||||
public bool IsTransient { get; set; }
|
||||
|
||||
/// <summary>Intune device id extracted from the MDM client certificate's Subject CN.</summary>
|
||||
public string? IntuneDeviceId { get; set; }
|
||||
|
||||
/// <summary>Cloud PC object id (virtualEndpoint/cloudPCs) when validation succeeded.</summary>
|
||||
public string? CloudPcId { get; set; }
|
||||
|
||||
/// <summary>Intune device name of the Cloud PC (CPC-*) — diagnostic only, never a gate.</summary>
|
||||
public string? ManagedDeviceName { get; set; }
|
||||
|
||||
/// <summary>Windows 365 service plan (e.g. "Cloud PC Enterprise 2vCPU/4GB/128GB") — diagnostic only.</summary>
|
||||
public string? ServicePlanName { get; set; }
|
||||
|
||||
public string? ErrorMessage { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -67,9 +67,9 @@ public enum EndpointPolicy
|
||||
/// <summary>
|
||||
/// Own-tenant Admin, OR a platform role (Global Admin / Global Reader). Tenant-scoped admin-tier
|
||||
/// READ access (e.g. GET tenants/{tenantId}/graph-permissions/status). Like
|
||||
/// <see cref="TenantAdminOrGA"/> but additionally admits the read-only Global Reader — used only
|
||||
/// on GET routes so the reader gets visibility without write power, while tenant
|
||||
/// Operators/Viewers stay excluded. (GET config/{tenantId} moved OFF this tier to MemberRead:
|
||||
/// <see cref="TenantAdminOrGA"/> but additionally admits the read-only Global Reader — used on
|
||||
/// GET routes (and read-in-effect POSTs like rules/analyze/dryrun) so the reader gets
|
||||
/// visibility without write power, while tenant Operators/Viewers stay excluded. (GET config/{tenantId} moved OFF this tier to MemberRead:
|
||||
/// its handler redacts secrets for every non-admin caller, so member-tier read is safe there.)
|
||||
/// </summary>
|
||||
TenantAdminOrGlobalReader,
|
||||
@@ -264,6 +264,14 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("GET", "rules/gather", EndpointPolicy.MemberRead),
|
||||
new("GET", "rules/analyze", EndpointPolicy.MemberRead),
|
||||
new("GET", "rules/ime-log-patterns", EndpointPolicy.MemberRead),
|
||||
// POST by shape (draft rule in the body) but a pure READ in effect: evaluates the draft
|
||||
// against a session's events and persists NOTHING (DryRunAnalyzeRuleFunction). Admin-read
|
||||
// tier so the read-only Global Reader can help debug rules while tenant Operators/Viewers
|
||||
// stay excluded from the authoring surface.
|
||||
new("POST", "rules/analyze/dryrun", EndpointPolicy.TenantAdminOrGlobalReader),
|
||||
// Same shape: POST body in, pure compute out (regex vs pasted sample lines with the
|
||||
// agent's exact .NET matching semantics) — touches no tenant data at all.
|
||||
new("POST", "rules/gather/test-pattern", EndpointPolicy.TenantAdminOrGlobalReader),
|
||||
// Member tier so Operators/Viewers can see their tenant's configuration read-only in the
|
||||
// Settings UI. Safe because the handler redacts by default: only a Global Admin or the
|
||||
// tenant's OWN admin gets the unredacted secrets — every other admitted caller (Operator,
|
||||
|
||||
@@ -33,9 +33,10 @@ namespace AutopilotMonitor.Functions.Security
|
||||
ILogger logger,
|
||||
string? sessionId = null,
|
||||
BootstrapSessionService? bootstrapSessionService = null,
|
||||
DeviceAssociationValidator? deviceAssociationValidator = null)
|
||||
DeviceAssociationValidator? deviceAssociationValidator = null,
|
||||
CloudPcDeviceValidator? cloudPcDeviceValidator = null)
|
||||
{
|
||||
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator);
|
||||
var validator = new SecurityValidator(configService, adminConfigService, rateLimitService, autopilotDeviceValidator, corporateIdentifierValidator, logger, bootstrapSessionService, deviceAssociationValidator, cloudPcDeviceValidator);
|
||||
var validation = await validator.ValidateRequestAsync(req, tenantId, sessionId);
|
||||
|
||||
if (!validation.IsValid)
|
||||
|
||||
@@ -47,6 +47,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
private readonly AutopilotDeviceValidator _autopilotDeviceValidator;
|
||||
private readonly CorporateIdentifierValidator _corporateIdentifierValidator;
|
||||
private readonly DeviceAssociationValidator? _deviceAssociationValidator;
|
||||
private readonly CloudPcDeviceValidator? _cloudPcDeviceValidator;
|
||||
private readonly BootstrapSessionService? _bootstrapSessionService;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
@@ -58,7 +59,8 @@ namespace AutopilotMonitor.Functions.Security
|
||||
CorporateIdentifierValidator corporateIdentifierValidator,
|
||||
ILogger logger,
|
||||
BootstrapSessionService? bootstrapSessionService = null,
|
||||
DeviceAssociationValidator? deviceAssociationValidator = null)
|
||||
DeviceAssociationValidator? deviceAssociationValidator = null,
|
||||
CloudPcDeviceValidator? cloudPcDeviceValidator = null)
|
||||
{
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
@@ -66,6 +68,7 @@ namespace AutopilotMonitor.Functions.Security
|
||||
_autopilotDeviceValidator = autopilotDeviceValidator;
|
||||
_corporateIdentifierValidator = corporateIdentifierValidator;
|
||||
_deviceAssociationValidator = deviceAssociationValidator;
|
||||
_cloudPcDeviceValidator = cloudPcDeviceValidator;
|
||||
_bootstrapSessionService = bootstrapSessionService;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -235,14 +238,14 @@ namespace AutopilotMonitor.Functions.Security
|
||||
// Security validation is always enforced (no longer configurable per tenant)
|
||||
// Hard gate: tenant must enable at least one device validation method before agent traffic is accepted.
|
||||
// Global Admins can set AllowInsecureAgentRequests=true in the config row for test tenants.
|
||||
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.AllowInsecureAgentRequests)
|
||||
if (!config.ValidateAutopilotDevice && !config.ValidateCorporateIdentifier && !config.ValidateCloudPcDevice && !config.AllowInsecureAgentRequests)
|
||||
{
|
||||
return new SecurityValidationResult
|
||||
{
|
||||
IsValid = false,
|
||||
StatusCode = HttpStatusCode.Forbidden,
|
||||
ErrorMessage = "Device validation is required",
|
||||
Details = "Enable 'Autopilot Device Validation' or 'Corporate Identifier Validation' in Configuration before using the agent ingestion endpoints."
|
||||
Details = "Enable 'Autopilot Device Validation', 'Corporate Identifier Validation' or 'Windows 365 Cloud PC Validation' in Configuration before using the agent ingestion endpoints."
|
||||
};
|
||||
}
|
||||
|
||||
@@ -364,7 +367,29 @@ namespace AutopilotMonitor.Functions.Security
|
||||
}
|
||||
}
|
||||
|
||||
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier) && !deviceValidated)
|
||||
// W365 fallback: Cloud PCs are structurally never Autopilot-registered, so the serial
|
||||
// lookups above always miss for them. Identity here comes from the chain-validated
|
||||
// client certificate, NOT from spoofable headers: the Subject CN carries the Intune
|
||||
// device id, and only a Windows-365-service-provisioned machine has a cloudPC object
|
||||
// with that managedDeviceId — a regular Intune-enrolled (non-Autopilot) device still
|
||||
// ends in the 403 below.
|
||||
if (!deviceValidated && config.ValidateCloudPcDevice && _cloudPcDeviceValidator != null)
|
||||
{
|
||||
TryGetIntuneDeviceIdFromCertSubject(certValidation.Subject, out var intuneDeviceId);
|
||||
var cloudPcResult = await _cloudPcDeviceValidator.ValidateCloudPcAsync(tenantId, intuneDeviceId, sessionId);
|
||||
if (cloudPcResult.IsValid)
|
||||
{
|
||||
deviceValidated = true;
|
||||
validatedBy = ValidatorType.CloudPc;
|
||||
}
|
||||
else
|
||||
{
|
||||
deviceValidationError = cloudPcResult.ErrorMessage;
|
||||
deviceValidationTransient = cloudPcResult.IsTransient;
|
||||
}
|
||||
}
|
||||
|
||||
if ((config.ValidateAutopilotDevice || config.ValidateCorporateIdentifier || config.ValidateCloudPcDevice) && !deviceValidated)
|
||||
{
|
||||
// Transient failures (Graph API down, token issues) → 503 Retry-After so agent retries
|
||||
// Definitive failures (device not registered) → 403 Forbidden
|
||||
@@ -441,6 +466,37 @@ namespace AutopilotMonitor.Functions.Security
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the Intune device id from an MDM client certificate subject. Certs issued by
|
||||
/// the Microsoft Intune MDM Device CA carry the Intune managedDevice id as the CN
|
||||
/// (field-verified 2026-08-06 on a W365 Cloud PC). Returns false when the subject has no
|
||||
/// CN or the CN is not a canonical GUID — the CloudPc validator then rejects it as
|
||||
/// "not a valid Intune device id" without ever touching Graph.
|
||||
/// </summary>
|
||||
internal static bool TryGetIntuneDeviceIdFromCertSubject(string? subject, out string? intuneDeviceId)
|
||||
{
|
||||
intuneDeviceId = null;
|
||||
if (string.IsNullOrWhiteSpace(subject))
|
||||
return false;
|
||||
|
||||
foreach (var part in subject!.Split(','))
|
||||
{
|
||||
var trimmed = part.Trim();
|
||||
if (!trimmed.StartsWith("CN=", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var value = trimmed.Substring(3).Trim();
|
||||
if (IsValidGuid(value))
|
||||
{
|
||||
intuneDeviceId = value.ToLowerInvariant();
|
||||
return true;
|
||||
}
|
||||
return false; // first CN wins; a non-GUID CN is definitive (not an Intune MDM device cert shape)
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits a structured warning with request-side context (path, hardware headers, agent
|
||||
/// version) at every fail-fast rejection point. Pairs with the cert-side warnings emitted
|
||||
|
||||
@@ -259,6 +259,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
public async Task<bool> CreateRuleAsync(string tenantId, AnalyzeRule rule)
|
||||
{
|
||||
// The numeric built-in namespace stays closed to tenant rules even when an
|
||||
// ID is currently unused — a later built-in with that ID would silently
|
||||
// shadow the tenant copy (merge collision guard: global wins).
|
||||
if (RuleIdPolicy.IsReservedBuiltInId(rule.RuleId))
|
||||
{
|
||||
throw new InvalidOperationException(RuleIdPolicy.ReservedMessage(rule.RuleId));
|
||||
}
|
||||
|
||||
if (await _ruleRepo.AnalyzeRuleExistsAsync("global", rule.RuleId)
|
||||
|| await _ruleRepo.AnalyzeRuleExistsAsync(tenantId, rule.RuleId))
|
||||
{
|
||||
@@ -293,6 +301,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, state);
|
||||
}
|
||||
|
||||
// Custom-rule updates upsert into the tenant partition — without this guard a
|
||||
// PUT with a reserved ID (and IsBuiltIn=false) would bypass the create-time check.
|
||||
if (RuleIdPolicy.IsReservedBuiltInId(rule.RuleId))
|
||||
{
|
||||
throw new InvalidOperationException(RuleIdPolicy.ReservedMessage(rule.RuleId));
|
||||
}
|
||||
|
||||
// Custom rules are fully tenant-owned: fold a notify override into the row default
|
||||
// (there is no separate RuleState for them) — mirrors how the UI edits Enabled.
|
||||
if (rule.Notify.HasValue)
|
||||
|
||||
+20
-7
@@ -450,16 +450,29 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
if (!whiteGloveStatusTransitioned)
|
||||
{
|
||||
_logger.LogWarning("{SessionPrefix} WhiteGlove UpdateSessionStatusAsync failed, attempting unconditional fallback for IsPreProvisioned + Status", sessionPrefix);
|
||||
try
|
||||
// Duplicate vs. genuine write failure: the agent emits whiteglove_complete twice
|
||||
// (raw Shell-Core event + engine timeline event, ~1s apart, usually in separate
|
||||
// batches). When the session is already sealed as Pending/IsPreProvisioned the
|
||||
// refused write is the duplicate — the unconditional fallback below must NOT run,
|
||||
// it forced whiteGloveStatusTransitioned=true and sent a second webhook.
|
||||
var sealedSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId);
|
||||
if (sealedSession?.Status == SessionStatus.Pending && sealedSession.IsPreProvisioned == true)
|
||||
{
|
||||
await _sessionRepo.SetSessionPreProvisionedAsync(request.TenantId, request.SessionId, true, SessionStatus.Pending, isUserDriven: false);
|
||||
whiteGloveStatusTransitioned = true;
|
||||
_logger.LogInformation("{SessionPrefix} WhiteGlove fallback succeeded: IsPreProvisioned + Status=Pending set via unconditional merge", sessionPrefix);
|
||||
_logger.LogInformation("{SessionPrefix} WhiteGlove already sealed (Pending/IsPreProvisioned) — duplicate whiteglove_complete, no fallback/notification", sessionPrefix);
|
||||
}
|
||||
catch (Exception fallbackEx)
|
||||
else
|
||||
{
|
||||
_logger.LogError(fallbackEx, "{SessionPrefix} WhiteGlove fallback SetSessionPreProvisionedAsync also failed", sessionPrefix);
|
||||
_logger.LogWarning("{SessionPrefix} WhiteGlove UpdateSessionStatusAsync failed, attempting unconditional fallback for IsPreProvisioned + Status", sessionPrefix);
|
||||
try
|
||||
{
|
||||
await _sessionRepo.SetSessionPreProvisionedAsync(request.TenantId, request.SessionId, true, SessionStatus.Pending, isUserDriven: false);
|
||||
whiteGloveStatusTransitioned = true;
|
||||
_logger.LogInformation("{SessionPrefix} WhiteGlove fallback succeeded: IsPreProvisioned + Status=Pending set via unconditional merge", sessionPrefix);
|
||||
}
|
||||
catch (Exception fallbackEx)
|
||||
{
|
||||
_logger.LogError(fallbackEx, "{SessionPrefix} WhiteGlove fallback SetSessionPreProvisionedAsync also failed", sessionPrefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -133,6 +133,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// </summary>
|
||||
public async Task<bool> CreateRuleAsync(string tenantId, GatherRule rule)
|
||||
{
|
||||
// The numeric built-in namespace stays closed to tenant rules even when an
|
||||
// ID is currently unused — a later built-in with that ID would silently
|
||||
// shadow the tenant copy (merge collision guard: global wins).
|
||||
if (RuleIdPolicy.IsReservedBuiltInId(rule.RuleId))
|
||||
{
|
||||
throw new InvalidOperationException(RuleIdPolicy.ReservedMessage(rule.RuleId));
|
||||
}
|
||||
|
||||
if (await _ruleRepo.GatherRuleExistsAsync("global", rule.RuleId)
|
||||
|| await _ruleRepo.GatherRuleExistsAsync(tenantId, rule.RuleId))
|
||||
{
|
||||
@@ -167,6 +175,14 @@ namespace AutopilotMonitor.Functions.Services
|
||||
return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, new RuleState { Enabled = rule.Enabled });
|
||||
}
|
||||
|
||||
// Custom-rule updates upsert into the tenant partition — without this guard a
|
||||
// PUT with a reserved ID whose global row is absent (e.g. retired) would bypass
|
||||
// the create-time check.
|
||||
if (RuleIdPolicy.IsReservedBuiltInId(rule.RuleId))
|
||||
{
|
||||
throw new InvalidOperationException(RuleIdPolicy.ReservedMessage(rule.RuleId));
|
||||
}
|
||||
|
||||
// Custom rule. The portal toggle PUTs only { enabled, isBuiltIn, isCommunity } —
|
||||
// full-storing that payload would wipe Title/Target/Trigger/Parameters. Merge
|
||||
// toggle-style partials into the existing row; full payloads (edit flow) replace
|
||||
|
||||
+13
-13
@@ -22,7 +22,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
TimeSpan? duration,
|
||||
string? sessionUrl = null)
|
||||
{
|
||||
var title = success ? "\u2705 Enrollment Succeeded" : "\u274c Enrollment Failed";
|
||||
var title = success ? "\ud83d\udfe2 Enrollment Succeeded" : "\ud83d\udd34 Enrollment Failed";
|
||||
var themeColor = success ? "00B050" : "FF0000";
|
||||
var severity = success ? NotificationSeverity.Success : NotificationSeverity.Error;
|
||||
var summary = success
|
||||
@@ -76,8 +76,8 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
string? sessionUrl = null)
|
||||
{
|
||||
var title = isResume
|
||||
? "▶️ Pre-Provisioning Resumed"
|
||||
: "🚀 Enrollment Started";
|
||||
? "🟡 Pre-Provisioning Resumed"
|
||||
: "🟡 Enrollment Started";
|
||||
var summary = isResume
|
||||
? $"Pre-Provisioning Resumed: {deviceName ?? "Unknown Device"}"
|
||||
: $"Enrollment Started: {deviceName ?? "Unknown Device"}";
|
||||
@@ -115,7 +115,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
TimeSpan? duration,
|
||||
string? sessionUrl = null)
|
||||
{
|
||||
var title = success ? "\ud83d\udfe2 Pre-Provisioning Completed" : "\u274c Pre-Provisioning Failed";
|
||||
var title = success ? "\ud83d\udfe2 Pre-Provisioning Completed" : "\ud83d\udd34 Pre-Provisioning Failed";
|
||||
var themeColor = success ? "0078D4" : "FF0000";
|
||||
var severity = success ? NotificationSeverity.Success : NotificationSeverity.Error;
|
||||
var summary = success
|
||||
@@ -181,7 +181,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
return new NotificationAlert
|
||||
{
|
||||
EventType = "hardware_rejected",
|
||||
Title = "\u26a0\ufe0f Hardware Not Whitelisted",
|
||||
Title = "\ud83d\udfe1 Hardware Not Whitelisted",
|
||||
Summary = $"Device rejected: {hardwareText} is not in your hardware whitelist",
|
||||
Severity = NotificationSeverity.Warning,
|
||||
ThemeColor = "FFA500",
|
||||
@@ -208,7 +208,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
var (emoji, themeColor, severity) = result.Severity switch
|
||||
{
|
||||
"critical" => ("🟥", "FF0000", NotificationSeverity.Error),
|
||||
"critical" => ("🔴", "FF0000", NotificationSeverity.Error),
|
||||
"high" => ("🟠", "FF0000", NotificationSeverity.Error),
|
||||
"warning" => ("🟡", "FFA500", NotificationSeverity.Warning),
|
||||
_ => ("🔵", "0078D4", NotificationSeverity.Info),
|
||||
@@ -267,7 +267,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
var emoji = r.Severity switch
|
||||
{
|
||||
"critical" => "\ud83d\udfe5",
|
||||
"critical" => "\ud83d\udd34",
|
||||
"high" => "\ud83d\udfe0",
|
||||
_ => "\ud83d\udfe1"
|
||||
};
|
||||
@@ -308,7 +308,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
{
|
||||
case "SuccessRate":
|
||||
label = "Success Rate";
|
||||
title = $"\u26a0\ufe0f SLA Breach: Success Rate {currentRate:F1}%";
|
||||
title = $"\ud83d\udfe1 SLA Breach: Success Rate {currentRate:F1}%";
|
||||
summary = $"SLA breach: success rate {currentRate:F1}% is below target {targetRate:F1}%";
|
||||
period = "Current Month";
|
||||
facts.Add(new NotificationFact { Name = "Breach Type", Value = label });
|
||||
@@ -320,7 +320,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
case "Duration":
|
||||
label = "Duration (P95)";
|
||||
title = "\u26a0\ufe0f SLA Breach: P95 Duration Exceeds Target";
|
||||
title = "\ud83d\udfe1 SLA Breach: P95 Duration Exceeds Target";
|
||||
summary = $"SLA breach: P95 duration {currentRate:F1}min exceeds target {targetRate:F0}min";
|
||||
period = "Current Month";
|
||||
facts.Add(new NotificationFact { Name = "Breach Type", Value = label });
|
||||
@@ -330,7 +330,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
case "AppInstall":
|
||||
label = "App Install Success";
|
||||
title = $"\u26a0\ufe0f SLA Breach: App Install Success Rate {currentRate:F1}%";
|
||||
title = $"\ud83d\udfe1 SLA Breach: App Install Success Rate {currentRate:F1}%";
|
||||
summary = $"SLA breach: app install success rate {currentRate:F1}% is below target {targetRate:F1}%";
|
||||
period = "Current Week";
|
||||
facts.Add(new NotificationFact { Name = "Breach Type", Value = label });
|
||||
@@ -342,7 +342,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
|
||||
default:
|
||||
label = breachType;
|
||||
title = $"\u26a0\ufe0f SLA Breach: {breachType}";
|
||||
title = $"\ud83d\udfe1 SLA Breach: {breachType}";
|
||||
summary = $"SLA breach: {breachType} current {currentRate:F1} vs target {targetRate:F1}";
|
||||
period = "Current Period";
|
||||
facts.Add(new NotificationFact { Name = "Breach Type", Value = label });
|
||||
@@ -405,7 +405,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = "sla_resolved",
|
||||
Title = $"✅ SLA Breach Resolved: {label}",
|
||||
Title = $"🟢 SLA Breach Resolved: {label}",
|
||||
Summary = $"SLA breach resolved for tenant {tenantId}: {label} is back within target.",
|
||||
Severity = NotificationSeverity.Success,
|
||||
ThemeColor = "00B050",
|
||||
@@ -435,7 +435,7 @@ namespace AutopilotMonitor.Functions.Services.Notifications
|
||||
var alert = new NotificationAlert
|
||||
{
|
||||
EventType = "consecutive_failures",
|
||||
Title = $"\ud83d\udea8 {consecutiveFailures} Consecutive Enrollment Failures",
|
||||
Title = $"\ud83d\udd34 {consecutiveFailures} Consecutive Enrollment Failures",
|
||||
Summary = $"Alert: {consecutiveFailures} enrollments failed in a row for tenant {tenantId}",
|
||||
Severity = NotificationSeverity.Error,
|
||||
ThemeColor = "FF0000",
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Dry-run (diagnostic) evaluation of a single draft analyze rule against a session's events.
|
||||
/// Powers the rule-authoring loop (portal / MCP `test_analyze_rule`): the author gets a full
|
||||
/// per-condition trace instead of the production path's fire-or-silent-null.
|
||||
///
|
||||
/// Deliberately side-effect free: nothing is persisted, the session status is never touched,
|
||||
/// no notifications are emitted. The trace loop mirrors <see cref="RuleEngine"/>.EvaluateRule
|
||||
/// but does NOT short-circuit on the first failed required condition — evaluating every
|
||||
/// condition is the whole point of a dry run. Both paths share the same evaluators
|
||||
/// (EvaluatePrecondition / EvaluateCondition / EvaluateConfidenceFactor), so a verdict of
|
||||
/// "fired" here is equivalent to EvaluateRule returning a result; the parity test
|
||||
/// (RuleEngineDryRunTests) pins that equivalence.
|
||||
/// </summary>
|
||||
public partial class RuleEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads the session's events (strict read — storage failures propagate) and dry-runs the
|
||||
/// given draft rule against them. Mirrors the event handling of AnalyzeSessionAsync
|
||||
/// (strict load + BackfillDerivedEventFields) so the dry-run sees exactly what a real
|
||||
/// analysis pass would see.
|
||||
/// </summary>
|
||||
public async Task<RuleDryRun> DryRunRuleAsync(string tenantId, string sessionId, AnalyzeRule rule)
|
||||
{
|
||||
var allEvents = await _sessionRepo.GetSessionEventsStrictAsync(tenantId, sessionId);
|
||||
if (allEvents.Count == 0)
|
||||
{
|
||||
return new RuleDryRun
|
||||
{
|
||||
Verdict = RuleDryRunVerdict.NoEvents,
|
||||
EventCount = 0,
|
||||
BaseConfidence = rule.BaseConfidence,
|
||||
ConfidenceThreshold = rule.ConfidenceThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
BackfillDerivedEventFields(allEvents);
|
||||
return DryRunEvaluate(rule, allEvents);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trace twin of EvaluateRule. Keep the confidence arithmetic and the factor evaluation
|
||||
/// ORDER in lock-step with EvaluateRule — factors are evaluated sequentially against the
|
||||
/// accumulating matchedConditions dictionary (a factor's "exists" check can observe
|
||||
/// earlier factor_<signal> entries), so reordering would change results.
|
||||
/// </summary>
|
||||
internal RuleDryRun DryRunEvaluate(AnalyzeRule rule, List<EnrollmentEvent> events)
|
||||
{
|
||||
var dry = new RuleDryRun
|
||||
{
|
||||
EventCount = events.Count,
|
||||
BaseConfidence = rule.BaseConfidence,
|
||||
ConfidenceThreshold = rule.ConfidenceThreshold,
|
||||
};
|
||||
|
||||
var preconditionsPassed = true;
|
||||
if (rule.Preconditions != null)
|
||||
{
|
||||
foreach (var pre in rule.Preconditions)
|
||||
{
|
||||
var passed = EvaluatePrecondition(pre, events);
|
||||
if (!passed) preconditionsPassed = false;
|
||||
dry.Preconditions.Add(new RuleDryRunPrecondition
|
||||
{
|
||||
Source = pre.Source,
|
||||
EventType = pre.EventType,
|
||||
DataField = string.IsNullOrEmpty(pre.DataField) ? null : pre.DataField,
|
||||
Operator = pre.Operator,
|
||||
Value = pre.Value,
|
||||
Passed = passed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate ALL conditions (no early break — the author wants the full picture).
|
||||
// Outcome-equivalent to EvaluateRule: a failed required condition makes the
|
||||
// production path return null before matchedConditions is ever used.
|
||||
var matchedConditions = new Dictionary<string, object>();
|
||||
var allRequiredMet = true;
|
||||
foreach (var condition in rule.Conditions)
|
||||
{
|
||||
var (matched, evidence) = EvaluateCondition(condition, events);
|
||||
if (condition.Required && !matched) allRequiredMet = false;
|
||||
if (matched) matchedConditions[condition.Signal] = evidence;
|
||||
|
||||
dry.Conditions.Add(new RuleDryRunCondition
|
||||
{
|
||||
Signal = condition.Signal,
|
||||
Source = condition.Source,
|
||||
EventType = string.IsNullOrEmpty(condition.EventType) ? null : condition.EventType,
|
||||
Required = condition.Required,
|
||||
Matched = matched,
|
||||
// Matched: the evidence dictionary the production path stores. Not matched:
|
||||
// the evaluator's reason string ("no matching events", …).
|
||||
Evidence = evidence,
|
||||
MatchingEventCount = string.IsNullOrEmpty(condition.EventType)
|
||||
? null
|
||||
: events.Count(e => MatchesEventType(e, condition.EventType)),
|
||||
});
|
||||
}
|
||||
|
||||
dry.MatchedConditions = matchedConditions;
|
||||
|
||||
if (!preconditionsPassed)
|
||||
{
|
||||
dry.Verdict = RuleDryRunVerdict.SkippedByPrecondition;
|
||||
return dry;
|
||||
}
|
||||
|
||||
if (!allRequiredMet)
|
||||
{
|
||||
dry.Verdict = RuleDryRunVerdict.RequiredConditionNotMet;
|
||||
return dry;
|
||||
}
|
||||
|
||||
if (matchedConditions.Count == 0)
|
||||
{
|
||||
dry.Verdict = RuleDryRunVerdict.NoConditionsMatched;
|
||||
return dry;
|
||||
}
|
||||
|
||||
var confidence = rule.BaseConfidence;
|
||||
foreach (var factor in rule.ConfidenceFactors)
|
||||
{
|
||||
var factorMatched = EvaluateConfidenceFactor(factor, events, matchedConditions);
|
||||
if (factorMatched)
|
||||
{
|
||||
confidence += factor.Weight;
|
||||
matchedConditions[$"factor_{factor.Signal}"] = true;
|
||||
}
|
||||
|
||||
dry.ConfidenceFactors.Add(new RuleDryRunFactor
|
||||
{
|
||||
Signal = factor.Signal,
|
||||
Condition = factor.Condition,
|
||||
Weight = factor.Weight,
|
||||
Matched = factorMatched,
|
||||
});
|
||||
}
|
||||
|
||||
confidence = Math.Min(confidence, 100);
|
||||
dry.FinalConfidence = confidence;
|
||||
|
||||
if (confidence < rule.ConfidenceThreshold)
|
||||
{
|
||||
dry.Verdict = RuleDryRunVerdict.BelowConfidenceThreshold;
|
||||
return dry;
|
||||
}
|
||||
|
||||
dry.Verdict = RuleDryRunVerdict.Fired;
|
||||
dry.WouldMarkSessionAsFailed = rule.MarkSessionAsFailed ?? rule.MarkSessionAsFailedDefault;
|
||||
return dry;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verdict strings for <see cref="RuleDryRun.Verdict"/>. Stable API contract.</summary>
|
||||
public static class RuleDryRunVerdict
|
||||
{
|
||||
public const string Fired = "fired";
|
||||
public const string SkippedByPrecondition = "skipped_by_precondition";
|
||||
public const string RequiredConditionNotMet = "required_condition_not_met";
|
||||
public const string NoConditionsMatched = "no_conditions_matched";
|
||||
public const string BelowConfidenceThreshold = "below_confidence_threshold";
|
||||
public const string NoEvents = "no_events";
|
||||
}
|
||||
|
||||
/// <summary>Full diagnostic trace of one dry-run evaluation. Serialized camelCase to clients.</summary>
|
||||
public sealed class RuleDryRun
|
||||
{
|
||||
public string Verdict { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Number of events in the session the rule was evaluated against.</summary>
|
||||
public int EventCount { get; set; }
|
||||
|
||||
public List<RuleDryRunPrecondition> Preconditions { get; } = new();
|
||||
public List<RuleDryRunCondition> Conditions { get; } = new();
|
||||
|
||||
/// <summary>Empty unless all required conditions were met (mirrors the production path,
|
||||
/// which never reaches factor evaluation otherwise).</summary>
|
||||
public List<RuleDryRunFactor> ConfidenceFactors { get; } = new();
|
||||
|
||||
public int BaseConfidence { get; set; }
|
||||
|
||||
/// <summary>base + matched factor weights, capped at 100. Null when the evaluation ended
|
||||
/// before the confidence stage (precondition skip / required miss / nothing matched).</summary>
|
||||
public int? FinalConfidence { get; set; }
|
||||
|
||||
public int ConfidenceThreshold { get; set; }
|
||||
|
||||
/// <summary>True only for verdict "fired" AND the rule's effective MarkSessionAsFailed flag.
|
||||
/// The dry-run itself never touches the session.</summary>
|
||||
public bool WouldMarkSessionAsFailed { get; set; }
|
||||
|
||||
/// <summary>The evidence map exactly as the production path would persist it on a
|
||||
/// RuleResult — keys are condition signals (plus factor_* markers). Clients use it to
|
||||
/// preview {{token}} interpolation of explanation/remediation.</summary>
|
||||
public Dictionary<string, object>? MatchedConditions { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RuleDryRunPrecondition
|
||||
{
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string EventType { get; set; } = string.Empty;
|
||||
public string? DataField { get; set; }
|
||||
public string? Operator { get; set; }
|
||||
public string? Value { get; set; }
|
||||
public bool Passed { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RuleDryRunCondition
|
||||
{
|
||||
public string Signal { get; set; } = string.Empty;
|
||||
public string Source { get; set; } = string.Empty;
|
||||
public string? EventType { get; set; }
|
||||
public bool Required { get; set; }
|
||||
public bool Matched { get; set; }
|
||||
|
||||
/// <summary>Matched: the evidence dictionary (eventId, timestamp, field, value, …).
|
||||
/// Not matched: the evaluator's reason string (e.g. "no matching events").</summary>
|
||||
public object? Evidence { get; set; }
|
||||
|
||||
/// <summary>How many session events have this condition's eventType at all — the first
|
||||
/// thing an author checks when a condition unexpectedly doesn't match. Null when the
|
||||
/// condition has no eventType.</summary>
|
||||
public int? MatchingEventCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class RuleDryRunFactor
|
||||
{
|
||||
public string Signal { get; set; } = string.Empty;
|
||||
public string Condition { get; set; } = string.Empty;
|
||||
public int Weight { get; set; }
|
||||
public bool Matched { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -696,6 +696,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (filter.IsPreProvisioned.HasValue && session.IsPreProvisioned != filter.IsPreProvisioned.Value) return false;
|
||||
if (filter.IsHybridJoin.HasValue && session.IsHybridJoin != filter.IsHybridJoin.Value) return false;
|
||||
if (filter.IsSelfDeployingProfile.HasValue && session.IsSelfDeployingProfile != filter.IsSelfDeployingProfile.Value) return false;
|
||||
if (filter.IsCloudPc.HasValue && session.IsCloudPc != filter.IsCloudPc.Value) return false;
|
||||
if (!string.IsNullOrEmpty(filter.GeoCountry) &&
|
||||
!string.Equals(session.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
@@ -911,6 +912,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (filter.IsPreProvisioned.HasValue && session.IsPreProvisioned != filter.IsPreProvisioned.Value) continue;
|
||||
if (filter.IsHybridJoin.HasValue && session.IsHybridJoin != filter.IsHybridJoin.Value) continue;
|
||||
if (filter.IsSelfDeployingProfile.HasValue && session.IsSelfDeployingProfile != filter.IsSelfDeployingProfile.Value) continue;
|
||||
if (filter.IsCloudPc.HasValue && session.IsCloudPc != filter.IsCloudPc.Value) continue;
|
||||
if (!string.IsNullOrEmpty(filter.GeoCountry) &&
|
||||
!string.Equals(session.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
@@ -952,6 +954,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (filter.IsPreProvisioned.HasValue && s.IsPreProvisioned != filter.IsPreProvisioned.Value) return false;
|
||||
if (filter.IsHybridJoin.HasValue && s.IsHybridJoin != filter.IsHybridJoin.Value) return false;
|
||||
if (filter.IsSelfDeployingProfile.HasValue && s.IsSelfDeployingProfile != filter.IsSelfDeployingProfile.Value) return false;
|
||||
if (filter.IsCloudPc.HasValue && s.IsCloudPc != filter.IsCloudPc.Value) return false;
|
||||
if (!string.IsNullOrEmpty(filter.GeoCountry) &&
|
||||
!string.Equals(s.GeoCountry, filter.GeoCountry, StringComparison.OrdinalIgnoreCase)) return false;
|
||||
if (filter.StartedAfter.HasValue && s.StartedAt < filter.StartedAfter.Value) return false;
|
||||
|
||||
@@ -132,6 +132,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["IsHybridJoin"] = sessionEntity.GetBoolean("IsHybridJoin") ?? false,
|
||||
["IsUserDriven"] = sessionEntity.GetBoolean("IsUserDriven") ?? false,
|
||||
["IsSelfDeployingProfile"] = sessionEntity.GetBoolean("IsSelfDeployingProfile") ?? false,
|
||||
["IsCloudPc"] = sessionEntity.GetBoolean("IsCloudPc") ?? false,
|
||||
["AgentVersion"] = sessionEntity.GetString("AgentVersion") ?? string.Empty,
|
||||
// Search-filterable column: search/MCP push an OData filter on ImeAgentVersion against
|
||||
// the index, so it MUST be projected (was previously absent → filter matched nothing).
|
||||
@@ -433,6 +434,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
bool isPreProvisioned = registration.IsPreProvisioned;
|
||||
bool isHybridJoin = registration.IsHybridJoin;
|
||||
bool isSelfDeployingProfile = registration.IsSelfDeployingProfile;
|
||||
bool isCloudPc = registration.IsCloudPc;
|
||||
DateTime? lastEventAt = null;
|
||||
int? durationSeconds = null;
|
||||
string? diagnosticsBlobName = null;
|
||||
@@ -480,6 +482,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// the Autopilot policy cache was momentarily unreadable must not pin the
|
||||
// self-deploying marker to false forever — once observed true, it stays true.
|
||||
isSelfDeployingProfile = (existingEntity.GetBoolean("IsSelfDeployingProfile") ?? false) || isSelfDeployingProfile;
|
||||
// Same sticky-true OR: the W365 markers are read at boot; a transient
|
||||
// registry/service hiccup on a later re-registration must not clear an
|
||||
// already-observed Cloud PC fact.
|
||||
isCloudPc = (existingEntity.GetBoolean("IsCloudPc") ?? false) || isCloudPc;
|
||||
lastEventAt = existingEntity.GetDateTimeOffset("LastEventAt")?.UtcDateTime;
|
||||
durationSeconds = existingEntity.GetInt32("DurationSeconds");
|
||||
diagnosticsBlobName = existingEntity.GetString("DiagnosticsBlobName");
|
||||
@@ -547,6 +553,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
["IsPreProvisioned"] = isPreProvisioned,
|
||||
["IsHybridJoin"] = isHybridJoin,
|
||||
["IsSelfDeployingProfile"] = isSelfDeployingProfile,
|
||||
["IsCloudPc"] = isCloudPc,
|
||||
["StartedAt"] = EnsureUtc(startedAt),
|
||||
["AgentVersion"] = registration.AgentVersion ?? string.Empty,
|
||||
["EnrollmentType"] = registration.EnrollmentType ?? "v1",
|
||||
@@ -1543,6 +1550,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
SessionStatus.Succeeded => existingStatus != SessionStatus.Succeeded.ToString(),
|
||||
SessionStatus.Failed or SessionStatus.Incomplete => !existingTerminal,
|
||||
SessionStatus.AwaitingUser => !existingTerminal,
|
||||
// Idempotency: the agent emits whiteglove_complete twice (raw Shell-Core event
|
||||
// + engine timeline event, ~1s apart). The second Pending write must not report
|
||||
// a "transition" — that fired duplicate Pre-Provisioning Completed webhooks.
|
||||
SessionStatus.Pending => existingStatus != SessionStatus.Pending.ToString(),
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
@@ -2935,6 +2946,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
IsPreProvisioned = entity.GetBoolean("IsPreProvisioned") ?? false,
|
||||
IsHybridJoin = entity.GetBoolean("IsHybridJoin") ?? false,
|
||||
IsSelfDeployingProfile = entity.GetBoolean("IsSelfDeployingProfile") ?? false,
|
||||
IsCloudPc = entity.GetBoolean("IsCloudPc") ?? false,
|
||||
ResumedAt = SafeGetDateTime(entity, "ResumedAt"),
|
||||
StalledAt = SafeGetDateTime(entity, "StalledAt"),
|
||||
OsName = entity.GetString("OsName") ?? string.Empty,
|
||||
|
||||
+5
-3
@@ -10,6 +10,8 @@
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^5.2.1",
|
||||
"fuse.js": "^7.5.0",
|
||||
@@ -1736,9 +1738,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^5.2.1",
|
||||
"fuse.js": "^7.5.0",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Drift guards for the rule-authoring surface:
|
||||
*
|
||||
* 1. rule-authoring.generated.ts MUST equal the repo source of truth
|
||||
* (rules/guardrails.json + rules/schema/*.schema.json). CI's guardrails-in-sync
|
||||
* job re-runs combine.js, but this test also catches a hand-edited generated
|
||||
* file locally. Same-repo only; skipped in an isolated package.
|
||||
* 2. The hand-written RULE_AUTHORING_GUIDE enum lists MUST equal the schema enums —
|
||||
* the guide is prose, the schema is contract; they may never diverge.
|
||||
* 3. Every rule-authoring resource must fit the get_resource size cap.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { GATHER_RULE_SCHEMA, ANALYZE_RULE_SCHEMA, RULE_GUARDRAILS } from '../rule-authoring.generated.js';
|
||||
import { RULE_AUTHORING_GUIDE } from '../rule-authoring-guide.js';
|
||||
import { getResourceContent } from '../resource-catalog.js';
|
||||
import { MAX_RESULT_SIZE_CHARS } from '../tools/shared.js';
|
||||
|
||||
/** Walk up from this test file to the repo root (marked by AutopilotMonitor.sln). */
|
||||
function findRulesDir(): string | null {
|
||||
let dir = dirname(fileURLToPath(import.meta.url));
|
||||
for (let i = 0; i < 12; i++) {
|
||||
if (existsSync(join(dir, 'AutopilotMonitor.sln'))) {
|
||||
const p = join(dir, 'rules');
|
||||
return existsSync(p) ? p : null;
|
||||
}
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function schemaEnum(schema: Record<string, unknown>, defName: string | null, propName: string): string[] {
|
||||
// Navigate $defs.<defName>.properties.<propName>.enum (or items.enum for arrays).
|
||||
const defs = (schema['$defs'] ?? {}) as Record<string, unknown>;
|
||||
const root = defName ? (defs[defName] as Record<string, unknown>) : schema;
|
||||
expect(root, `$defs.${defName} missing`).toBeTruthy();
|
||||
const props = root['properties'] as Record<string, unknown>;
|
||||
const prop = props[propName] as Record<string, unknown> | undefined;
|
||||
expect(prop, `property ${propName} missing`).toBeTruthy();
|
||||
const en = (prop!['enum'] ?? (prop!['items'] as Record<string, unknown> | undefined)?.['enum']) as string[] | undefined;
|
||||
expect(en, `enum for ${propName} missing`).toBeTruthy();
|
||||
return en!;
|
||||
}
|
||||
|
||||
describe('rule-authoring.generated.ts drift vs rules/ source of truth', () => {
|
||||
const rulesDir = findRulesDir();
|
||||
const itOrSkip = rulesDir ? it : it.skip;
|
||||
|
||||
itOrSkip('guardrails match rules/guardrails.json', () => {
|
||||
const source = JSON.parse(readFileSync(join(rulesDir!, 'guardrails.json'), 'utf8'));
|
||||
expect(RULE_GUARDRAILS).toEqual(source);
|
||||
});
|
||||
|
||||
itOrSkip('gather schema matches rules/schema/gather-rule.schema.json', () => {
|
||||
const source = JSON.parse(readFileSync(join(rulesDir!, 'schema', 'gather-rule.schema.json'), 'utf8'));
|
||||
expect(GATHER_RULE_SCHEMA).toEqual(source);
|
||||
});
|
||||
|
||||
itOrSkip('analyze schema matches rules/schema/analyze-rule.schema.json', () => {
|
||||
const source = JSON.parse(readFileSync(join(rulesDir!, 'schema', 'analyze-rule.schema.json'), 'utf8'));
|
||||
expect(ANALYZE_RULE_SCHEMA).toEqual(source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RULE_AUTHORING_GUIDE enums vs schema contract', () => {
|
||||
it('gather collector types, triggers, categories, severities match the gather schema', () => {
|
||||
expect([...RULE_AUTHORING_GUIDE.enums.gatherCollectorTypes]).toEqual(
|
||||
schemaEnum(GATHER_RULE_SCHEMA, 'gatherRule', 'collectorType'));
|
||||
expect([...RULE_AUTHORING_GUIDE.enums.gatherTriggers]).toEqual(
|
||||
schemaEnum(GATHER_RULE_SCHEMA, 'gatherRule', 'trigger'));
|
||||
expect([...RULE_AUTHORING_GUIDE.enums.gatherCategories]).toEqual(
|
||||
schemaEnum(GATHER_RULE_SCHEMA, 'gatherRule', 'category'));
|
||||
expect([...RULE_AUTHORING_GUIDE.enums.gatherOutputSeverities]).toEqual(
|
||||
schemaEnum(GATHER_RULE_SCHEMA, 'gatherRule', 'outputSeverity'));
|
||||
// triggerPhase itself is a free string in the schema (empty = every transition);
|
||||
// the canonical phase list lives on activePhases/activeFromPhase.
|
||||
expect([...RULE_AUTHORING_GUIDE.gatherRules.phases]).toEqual(
|
||||
schemaEnum(GATHER_RULE_SCHEMA, 'gatherRule', 'activePhases'));
|
||||
});
|
||||
|
||||
it('analyze condition sources, operators, categories, severities match the analyze schema', () => {
|
||||
expect([...RULE_AUTHORING_GUIDE.enums.analyzeConditionSources]).toEqual(
|
||||
schemaEnum(ANALYZE_RULE_SCHEMA, 'ruleCondition', 'source'));
|
||||
expect([...RULE_AUTHORING_GUIDE.analyzeRules.operators]).toEqual(
|
||||
schemaEnum(ANALYZE_RULE_SCHEMA, 'ruleCondition', 'operator'));
|
||||
expect([...RULE_AUTHORING_GUIDE.enums.analyzeCategories]).toEqual(
|
||||
schemaEnum(ANALYZE_RULE_SCHEMA, 'analyzeRule', 'category'));
|
||||
expect([...RULE_AUTHORING_GUIDE.enums.analyzeSeverities]).toEqual(
|
||||
schemaEnum(ANALYZE_RULE_SCHEMA, 'analyzeRule', 'severity'));
|
||||
});
|
||||
|
||||
it('guide condition-source prose covers every schema source (and no phantom)', () => {
|
||||
const documented = Object.keys(RULE_AUTHORING_GUIDE.analyzeRules.conditionSources).sort();
|
||||
const schema = [...schemaEnum(ANALYZE_RULE_SCHEMA, 'ruleCondition', 'source')].sort();
|
||||
expect(documented).toEqual(schema);
|
||||
});
|
||||
|
||||
it('guide collector-type prose covers every schema collector type (and no phantom)', () => {
|
||||
const documented = Object.keys(RULE_AUTHORING_GUIDE.gatherRules.collectorTypes).sort();
|
||||
const schema = [...schemaEnum(GATHER_RULE_SCHEMA, 'gatherRule', 'collectorType')].sort();
|
||||
expect(documented).toEqual(schema);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rule-authoring resources fit the get_resource size cap', () => {
|
||||
it.each(['rule_authoring_guide', 'rule_schemas', 'rule_guardrails'] as const)('%s stays under the cap', (name) => {
|
||||
const size = JSON.stringify(getResourceContent(name), null, 2).length;
|
||||
expect(size).toBeLessThan(MAX_RESULT_SIZE_CHARS.small);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Matrix tests for the MCP-local rule draft validator: schema layer, guardrail
|
||||
* layer (agent matching semantics), and the semantic lint that catches
|
||||
* schema-valid-but-silently-dead rules.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateRuleDraft, type ValidationFinding } from '../rule-validation.js';
|
||||
|
||||
const errors = (findings: ValidationFinding[]) => findings.filter((f) => f.level === 'error').map((f) => f.message);
|
||||
const warnings = (findings: ValidationFinding[]) => findings.filter((f) => f.level === 'warning').map((f) => f.message);
|
||||
|
||||
function validGather(): Record<string, unknown> {
|
||||
return {
|
||||
ruleId: 'GATHER-DEVICE-101',
|
||||
title: 'Pending file renames',
|
||||
collectorType: 'registry',
|
||||
target: 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Component Based Servicing\\RebootPending',
|
||||
trigger: 'startup',
|
||||
outputEventType: 'gather_pending_file_renames',
|
||||
};
|
||||
}
|
||||
|
||||
function validAnalyze(): Record<string, unknown> {
|
||||
return {
|
||||
ruleId: 'ANALYZE-APP-101',
|
||||
title: 'Repeated install failures',
|
||||
severity: 'high',
|
||||
category: 'apps',
|
||||
baseConfidence: 55,
|
||||
confidenceThreshold: 50,
|
||||
conditions: [
|
||||
{
|
||||
signal: 'app_retry_storm',
|
||||
source: 'event_count',
|
||||
eventType: 'app_install_failed',
|
||||
dataField: 'appId',
|
||||
operator: 'count_per_group_gte',
|
||||
value: '3',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
explanation: 'App {{appName}} failed repeatedly (last error {{errorCode}}).',
|
||||
};
|
||||
}
|
||||
|
||||
describe('type detection', () => {
|
||||
it('detects gather via collectorType and analyze via conditions', () => {
|
||||
expect(validateRuleDraft(validGather()).ruleType).toBe('gather');
|
||||
expect(validateRuleDraft(validAnalyze()).ruleType).toBe('analyze');
|
||||
});
|
||||
|
||||
it('unknown shape yields a single explanatory error', () => {
|
||||
const r = validateRuleDraft({ title: 'what am I' });
|
||||
expect(r.ruleType).toBe('unknown');
|
||||
expect(r.valid).toBe(false);
|
||||
expect(r.findings).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema layer', () => {
|
||||
it('valid drafts pass', () => {
|
||||
expect(validateRuleDraft(validGather()).valid).toBe(true);
|
||||
expect(validateRuleDraft(validAnalyze()).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('missing required fields fail with schema errors', () => {
|
||||
const gather = validGather();
|
||||
delete gather.outputEventType;
|
||||
expect(errors(validateRuleDraft(gather).findings).some((m) => m.includes('outputEventType'))).toBe(true);
|
||||
|
||||
const analyze = validAnalyze();
|
||||
delete analyze.explanation;
|
||||
expect(errors(validateRuleDraft(analyze).findings).some((m) => m.includes('explanation'))).toBe(true);
|
||||
});
|
||||
|
||||
it('enum violations and unknown properties fail', () => {
|
||||
const bad = validAnalyze();
|
||||
bad.severity = 'catastrophic';
|
||||
(bad as Record<string, unknown>).madeUpField = 1;
|
||||
const msgs = errors(validateRuleDraft(bad).findings);
|
||||
expect(msgs.some((m) => m.includes('/severity'))).toBe(true);
|
||||
expect(msgs.some((m) => m.includes('additional'))).toBe(true);
|
||||
});
|
||||
|
||||
it('bad ruleId pattern fails', () => {
|
||||
const bad = validGather();
|
||||
bad.ruleId = 'GATHER-device-1';
|
||||
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('/ruleId'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gather guardrails (agent matching semantics)', () => {
|
||||
it('accepts an allow-listed registry target with hive prefix', () => {
|
||||
expect(validateRuleDraft(validGather()).valid).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a registry target outside the allowlist', () => {
|
||||
const bad = validGather();
|
||||
bad.target = 'HKLM\\SOFTWARE\\Contoso\\Secrets';
|
||||
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('registry target'))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects prefix spoofing (segment boundary)', () => {
|
||||
const bad = validGather();
|
||||
bad.target = 'HKLM\\SOFTWARE\\Microsoft\\EnrollmentsEvil\\x'; // "...\\Enrollments" is allowed, "EnrollmentsEvil" is not
|
||||
expect(validateRuleDraft(bad).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects hard-blocked file paths even though C:\\Users is not in filePrefixes', () => {
|
||||
const bad = validGather();
|
||||
bad.collectorType = 'file';
|
||||
bad.target = 'C:\\Users\\admin\\secret.txt';
|
||||
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts an allow-listed file target and rejects others', () => {
|
||||
const ok = validGather();
|
||||
ok.collectorType = 'file';
|
||||
ok.target = 'C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log';
|
||||
expect(validateRuleDraft(ok).valid).toBe(true);
|
||||
|
||||
const bad = validGather();
|
||||
bad.collectorType = 'file';
|
||||
bad.target = 'C:\\Temp\\anything.log';
|
||||
expect(validateRuleDraft(bad).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('WMI: prefix must be whitespace-bounded', () => {
|
||||
const ok = validGather();
|
||||
ok.collectorType = 'wmi';
|
||||
ok.target = 'SELECT * FROM Win32_BIOS WHERE PrimaryBIOS = TRUE';
|
||||
expect(validateRuleDraft(ok).valid).toBe(true);
|
||||
|
||||
const spoof = validGather();
|
||||
spoof.collectorType = 'wmi';
|
||||
spoof.target = 'SELECT * FROM Win32_BIOSX';
|
||||
expect(validateRuleDraft(spoof).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('commands match exactly — extra arguments are rejected', () => {
|
||||
const ok = validGather();
|
||||
ok.collectorType = 'command_allowlisted';
|
||||
ok.target = 'dsregcmd /status';
|
||||
expect(validateRuleDraft(ok).valid).toBe(true);
|
||||
|
||||
const bad = validGather();
|
||||
bad.collectorType = 'command_allowlisted';
|
||||
bad.target = 'dsregcmd /status; whoami';
|
||||
expect(validateRuleDraft(bad).valid).toBe(false);
|
||||
});
|
||||
|
||||
it('event log: hard-blocked channels rejected, allow-listed channel with /Admin suffix accepted', () => {
|
||||
const blocked = validGather();
|
||||
blocked.collectorType = 'eventlog';
|
||||
blocked.target = 'Security';
|
||||
expect(errors(validateRuleDraft(blocked).findings).some((m) => m.includes('hard-blocked'))).toBe(true);
|
||||
|
||||
const ok = validGather();
|
||||
ok.collectorType = 'eventlog';
|
||||
ok.target = 'Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin';
|
||||
expect(validateRuleDraft(ok).valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logparser parameter lint', () => {
|
||||
function logparserRule(parameters: Record<string, unknown>): Record<string, unknown> {
|
||||
const r = validGather();
|
||||
r.collectorType = 'logparser';
|
||||
r.target = 'C:\\Windows\\Logs\\CustomApp\\install.log';
|
||||
r.parameters = parameters;
|
||||
return r;
|
||||
}
|
||||
|
||||
it('missing pattern is an error', () => {
|
||||
expect(errors(validateRuleDraft(logparserRule({})).findings).some((m) => m.includes('parameters.pattern'))).toBe(true);
|
||||
});
|
||||
|
||||
it('non-compiling pattern is an error', () => {
|
||||
expect(errors(validateRuleDraft(logparserRule({ pattern: '([unclosed' })).findings)
|
||||
.some((m) => m.includes('does not compile'))).toBe(true);
|
||||
});
|
||||
|
||||
it('valid pattern yields the .NET/case-sensitivity info pointing to test_log_pattern', () => {
|
||||
const r = validateRuleDraft(logparserRule({ pattern: '(?<level>ERROR)', format: 'text' }));
|
||||
expect(r.valid).toBe(true);
|
||||
expect(r.findings.some((f) => f.level === 'info' && f.message.includes('test_log_pattern'))).toBe(true);
|
||||
});
|
||||
|
||||
it('unknown format value warns (agent silently treats it as cmtrace)', () => {
|
||||
expect(warnings(validateRuleDraft(logparserRule({ pattern: 'x', format: 'json' })).findings)
|
||||
.some((m) => m.includes('CMTrace mode'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gather semantic lint', () => {
|
||||
it('interval without intervalSeconds is an error; on_event without triggerEventType is an error', () => {
|
||||
const a = validGather();
|
||||
a.trigger = 'interval';
|
||||
expect(errors(validateRuleDraft(a).findings).some((m) => m.includes('intervalSeconds'))).toBe(true);
|
||||
|
||||
const b = validGather();
|
||||
b.trigger = 'on_event';
|
||||
expect(errors(validateRuleDraft(b).findings).some((m) => m.includes('triggerEventType'))).toBe(true);
|
||||
});
|
||||
|
||||
it('outputEventType colliding with a built-in event type warns', () => {
|
||||
const bad = validGather();
|
||||
bad.outputEventType = 'app_install_failed';
|
||||
expect(warnings(validateRuleDraft(bad).findings).some((m) => m.includes('collides'))).toBe(true);
|
||||
});
|
||||
|
||||
it('outputEventType without gather_ prefix warns', () => {
|
||||
const r = validGather();
|
||||
r.outputEventType = 'pending_file_renames';
|
||||
expect(warnings(validateRuleDraft(r).findings).some((m) => m.includes('gather_'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyze semantic lint', () => {
|
||||
it('unreachable threshold is an error, above-base-with-factors is info', () => {
|
||||
const dead = validAnalyze();
|
||||
dead.confidenceThreshold = 90;
|
||||
dead.baseConfidence = 50;
|
||||
expect(errors(validateRuleDraft(dead).findings).some((m) => m.includes('never fire'))).toBe(true);
|
||||
|
||||
const corroborated = validAnalyze();
|
||||
corroborated.confidenceThreshold = 60;
|
||||
corroborated.confidenceFactors = [{ signal: 'enrollment_failed', condition: 'count >= 1', weight: 15 }];
|
||||
const r = validateRuleDraft(corroborated);
|
||||
expect(r.valid).toBe(true);
|
||||
expect(r.findings.some((f) => f.level === 'info' && f.message.includes('confidence factors'))).toBe(true);
|
||||
});
|
||||
|
||||
it('non-evaluable factor condition shapes are errors', () => {
|
||||
for (const condition of ['count > 3', 'Count >= 3', 'phase_duration >= 30', 'always']) {
|
||||
const bad = validAnalyze();
|
||||
bad.confidenceFactors = [{ signal: 'x', condition, weight: 10 }];
|
||||
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('not evaluable')), condition).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('"exists" factor pointing at a non-existent signal warns', () => {
|
||||
const bad = validAnalyze();
|
||||
bad.confidenceFactors = [{ signal: 'no_such_signal', condition: 'exists', weight: 10 }];
|
||||
expect(warnings(validateRuleDraft(bad).findings).some((m) => m.includes('no_such_signal'))).toBe(true);
|
||||
});
|
||||
|
||||
it('unknown event types warn; gather_ event types are info', () => {
|
||||
const unknown = validAnalyze();
|
||||
(unknown.conditions as Array<Record<string, unknown>>)[0].eventType = 'made_up_event';
|
||||
expect(warnings(validateRuleDraft(unknown).findings).some((m) => m.includes('made_up_event'))).toBe(true);
|
||||
|
||||
const gatherRef = validAnalyze();
|
||||
(gatherRef.conditions as Array<Record<string, unknown>>)[0].eventType = 'gather_pending_file_renames';
|
||||
const r = validateRuleDraft(gatherRef);
|
||||
expect(r.valid).toBe(true);
|
||||
expect(r.findings.some((f) => f.level === 'info' && f.message.includes('gather_'))).toBe(true);
|
||||
});
|
||||
|
||||
it('duplicate signals and correlation without joinField are errors', () => {
|
||||
const dup = validAnalyze();
|
||||
(dup.conditions as Array<Record<string, unknown>>).push({
|
||||
signal: 'app_retry_storm', source: 'event_type', eventType: 'os_info', operator: 'exists', value: '',
|
||||
});
|
||||
expect(errors(validateRuleDraft(dup).findings).some((m) => m.includes('duplicate signal'))).toBe(true);
|
||||
|
||||
const corr = validAnalyze();
|
||||
(corr.conditions as Array<Record<string, unknown>>)[0] = {
|
||||
signal: 'pair', source: 'event_correlation', eventType: 'app_install_started', correlateEventType: 'app_install_failed',
|
||||
};
|
||||
expect(errors(validateRuleDraft(corr).findings).some((m) => m.includes('joinField'))).toBe(true);
|
||||
});
|
||||
|
||||
it('invalid regex is an error; unanchored not_regex warns', () => {
|
||||
const bad = validAnalyze();
|
||||
(bad.conditions as Array<Record<string, unknown>>)[0] = {
|
||||
signal: 's', source: 'event_data', eventType: 'app_install_failed', dataField: 'errorCode', operator: 'regex', value: '([unclosed',
|
||||
};
|
||||
expect(errors(validateRuleDraft(bad).findings).some((m) => m.includes('not a valid regex'))).toBe(true);
|
||||
|
||||
const unanchored = validAnalyze();
|
||||
(unanchored.conditions as Array<Record<string, unknown>>)[0] = {
|
||||
signal: 's', source: 'event_data_array', eventType: 'provisioning_package_scan', dataField: 'artifacts',
|
||||
itemField: 'identity', operator: 'not_regex', value: 'Microsoft\\.Windows',
|
||||
};
|
||||
expect(warnings(validateRuleDraft(unanchored).findings).some((m) => m.includes('anchored'))).toBe(true);
|
||||
});
|
||||
|
||||
it('unresolvable {{tokens}} warn, resolvable ones (dataField/auto/signal) do not', () => {
|
||||
const r = validateRuleDraft(validAnalyze());
|
||||
// {{appName}} + {{errorCode}} are auto-captured fields → no warning.
|
||||
expect(warnings(r.findings).filter((m) => m.includes('{{'))).toEqual([]);
|
||||
|
||||
const bad = validAnalyze();
|
||||
bad.explanation = 'Value is {{nonexistent_token}}.';
|
||||
expect(warnings(validateRuleDraft(bad).findings).some((m) => m.includes('nonexistent_token'))).toBe(true);
|
||||
});
|
||||
|
||||
it('markSessionAsFailedDefault=true yields an info finding', () => {
|
||||
const ko = validAnalyze();
|
||||
ko.markSessionAsFailedDefault = true;
|
||||
expect(validateRuleDraft(ko).findings.some((f) => f.level === 'info' && f.message.includes('KO criterion'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reserved built-in namespace', () => {
|
||||
it('warns for numeric built-in-shaped IDs (backend rejects tenant creates with 409)', () => {
|
||||
const a = validateRuleDraft(validAnalyze()); // ANALYZE-APP-101
|
||||
expect(a.valid).toBe(true); // warning only — platform built-ins legitimately live here
|
||||
expect(warnings(a.findings).some((m) => m.includes('reserved built-in namespace'))).toBe(true);
|
||||
|
||||
const g = validateRuleDraft(validGather()); // GATHER-DEVICE-101
|
||||
expect(warnings(g.findings).some((m) => m.includes('reserved built-in namespace'))).toBe(true);
|
||||
});
|
||||
|
||||
it('case variants are covered', () => {
|
||||
const r = validateRuleDraft({ ...validAnalyze(), ruleId: 'ANALYZE-SEC-002' });
|
||||
expect(warnings(r.findings).some((m) => m.includes("'ANALYZE-SEC-002'"))).toBe(true);
|
||||
});
|
||||
|
||||
it('the CUSTOM category is the sanctioned tenant namespace and stays clean', () => {
|
||||
const a = validateRuleDraft({ ...validAnalyze(), ruleId: 'ANALYZE-CUSTOM-001' });
|
||||
expect(warnings(a.findings).some((m) => m.includes('reserved built-in namespace'))).toBe(false);
|
||||
|
||||
const g = validateRuleDraft({ ...validGather(), ruleId: 'GATHER-CUSTOM-101' });
|
||||
expect(warnings(g.findings).some((m) => m.includes('reserved built-in namespace'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* telemetry.ts reads MCP_TOOL_LOGGING at module load, so each scenario
|
||||
* re-imports the module with the env stubbed accordingly.
|
||||
*/
|
||||
async function loadTelemetry(loggingEnabled: boolean) {
|
||||
vi.resetModules();
|
||||
if (loggingEnabled) {
|
||||
vi.stubEnv('MCP_TOOL_LOGGING', 'true');
|
||||
} else {
|
||||
vi.stubEnv('MCP_TOOL_LOGGING', '');
|
||||
}
|
||||
return await import('../telemetry.js');
|
||||
}
|
||||
|
||||
function lastLoggedJson(spy: ReturnType<typeof vi.spyOn>): Record<string, unknown> {
|
||||
expect(spy).toHaveBeenCalled();
|
||||
const line = spy.mock.calls[spy.mock.calls.length - 1][0] as string;
|
||||
return JSON.parse(line) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('summarizeArgs', () => {
|
||||
it('drops null/undefined entries and keeps scalars', async () => {
|
||||
const { summarizeArgs } = await loadTelemetry(false);
|
||||
const out = summarizeArgs({ a: 'x', b: 42, c: true, d: null, e: undefined });
|
||||
expect(out).toEqual({ a: 'x', b: '42', c: 'true' });
|
||||
});
|
||||
|
||||
it('caps long string values and annotates the cut', async () => {
|
||||
const { summarizeArgs } = await loadTelemetry(false);
|
||||
const out = summarizeArgs({ q: 'y'.repeat(500) })!;
|
||||
expect(out.q.length).toBeLessThan(230);
|
||||
expect(out.q).toContain('…(+300)');
|
||||
});
|
||||
|
||||
it('stringifies and caps object values (validate_rule sends whole rule objects)', async () => {
|
||||
const { summarizeArgs } = await loadTelemetry(false);
|
||||
const out = summarizeArgs({ rule: { name: 'r', conditions: 'z'.repeat(400) } })!;
|
||||
expect(out.rule.length).toBeLessThan(230);
|
||||
expect(out.rule).toContain('"name":"r"');
|
||||
});
|
||||
|
||||
it('bounds the total summary size across many keys', async () => {
|
||||
const { summarizeArgs } = await loadTelemetry(false);
|
||||
const args: Record<string, unknown> = {};
|
||||
for (let i = 0; i < 50; i++) args[`k${i}`] = 'v'.repeat(100);
|
||||
const out = summarizeArgs(args)!;
|
||||
expect(out['…']).toBe('args summary truncated');
|
||||
expect(JSON.stringify(out).length).toBeLessThan(2500);
|
||||
});
|
||||
|
||||
it('returns undefined for empty args', async () => {
|
||||
const { summarizeArgs } = await loadTelemetry(false);
|
||||
expect(summarizeArgs({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withToolTelemetry', () => {
|
||||
it('does not log when disabled but still returns the result', async () => {
|
||||
const { withToolTelemetry } = await loadTelemetry(false);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const result = await withToolTelemetry('get_session', { sessionId: 's1' }, () => 'ok');
|
||||
expect(result).toBe('ok');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs one tool_call line with duration, size and args when enabled', async () => {
|
||||
const { withToolTelemetry } = await loadTelemetry(true);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const toolResult = {
|
||||
content: [{ type: 'text', text: '{"data":1}' }],
|
||||
_meta: { 'anthropic/maxResultSizeChars': 50_000 },
|
||||
};
|
||||
await withToolTelemetry('search_sessions', { tenantId: 't1', days: 7 }, () => toolResult);
|
||||
const line = lastLoggedJson(spy);
|
||||
expect(line.type).toBe('tool_call');
|
||||
expect(line.tool).toBe('search_sessions');
|
||||
expect(typeof line.durationMs).toBe('number');
|
||||
expect(line.isError).toBe(false);
|
||||
expect(line.resultChars).toBe(10);
|
||||
expect(line.overCap).toBe(false);
|
||||
expect(line.args).toEqual({ tenantId: 't1', days: '7' });
|
||||
});
|
||||
|
||||
it('flags overCap when the result exceeds the inline-size hint', async () => {
|
||||
const { withToolTelemetry } = await loadTelemetry(true);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const toolResult = {
|
||||
content: [{ type: 'text', text: 'x'.repeat(100) }],
|
||||
_meta: { 'anthropic/maxResultSizeChars': 10 },
|
||||
};
|
||||
await withToolTelemetry('query_table', {}, () => toolResult);
|
||||
expect(lastLoggedJson(spy).overCap).toBe(true);
|
||||
});
|
||||
|
||||
it('marks soft errors: toolError RETURNS isError:true instead of throwing', async () => {
|
||||
const { withToolTelemetry } = await loadTelemetry(true);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const errorResult = { isError: true, content: [{ type: 'text', text: 'Backend error' }] };
|
||||
const result = await withToolTelemetry('get_metrics', {}, () => errorResult);
|
||||
expect(result).toBe(errorResult);
|
||||
expect(lastLoggedJson(spy).isError).toBe(true);
|
||||
});
|
||||
|
||||
it('marks thrown errors and rethrows them', async () => {
|
||||
const { withToolTelemetry } = await loadTelemetry(true);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await expect(
|
||||
withToolTelemetry('get_session', {}, () => { throw new Error('boom'); }),
|
||||
).rejects.toThrow('boom');
|
||||
expect(lastLoggedJson(spy).isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logSearchZeroHit', () => {
|
||||
it('is silent when disabled', async () => {
|
||||
const { logSearchZeroHit } = await loadTelemetry(false);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
logSearchZeroHit('search_docs', 'how do I frobnicate');
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('logs the query (capped) with extra detail when enabled', async () => {
|
||||
const { logSearchZeroHit } = await loadTelemetry(true);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
logSearchZeroHit('search_events', 'q'.repeat(400), { eventsFetched: 12 });
|
||||
const line = lastLoggedJson(spy);
|
||||
expect(line.type).toBe('search_zero_hit');
|
||||
expect(line.tool).toBe('search_events');
|
||||
expect((line.query as string).length).toBeLessThan(330);
|
||||
expect(line.eventsFetched).toBe(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logToolCallRejection', () => {
|
||||
it('logs tool name, code and capped message when enabled', async () => {
|
||||
const { logToolCallRejection } = await loadTelemetry(true);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
logToolCallRejection('get_session', -32602, 'Invalid arguments for tool get_session: ' + 'z'.repeat(600));
|
||||
const line = lastLoggedJson(spy);
|
||||
expect(line.type).toBe('tool_call_rejected');
|
||||
expect(line.tool).toBe('get_session');
|
||||
expect(line.errorCode).toBe(-32602);
|
||||
expect((line.message as string).length).toBeLessThan(530);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attachToolCallRejectionSniffer', () => {
|
||||
const ERROR_ENVELOPE = JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
error: { code: -32602, message: 'Input validation error: Invalid arguments for tool get_session: sessionId must be a UUID' },
|
||||
});
|
||||
|
||||
// What SDK 1.30 ACTUALLY sends for Zod/unknown-tool failures: a successful
|
||||
// JSON-RPC response whose RESULT is an isError CallToolResult (verified
|
||||
// against a live server — there is no JSON-RPC error envelope).
|
||||
const REJECTION_RESULT = JSON.stringify({
|
||||
result: {
|
||||
content: [{ type: 'text', text: 'MCP error -32602: Input validation error: Invalid arguments for tool get_session: sessionId must be a UUID at sessionId' }],
|
||||
isError: true,
|
||||
},
|
||||
jsonrpc: '2.0',
|
||||
id: 2,
|
||||
});
|
||||
|
||||
function fakeRes() {
|
||||
const calls: string[] = [];
|
||||
return {
|
||||
calls,
|
||||
write: vi.fn((..._args: never[]) => { calls.push('write'); return true; }),
|
||||
end: vi.fn((..._args: never[]) => { calls.push('end'); return undefined as unknown; }),
|
||||
};
|
||||
}
|
||||
|
||||
it('catches the real SDK 1.30 shape: isError result streamed as Uint8Array chunks, bare end()', async () => {
|
||||
const { attachToolCallRejectionSniffer } = await loadTelemetry(true);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = fakeRes();
|
||||
attachToolCallRejectionSniffer('get_session', res);
|
||||
|
||||
const bytes = new TextEncoder().encode(REJECTION_RESULT);
|
||||
// Split mid-envelope to prove reassembly across chunks works.
|
||||
res.write(bytes.slice(0, 40) as never);
|
||||
res.write(bytes.slice(40) as never);
|
||||
res.end();
|
||||
|
||||
const line = lastLoggedJson(spy);
|
||||
expect(line.type).toBe('tool_call_rejected');
|
||||
expect(line.tool).toBe('get_session');
|
||||
expect(line.errorCode).toBe(-32602);
|
||||
expect(line.message).toContain('sessionId must be a UUID');
|
||||
// The "MCP error -32602: " prefix is stripped (it lives in errorCode).
|
||||
expect((line.message as string).startsWith('Input validation error')).toBe(true);
|
||||
// The response itself went through untouched.
|
||||
expect(res.calls).toEqual(['write', 'write', 'end']);
|
||||
});
|
||||
|
||||
it('also catches a protocol-level JSON-RPC error envelope', async () => {
|
||||
const { attachToolCallRejectionSniffer } = await loadTelemetry(true);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = fakeRes();
|
||||
attachToolCallRejectionSniffer('get_session', res);
|
||||
res.end(ERROR_ENVELOPE as never);
|
||||
const line = lastLoggedJson(spy);
|
||||
expect(line.type).toBe('tool_call_rejected');
|
||||
expect(line.errorCode).toBe(-32602);
|
||||
});
|
||||
|
||||
it('does NOT log a handler soft error (toolError result) — withToolTelemetry owns those', async () => {
|
||||
const { attachToolCallRejectionSniffer } = await loadTelemetry(true);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = fakeRes();
|
||||
attachToolCallRejectionSniffer('get_metrics', res);
|
||||
res.end(JSON.stringify({
|
||||
result: { content: [{ type: 'text', text: '**Backend error in get_metrics** (HTTP 503): the server returned an error.' }], isError: true },
|
||||
jsonrpc: '2.0',
|
||||
id: 3,
|
||||
}) as never);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('catches the direct path: whole rejection result as a single end(string)', async () => {
|
||||
const { attachToolCallRejectionSniffer } = await loadTelemetry(true);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = fakeRes();
|
||||
attachToolCallRejectionSniffer('query_table', res);
|
||||
res.end(REJECTION_RESULT as never);
|
||||
expect(lastLoggedJson(spy).tool).toBe('query_table');
|
||||
});
|
||||
|
||||
it('stays silent for successful results and stops buffering past the cap', async () => {
|
||||
const { attachToolCallRejectionSniffer } = await loadTelemetry(true);
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const res = fakeRes();
|
||||
attachToolCallRejectionSniffer('search_sessions', res);
|
||||
// A large successful tool result (which even CONTAINS the word "error" in data).
|
||||
const big = JSON.stringify({ jsonrpc: '2.0', id: 1, result: { content: [{ type: 'text', text: '"error" '.repeat(3000) }] } });
|
||||
for (let i = 0; i < big.length; i += 1000) res.write(big.slice(i, i + 1000) as never);
|
||||
res.end();
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not wrap the response at all when logging is disabled', async () => {
|
||||
const { attachToolCallRejectionSniffer } = await loadTelemetry(false);
|
||||
const res = fakeRes();
|
||||
const originalEnd = res.end;
|
||||
attachToolCallRejectionSniffer('get_session', res);
|
||||
expect(res.end).toBe(originalEnd);
|
||||
});
|
||||
});
|
||||
@@ -150,7 +150,14 @@ describe('role catalog snapshot — privilege-leak guard', () => {
|
||||
'search_sessions',
|
||||
'search_sessions_by_cve',
|
||||
'search_sessions_by_event',
|
||||
// Rule-authoring trio, available to every role: validate_rule is MCP-local
|
||||
// (no backend call at all); test_analyze_rule's dryrun endpoint and
|
||||
// test_log_pattern's test-pattern endpoint enforce TenantAdminOrGlobalReader
|
||||
// server-side (a Viewer's call 403s there).
|
||||
'test_analyze_rule',
|
||||
'test_log_pattern',
|
||||
'update_tenant_config',
|
||||
'validate_rule',
|
||||
];
|
||||
|
||||
// ── Named difference lists (each a deliberate role-boundary decision). ──
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Handler tests for the rule-authoring tools (tools/rules.ts): request shape of the
|
||||
* dry-run POST, interpolation of the returned trace, and validate_rule's local
|
||||
* (no-backend) behaviour.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { vi } from 'vitest';
|
||||
import { registerRuleTools } from '../tools/rules.js';
|
||||
|
||||
const { apiFetchMock } = vi.hoisted(() => ({ apiFetchMock: vi.fn() }));
|
||||
vi.mock('../client.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../client.js')>();
|
||||
return { ...actual, apiFetch: apiFetchMock };
|
||||
});
|
||||
|
||||
type Handler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }>; isError?: boolean }>;
|
||||
|
||||
function captureToolHandlers(ga: boolean): Record<string, Handler> {
|
||||
const handlers: Record<string, Handler> = {};
|
||||
const fake = { registerTool: (name: string, _def: unknown, handler: Handler) => { handlers[name] = handler; } };
|
||||
registerRuleTools(fake as never, ga);
|
||||
return handlers;
|
||||
}
|
||||
|
||||
const SESSION = 'b2c3d4e5-f6a7-8901-bcde-f12345678901';
|
||||
|
||||
const ANALYZE_DRAFT = {
|
||||
ruleId: 'ANALYZE-APP-101',
|
||||
title: 'Repeated install failures',
|
||||
severity: 'high',
|
||||
category: 'apps',
|
||||
conditions: [
|
||||
{ signal: 'app_failure', source: 'event_type', eventType: 'app_install_failed', operator: 'exists', value: '', required: true },
|
||||
],
|
||||
explanation: 'App {{appName}} failed with {{errorCode}}.',
|
||||
remediation: [{ title: 'Check {{appName}}', steps: ['Inspect error {{errorCode}}'] }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetchMock.mockReset();
|
||||
});
|
||||
|
||||
describe('test_analyze_rule', () => {
|
||||
it('POSTs sessionId + rule to the dryrun endpoint and interpolates the response', async () => {
|
||||
apiFetchMock.mockResolvedValue({
|
||||
success: true,
|
||||
sessionId: SESSION,
|
||||
result: {
|
||||
verdict: 'fired',
|
||||
finalConfidence: 65,
|
||||
matchedConditions: {
|
||||
app_failure: { eventType: 'app_install_failed', appName: 'Contoso App', errorCode: '0x80070005' },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const handlers = captureToolHandlers(false);
|
||||
const res = await handlers.test_analyze_rule({ sessionId: SESSION, rule: ANALYZE_DRAFT });
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
const [path, init] = apiFetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(path).toBe('/api/rules/analyze/dryrun');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(String(init.body));
|
||||
expect(body.sessionId).toBe(SESSION);
|
||||
expect(body.rule.ruleId).toBe('ANALYZE-APP-101');
|
||||
|
||||
expect(res.isError).toBeFalsy();
|
||||
const payload = JSON.parse(res.content[0].text);
|
||||
expect(payload.result.verdict).toBe('fired');
|
||||
expect(payload.interpolatedExplanation).toBe('App Contoso App failed with 0x80070005.');
|
||||
expect(payload.interpolatedRemediation[0].title).toBe('Check Contoso App');
|
||||
expect(payload.interpolatedRemediation[0].steps[0]).toBe('Inspect error 0x80070005');
|
||||
});
|
||||
|
||||
it('leaves tokens untouched when the dry-run produced no evidence', async () => {
|
||||
apiFetchMock.mockResolvedValue({
|
||||
success: true,
|
||||
sessionId: SESSION,
|
||||
result: { verdict: 'required_condition_not_met', matchedConditions: {} },
|
||||
});
|
||||
|
||||
const handlers = captureToolHandlers(false);
|
||||
const res = await handlers.test_analyze_rule({ sessionId: SESSION, rule: ANALYZE_DRAFT });
|
||||
const payload = JSON.parse(res.content[0].text);
|
||||
expect(payload.interpolatedExplanation).toBe('App {{appName}} failed with {{errorCode}}.');
|
||||
});
|
||||
|
||||
it('surfaces backend errors via toolError (no throw)', async () => {
|
||||
apiFetchMock.mockRejectedValue(new Error('boom'));
|
||||
const handlers = captureToolHandlers(false);
|
||||
const res = await handlers.test_analyze_rule({ sessionId: SESSION, rule: ANALYZE_DRAFT });
|
||||
expect(res.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validate_rule', () => {
|
||||
it('is fully MCP-local (never calls the backend) and reports findings', async () => {
|
||||
const handlers = captureToolHandlers(false);
|
||||
const res = await handlers.validate_rule({ rule: { ...ANALYZE_DRAFT, confidenceThreshold: 99, baseConfidence: 50 } });
|
||||
|
||||
expect(apiFetchMock).not.toHaveBeenCalled();
|
||||
const payload = JSON.parse(res.content[0].text);
|
||||
expect(payload.ruleType).toBe('analyze');
|
||||
expect(payload.valid).toBe(false);
|
||||
expect(payload.errorCount).toBeGreaterThan(0);
|
||||
expect(payload.findings.some((f: { message: string }) => f.message.includes('never fire'))).toBe(true);
|
||||
});
|
||||
|
||||
it('valid analyze draft points to test_analyze_rule as the next step', async () => {
|
||||
const handlers = captureToolHandlers(false);
|
||||
const res = await handlers.validate_rule({ rule: ANALYZE_DRAFT });
|
||||
const payload = JSON.parse(res.content[0].text);
|
||||
expect(payload.valid).toBe(true);
|
||||
expect(payload.nextStep).toContain('test_analyze_rule');
|
||||
});
|
||||
|
||||
it('valid logparser draft points to test_log_pattern as the next step', async () => {
|
||||
const handlers = captureToolHandlers(false);
|
||||
const res = await handlers.validate_rule({
|
||||
rule: {
|
||||
ruleId: 'GATHER-APPS-101',
|
||||
title: 'CustomApp installer errors',
|
||||
collectorType: 'logparser',
|
||||
target: 'C:\\Windows\\Logs\\CustomApp\\install*.log',
|
||||
parameters: { pattern: '(?<level>ERROR|FATAL)', format: 'text' },
|
||||
trigger: 'startup',
|
||||
outputEventType: 'gather_customapp_errors',
|
||||
},
|
||||
});
|
||||
const payload = JSON.parse(res.content[0].text);
|
||||
expect(payload.valid).toBe(true);
|
||||
expect(payload.nextStep).toContain('test_log_pattern');
|
||||
});
|
||||
});
|
||||
|
||||
describe('test_log_pattern', () => {
|
||||
it('POSTs pattern + sampleLines + format to the test-pattern endpoint', async () => {
|
||||
apiFetchMock.mockResolvedValue({
|
||||
success: true,
|
||||
format: 'text',
|
||||
result: { matchCount: 1, parseFailureCount: 0, timeoutCount: 0, lines: [], notes: [] },
|
||||
});
|
||||
|
||||
const handlers = captureToolHandlers(false);
|
||||
const res = await handlers.test_log_pattern({
|
||||
pattern: '(?<level>ERROR)',
|
||||
sampleLines: ['ERROR one', 'INFO two'],
|
||||
format: 'text',
|
||||
});
|
||||
|
||||
expect(apiFetchMock).toHaveBeenCalledTimes(1);
|
||||
const [path, init] = apiFetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(path).toBe('/api/rules/gather/test-pattern');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(String(init.body));
|
||||
expect(body.pattern).toBe('(?<level>ERROR)');
|
||||
expect(body.sampleLines).toEqual(['ERROR one', 'INFO two']);
|
||||
expect(body.format).toBe('text');
|
||||
|
||||
expect(res.isError).toBeFalsy();
|
||||
const payload = JSON.parse(res.content[0].text);
|
||||
expect(payload.result.matchCount).toBe(1);
|
||||
});
|
||||
|
||||
it('omits format when not given (backend defaults to cmtrace like the agent)', async () => {
|
||||
apiFetchMock.mockResolvedValue({ success: true, format: 'cmtrace', result: {} });
|
||||
const handlers = captureToolHandlers(false);
|
||||
await handlers.test_log_pattern({ pattern: 'x', sampleLines: ['line'] });
|
||||
const body = JSON.parse(String((apiFetchMock.mock.calls[0] as [string, RequestInit])[1].body));
|
||||
expect(body.format).toBeUndefined();
|
||||
});
|
||||
|
||||
it('surfaces backend errors via toolError (no throw)', async () => {
|
||||
apiFetchMock.mockRejectedValue(new Error('boom'));
|
||||
const handlers = captureToolHandlers(false);
|
||||
const res = await handlers.test_log_pattern({ pattern: 'x', sampleLines: ['line'] });
|
||||
expect(res.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import { buildEventTypeSearchDocs } from './resource-catalog.js';
|
||||
import { createOAuthRouter } from './oauth.js';
|
||||
import { accessGuard } from './access-guard.js';
|
||||
import { hasGlobalScope, isGlobalAdmin, isDelegated, getDelegatedTenantIds, getHomeTenantId } from './client.js';
|
||||
import { toolLoggingEnabled, attachToolCallRejectionSniffer } from './telemetry.js';
|
||||
import { API_BASE_URL } from './config.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -372,6 +373,17 @@ app.all('/mcp', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sniff the JSON-RPC error envelope on tool calls (Zod argument rejection,
|
||||
// unknown tool name): the SDK answers these BEFORE any tool handler runs,
|
||||
// so observing the response write is the only place they are visible — and
|
||||
// they are the strongest "the schema/description confuses the model" signal.
|
||||
// Stateless mode guarantees one JSON-RPC request per POST, so the tool name
|
||||
// from the request body is unambiguous.
|
||||
if (toolLoggingEnabled && req.body?.method === 'tools/call') {
|
||||
const toolName = typeof req.body?.params?.name === 'string' ? req.body.params.name : 'unknown';
|
||||
attachToolCallRejectionSniffer(toolName, res);
|
||||
}
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined, // stateless: no session tracking
|
||||
// Return a single buffered application/json response instead of an SSE
|
||||
|
||||
@@ -141,6 +141,45 @@ export function registerPrompts(server: McpServer, ga: boolean): void {
|
||||
}),
|
||||
);
|
||||
|
||||
server.registerPrompt(
|
||||
'author-rule',
|
||||
{
|
||||
title: 'Author a Gather / Analyze Rule',
|
||||
description:
|
||||
'Guided creation of a custom gather or analyze rule from a scenario description, ' +
|
||||
'with validation and a session dry-run before anything is deployed.',
|
||||
argsSchema: {
|
||||
scenario: z.string().describe('What should be detected or collected, in plain language'),
|
||||
sessionId: z.string().optional().describe('Optional: a recent session UUID to dry-run the draft analyze rule against'),
|
||||
},
|
||||
},
|
||||
({ scenario, sessionId }) => ({
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: {
|
||||
type: 'text',
|
||||
text:
|
||||
`Create an Autopilot Monitor rule for this scenario: ${scenario}\n\n` +
|
||||
'Work through this order:\n' +
|
||||
'1. Read get_resource(name="rule_authoring_guide") plus get_resource(name="rule_schemas") ' +
|
||||
'and get_resource(name="event_types"). For gather rules also get_resource(name="rule_guardrails").\n' +
|
||||
'2. Decide: does this need a gather rule (new data from the device), an analyze rule ' +
|
||||
'(evaluate existing events), or both? If both, draft the gather rule first and reference ' +
|
||||
'its outputEventType from the analyze rule.\n' +
|
||||
'3. Draft the rule JSON and call validate_rule with it. Fix every error; explain warnings you keep.\n' +
|
||||
(sessionId
|
||||
? `4. Dry-run the analyze rule: test_analyze_rule(sessionId="${sessionId}", rule=<draft>). ` +
|
||||
'Walk through the per-condition trace; iterate until the verdict matches the expectation, and check the interpolated explanation reads well.\n'
|
||||
: '4. Ask me for a recent sessionId (one where the rule SHOULD fire and ideally one where it should not) and dry-run the analyze rule with test_analyze_rule.\n') +
|
||||
'5. Present the final rule JSON with a short explanation of when it fires, its confidence ' +
|
||||
'model, and how to create it in the portal (Settings → Gather Rules / Analyze Rules).',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
// Global Admin only — relies on get_platform_metrics (a GA-only tool that is
|
||||
// not registered for normal users). Hidden from non-GA so it never references
|
||||
// a tool they cannot see.
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* stateless-HTTP MCP clients whose resource discovery is broken).
|
||||
*/
|
||||
import { DIAG_ZIP_MAP } from './diag-zip-map.js';
|
||||
import { RULE_AUTHORING_GUIDE } from './rule-authoring-guide.js';
|
||||
import { GATHER_RULE_SCHEMA, ANALYZE_RULE_SCHEMA, RULE_GUARDRAILS } from './rule-authoring.generated.js';
|
||||
|
||||
// Single source of truth for the model-facing event-type catalog. MUST stay in
|
||||
// sync with C# `Constants.EventTypes` (Shared) — enforced by the vitest drift test
|
||||
@@ -378,7 +380,13 @@ export function assertKnownDevicePropertyKeys(keys: string[]): void {
|
||||
);
|
||||
}
|
||||
|
||||
export type ResourceName = 'event_types' | 'device_properties' | 'diag_zip_layout';
|
||||
export type ResourceName =
|
||||
| 'event_types'
|
||||
| 'device_properties'
|
||||
| 'diag_zip_layout'
|
||||
| 'rule_authoring_guide'
|
||||
| 'rule_schemas'
|
||||
| 'rule_guardrails';
|
||||
|
||||
export function getResourceContent(name: ResourceName): unknown {
|
||||
switch (name) {
|
||||
@@ -388,5 +396,14 @@ export function getResourceContent(name: ResourceName): unknown {
|
||||
return DEVICE_PROPERTIES_CATALOG;
|
||||
case 'diag_zip_layout':
|
||||
return DIAG_ZIP_MAP;
|
||||
case 'rule_authoring_guide':
|
||||
return RULE_AUTHORING_GUIDE;
|
||||
case 'rule_schemas':
|
||||
return {
|
||||
gatherRuleSchema: GATHER_RULE_SCHEMA,
|
||||
analyzeRuleSchema: ANALYZE_RULE_SCHEMA,
|
||||
};
|
||||
case 'rule_guardrails':
|
||||
return RULE_GUARDRAILS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { EVENT_TYPES_CATALOG, DEVICE_PROPERTIES_CATALOG } from './resource-catalog.js';
|
||||
import { EVENT_TYPES_CATALOG, DEVICE_PROPERTIES_CATALOG, getResourceContent } from './resource-catalog.js';
|
||||
import { DIAG_ZIP_MAP } from './diag-zip-map.js';
|
||||
|
||||
/**
|
||||
@@ -71,4 +71,72 @@ export function registerResources(server: McpServer): void {
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'rule_authoring_guide',
|
||||
'autopilot://rule-authoring-guide',
|
||||
{
|
||||
title: 'Rule Authoring Guide',
|
||||
mimeType: 'application/json',
|
||||
description:
|
||||
'Complete guide for authoring gather and analyze rules: collector types, triggers, ' +
|
||||
'condition sources, operators, confidence model, {{token}} interpolation, guardrail ' +
|
||||
'semantics and common pitfalls. Read together with rule_schemas and rule_guardrails; ' +
|
||||
'validate drafts with validate_rule and dry-run analyze rules with test_analyze_rule.',
|
||||
},
|
||||
async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: 'autopilot://rule-authoring-guide',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(getResourceContent('rule_authoring_guide'), null, 2),
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'rule_schemas',
|
||||
'autopilot://rule-schemas',
|
||||
{
|
||||
title: 'Rule JSON Schemas',
|
||||
mimeType: 'application/json',
|
||||
description:
|
||||
'The JSON Schemas (2020-12) for gather rules and analyze rules — the exact validation ' +
|
||||
'contract (required fields, enums, patterns). Generated from rules/schema/ in the ' +
|
||||
'product repository.',
|
||||
},
|
||||
async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: 'autopilot://rule-schemas',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(getResourceContent('rule_schemas'), null, 2),
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
'rule_guardrails',
|
||||
'autopilot://rule-guardrails',
|
||||
{
|
||||
title: 'Gather Rule Guardrails',
|
||||
mimeType: 'application/json',
|
||||
description:
|
||||
'The collection allowlists enforced on-device for gather rules: registry/file/WMI/' +
|
||||
'diagnostics path prefixes, event log channels (plus hard-blocked channels) and the ' +
|
||||
'exact allowed command list. A gather rule whose target is not covered here will be ' +
|
||||
'blocked by the agent.',
|
||||
},
|
||||
async () => ({
|
||||
contents: [
|
||||
{
|
||||
uri: 'autopilot://rule-guardrails',
|
||||
mimeType: 'application/json',
|
||||
text: JSON.stringify(getResourceContent('rule_guardrails'), null, 2),
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* The rule-authoring guide served via get_resource(name="rule_authoring_guide").
|
||||
*
|
||||
* This is the complete, deterministic knowledge an AI needs to author gather
|
||||
* and analyze rules — the counterpart to retrieval via search_docs, which only
|
||||
* returns fragments. Everything here mirrors verified engine behaviour:
|
||||
* - condition/factor semantics: Backend RuleEngine.ConditionEvaluators.cs
|
||||
* - guardrail matching: Agent GatherRuleGuards.cs
|
||||
* - schema contract: rules/schema/*.schema.json (served as rule_schemas,
|
||||
* generated into rule-authoring.generated.ts — the drift test pins the two).
|
||||
*
|
||||
* Keep enum lists in sync with the schemas — __tests__/rule-authoring-drift.test.ts
|
||||
* fails the build when they diverge.
|
||||
*/
|
||||
|
||||
export const RULE_AUTHORING_GUIDE = {
|
||||
overview:
|
||||
'Autopilot Monitor has two customer-authorable rule families:\n' +
|
||||
'- GATHER rules run ON the device during enrollment and collect extra data ' +
|
||||
'(registry values, event log entries, WMI queries, files, allow-listed commands, parsed logs). ' +
|
||||
'Each execution emits an event with the rule\'s outputEventType.\n' +
|
||||
'- ANALYZE rules run in the backend AFTER enrollment (and on-demand) against the session\'s ' +
|
||||
'event stream and produce findings with explanation, remediation and a confidence score.\n' +
|
||||
'They compose: a gather rule collects a signal, an analyze rule turns it into a finding.',
|
||||
|
||||
authoringWorkflow: [
|
||||
'1. Read this guide plus get_resource(name="rule_schemas") (the JSON Schema contract), ' +
|
||||
'get_resource(name="rule_guardrails") (what gather rules may touch) and ' +
|
||||
'get_resource(name="event_types") (which event types exist).',
|
||||
'2. Draft the rule as JSON. If the analysis needs data no existing event carries, draft the ' +
|
||||
'gather rule FIRST, then reference its outputEventType in the analyze rule.',
|
||||
'3. Call validate_rule with the draft — fix every error, take warnings seriously.',
|
||||
'4. For analyze rules: call test_analyze_rule with the draft plus a sessionId of a real ' +
|
||||
'session from your tenant (ideally one where the rule SHOULD fire and one where it should ' +
|
||||
'NOT). The dry-run returns a per-condition trace with evidence — iterate until the verdict ' +
|
||||
'matches your expectation. Nothing is persisted by a dry-run.',
|
||||
'5. Gather rules cannot be dry-run against a session (they execute on devices). For logparser ' +
|
||||
'rules, test the regex with test_log_pattern against sample lines pasted from the real log ' +
|
||||
'file — it uses the agent\'s exact .NET matching semantics. Then deploy to a test device/VM; ' +
|
||||
'the agent debug log setting for gather rules shows per-rule execution detail on-device.',
|
||||
'6. Create the finished rule in the portal (Settings → Gather Rules / Analyze Rules). Custom ' +
|
||||
'rules are tenant-scoped. Well-tested rules can be proposed as community rules via GitHub.',
|
||||
],
|
||||
|
||||
ids: {
|
||||
gather: 'Tenant custom gather rules: GATHER-CUSTOM-{NNN} (e.g. GATHER-CUSTOM-011), matching ^GATHER-[A-Z]+-\\d{3}$.',
|
||||
analyze: 'Tenant custom analyze rules: ANALYZE-CUSTOM-{NNN} (e.g. ANALYZE-CUSTOM-101), matching ^ANALYZE-[A-Z]+-\\d{3}$.',
|
||||
note:
|
||||
'The numeric namespace (ANALYZE|GATHER)-<CATEGORY>-<NUMBER> outside the CUSTOM category is ' +
|
||||
'RESERVED for rules shipped with the platform — the backend rejects tenant custom rules with ' +
|
||||
'such IDs (HTTP 409), including currently unused numbers: gaps are usually retired built-ins ' +
|
||||
'that may return. Use the CUSTOM category: it is the one scheme that passes both the schema ' +
|
||||
'pattern and the reservation. (The portal also accepts free-form IDs like an organization ' +
|
||||
'prefix, but those fail the strict schema check in validate_rule — prefer CUSTOM.)',
|
||||
},
|
||||
|
||||
gatherRules: {
|
||||
collectorTypes: {
|
||||
registry:
|
||||
'Read a registry value or enumerate subkeys. target = key path WITHOUT the HKLM\\ prefix ' +
|
||||
'semantics of your choice — use the exact prefixes from rule_guardrails (they are rooted at ' +
|
||||
'HKLM). parameters: valueName (single value), listSubkeys ("true"), severityIfExists / ' +
|
||||
'severityIfNotExists to escalate the emitted event severity.',
|
||||
eventlog:
|
||||
'Query a Windows event log channel. target = channel name (must be in the guardrails ' +
|
||||
'eventLogChannels allowlist; Security/PowerShell/Sysmon channels are hard-blocked). ' +
|
||||
'parameters: eventId, source, maxEntries.',
|
||||
wmi: 'Run a WMI query. target = the query; it must start with one of the allow-listed prefixes ' +
|
||||
'(e.g. "SELECT * FROM Win32_BIOS").',
|
||||
file:
|
||||
'Check file/directory existence, optionally read content. target = path under an allow-listed ' +
|
||||
'prefix. parameters: readContent ("true"). C:\\Users is blocked.',
|
||||
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.',
|
||||
logparser:
|
||||
'Parse log files with a regex. target = file/glob under an allow-listed prefix. parameters: ' +
|
||||
'pattern (regex, named groups become event data fields on the emitted timeline event), ' +
|
||||
'format ("cmtrace" = default: each line is parsed as CMTrace/IME format first and the regex ' +
|
||||
'runs against the parsed MESSAGE only; "text" = regex against the raw line), maxLines, ' +
|
||||
'trackPosition ("true" = only new lines on re-runs). Matching semantics: .NET regex engine, ' +
|
||||
'case-SENSITIVE (unlike analyze-rule regex conditions), first match per line. ALWAYS verify ' +
|
||||
'the pattern with test_log_pattern against real lines from the log file — engines like ' +
|
||||
'JS/PHP/Python behave subtly differently from the agent\'s .NET engine.',
|
||||
},
|
||||
triggers: {
|
||||
startup: 'Once when the agent starts.',
|
||||
phase_change:
|
||||
'When the enrollment reaches a phase (set triggerPhase). Phases follow the engine-reduced ' +
|
||||
'timeline — the same phases the portal timeline shows, not raw ESP registry flips.',
|
||||
phase_exit: 'When the enrollment leaves a phase (set triggerPhase).',
|
||||
interval: 'Periodically (set intervalSeconds).',
|
||||
on_event:
|
||||
'When a specific event type is emitted (set triggerEventType). This sees every emitted ' +
|
||||
'event including engine events such as enrollment_complete.',
|
||||
},
|
||||
phases: ['Start', 'DevicePreparation', 'DeviceSetup', 'AppsDevice', 'AccountSetup', 'AppsUser', 'FinalizingSetup', 'Complete'],
|
||||
scoping:
|
||||
'Optional activePhases (array) or activeFromPhase restrict when the rule may run at all; ' +
|
||||
'emitMode "on_change" emits only when the collected payload differs from the previous run ' +
|
||||
'(default "always").',
|
||||
output:
|
||||
'outputEventType names the emitted event; use the gather_ prefix convention (e.g. ' +
|
||||
'"gather_pending_reboot") so analyze rules and humans can tell collected events from ' +
|
||||
'built-in agent events. outputSeverity: Info | Warning | Error | Critical.',
|
||||
guardrails:
|
||||
'Every target is checked on-device against rules/guardrails.json (served as ' +
|
||||
'rule_guardrails). Matching semantics: registry/file prefixes are segment-bounded (the next ' +
|
||||
'character after the prefix must be "\\"), WMI prefixes bound on whitespace, commands match ' +
|
||||
'exactly, event log channels bound on "/". Hard blocks (Security event log, PowerShell ' +
|
||||
'channels, C:\\Users, download/exec-style commands) are enforced in agent code and cannot be ' +
|
||||
'lifted by config. If a needed target is not allow-listed, the allowlist itself has to be ' +
|
||||
'extended via a GitHub contribution to rules/guardrails.json — a rule alone cannot widen it.',
|
||||
template: {
|
||||
ruleId: 'GATHER-CUSTOM-101',
|
||||
title: 'Collect pending file rename operations',
|
||||
description: 'Reads PendingFileRenameOperations to detect a pending reboot blocking installs.',
|
||||
category: 'device',
|
||||
version: '1.0.0',
|
||||
author: 'Contoso IT',
|
||||
enabled: true,
|
||||
collectorType: 'registry',
|
||||
target: 'SYSTEM\\CurrentControlSet\\Control\\Session Manager',
|
||||
parameters: { valueName: 'PendingFileRenameOperations' },
|
||||
trigger: 'phase_change',
|
||||
triggerPhase: 'DeviceSetup',
|
||||
outputEventType: 'gather_pending_file_renames',
|
||||
outputSeverity: 'Info',
|
||||
tags: ['device', 'reboot'],
|
||||
},
|
||||
},
|
||||
|
||||
analyzeRules: {
|
||||
conditionSources: {
|
||||
event_type:
|
||||
'Does an event of eventType exist? With dataField+operator+value it additionally filters ' +
|
||||
'on a field inside those events. Most common source.',
|
||||
event_data:
|
||||
'Compare a data field inside events of eventType (dataField + operator + value). Supports ' +
|
||||
'dot-paths into nested objects; "message" resolves to the event message.',
|
||||
event_data_array:
|
||||
'Iterate an ARRAY field (dataField) inside ONE event and test a sub-field of each element ' +
|
||||
'(itemField; empty = element itself). Matches if ANY element passes. Allow-list pattern: ' +
|
||||
'operator not_regex against an ANCHORED allow-regex (start with ^ and use \\b boundaries) ' +
|
||||
'fires only for elements NOT on the list — an unanchored regex would let ' +
|
||||
'"Evil.Prefix.AllowedName.Suffix" impostors through.',
|
||||
event_count:
|
||||
'Count events of eventType: operator count_gte (global) or count_per_group_gte (per ' +
|
||||
'distinct dataField value, e.g. 3+ failures of the SAME app via dataField=appId). Optional ' +
|
||||
'filterField/filterOperator/filterValue restrict which events are counted.',
|
||||
phase_duration:
|
||||
'Match a phase (eventType=esp_phase_changed, dataField=espPhase, operator=equals, ' +
|
||||
'value=<phase>); the evidence carries durationSeconds. Combine with a confidenceFactor ' +
|
||||
'"phase_duration > N" for the actual duration check.',
|
||||
app_install_duration: 'Compare app install duration in seconds (operator gt/lt/gte/lte, value=seconds).',
|
||||
event_correlation:
|
||||
'Join two event types over a shared field: eventType + correlateEventType + joinField, ' +
|
||||
'optional timeWindowSeconds, optional eventAFilterField/-Operator/-Value to pre-filter the ' +
|
||||
'first event, optional dataField+operator+value tested on the correlated pair.',
|
||||
},
|
||||
caseSensitivity:
|
||||
'source values are matched CASE-SENSITIVELY by the engine — write them exactly as listed ' +
|
||||
'(lowercase with underscores). Event types match case-insensitively.',
|
||||
operators: [
|
||||
'equals', 'not_equals', 'contains', 'not_contains', 'regex', 'not_regex',
|
||||
'gt', 'lt', 'gte', 'lte', 'exists', 'not_exists', 'count_gte', 'count_per_group_gte', 'in', 'not_in',
|
||||
],
|
||||
operatorNotes:
|
||||
'String comparisons are case-insensitive; regex runs case-insensitive with a 1s timeout ' +
|
||||
'(.NET regex syntax); in/not_in take a comma-separated value list. An operator the engine ' +
|
||||
'does not know silently evaluates to FALSE in production — validate_rule catches this.',
|
||||
required:
|
||||
'required=true conditions are the firing core: ALL of them must match or the rule produces ' +
|
||||
'nothing. required=false conditions are optional reinforcers whose evidence feeds ' +
|
||||
'confidenceFactors. A rule where NO condition matched never fires, even if all conditions ' +
|
||||
'are optional.',
|
||||
preconditions:
|
||||
'Optional gate BEFORE conditions: source must be "event_data"; all preconditions are ' +
|
||||
'AND-combined and a failing one silently skips the rule (no finding, no UI card). Use for ' +
|
||||
'applicability ("skip on virtual machines", "only when marker event absent via not_exists").',
|
||||
suppressByEvent:
|
||||
'On a condition, suppressByEvent {eventType, joinField} drops matches that a resolving event ' +
|
||||
'shares the joinField value with — e.g. app_install_failed suppressed by a later ' +
|
||||
'app_install_completed of the same appId.',
|
||||
confidence: {
|
||||
model:
|
||||
'If all required conditions match (and at least one condition matched), score = ' +
|
||||
'baseConfidence + sum of weights of matched confidenceFactors, capped at 100. The rule ' +
|
||||
'fires only if score >= confidenceThreshold.',
|
||||
factorConditions:
|
||||
'A factor\'s condition must be EXACTLY one of these shapes (anything else silently never ' +
|
||||
'matches): "exists" (the condition signal named by factor.signal matched), "count >= N" ' +
|
||||
'(factor.signal is an EVENT TYPE here — counts events of that type, case-sensitive), ' +
|
||||
'"phase_duration > N" (seconds, requires a matched phase_duration condition).',
|
||||
bestPractices:
|
||||
'baseConfidence 40-60 for single-signal rules, 50-70 for correlations; set ' +
|
||||
'confidenceThreshold at or slightly below baseConfidence so the rule can fire without ' +
|
||||
'factors; weight optional reinforcers 10-20 each.',
|
||||
},
|
||||
interpolation:
|
||||
'explanation and remediation text may contain {{token}} placeholders, resolved from the ' +
|
||||
'matched evidence at display time: first a matched condition whose dataField equals the ' +
|
||||
'token, then the auto-captured fields appId / appName / errorPatternId / errorCode / ' +
|
||||
'exitCode / status from the matched events, then a condition SIGNAL name. Unresolved tokens ' +
|
||||
'stay visible as {{token}} so typos are noticeable. test_analyze_rule returns the ' +
|
||||
'interpolated preview for a real session.',
|
||||
markSessionAsFailed:
|
||||
'markSessionAsFailedDefault=true escalates a firing rule to a terminal Failed session ' +
|
||||
'status (KO criterion). Use sparingly — only for findings that genuinely mean the ' +
|
||||
'enrollment failed.',
|
||||
template: {
|
||||
ruleId: 'ANALYZE-CUSTOM-101',
|
||||
title: 'Repeated install failures of the same app',
|
||||
description: 'Detects an app failing to install 3+ times in one enrollment.',
|
||||
severity: 'high',
|
||||
category: 'apps',
|
||||
version: '1.0.0',
|
||||
author: 'Contoso IT',
|
||||
enabled: true,
|
||||
trigger: 'single',
|
||||
baseConfidence: 55,
|
||||
conditions: [
|
||||
{
|
||||
signal: 'app_retry_storm',
|
||||
source: 'event_count',
|
||||
eventType: 'app_install_failed',
|
||||
dataField: 'appId',
|
||||
operator: 'count_per_group_gte',
|
||||
value: '3',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
confidenceFactors: [
|
||||
{ signal: 'enrollment_failed', condition: 'count >= 1', weight: 15 },
|
||||
],
|
||||
confidenceThreshold: 50,
|
||||
explanation: 'App {{appName}} failed to install repeatedly (last error {{errorCode}}).',
|
||||
remediation: [
|
||||
{ title: 'Check the app package', steps: ['Review the install command line', 'Test the package on a reference device'] },
|
||||
],
|
||||
tags: ['apps', 'retry'],
|
||||
},
|
||||
},
|
||||
|
||||
enums: {
|
||||
gatherCategories: ['network', 'identity', 'apps', 'device', 'esp', 'enrollment'],
|
||||
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'],
|
||||
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'],
|
||||
},
|
||||
|
||||
commonPitfalls: [
|
||||
'Referencing an event type that never occurs in real sessions — check get_resource(name="event_types") and use test_analyze_rule\'s per-condition matchingEventCount to verify.',
|
||||
'confidenceThreshold higher than baseConfidence plus all factor weights — the rule can mathematically never fire.',
|
||||
'Unanchored allow-list regex with not_regex — anchors (^, \\b) are mandatory or impostor values pass.',
|
||||
'A confidenceFactor condition like "count > 3" or "Count >= 3" — only the exact shapes "exists", "count >= N", "phase_duration > N" evaluate.',
|
||||
'Gather targets outside the guardrail allowlists — the agent blocks them at run time; validate_rule checks this up front.',
|
||||
'Testing a logparser regex in a JS/PHP/Python tester and assuming it behaves the same on the device — the agent uses the .NET engine and matches case-SENSITIVELY. Use test_log_pattern, which runs the exact agent semantics.',
|
||||
'Running a plain-text log through the default cmtrace format — every line fails CMTrace parsing and nothing ever matches. Set parameters.format="text" (test_log_pattern reports this explicitly).',
|
||||
'Duplicate condition signals — evidence is keyed by signal, the second overwrites the first.',
|
||||
'Expecting gather events in sessions that ran BEFORE the gather rule was deployed — dry-run against a recent session.',
|
||||
],
|
||||
} as const;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* MCP-local validation of draft gather/analyze rules for the `validate_rule` tool.
|
||||
*
|
||||
* Three layers:
|
||||
* 1. JSON-Schema validation (ajv, draft 2020-12) against the baked schemas — the same
|
||||
* contract CI enforces on the built-in rule catalog.
|
||||
* 2. Guardrail checks for gather targets, mirroring the AGENT's enforcement semantics
|
||||
* (GatherRuleGuards.cs): hive-stripped, segment-bounded prefix matching for
|
||||
* registry/file paths, whitespace-bounded prefixes for WMI, EXACT matching for
|
||||
* commands, '/'-bounded channel matching for event logs, plus the hard blocks that
|
||||
* config can never lift.
|
||||
* 3. Semantic lint that catches rules which would be schema-valid but silently dead or
|
||||
* misleading in production (unreachable confidence threshold, non-evaluable factor
|
||||
* DSL, unknown event types, unanchored allow-list regex, unresolvable {{tokens}}).
|
||||
*
|
||||
* Everything here is advisory pre-flight: the agent re-enforces guardrails on-device
|
||||
* and the backend re-validates structure on dry-run. This module must therefore only
|
||||
* ever REJECT things production would reject or silently ignore — never invent its
|
||||
* own stricter contract.
|
||||
*/
|
||||
import { Ajv2020 } from 'ajv/dist/2020.js';
|
||||
import addFormatsModule from 'ajv-formats';
|
||||
|
||||
// ajv-formats ships CJS with `export =` typings that Node16 module resolution
|
||||
// refuses to treat as callable through the default import — at runtime the
|
||||
// default IS the plugin function. Narrow cast, no behaviour involved.
|
||||
const addFormats = addFormatsModule as unknown as (ajv: Ajv2020) => void;
|
||||
import { GATHER_RULE_SCHEMA, ANALYZE_RULE_SCHEMA, RULE_GUARDRAILS } from './rule-authoring.generated.js';
|
||||
import { isKnownEventType } from './resource-catalog.js';
|
||||
|
||||
export type FindingLevel = 'error' | 'warning' | 'info';
|
||||
|
||||
export interface ValidationFinding {
|
||||
level: FindingLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
ruleType: 'gather' | 'analyze' | 'unknown';
|
||||
/** true when no error-level findings exist (warnings/info allowed). */
|
||||
valid: boolean;
|
||||
findings: ValidationFinding[];
|
||||
}
|
||||
|
||||
// ── ajv setup (module-level: compiled once per process) ─────────────────────
|
||||
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
||||
addFormats(ajv);
|
||||
ajv.addSchema(GATHER_RULE_SCHEMA);
|
||||
ajv.addSchema(ANALYZE_RULE_SCHEMA);
|
||||
|
||||
/** Validate against the single-rule $def directly — the top-level oneOf (rule |
|
||||
* {rules:[...]} wrapper) would produce confusing double errors for drafts. */
|
||||
function schemaValidate(schemaId: unknown, defName: string, rule: unknown): ValidationFinding[] {
|
||||
const validate = ajv.getSchema(`${String(schemaId)}#/$defs/${defName}`);
|
||||
if (!validate) throw new Error(`Schema $defs/${defName} not found — generated schema changed shape`);
|
||||
if (validate(rule)) return [];
|
||||
return (validate.errors ?? []).map((e) => ({
|
||||
level: 'error' as const,
|
||||
message: `schema: ${e.instancePath || '(root)'} ${e.message}${e.keyword === 'enum' ? ` (${JSON.stringify((e.params as { allowedValues?: unknown[] }).allowedValues)})` : ''}`,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Guardrail matching (mirrors GatherRuleGuards.cs) ────────────────────────
|
||||
|
||||
const flatRegistryPrefixes = RULE_GUARDRAILS.registryPrefixes.flatMap((g) => [...g.prefixes]);
|
||||
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),
|
||||
* missing ones here just cost a pre-flight warning. */
|
||||
const HARD_BLOCKED_PATH_PREFIXES = ['C:\\Users', 'C:\\Windows\\System32\\config'];
|
||||
|
||||
/** Segment-bounded prefix match: target equals the prefix or continues with '\'. */
|
||||
function pathPrefixAllowed(target: string, prefixes: readonly string[]): boolean {
|
||||
const t = target.toLowerCase();
|
||||
return prefixes.some((p) => {
|
||||
const pl = p.toLowerCase();
|
||||
return t === pl || t.startsWith(pl + '\\');
|
||||
});
|
||||
}
|
||||
|
||||
function stripRegistryHive(target: string): string {
|
||||
return target.replace(/^(HKLM|HKEY_LOCAL_MACHINE|HKCU|HKEY_CURRENT_USER)\\/i, '');
|
||||
}
|
||||
|
||||
function checkGatherTarget(collectorType: string, target: string): ValidationFinding[] {
|
||||
const findings: ValidationFinding[] = [];
|
||||
switch (collectorType) {
|
||||
case 'registry': {
|
||||
const subPath = stripRegistryHive(target);
|
||||
if (!pathPrefixAllowed(subPath, flatRegistryPrefixes)) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message:
|
||||
`guardrails: registry target "${target}" is not under any allowed prefix. ` +
|
||||
'See get_resource(name="rule_guardrails") → registryPrefixes. Note: the last allowed path segment must be followed by "\\" or end-of-string (segment-bounded matching). ' +
|
||||
'Widening the allowlist requires a contribution to rules/guardrails.json in the product repository.',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'file':
|
||||
case 'logparser': {
|
||||
const blocked = HARD_BLOCKED_PATH_PREFIXES.find((p) => pathPrefixAllowed(target, [p]));
|
||||
if (blocked) {
|
||||
findings.push({ level: 'error', message: `guardrails: "${target}" is under the hard-blocked path ${blocked} — the agent always refuses this, it cannot be allow-listed.` });
|
||||
break;
|
||||
}
|
||||
const allowed = collectorType === 'logparser'
|
||||
? [...RULE_GUARDRAILS.filePrefixes, ...RULE_GUARDRAILS.diagnosticsPathPrefixes]
|
||||
: [...RULE_GUARDRAILS.filePrefixes];
|
||||
// logparser targets may carry a glob suffix (*.log) — prefix matching still applies.
|
||||
if (!pathPrefixAllowed(target, allowed) && !allowed.some((p) => target.toLowerCase().startsWith(p.toLowerCase() + '\\'))) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message: `guardrails: ${collectorType} target "${target}" is not under any allowed file prefix (rule_guardrails → filePrefixes${collectorType === 'logparser' ? ' / diagnosticsPathPrefixes' : ''}).`,
|
||||
});
|
||||
}
|
||||
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)));
|
||||
});
|
||||
if (!ok) {
|
||||
findings.push({ level: 'error', message: `guardrails: WMI query "${target}" does not start with any allowed query prefix (rule_guardrails → wmiQueryPrefixes).` });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'command_allowlisted': {
|
||||
const t = target.trim().toLowerCase();
|
||||
if (!flatCommands.some((c) => c.trim().toLowerCase() === t)) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message: `guardrails: command "${target}" is not on the allowlist. Commands match EXACTLY (no extra arguments). See rule_guardrails → allowedCommands.`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'eventlog': {
|
||||
const channel = target.split('/')[0];
|
||||
const blocked = RULE_GUARDRAILS.blockedEventLogChannels.some((b) => b.toLowerCase() === channel.toLowerCase());
|
||||
if (blocked) {
|
||||
findings.push({ level: 'error', message: `guardrails: event log channel "${channel}" is hard-blocked (security-sensitive) and can never be collected.` });
|
||||
break;
|
||||
}
|
||||
const ok = flatEventLogChannels.some((c) => {
|
||||
const cl = c.toLowerCase();
|
||||
const tl = target.toLowerCase();
|
||||
return tl === cl || tl.startsWith(cl + '/');
|
||||
});
|
||||
if (!ok) {
|
||||
findings.push({ level: 'error', message: `guardrails: event log channel "${target}" is not on the allowlist (rule_guardrails → eventLogChannels).` });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break; // unknown collectorType is already a schema error
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ── Analyze-rule semantic lint ──────────────────────────────────────────────
|
||||
|
||||
/** Mirrors the backend evidence auto-capture whitelist (AddDataFieldsToEvidence) and
|
||||
* the interpolation resolution order (interpolate-rule-template.ts). */
|
||||
const AUTO_FIELDS = ['appId', 'appName', 'errorPatternId', 'errorCode', 'exitCode', 'status'];
|
||||
|
||||
/** The only condition strings EvaluateConfidenceFactor parses — anything else is
|
||||
* silently false in production. Exact spacing matters. */
|
||||
const FACTOR_CONDITION_SHAPE = /^(exists|count >= ?\d+|phase_duration > ?\d+)$/;
|
||||
|
||||
interface DraftCondition {
|
||||
signal?: string;
|
||||
source?: string;
|
||||
eventType?: string;
|
||||
dataField?: string;
|
||||
operator?: string;
|
||||
value?: string;
|
||||
required?: boolean;
|
||||
correlateEventType?: string;
|
||||
joinField?: string;
|
||||
}
|
||||
|
||||
function checkEventTypeKnown(eventType: string | undefined, where: string): ValidationFinding[] {
|
||||
if (!eventType) return [];
|
||||
if (isKnownEventType(eventType)) return [];
|
||||
if (eventType.startsWith('gather_')) {
|
||||
return [{
|
||||
level: 'info',
|
||||
message: `${where}: eventType "${eventType}" follows the gather_ convention — make sure a gather rule with this outputEventType is deployed, otherwise the condition never matches.`,
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
level: 'warning',
|
||||
message: `${where}: eventType "${eventType}" is not a known built-in event type (see get_resource(name="event_types")). If it is the outputEventType of a custom gather rule this is fine; otherwise the condition will never match.`,
|
||||
}];
|
||||
}
|
||||
|
||||
function extractTokens(rule: Record<string, unknown>): string[] {
|
||||
const texts: string[] = [];
|
||||
if (typeof rule.explanation === 'string') texts.push(rule.explanation);
|
||||
if (Array.isArray(rule.remediation)) {
|
||||
for (const step of rule.remediation as Array<{ title?: string; steps?: string[] }>) {
|
||||
if (typeof step?.title === 'string') texts.push(step.title);
|
||||
for (const s of step?.steps ?? []) if (typeof s === 'string') texts.push(s);
|
||||
}
|
||||
}
|
||||
const tokens = new Set<string>();
|
||||
for (const text of texts) {
|
||||
for (const m of text.matchAll(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g)) tokens.add(m[1]);
|
||||
}
|
||||
return [...tokens];
|
||||
}
|
||||
|
||||
function lintAnalyzeRule(rule: Record<string, unknown>): ValidationFinding[] {
|
||||
const findings: ValidationFinding[] = [];
|
||||
const conditions = (Array.isArray(rule.conditions) ? rule.conditions : []) as DraftCondition[];
|
||||
|
||||
const seenSignals = new Set<string>();
|
||||
for (let i = 0; i < conditions.length; i++) {
|
||||
const c = conditions[i];
|
||||
const label = `conditions[${i}]`;
|
||||
|
||||
if (c.signal) {
|
||||
if (seenSignals.has(c.signal)) {
|
||||
findings.push({ level: 'error', message: `${label}: duplicate signal "${c.signal}" — evidence is keyed by signal, the second entry overwrites the first.` });
|
||||
}
|
||||
seenSignals.add(c.signal);
|
||||
}
|
||||
|
||||
if (c.source === 'event_correlation') {
|
||||
if (!c.correlateEventType) findings.push({ level: 'error', message: `${label}: event_correlation requires correlateEventType.` });
|
||||
if (!c.joinField) findings.push({ level: 'error', message: `${label}: event_correlation requires joinField.` });
|
||||
findings.push(...checkEventTypeKnown(c.correlateEventType, `${label}.correlateEventType`));
|
||||
}
|
||||
|
||||
findings.push(...checkEventTypeKnown(c.eventType, label));
|
||||
|
||||
if ((c.operator === 'regex' || c.operator === 'not_regex') && c.value) {
|
||||
try {
|
||||
new RegExp(c.value, 'i');
|
||||
} catch (e) {
|
||||
findings.push({ level: 'error', message: `${label}: value is not a valid regex (${(e as Error).message}). The engine uses .NET regex syntax — stick to the common subset.` });
|
||||
}
|
||||
if (c.operator === 'not_regex' && !c.value.startsWith('^')) {
|
||||
findings.push({
|
||||
level: 'warning',
|
||||
message: `${label}: not_regex allow-list pattern is not anchored with "^" — unanchored patterns let impostor values (e.g. an allowed name embedded in a longer string) pass. Anchor with ^ and \\b.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ((c.source === 'event_data' || c.source === 'event_data_array') && !c.dataField) {
|
||||
findings.push({ level: 'error', message: `${label}: source ${c.source} requires dataField.` });
|
||||
}
|
||||
}
|
||||
|
||||
// Precondition event types.
|
||||
const preconditions = (Array.isArray(rule.preconditions) ? rule.preconditions : []) as DraftCondition[];
|
||||
for (let i = 0; i < preconditions.length; i++) {
|
||||
findings.push(...checkEventTypeKnown(preconditions[i].eventType, `preconditions[${i}]`));
|
||||
}
|
||||
|
||||
// Confidence factors: exact DSL shapes; "count >= N" counts EVENTS of type factor.signal.
|
||||
const factors = (Array.isArray(rule.confidenceFactors) ? rule.confidenceFactors : []) as Array<{ signal?: string; condition?: string; weight?: number }>;
|
||||
let positiveWeights = 0;
|
||||
for (let i = 0; i < factors.length; i++) {
|
||||
const f = factors[i];
|
||||
if (!f.condition || !FACTOR_CONDITION_SHAPE.test(f.condition)) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message: `confidenceFactors[${i}]: condition "${f.condition}" is not evaluable — supported shapes (exact spacing): "exists", "count >= N", "phase_duration > N". In production this factor would silently never apply.`,
|
||||
});
|
||||
} else if (f.condition === 'exists' && f.signal && !seenSignals.has(f.signal)) {
|
||||
findings.push({
|
||||
level: 'warning',
|
||||
message: `confidenceFactors[${i}]: "exists" refers to signal "${f.signal}" but no condition declares that signal — the factor can never apply.`,
|
||||
});
|
||||
} else if (f.condition.startsWith('count >=')) {
|
||||
findings.push(...checkEventTypeKnown(f.signal, `confidenceFactors[${i}].signal (event type for "count >=")`));
|
||||
}
|
||||
if (typeof f.weight === 'number' && f.weight > 0) positiveWeights += f.weight;
|
||||
}
|
||||
|
||||
// Threshold reachability.
|
||||
const base = typeof rule.baseConfidence === 'number' ? rule.baseConfidence : 50;
|
||||
const threshold = typeof rule.confidenceThreshold === 'number' ? rule.confidenceThreshold : 40;
|
||||
if (threshold > base + positiveWeights) {
|
||||
findings.push({
|
||||
level: 'error',
|
||||
message: `confidenceThreshold (${threshold}) exceeds baseConfidence + all positive factor weights (${base} + ${positiveWeights}) — the rule can mathematically never fire.`,
|
||||
});
|
||||
} else if (threshold > base) {
|
||||
findings.push({
|
||||
level: 'info',
|
||||
message: `confidenceThreshold (${threshold}) is above baseConfidence (${base}) — the rule only fires when confidence factors apply. Intentional for corroborated findings; lower the threshold if the required conditions alone should fire it.`,
|
||||
});
|
||||
}
|
||||
|
||||
// {{token}} resolvability (interpolation order: dataField → auto fields → signal).
|
||||
const resolvable = new Set<string>([...AUTO_FIELDS]);
|
||||
for (const c of conditions) {
|
||||
if (c.dataField) resolvable.add(c.dataField);
|
||||
if (c.signal) resolvable.add(c.signal);
|
||||
}
|
||||
for (const token of extractTokens(rule)) {
|
||||
if (!resolvable.has(token)) {
|
||||
findings.push({
|
||||
level: 'warning',
|
||||
message: `explanation/remediation references {{${token}}} but no condition dataField/signal or auto-captured field (${AUTO_FIELDS.join(', ')}) resolves it — it will render literally as {{${token}}}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (rule.markSessionAsFailedDefault === true) {
|
||||
findings.push({
|
||||
level: 'info',
|
||||
message: 'markSessionAsFailedDefault=true escalates every firing of this rule to a terminal FAILED session status (KO criterion). Dry-run against sessions that should NOT fail before enabling.',
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ── Gather-rule semantic lint ───────────────────────────────────────────────
|
||||
|
||||
function lintGatherRule(rule: Record<string, unknown>): ValidationFinding[] {
|
||||
const findings: ValidationFinding[] = [];
|
||||
const trigger = rule.trigger as string | undefined;
|
||||
|
||||
if (trigger === 'interval' && typeof rule.intervalSeconds !== 'number') {
|
||||
findings.push({ level: 'error', message: 'trigger "interval" requires intervalSeconds.' });
|
||||
}
|
||||
if (trigger === 'on_event' && !rule.triggerEventType) {
|
||||
findings.push({ level: 'error', message: 'trigger "on_event" requires triggerEventType.' });
|
||||
}
|
||||
if ((trigger === 'phase_change' || trigger === 'phase_exit') && !rule.triggerPhase) {
|
||||
findings.push({ level: 'info', message: `trigger "${trigger}" without triggerPhase fires on EVERY phase transition — set triggerPhase to collect once at a specific phase.` });
|
||||
}
|
||||
|
||||
const outputEventType = rule.outputEventType as string | undefined;
|
||||
if (outputEventType) {
|
||||
if (isKnownEventType(outputEventType)) {
|
||||
findings.push({
|
||||
level: 'warning',
|
||||
message: `outputEventType "${outputEventType}" collides with a built-in event type — analyze rules and the timeline could no longer tell your collected events from agent events. Use a gather_ prefixed name.`,
|
||||
});
|
||||
} else if (!outputEventType.startsWith('gather_')) {
|
||||
findings.push({ level: 'warning', message: `outputEventType "${outputEventType}" does not follow the gather_ naming convention (e.g. "gather_${outputEventType}").` });
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof rule.collectorType === 'string' && typeof rule.target === 'string') {
|
||||
findings.push(...checkGatherTarget(rule.collectorType, rule.target));
|
||||
}
|
||||
|
||||
if (rule.collectorType === 'logparser') {
|
||||
findings.push(...lintLogparserParameters(rule));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/** logparser-specific parameter lint. Matching runs on the DEVICE with .NET regex —
|
||||
* this only pre-checks what would make the rule dead; real pattern testing belongs in
|
||||
* test_log_pattern (agent-exact engine). */
|
||||
function lintLogparserParameters(rule: Record<string, unknown>): ValidationFinding[] {
|
||||
const findings: ValidationFinding[] = [];
|
||||
const parameters = (rule.parameters ?? {}) as Record<string, unknown>;
|
||||
const pattern = parameters.pattern;
|
||||
|
||||
if (typeof pattern !== 'string' || pattern.length === 0) {
|
||||
findings.push({ level: 'error', message: 'logparser requires parameters.pattern — without it the rule can never match.' });
|
||||
return findings;
|
||||
}
|
||||
|
||||
try {
|
||||
// JS compile is only an approximation of the agent's .NET engine — good enough to
|
||||
// catch syntax errors, not equivalence.
|
||||
new RegExp(pattern);
|
||||
} catch (e) {
|
||||
findings.push({ level: 'error', message: `parameters.pattern does not compile (${(e as Error).message}). Note the agent uses .NET regex syntax.` });
|
||||
}
|
||||
|
||||
findings.push({
|
||||
level: 'info',
|
||||
message:
|
||||
'logparser matching is case-SENSITIVE and runs on the agent\'s .NET regex engine — a pattern verified in a JS/PHP/Python tester can behave differently. ' +
|
||||
'Test it with test_log_pattern(pattern, sampleLines) against real lines from the log file before deploying.',
|
||||
});
|
||||
|
||||
const format = parameters.format;
|
||||
if (typeof format === 'string' && format.toLowerCase() !== 'text' && format.toLowerCase() !== 'cmtrace') {
|
||||
findings.push({
|
||||
level: 'warning',
|
||||
message: `parameters.format "${format}" is not a known format — the agent treats anything other than "text" as CMTrace mode.`,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// Mirror of AutopilotMonitor.Shared.Models.RuleIdPolicy — keep in sync. The CUSTOM
|
||||
// category is the sanctioned tenant namespace; everything else in the numeric grid is
|
||||
// reserved for platform built-ins.
|
||||
const RESERVED_BUILTIN_ID = /^(ANALYZE|GATHER)-(?!CUSTOM-)[A-Z]+-\d+$/i;
|
||||
|
||||
// ── Entry point ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function validateRuleDraft(rule: Record<string, unknown>): ValidationResult {
|
||||
const ruleId = typeof rule.ruleId === 'string' ? rule.ruleId : '';
|
||||
const ruleType: ValidationResult['ruleType'] =
|
||||
typeof rule.collectorType === 'string' || ruleId.startsWith('GATHER-')
|
||||
? 'gather'
|
||||
: Array.isArray(rule.conditions) || ruleId.startsWith('ANALYZE-')
|
||||
? 'analyze'
|
||||
: 'unknown';
|
||||
|
||||
if (ruleType === 'unknown') {
|
||||
return {
|
||||
ruleType,
|
||||
valid: false,
|
||||
findings: [{
|
||||
level: 'error',
|
||||
message:
|
||||
'Cannot tell whether this is a gather rule (collectorType/target, ruleId GATHER-…) or an analyze rule (conditions, ruleId ANALYZE-…). ' +
|
||||
'Read get_resource(name="rule_authoring_guide") for the two shapes.',
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
const findings = ruleType === 'gather'
|
||||
? [...schemaValidate(GATHER_RULE_SCHEMA.$id, 'gatherRule', rule), ...lintGatherRule(rule)]
|
||||
: [...schemaValidate(ANALYZE_RULE_SCHEMA.$id, 'analyzeRule', rule), ...lintAnalyzeRule(rule)];
|
||||
|
||||
// Warning, not error: platform built-ins legitimately live in this namespace, and this
|
||||
// module cannot see whether the caller is authoring one. For tenant custom rules the
|
||||
// backend enforces the reservation at create/update time (HTTP 409).
|
||||
if (RESERVED_BUILTIN_ID.test(ruleId)) {
|
||||
findings.push({
|
||||
level: 'warning',
|
||||
message:
|
||||
`ruleId '${ruleId}' lies in the reserved built-in namespace (ANALYZE|GATHER)-<CATEGORY>-<NUMBER> — ` +
|
||||
'the backend rejects tenant custom rules with such IDs (gaps included: they are usually retired rules that may return). ' +
|
||||
"Use the CUSTOM category instead (e.g. 'ANALYZE-CUSTOM-001'). " +
|
||||
'Ignore this warning only when authoring a platform built-in rule.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ruleType,
|
||||
valid: !findings.some((f) => f.level === 'error'),
|
||||
findings,
|
||||
};
|
||||
}
|
||||
@@ -1,33 +1,226 @@
|
||||
import { runWithToolName } from './client.js';
|
||||
import { runWithToolName, hasGlobalScope, isDelegated } from './client.js';
|
||||
|
||||
const toolLoggingEnabled = process.env.MCP_TOOL_LOGGING === 'true';
|
||||
export const toolLoggingEnabled = process.env.MCP_TOOL_LOGGING === 'true';
|
||||
|
||||
// Caps keep a single log line small no matter what the model sends (validate_rule
|
||||
// takes whole rule JSON objects; queries are free text).
|
||||
const ARG_VALUE_CAP = 200;
|
||||
const ARGS_TOTAL_CAP = 1500;
|
||||
const QUERY_CAP = 300;
|
||||
|
||||
function cap(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max) + `…(+${s.length - max})` : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact, size-bounded view of the tool arguments for the log line. Values are
|
||||
* individually capped and the whole summary is bounded, so a pathological arg
|
||||
* object can never bloat a log line. Null/undefined entries are dropped.
|
||||
*/
|
||||
export function summarizeArgs(args: Record<string, unknown>): Record<string, string> | undefined {
|
||||
const out: Record<string, string> = {};
|
||||
let total = 0;
|
||||
for (const [key, value] of Object.entries(args)) {
|
||||
if (value === null || value === undefined) continue;
|
||||
const rendered =
|
||||
typeof value === 'string' ? cap(value, ARG_VALUE_CAP) : cap(JSON.stringify(value) ?? '', ARG_VALUE_CAP);
|
||||
total += key.length + rendered.length;
|
||||
if (total > ARGS_TOTAL_CAP) {
|
||||
out['…'] = 'args summary truncated';
|
||||
break;
|
||||
}
|
||||
out[key] = rendered;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
/** Caller scope for usage analysis — which audience a tool struggle belongs to. */
|
||||
function callerScope(): 'ga' | 'delegated' | 'tenant' {
|
||||
if (hasGlobalScope()) return 'ga';
|
||||
if (isDelegated()) return 'delegated';
|
||||
return 'tenant';
|
||||
}
|
||||
|
||||
interface ToolResultShape {
|
||||
isError?: boolean;
|
||||
content?: Array<{ type?: string; text?: string }>;
|
||||
_meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps an MCP tool handler to:
|
||||
* 1. Always: propagate the tool name via AsyncLocalStorage so apiFetch sends
|
||||
* the X-MCP-Tool-Name header to the backend (tracked in App Insights).
|
||||
* 2. Optionally (MCP_TOOL_LOGGING=true): emit structured JSON to stderr,
|
||||
* queryable via Container App Logs in Azure Monitor.
|
||||
* 2. Optionally (MCP_TOOL_LOGGING=true): emit one structured JSON line per call
|
||||
* to stderr, queryable via Container App Logs in Azure Monitor. Beyond
|
||||
* duration, the line carries the quality signals backend telemetry cannot
|
||||
* see: soft errors (handlers never throw — toolError RETURNS `isError:
|
||||
* true`), result size vs. the tool's inline-size cap (overCap = the host
|
||||
* will truncate), a size-bounded argument summary, and the caller scope.
|
||||
* This is also the ONLY telemetry for tools that never call the backend
|
||||
* (search_docs, search_knowledge ranking, validate_rule, get_resource).
|
||||
*/
|
||||
export async function withToolTelemetry<T>(toolName: string, fn: () => T | Promise<T>): Promise<T> {
|
||||
export async function withToolTelemetry<T>(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
fn: () => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
if (!toolLoggingEnabled) {
|
||||
return runWithToolName(toolName, fn) as Promise<T>;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
let isError = false;
|
||||
let threw = false;
|
||||
let result: T | undefined;
|
||||
try {
|
||||
return await runWithToolName(toolName, fn);
|
||||
result = await runWithToolName(toolName, fn);
|
||||
return result;
|
||||
} catch (err) {
|
||||
isError = true;
|
||||
threw = true;
|
||||
throw err;
|
||||
} finally {
|
||||
console.error(JSON.stringify({
|
||||
type: 'tool_call',
|
||||
tool: toolName,
|
||||
durationMs: Date.now() - start,
|
||||
isError,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
try {
|
||||
const r = result as ToolResultShape | undefined;
|
||||
const resultChars = r?.content?.reduce((sum, c) => sum + (c.text?.length ?? 0), 0) ?? 0;
|
||||
const capValue = Number(r?._meta?.['anthropic/maxResultSizeChars']);
|
||||
console.error(JSON.stringify({
|
||||
type: 'tool_call',
|
||||
tool: toolName,
|
||||
durationMs: Date.now() - start,
|
||||
isError: threw || r?.isError === true,
|
||||
resultChars,
|
||||
// Result exceeds the inline-size hint → the host truncates it. A tool
|
||||
// that is frequently overCap needs tighter defaults or projections.
|
||||
overCap: Number.isFinite(capValue) && capValue > 0 ? resultChars > capValue : false,
|
||||
scope: callerScope(),
|
||||
args: summarizeArgs(args),
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
} catch {
|
||||
// Telemetry must never break a tool response.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zero-hit search log — the "does content/a tool for this exist at all?" signal.
|
||||
* Reviewing these queries periodically is the most direct way to find unmet
|
||||
* demand (missing docs, missing knowledge-base rules, missing tools).
|
||||
*/
|
||||
export function logSearchZeroHit(tool: string, query: string, detail?: Record<string, unknown>): void {
|
||||
if (!toolLoggingEnabled) return;
|
||||
try {
|
||||
console.error(JSON.stringify({
|
||||
type: 'search_zero_hit',
|
||||
tool,
|
||||
query: cap(query, QUERY_CAP),
|
||||
...detail,
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
} catch {
|
||||
// Telemetry must never break a tool response.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON-RPC-level tool-call rejection (Zod arg validation, unknown tool). These
|
||||
* never reach a tool handler — the SDK rejects them before dispatch — so
|
||||
* without this log the strongest "the schema/description confuses the model"
|
||||
* signal is invisible everywhere.
|
||||
*/
|
||||
export function logToolCallRejection(toolName: string, errorCode: number, message: string): void {
|
||||
if (!toolLoggingEnabled) return;
|
||||
try {
|
||||
console.error(JSON.stringify({
|
||||
type: 'tool_call_rejected',
|
||||
tool: toolName,
|
||||
errorCode,
|
||||
message: cap(message, 500),
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
} catch {
|
||||
// Telemetry must never break a tool response.
|
||||
}
|
||||
}
|
||||
|
||||
// Error envelopes are tiny; anything larger is a successful tool result and is
|
||||
// skipped instead of buffered.
|
||||
const SNIFF_BUFFER_CAP = 10_000;
|
||||
|
||||
interface SniffableResponse {
|
||||
write: (...args: never[]) => boolean;
|
||||
end: (...args: never[]) => unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observe the JSON-RPC response of a tools/call POST for a rejection and log
|
||||
* it via logToolCallRejection. Two shapes count as a rejection: a JSON-RPC
|
||||
* error envelope, and — the shape SDK 1.30 actually produces for Zod/unknown-
|
||||
* tool failures — a CallToolResult with isError:true whose text starts with
|
||||
* `MCP error <code>: `. The SDK bridges the web-standard Response to the Node
|
||||
* res through Hono's getRequestListener, which depending on the body type
|
||||
* either calls res.end(string | Uint8Array) directly or pipes a ReadableStream
|
||||
* through res.write chunks followed by a bare res.end() — so BOTH must be
|
||||
* captured. Buffering stops at SNIFF_BUFFER_CAP (real rejections are a few
|
||||
* hundred bytes). Never throws into the response path.
|
||||
*/
|
||||
export function attachToolCallRejectionSniffer(toolName: string, res: SniffableResponse): void {
|
||||
if (!toolLoggingEnabled) return;
|
||||
|
||||
let captured = '';
|
||||
let overflowed = false;
|
||||
const capture = (chunk: unknown): void => {
|
||||
if (overflowed || chunk == null) return;
|
||||
let text: string | undefined;
|
||||
if (typeof chunk === 'string') text = chunk;
|
||||
// Covers Node Buffers too (Buffer extends Uint8Array).
|
||||
else if (chunk instanceof Uint8Array) text = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString('utf8');
|
||||
if (text === undefined) return;
|
||||
captured += text;
|
||||
if (captured.length > SNIFF_BUFFER_CAP) {
|
||||
overflowed = true;
|
||||
captured = '';
|
||||
}
|
||||
};
|
||||
|
||||
const originalWrite = res.write.bind(res);
|
||||
const originalEnd = res.end.bind(res);
|
||||
res.write = ((chunk: unknown, ...rest: unknown[]) => {
|
||||
try {
|
||||
capture(chunk);
|
||||
} catch {
|
||||
// Sniffing must never break the response.
|
||||
}
|
||||
return originalWrite(chunk as never, ...(rest as never[]));
|
||||
}) as typeof res.write;
|
||||
res.end = ((chunk?: unknown, ...rest: unknown[]) => {
|
||||
try {
|
||||
capture(chunk);
|
||||
if (!overflowed && (captured.includes('"error"') || captured.includes('"isError"'))) {
|
||||
const parsed = JSON.parse(captured) as {
|
||||
error?: { code?: number; message?: unknown };
|
||||
result?: { isError?: boolean; content?: Array<{ type?: string; text?: string }> };
|
||||
};
|
||||
if (parsed?.error?.code !== undefined) {
|
||||
// Protocol-level JSON-RPC error envelope (e.g. malformed request).
|
||||
logToolCallRejection(toolName, parsed.error.code, String(parsed.error.message ?? ''));
|
||||
} else if (parsed?.result?.isError === true) {
|
||||
// The SDK catches every McpError thrown before/around the handler
|
||||
// (Zod input validation, unknown/disabled tool) and wraps it as a
|
||||
// CallToolResult whose text is `MCP error <code>: <message>` — NOT
|
||||
// as a JSON-RPC error envelope. Handler-produced soft errors
|
||||
// (toolError) never start with that prefix, so they are not
|
||||
// double-logged here (withToolTelemetry already records them).
|
||||
const text = parsed.result.content?.find((c) => c.type === 'text')?.text ?? '';
|
||||
const match = /^MCP error (-?\d+): /.exec(text);
|
||||
if (match) {
|
||||
logToolCallRejection(toolName, Number(match[1]), text.slice(match[0].length));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Sniffing must never break the response.
|
||||
}
|
||||
return originalEnd(chunk as never, ...(rest as never[]));
|
||||
}) as typeof res.end;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SearchProvider } from './search-provider.js';
|
||||
import { registerSessionTools } from './tools/sessions.js';
|
||||
import { registerSearchTools } from './tools/search.js';
|
||||
import { registerAdminTools } from './tools/admin.js';
|
||||
import { registerRuleTools } from './tools/rules.js';
|
||||
import type { DocsSearchBundle } from './search-provider.js';
|
||||
|
||||
/**
|
||||
@@ -39,6 +40,7 @@ export function registerTools(
|
||||
registerSessionTools(server, ga, delegated);
|
||||
registerSearchTools(server, knowledgeBase, eventTypeIndex, docs, ga, delegated);
|
||||
registerAdminTools(server, ga, strictGa, delegated);
|
||||
registerRuleTools(server, ga);
|
||||
sortToolCatalog(server);
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_api_usage', async () => {
|
||||
async (args) => withToolTelemetry('get_api_usage', args, async () => {
|
||||
try {
|
||||
let data: unknown;
|
||||
const params: Record<string, string | undefined> = { tenantId: args.tenantId, dateFrom: args.dateFrom, dateTo: args.dateTo };
|
||||
@@ -310,7 +310,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_geographic_metrics', async () => {
|
||||
async (args) => withToolTelemetry('get_geographic_metrics', args, async () => {
|
||||
try {
|
||||
const { tenantId: rawTenantId, ...rest } = args;
|
||||
// Delegated (MSP): require a managed tenantId (no aggregate); no-op for GA/Reader/tenant users.
|
||||
@@ -356,7 +356,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_geographic_sessions', async () => {
|
||||
async (args) => withToolTelemetry('get_geographic_sessions', args, async () => {
|
||||
try {
|
||||
const { tenantId: rawTenantId, locationKey, country, region, city, pageSize, continuation, ...rest } = args;
|
||||
// Offset-based client-side pager (geo-offset:N carries no tenantId), so the explicit tenantId is
|
||||
@@ -420,7 +420,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_platform_metrics', async () => {
|
||||
async (args) => withToolTelemetry('get_platform_metrics', args, async () => {
|
||||
try {
|
||||
type SessionMetric = {
|
||||
sessionId: string; tenantId: string; deviceName?: string; model?: string; status?: string;
|
||||
@@ -516,7 +516,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_usage_metrics', async () => {
|
||||
async (args) => withToolTelemetry('get_usage_metrics', args, async () => {
|
||||
try {
|
||||
const { tenantId: rawTenantId, days } = args;
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
@@ -580,7 +580,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('list_tenants', async () => {
|
||||
async (args) => withToolTelemetry('list_tenants', args, async () => {
|
||||
try {
|
||||
const { query, tenantId, fields, pageSize, continuation } = args;
|
||||
|
||||
@@ -689,7 +689,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_audit_logs', async () => {
|
||||
async (args) => withToolTelemetry('get_audit_logs', args, async () => {
|
||||
try {
|
||||
const { tenantId: rawTenantId, dateFrom, dateTo, action, performedBy, entityType, entityId, pageSize, continuation } = args;
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
@@ -733,7 +733,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_ops_events', async () => {
|
||||
async (args) => withToolTelemetry('get_ops_events', args, async () => {
|
||||
try {
|
||||
const { category, tenantId, dateFrom, dateTo, pageSize, continuation } = args;
|
||||
const path = followNextLink(
|
||||
@@ -769,7 +769,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('list_session_reports', async () => {
|
||||
async (args) => withToolTelemetry('list_session_reports', args, async () => {
|
||||
try {
|
||||
const { tenantId, pageSize, continuation } = args;
|
||||
const path = followNextLink(
|
||||
@@ -834,7 +834,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('query_raw_events', async () => {
|
||||
async (args) => withToolTelemetry('query_raw_events', args, async () => {
|
||||
try {
|
||||
const { tenantId: rawTenantId, sessionId, eventType, severity, source, startedAfter, startedBefore, fields, pageSize, continuation } = args;
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
@@ -894,6 +894,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
isPreProvisioned: z.boolean().optional().describe('Filter pre-provisioned (white-glove) sessions'),
|
||||
isHybridJoin: z.boolean().optional().describe('Filter hybrid AAD-join sessions'),
|
||||
isSelfDeployingProfile: z.boolean().optional().describe('Filter self-deploying/kiosk profile sessions (CloudAssignedOobeConfig 0x20|0x40)'),
|
||||
isCloudPc: z.boolean().optional().describe('Filter Windows 365 Cloud PC sessions (agent-detected W365 markers, sticky-true; independent of ValidatedBy="CloudPc")'),
|
||||
connectionType: z.enum(['WiFi', 'Ethernet']).optional()
|
||||
.describe('Active network connection type ("WiFi" or "Ethernet", exact match on the indexed column; last emission wins). Sessions predating the projection lack the column and are excluded.'),
|
||||
fields: z.string().optional().describe('Comma-separated pass-through projection over the literal stored column names (case-insensitive, PascalCase, e.g. "Status,OsEdition,ImeAgentVersion,GeoCity"); narrows the row but never drops a real column. PartitionKey + RowKey are always kept. Omit for the full raw row.'),
|
||||
@@ -904,18 +905,18 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('query_raw_sessions', async () => {
|
||||
async (args) => withToolTelemetry('query_raw_sessions', args, async () => {
|
||||
try {
|
||||
const { tenantId: rawTenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix,
|
||||
imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild,
|
||||
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, connectionType, fields, pageSize, continuation } = args;
|
||||
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, isCloudPc, connectionType, fields, pageSize, continuation } = args;
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
const basePath = pickGlobalOrTenantPath('/api/global/raw/sessions', '/api/raw/sessions', tenantId);
|
||||
const path = followNextLink(
|
||||
basePath,
|
||||
{ tenantId, status, startedAfter, startedBefore, serialNumber, agentVersion, agentVersionPrefix,
|
||||
imeAgentVersion, imeAgentVersionPrefix, manufacturer, model, enrollmentType, deviceName, osBuild,
|
||||
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, connectionType, fields, pageSize },
|
||||
geoCountry, isPreProvisioned, isHybridJoin, isSelfDeployingProfile, isCloudPc, connectionType, fields, pageSize },
|
||||
continuation,
|
||||
);
|
||||
const data = await apiFetch(path);
|
||||
@@ -942,7 +943,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
inputSchema: {},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('list_tables', async () => {
|
||||
async (args) => withToolTelemetry('list_tables', args, async () => {
|
||||
try {
|
||||
const data = await apiFetch('/api/global/raw/tables');
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
@@ -982,7 +983,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('query_table', async () => {
|
||||
async (args) => withToolTelemetry('query_table', args, async () => {
|
||||
try {
|
||||
const { tableName, partitionKey, rowKeyPrefix, filter, fields, pageSize, continuation } = args;
|
||||
const basePath = `/api/global/raw/tables/${encodeURIComponent(tableName)}`;
|
||||
@@ -1029,7 +1030,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY_OPEN,
|
||||
},
|
||||
async (args) => withToolTelemetry('query_backend_logs', async () => {
|
||||
async (args) => withToolTelemetry('query_backend_logs', args, async () => {
|
||||
try {
|
||||
const data = await apiFetch('/api/global/raw/logs', {
|
||||
method: 'POST',
|
||||
@@ -1065,7 +1066,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_tenant_config', async () => {
|
||||
async (args) => withToolTelemetry('get_tenant_config', args, async () => {
|
||||
try {
|
||||
// view=redacted is load-bearing: without it the backend serves a GA the
|
||||
// clear-text secrets, which must never enter model context (pinned by
|
||||
@@ -1092,7 +1093,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
inputSchema: {},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_tenant_config_schema', async () => {
|
||||
async (args) => withToolTelemetry('get_tenant_config_schema', args, async () => {
|
||||
try {
|
||||
const data = await apiFetch('/api/config/fields-schema');
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
@@ -1127,7 +1128,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: MUTATING,
|
||||
},
|
||||
async (args) => withToolTelemetry('update_tenant_config', async () => {
|
||||
async (args) => withToolTelemetry('update_tenant_config', args, async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/config/${encodeURIComponent(args.tenantId)}/fields`, {
|
||||
method: 'PATCH',
|
||||
@@ -1156,7 +1157,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('list_tenant_config_backups', async () => {
|
||||
async (args) => withToolTelemetry('list_tenant_config_backups', args, async () => {
|
||||
try {
|
||||
const query = args.max != null ? `?max=${encodeURIComponent(String(args.max))}` : '';
|
||||
const data = await apiFetch(`/api/config/${encodeURIComponent(args.tenantId)}/backups${query}`);
|
||||
@@ -1189,7 +1190,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: MUTATING,
|
||||
},
|
||||
async (args) => withToolTelemetry('revert_tenant_config', async () => {
|
||||
async (args) => withToolTelemetry('revert_tenant_config', args, async () => {
|
||||
try {
|
||||
const data = await apiFetch(`/api/config/${encodeURIComponent(args.tenantId)}/revert`, {
|
||||
method: 'POST',
|
||||
@@ -1231,7 +1232,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_rule_stats', async () => {
|
||||
async (args) => withToolTelemetry('get_rule_stats', args, async () => {
|
||||
try {
|
||||
const tenantId = enforceDelegatedTenant(args.tenantId);
|
||||
const params: Record<string, string | undefined> = {
|
||||
@@ -1272,7 +1273,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_vulnerability_summary', async () => {
|
||||
async (args) => withToolTelemetry('get_vulnerability_summary', args, async () => {
|
||||
try {
|
||||
const { tenantId: rawTenantId, days, topN } = args;
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
@@ -1312,7 +1313,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_app_install_metrics', async () => {
|
||||
async (args) => withToolTelemetry('get_app_install_metrics', args, async () => {
|
||||
try {
|
||||
const { tenantId: rawTenantId, days } = args;
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
@@ -1357,7 +1358,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_time_attribution', async () => {
|
||||
async (args) => withToolTelemetry('get_time_attribution', args, async () => {
|
||||
try {
|
||||
const { sessionId, tenantId: rawTenantId } = args;
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
@@ -1410,7 +1411,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_device_history', async () => {
|
||||
async (args) => withToolTelemetry('get_device_history', args, async () => {
|
||||
try {
|
||||
const { serialNumber, days, tenantId: rawTenantId } = args;
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
@@ -1483,7 +1484,7 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_software_inventory', async () => {
|
||||
async (args) => withToolTelemetry('get_software_inventory', args, async () => {
|
||||
try {
|
||||
const { tenantId: rawTenantId, scope, pageSize, continuation } = args as {
|
||||
tenantId?: string; scope?: 'inventory' | 'unmatched'; pageSize: number; continuation?: string;
|
||||
@@ -1540,13 +1541,18 @@ export function registerAdminTools(server: McpServer, ga: boolean, strictGa: boo
|
||||
'stateless HTTP MCP servers). Available names:\n' +
|
||||
' - "event_types": catalog of valid eventType strings for search_sessions_by_event\n' +
|
||||
' - "device_properties": catalog of dot-notation keys for the deviceProperties filter on search_sessions\n' +
|
||||
' - "diag_zip_layout": expected file layout of an agent diagnostics ZIP (what get_session_diagnostics returns you for local analysis)',
|
||||
' - "diag_zip_layout": expected file layout of an agent diagnostics ZIP (what get_session_diagnostics returns you for local analysis)\n' +
|
||||
' - "rule_authoring_guide": complete guide for authoring gather/analyze rules (read FIRST when asked to create a rule; then validate_rule / test_analyze_rule)\n' +
|
||||
' - "rule_schemas": the JSON Schemas for gather and analyze rules (the exact contract)\n' +
|
||||
' - "rule_guardrails": on-device collection allowlists a gather rule target must satisfy',
|
||||
inputSchema: {
|
||||
name: z.enum(['event_types', 'device_properties', 'diag_zip_layout']).describe('Resource name'),
|
||||
name: z
|
||||
.enum(['event_types', 'device_properties', 'diag_zip_layout', 'rule_authoring_guide', 'rule_schemas', 'rule_guardrails'])
|
||||
.describe('Resource name'),
|
||||
},
|
||||
annotations: READ_ONLY_OPEN,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_resource', async () => {
|
||||
async (args) => withToolTelemetry('get_resource', args, async () => {
|
||||
try {
|
||||
const data = getResourceContent(args.name);
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { apiFetch } from '../client.js';
|
||||
import { withToolTelemetry } from '../telemetry.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, toolResultText, SessionIdSchema } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
import { validateRuleDraft } from '../rule-validation.js';
|
||||
import { interpolateRuleTemplate } from '../interpolate-rule-template.js';
|
||||
|
||||
/**
|
||||
* Rule-authoring tools: validate a draft gather/analyze rule locally, and dry-run a
|
||||
* draft analyze rule against a real session via the backend's side-effect-free
|
||||
* dryrun endpoint. Registered for EVERY role — tenant admins are the primary
|
||||
* audience (the backend still enforces its own authorization: the dryrun route is
|
||||
* TenantAdmin / Global Admin / read-only Global Reader).
|
||||
*/
|
||||
export function registerRuleTools(server: McpServer, ga: boolean): void {
|
||||
// Tool: validate_rule (MCP-local, no backend call)
|
||||
server.registerTool(
|
||||
'validate_rule',
|
||||
{
|
||||
title: 'Validate Rule Draft',
|
||||
description:
|
||||
'Validates a DRAFT gather or analyze rule without deploying anything: JSON-Schema check ' +
|
||||
'(the exact contract from get_resource(name="rule_schemas")), guardrail pre-flight for ' +
|
||||
'gather targets (registry/file/WMI/command/event-log allowlists with the agent\'s ' +
|
||||
'matching semantics), and semantic lint (unreachable confidence threshold, non-evaluable ' +
|
||||
'confidenceFactor conditions, unknown event types, unanchored allow-list regex, ' +
|
||||
'unresolvable {{token}} placeholders). Returns findings as error/warning/info. ' +
|
||||
'Fix all errors, then dry-run analyze rules with test_analyze_rule against a real session. ' +
|
||||
'Read get_resource(name="rule_authoring_guide") first when authoring from scratch.',
|
||||
inputSchema: {
|
||||
rule: z.record(z.unknown()).describe('The draft rule JSON object (gather or analyze — detected automatically)'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('validate_rule', args, async () => {
|
||||
try {
|
||||
const result = validateRuleDraft(args.rule as Record<string, unknown>);
|
||||
return toolResultText(
|
||||
{
|
||||
ruleType: result.ruleType,
|
||||
valid: result.valid,
|
||||
errorCount: result.findings.filter((f) => f.level === 'error').length,
|
||||
findings: result.findings,
|
||||
nextStep: result.valid
|
||||
? (result.ruleType === 'analyze'
|
||||
? 'No errors. Dry-run it now: test_analyze_rule(sessionId=<recent session>, rule=<this draft>) — once against a session where it SHOULD fire, once where it should not.'
|
||||
: (args.rule as Record<string, unknown>).collectorType === 'logparser'
|
||||
? 'No errors. Now test the regex with the agent\'s exact .NET engine: test_log_pattern(pattern=<parameters.pattern>, sampleLines=<raw lines from the log file>, format=<parameters.format>) — then deploy to a test device.'
|
||||
: 'No errors. Gather rules run on-device and cannot be dry-run server-side — deploy to a test device and check the emitted events (the gather debug log setting shows per-rule detail).')
|
||||
: 'Fix the error-level findings and validate again.',
|
||||
},
|
||||
MAX_RESULT_SIZE_CHARS.small,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
return toolError('validate_rule', args, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Tool: test_log_pattern (backend .NET-regex test for logparser gather rules)
|
||||
server.registerTool(
|
||||
'test_log_pattern',
|
||||
{
|
||||
title: 'Test Logparser Pattern Against Sample Lines',
|
||||
description:
|
||||
'Tests a logparser gather-rule regex against pasted sample log lines using the AGENT\'s ' +
|
||||
'exact .NET matching semantics — the dry-run for logparser rules (which run on devices ' +
|
||||
'and cannot be tested against a session). Use this INSTEAD of testing the regex in ' +
|
||||
'JS/PHP/Python: .NET behaves subtly differently, and logparser matching is ' +
|
||||
'case-SENSITIVE (unlike analyze-rule regex conditions). Paste 10-50 representative raw ' +
|
||||
'lines from the customer\'s log file (include lines that must match AND lines that must ' +
|
||||
'not). format="cmtrace" (default) parses each line as CMTrace/IME format first and runs ' +
|
||||
'the regex against the parsed message; format="text" matches the raw line. Returns ' +
|
||||
'per-line outcomes with the exact capture groups that would land in the emitted ' +
|
||||
'timeline event\'s data. Nothing is stored.',
|
||||
inputSchema: {
|
||||
pattern: z.string().min(1).max(2000).describe('The regex (named groups become event data fields), .NET syntax'),
|
||||
sampleLines: z.array(z.string().max(8192)).min(1).max(200)
|
||||
.describe('Raw lines pasted from the log file (max 200)'),
|
||||
format: z.enum(['cmtrace', 'text']).optional()
|
||||
.describe('Log format, exactly like the rule\'s parameters.format: "cmtrace" (default, IME/SCCM style) or "text" (plain lines)'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('test_log_pattern', args, async () => {
|
||||
try {
|
||||
const data = await apiFetch('/api/rules/gather/test-pattern', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pattern: args.pattern, format: args.format, sampleLines: args.sampleLines }),
|
||||
});
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
return toolError('test_log_pattern', args, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Tool: test_analyze_rule (backend dry-run, side-effect free)
|
||||
server.registerTool(
|
||||
'test_analyze_rule',
|
||||
{
|
||||
title: 'Test Analyze Rule Against Session (Dry-Run)',
|
||||
description:
|
||||
'Dry-runs a DRAFT analyze rule against one real session\'s events and returns the full ' +
|
||||
'diagnostic trace: verdict (fired / required_condition_not_met / below_confidence_threshold ' +
|
||||
'/ skipped_by_precondition / no_conditions_matched / no_events), per-condition ' +
|
||||
'matched/evidence with matchingEventCount (how many session events even have that ' +
|
||||
'eventType — the first thing to check when a condition unexpectedly misses), the ' +
|
||||
'confidence breakdown per factor, and the interpolated explanation preview. ' +
|
||||
'NOTHING is persisted — safe to iterate. Use a recent session (older sessions predate ' +
|
||||
'newly deployed gather rules). Test both directions: a session where the rule should ' +
|
||||
'fire AND one where it should stay silent.' +
|
||||
(ga ? ' Platform-scope callers can target any tenant\'s session — the tenant is resolved from the session automatically.' : ''),
|
||||
inputSchema: {
|
||||
sessionId: SessionIdSchema.describe('Session UUID to evaluate the draft against'),
|
||||
rule: z.record(z.unknown()).describe('The draft analyze rule JSON object'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('test_analyze_rule', args, async () => {
|
||||
try {
|
||||
const data = await apiFetch('/api/rules/analyze/dryrun', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId: args.sessionId, rule: args.rule }),
|
||||
}) as { result?: { verdict?: string; matchedConditions?: Record<string, unknown> } };
|
||||
|
||||
const result = data.result;
|
||||
const rule = args.rule as Record<string, unknown>;
|
||||
const mc = result?.matchedConditions;
|
||||
|
||||
// Preview the explanation/remediation exactly as the portal would render them
|
||||
// for this session's evidence ({{token}} interpolation).
|
||||
const interpolatedExplanation = typeof rule.explanation === 'string'
|
||||
? interpolateRuleTemplate(rule.explanation, mc)
|
||||
: undefined;
|
||||
const interpolatedRemediation = Array.isArray(rule.remediation)
|
||||
? (rule.remediation as Array<{ title?: string; steps?: string[] }>).map((step) => ({
|
||||
title: interpolateRuleTemplate(step?.title, mc),
|
||||
steps: (step?.steps ?? []).map((s) => interpolateRuleTemplate(s, mc)),
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
return toolResultText(
|
||||
{ ...data, interpolatedExplanation, interpolatedRemediation },
|
||||
MAX_RESULT_SIZE_CHARS.small,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
return toolError('test_analyze_rule', args, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { z } from 'zod';
|
||||
import { apiFetch, buildQuery, enforceDelegatedTenant, followNextLink, pickGlobalOrTenantPath } from '../client.js';
|
||||
import { withToolTelemetry } from '../telemetry.js';
|
||||
import { withToolTelemetry, logSearchZeroHit } from '../telemetry.js';
|
||||
import type { SearchProvider, DocsSearchBundle } from '../search-provider.js';
|
||||
import { READ_ONLY, MAX_RESULT_SIZE_CHARS, toolResultText, SessionIdSchema, isBenignHealthDetectionReport, tenantIdDescription } from './shared.js';
|
||||
import { toolError } from './error-handler.js';
|
||||
@@ -1046,7 +1046,7 @@ export function registerSearchTools(
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('search_events', async () => {
|
||||
async (args) => withToolTelemetry('search_events', args, async () => {
|
||||
try {
|
||||
const { query, sessionId, tenantId: rawTenantId, depth, keywords, guaranteedTopRanked } = args;
|
||||
// Delegated (MSP): require a managed tenantId (no cross-tenant aggregate); no-op for others.
|
||||
@@ -1058,6 +1058,7 @@ export function registerSearchTools(
|
||||
// Extract keywords FIRST so we can use them for index-based pre-filtering
|
||||
const queryKeywords = resolveQueryKeywords(query, keywords);
|
||||
if (queryKeywords.length === 0) {
|
||||
logSearchZeroHit('search_events', query, { reason: 'no_keywords' });
|
||||
return toolResultText(
|
||||
{ query, resultCount: 0, results: [], note: 'No searchable keywords extracted from query.' },
|
||||
MAX_RESULT_SIZE_CHARS.small);
|
||||
@@ -1102,6 +1103,10 @@ export function registerSearchTools(
|
||||
const matchedSessionIds = [...new Set(results.map((r) => r.sessionId).filter(Boolean))];
|
||||
const semanticOnlyCount = results.filter((r) => r.semanticOnly).length;
|
||||
|
||||
if (results.length === 0) {
|
||||
logSearchZeroHit('search_events', query, { eventsFetched: events.length, sessionsSearched: sessionIds.length });
|
||||
}
|
||||
|
||||
return toolResultText({
|
||||
query,
|
||||
// Honest reporting: semantic type-selection contributed only when the
|
||||
@@ -1154,7 +1159,7 @@ export function registerSearchTools(
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('search_knowledge', async () => {
|
||||
async (args) => withToolTelemetry('search_knowledge', args, async () => {
|
||||
try {
|
||||
const { query, topK, type, minScore } = args;
|
||||
if (!knowledgeBase || knowledgeBase.size === 0) {
|
||||
@@ -1203,6 +1208,8 @@ export function registerSearchTools(
|
||||
matchType: errorCodeHitIds.has(r.id) ? ('error-code' as const) : undefined,
|
||||
}));
|
||||
|
||||
if (formatted.length === 0) logSearchZeroHit('search_knowledge', query);
|
||||
|
||||
return toolResultText({
|
||||
query,
|
||||
searchBackend: knowledgeBase.name,
|
||||
@@ -1263,7 +1270,7 @@ export function registerSearchTools(
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('search_docs', async () => {
|
||||
async (args) => withToolTelemetry('search_docs', args, async () => {
|
||||
try {
|
||||
const { query, topK, section, minScore } = args;
|
||||
|
||||
@@ -1370,6 +1377,8 @@ export function registerSearchTools(
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
if (formatted.length === 0) logSearchZeroHit('search_docs', query, section ? { section } : undefined);
|
||||
|
||||
return toolResultText({
|
||||
query,
|
||||
searchBackend: docsIndex.name,
|
||||
|
||||
@@ -125,6 +125,9 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
isHybridJoin: z.boolean().optional().describe('Filter by Hybrid Azure AD Join'),
|
||||
isSelfDeployingProfile: z.boolean().optional().describe(
|
||||
'Filter by self-deploying/kiosk Autopilot profile (CloudAssignedOobeConfig bits 0x20|0x40, agent-detected at registration)'),
|
||||
isCloudPc: z.boolean().optional().describe(
|
||||
'Filter by Windows 365 Cloud PC (agent-detected marker AND: Windows365 registry key + CloudManagedDesktopExtension service; sticky-true). ' +
|
||||
'Independent of validatedBy="CloudPc" (server-side Graph verification). Sessions from agents predating the field read as false.'),
|
||||
geoCountry: z.string().optional().describe('Country of enrollment (2-letter ISO code, e.g. "DE", "US")'),
|
||||
startedAfter: IsoDateString.optional().describe('ISO 8601 datetime — only sessions started after this'),
|
||||
startedBefore: IsoDateString.optional().describe('ISO 8601 datetime — only sessions started before this'),
|
||||
@@ -148,7 +151,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
'Use for counting / aggregation to avoid the response cap. Available: sessionId, tenantId, status, ' +
|
||||
'serialNumber, manufacturer, model, deviceName, osBuild, osName, startedAt, completedAt, ' +
|
||||
'durationSeconds, currentPhase, failureReason, eventCount, enrollmentType, isPreProvisioned, ' +
|
||||
'isUserDriven, isHybridJoin, isSelfDeployingProfile, agentVersion, imeAgentVersion, geoCountry, rebootCount, ' +
|
||||
'isUserDriven, isHybridJoin, isSelfDeployingProfile, isCloudPc, agentVersion, imeAgentVersion, geoCountry, rebootCount, ' +
|
||||
'avgApiLatencyMs, apiRequestCount (agent→backend HTTP round-trip; weight latency by apiRequestCount when aggregating, ' +
|
||||
'e.g. fields=geoCountry,avgApiLatencyMs,apiRequestCount for a per-country latency sweep; null on sessions from agents predating the field), ' +
|
||||
'connectionType ("WiFi"/"Ethernet"; null on sessions predating the projection).'),
|
||||
@@ -166,7 +169,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('search_sessions', async () => {
|
||||
async (args) => withToolTelemetry('search_sessions', args, async () => {
|
||||
try {
|
||||
const { deviceProperties, tenantId: rawTenantId, pageSize, continuation, ...rest } = args;
|
||||
// Delegated (MSP): require a managed tenantId (no aggregate); a page-2 call carries it inside the
|
||||
@@ -223,7 +226,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('search_sessions_by_event', async () => {
|
||||
async (args) => withToolTelemetry('search_sessions_by_event', args, async () => {
|
||||
try {
|
||||
const { eventType, tenantId: rawTenantId, pageSize, continuation } = args;
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
@@ -258,7 +261,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_session', async () => {
|
||||
async (args) => withToolTelemetry('get_session', args, async () => {
|
||||
try {
|
||||
const { sessionId, tenantId: rawTenantId, includeAnalysis } = args;
|
||||
// Delegated (MSP): the session lives in a managed tenant — require + validate it (the backend
|
||||
@@ -316,7 +319,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_session_diagnostics', async () => {
|
||||
async (args) => withToolTelemetry('get_session_diagnostics', args, async () => {
|
||||
try {
|
||||
const { sessionId, tenantId: rawTenantId } = args;
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
@@ -416,7 +419,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_session_events', async () => {
|
||||
async (args) => withToolTelemetry('get_session_events', args, async () => {
|
||||
try {
|
||||
const { sessionId, tenantId: rawTenantId, pageSize, continuation, eventType, severity, source, fields } = args;
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
@@ -458,7 +461,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_session_summary', async () => {
|
||||
async (args) => withToolTelemetry('get_session_summary', args, async () => {
|
||||
try {
|
||||
const { sessionId, tenantId: rawTenantId } = args;
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
@@ -490,8 +493,11 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
isHybridJoin: s.isHybridJoin ?? false,
|
||||
isUserDriven: s.isUserDriven ?? false,
|
||||
isSelfDeployingProfile: s.isSelfDeployingProfile ?? false,
|
||||
// Agent-detected Windows 365 Cloud PC marker (Windows365 registry key +
|
||||
// CloudManagedDesktopExtension service). Independent of validatedBy="CloudPc".
|
||||
isCloudPc: s.isCloudPc ?? false,
|
||||
// Backend device-validation path that admitted the device at registration:
|
||||
// "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap".
|
||||
// "AutopilotV1" | "CorporateIdentifier" | "DeviceAssociation" | "Bootstrap" | "CloudPc".
|
||||
// Null for sessions predating the field or tenants with device validation off.
|
||||
validatedBy: s.validatedBy || null,
|
||||
device: {
|
||||
@@ -654,7 +660,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_metrics', async () => {
|
||||
async (args) => withToolTelemetry('get_metrics', args, async () => {
|
||||
try {
|
||||
const { tenantId: rawTenantId, ...rest } = args;
|
||||
const tenantId = enforceDelegatedTenant(rawTenantId);
|
||||
@@ -719,7 +725,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('search_sessions_by_cve', async () => {
|
||||
async (args) => withToolTelemetry('search_sessions_by_cve', args, async () => {
|
||||
try {
|
||||
const { cveId, tenantId: rawTenantId, minCvssScore, overallRisk, pageSize, continuation } = args;
|
||||
const tenantId = enforceDelegatedTenantForPage(rawTenantId, continuation);
|
||||
@@ -755,7 +761,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('list_blocked_devices', async () => {
|
||||
async (args) => withToolTelemetry('list_blocked_devices', args, async () => {
|
||||
try {
|
||||
const { tenantId } = args;
|
||||
// GA: /api/global/devices/blocked (tenantId is filter); non-GA: /api/devices/blocked
|
||||
@@ -787,7 +793,7 @@ export function registerSessionTools(server: McpServer, ga: boolean, delegated:
|
||||
inputSchema: {},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('get_ime_version_history', async () => {
|
||||
async (args) => withToolTelemetry('get_ime_version_history', args, async () => {
|
||||
try {
|
||||
const data = await apiFetch('/api/metrics/ime-versions');
|
||||
return toolResultText(data, MAX_RESULT_SIZE_CHARS.small);
|
||||
|
||||
@@ -131,6 +131,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// </summary>
|
||||
public const string IsSelfDeployingProfile = "isSelfDeployingProfile";
|
||||
|
||||
/// <summary>
|
||||
/// On <c>EnrollmentFactsObserved</c>: "true" / "false" from
|
||||
/// <c>CloudPcDetector.DetectIsCloudPc()</c> — the Windows 365 Cloud PC marker AND
|
||||
/// (Windows365 registry key + CloudManagedDesktopExtension service). On a Cloud PC
|
||||
/// first connect the Device-ESP phase already ran headless at provisioning time, so
|
||||
/// the engine must expect the session to start at Account Setup with no DeviceSetup
|
||||
/// signals ever arriving.
|
||||
/// </summary>
|
||||
public const string IsCloudPc = "isCloudPc";
|
||||
|
||||
// --- InformationalEvent payload (plan §1.3, single-rail refactor) ------------
|
||||
// Mirrors the EnrollmentEvent fields the reducer must reconstruct for the
|
||||
// EmitEventTimelineEntry effect. EventType / Source are mandatory; the rest are
|
||||
|
||||
@@ -545,6 +545,18 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
.WithRegistrySelfDeployingProfile(isSelfDeploying, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
// W365 — record the Cloud PC marker fact for BOTH values (same rationale as the
|
||||
// self-deploying fact above). `true` is the engine's "no Device-ESP phase
|
||||
// expected" context: Device-ESP already ran headless at provisioning, the session
|
||||
// starts at Account Setup and DeviceSetup signals will never arrive. Set-once.
|
||||
if (signal.Payload != null
|
||||
&& signal.Payload.TryGetValue(SignalPayloadKeys.IsCloudPc, out var rawCloudPc)
|
||||
&& bool.TryParse(rawCloudPc, out var isCloudPc))
|
||||
{
|
||||
builder.ScenarioObservations = builder.ScenarioObservations
|
||||
.WithCloudPc(isCloudPc, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
|
||||
@@ -155,6 +155,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
["withUser"] = obs.AadUserJoinWithUserObserved.Value,
|
||||
};
|
||||
}
|
||||
// W365 — surfaced so terminal audit trails show the session ran on a Cloud PC,
|
||||
// where a missing DeviceSetup phase is expected (Device-ESP completed headless at
|
||||
// provisioning), not an anomaly. Positive marker only; false adds no signal.
|
||||
if (obs.CloudPc?.Value == true)
|
||||
{
|
||||
seen.Add("cloud_pc_marker");
|
||||
evidence["cloudPcMarker"] = OrdinalEvidence(obs.CloudPc.SourceSignalOrdinal);
|
||||
}
|
||||
|
||||
return new SignalCensusResult(seen, timestamps, evidence);
|
||||
}
|
||||
|
||||
@@ -80,6 +80,17 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// </summary>
|
||||
public SignalFact<bool>? RegistrySelfDeployingProfile { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Raw marker fact from <see cref="Signals.DecisionSignalKind.EnrollmentFactsObserved"/>
|
||||
/// (payload <c>isCloudPc</c>, from the Windows365 registry key + installed
|
||||
/// CloudManagedDesktopExtension service — the marker-AND field-verified by bootstrap
|
||||
/// v2.3-dev.2). <c>true</c> means the session runs on a Windows 365 Cloud PC whose
|
||||
/// Device-ESP already completed headless at provisioning time: no DeviceSetup phase
|
||||
/// signals are expected, monitoring effectively starts at Account Setup. Records BOTH
|
||||
/// values; <c>null</c> means the fact was never observed.
|
||||
/// </summary>
|
||||
public SignalFact<bool>? CloudPc { get; init; }
|
||||
|
||||
public EnrollmentScenarioObservations WithShellCoreWhiteGloveSuccessSeen(long sourceSignalOrdinal) =>
|
||||
ShellCoreWhiteGloveSuccessSeen != null
|
||||
? this
|
||||
@@ -119,5 +130,10 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
RegistrySelfDeployingProfile != null
|
||||
? this
|
||||
: this with { RegistrySelfDeployingProfile = new SignalFact<bool>(value, sourceSignalOrdinal) };
|
||||
|
||||
public EnrollmentScenarioObservations WithCloudPc(bool value, long sourceSignalOrdinal) =>
|
||||
CloudPc != null
|
||||
? this
|
||||
: this with { CloudPc = new SignalFact<bool>(value, sourceSignalOrdinal) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,14 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// </summary>
|
||||
public static class EnrollmentScenarioProfileUpdater
|
||||
{
|
||||
/// <summary>
|
||||
/// Profile reason recorded when the agent's Cloud PC marker detection reports a
|
||||
/// Windows 365 Cloud PC: the Device-ESP phase already ran headless at provisioning
|
||||
/// time, so the engine expects the session to start at Account Setup and DeviceSetup
|
||||
/// phase signals to never arrive.
|
||||
/// </summary>
|
||||
public const string CloudPcNoDeviceEspExpectedReason = "cloud_pc_first_connect:no_device_esp_expected";
|
||||
|
||||
// ================================================================== EnrollmentFactsObserved
|
||||
|
||||
/// <summary>
|
||||
@@ -62,6 +70,15 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
|
||||
if (signal.Payload != null)
|
||||
{
|
||||
// W365 Cloud PC marker, parsed up front because it steers the reason slot of
|
||||
// the enrollmentType branch below: when the payload carries isCloudPc=true the
|
||||
// final reason is always the Cloud PC annotation, so the generic v1 annotation
|
||||
// must not claim `changed` first (a repeat post would otherwise flip the reason
|
||||
// v1→annotation every time and break idempotency through the ordinal bump).
|
||||
var cloudPcAnnotates = signal.Payload.TryGetValue(SignalPayloadKeys.IsCloudPc, out var rawCloudPc)
|
||||
&& bool.TryParse(rawCloudPc, out var isCloudPc)
|
||||
&& isCloudPc;
|
||||
|
||||
if (signal.Payload.TryGetValue(SignalPayloadKeys.EnrollmentType, out var rawType)
|
||||
&& !string.IsNullOrEmpty(rawType)
|
||||
&& current.Mode == EnrollmentMode.Unknown)
|
||||
@@ -87,9 +104,11 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
reason = newReason;
|
||||
changed = true;
|
||||
}
|
||||
else if (!string.Equals(current.Reason, newReason, StringComparison.Ordinal))
|
||||
else if (!cloudPcAnnotates
|
||||
&& !string.Equals(current.Reason, newReason, StringComparison.Ordinal))
|
||||
{
|
||||
// v1 (or any other): keep Mode=Unknown but record the reason once.
|
||||
// v1 (or any other): keep Mode=Unknown but record the reason once —
|
||||
// unless the Cloud PC annotation below owns the slot for this payload.
|
||||
reason = newReason;
|
||||
changed = true;
|
||||
}
|
||||
@@ -126,6 +145,26 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
joinMode = isHybrid ? EnrollmentJoinMode.HybridAzureAdJoin : EnrollmentJoinMode.AzureAdJoin;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// W365 Cloud PC — record the "no Device-ESP phase expected" expectation as the
|
||||
// profile reason: Device-ESP already ran headless at provisioning, so the
|
||||
// session starts at Account Setup and a missing DeviceSetup phase is normal,
|
||||
// not an anomaly. Deliberately reason-only:
|
||||
// - Mode stays untouched — a Cloud PC first connect IS a classic user-driven
|
||||
// flow; ApplyAccountSetupObserved/ApplyImeUserSessionCompleted classify it.
|
||||
// - EspConfig stays untouched — it carries the CONFIGURED FirstSync semantics
|
||||
// (skip flags), and on a Cloud PC the tenant config typically still says
|
||||
// FullEsp even though the device half already happened pre-agent.
|
||||
// Runs last so it wins the reason slot over any annotation set earlier in the
|
||||
// same payload; the comparison against the LOCAL reason keeps repeat posts
|
||||
// idempotent (paired with the cloudPcAnnotates gate on the v1 branch above).
|
||||
// The raw fact (both values) lives in EnrollmentScenarioObservations.CloudPc.
|
||||
if (cloudPcAnnotates
|
||||
&& !string.Equals(reason, CloudPcNoDeviceEspExpectedReason, StringComparison.Ordinal))
|
||||
{
|
||||
reason = CloudPcNoDeviceEspExpectedReason;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return current;
|
||||
|
||||
@@ -30,6 +30,12 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public DateTime IngestedAt { get; set; }
|
||||
public string? SourceIp { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Agent-reported W365 marker verdict (CloudPcDetector). UNVERIFIED like every distress
|
||||
/// field; false for rows written before the field existed.
|
||||
/// </summary>
|
||||
public bool IsCloudPc { get; set; }
|
||||
|
||||
// Cert-context fields (V2 agents only). All optional; legacy entries leave these null.
|
||||
// Format-validated and length-capped at ingest; treat as UNVERIFIED claims.
|
||||
public string? CertSourceState { get; set; }
|
||||
|
||||
+8
-1
@@ -2,7 +2,7 @@ using System;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
namespace AutopilotMonitor.Shared.Logging
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a single parsed line from a CMTrace-format log file
|
||||
@@ -19,6 +19,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
||||
/// <summary>
|
||||
/// Parses CMTrace/SCCM log format used by IME and other Microsoft components.
|
||||
/// Format: <![LOG[{message}]LOG]!><time="{time}" date="{date}" component="{comp}" context="" type="{type}" thread="{thread}" file="">
|
||||
///
|
||||
/// Lives in Shared because it is the SINGLE parsing implementation for both sides:
|
||||
/// the agent's IME tracker / logparser gather collector (on-device) and the backend's
|
||||
/// rules/gather/test-pattern endpoint, which must reproduce the agent's matching
|
||||
/// semantics exactly so authors can test patterns without a device. Timestamp note:
|
||||
/// bias-less lines parse with DateTimeStyles.AssumeLocal — on the backend that is the
|
||||
/// SERVER's timezone, so the test endpoint reports match/parse results, not timestamps.
|
||||
/// </summary>
|
||||
public static class CmTraceLogParser
|
||||
{
|
||||
@@ -202,6 +202,19 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool ValidateDeviceAssociation { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to validate Windows 365 Cloud PCs as a fallback when the Autopilot /
|
||||
/// Corporate Identifier lookups miss. Cloud PCs are provisioned by the Windows 365
|
||||
/// service and are structurally never Autopilot-registered; this validator instead
|
||||
/// resolves the Intune device id from the (chain-validated) MDM client certificate's
|
||||
/// Subject CN and requires a matching <c>cloudPC</c> object
|
||||
/// (<c>virtualEndpoint/cloudPCs</c>, <c>managedDeviceId eq CN</c>) in the tenant.
|
||||
/// Only service-provisioned Cloud PCs have such an object, so no other enrolled
|
||||
/// device can pass this stage. Requires the optional Graph permission
|
||||
/// CloudPC.Read.All (feature "W365CloudPcValidation" in the grant script).
|
||||
/// </summary>
|
||||
public bool ValidateCloudPcDevice { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Emergency bypass for agent security gate (Global Admin use only).
|
||||
/// If true, agent requests are accepted even when ValidateAutopilotDevice is false.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user