mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f9576d4a9 | |||
| ee58242d33 | |||
| eca528b9b3 | |||
| 8305f5c850 | |||
| c2c02b929c | |||
| 1821af6f05 | |||
| 62599d972c | |||
| 21b751be64 | |||
| 63ea2e3cee | |||
| 93dcac44a4 | |||
| 72be8ac89f | |||
| cd570c8b3e | |||
| 6dfc564be6 | |||
| afecd18123 | |||
| 5a294ac4f6 | |||
| dbf6e50e2d | |||
| 062962ae41 | |||
| be08660976 | |||
| b032005f5b | |||
| 0ab568f479 | |||
| fced858a7a | |||
| ee947e2ca9 | |||
| 5f08a6ec68 | |||
| a97888ebd5 |
+25
-67
@@ -1,79 +1,37 @@
|
||||
## Workflow Orchestration
|
||||
# Agent Directives: Mechanical Overrides
|
||||
# Agent Directives
|
||||
|
||||
You are operating within a constrained context window and strict system prompts. To produce production-grade code, you MUST adhere to these overrides:
|
||||
- **Plan first.** Enter plan mode for any non-trivial task (3+ steps or an architectural decision) and write the spec before building. If something goes sideways, stop and re-plan instead of pushing on.
|
||||
- **Use subagents liberally** for research, exploration, and parallel analysis — one focus each — to keep the main context clean.
|
||||
- **Verify before claiming done.** Run the tests, check the logs, show the evidence. Diff against `main` where behaviour could regress.
|
||||
- **Fix bugs autonomously.** Given a report, a failing test, or an error, resolve it — no hand-holding.
|
||||
- **Capture corrections.** After any correction from the user, record the pattern and a preventing rule in `tasks/lessons.md`; review it at session start.
|
||||
- Read files over 500 LOC in chunks. Never treat one grep as proof of absence.
|
||||
|
||||
Codex reviews your output once you are done.
|
||||
|
||||
## Code Quality
|
||||
|
||||
### 1. Plan Mode Default
|
||||
- Enter plan mode for ANY non-trivial task (3+ steps or architectural decisions)
|
||||
- If something goes sideways, STOP and re-plan immediately — don't keep pushing
|
||||
- Use plan mode for specification steps, not just building
|
||||
- Write detailed specs upfront to reduce ambiguity
|
||||
|
||||
### 2. Subagent Strategy
|
||||
- Use subagents liberally to keep main context window clean
|
||||
- Offload research, exploration, and parallel analysis to subagents
|
||||
- For complex problems, throw more compute at it via subagents
|
||||
- One tack per subagent for focused execution
|
||||
|
||||
### 3. Self-Improvement Loop
|
||||
- After ANY correction from the user: update `tasks/lessons.md` with the pattern
|
||||
- Write rules for yourself that prevent the same mistake
|
||||
- Ruthlessly iterate on these lessons until mistake rate drops
|
||||
- Review lessons at session start for relevant project
|
||||
|
||||
### 4. Verification Before Done
|
||||
- Never mark a task complete without proving it works
|
||||
- Diff behavior between main and your changes when relevant
|
||||
- Ask yourself: "Would a staff engineer approve this?"
|
||||
- Run tests, check logs, demonstrate correctness
|
||||
|
||||
### 5. Demand Elegance (Balanced)
|
||||
- For non-trivial changes: pause and ask "is there a more elegant way?"
|
||||
- If a fix feels hacky: "Knowing everything I know now, implement the elegant solution"
|
||||
- Skip this for simple, obvious fixes — don't over-engineer
|
||||
- Challenge your own work before presenting it
|
||||
|
||||
### 6. Autonomous Bug Fixing
|
||||
- When given a bug report: just fix it. Don't ask for hand-holding
|
||||
- Point at logs, errors, failing tests — then resolve them
|
||||
- Zero context switching required from the user
|
||||
- Go fix failing CI tests without being told how
|
||||
|
||||
|
||||
|
||||
### Tools Execution
|
||||
- Read any file over 500 LOC in chunks using offset and limit parameters.
|
||||
- Do not assume a single grep caught everything.
|
||||
|
||||
### Code Quality
|
||||
- If architecture is flawed, state is duplicated, or patterns are inconsistent - propose and implement structural fixes. Ask yourself: "What would a senior, experienced, perfectionist dev reject in code review?" Fix all of it.
|
||||
|
||||
|
||||
Codex will review your output once you are done!
|
||||
|
||||
- Simplicity first: minimal, targeted changes. No temporary fixes — find the root cause.
|
||||
- Where architecture is flawed, state is duplicated, or patterns are inconsistent, propose and implement the structural fix. Ask what a perfectionist reviewer would reject, and fix that too.
|
||||
- On non-trivial changes, ask whether there is a more elegant way before presenting. Skip it for simple fixes — don't over-engineer.
|
||||
|
||||
## Task Management
|
||||
|
||||
1. **Plan First**: Write plan to `tasks/todo.md` with checkable items
|
||||
2. **Verify Plan**: Check in before starting implementation
|
||||
3. **Track Progress**: Mark items complete as you go
|
||||
4. **Explain Changes**: High-level summary at each step
|
||||
5. **Document Results**: Add review section to `tasks/todo.md`
|
||||
6. **Capture Lessons**: Update `tasks/lessons.md` after corrections
|
||||
Keep the plan in `tasks/todo.md` as checkable items, confirm it before implementing, tick items off as you go, and add a review section when done.
|
||||
|
||||
## Core Principles
|
||||
## Customer-Facing Claims
|
||||
|
||||
- **Simplicity First**: Make every change as simple as possible. Impact minimal code.
|
||||
- **No Laziness**: Find root causes. No temporary fixes. Senior developer standards.
|
||||
- **Minimal Impact**: Changes should only touch what's necessary. Avoid introducing bugs.
|
||||
`src/Web/autopilot-monitor-web/app/{about,terms,privacy}/page.tsx` and `autopilotmonitor-docs/trust/*` state facts customers and security reviewers rely on. They drift silently.
|
||||
|
||||
## Technical Docs — OKF Knowledge Bundle (`docs/`)
|
||||
- Verify every claim against the code before writing it — never carry one forward because it was already on the page.
|
||||
- Update them in the same change as: runtime/framework versions, roles, notification providers, agent deployment or lifecycle, diagnostics payload, isolation/delegation model, retention caps, sub-processors, or any default governing what is collected.
|
||||
- Describe only what customers can actually use; operator-only infrastructure is not a feature.
|
||||
- Trust pages: durable phrasing over exact figures, and bump their "Last reviewed" date.
|
||||
|
||||
`docs/` is an [Open Knowledge Format (OKF) v0.1](https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md) knowledge bundle for contributor/AI-facing technical documentation (customer docs live in the separate autopilotmonitor-docs repo).
|
||||
## Technical Docs — OKF Bundle (`docs/`)
|
||||
|
||||
- **Consume first**: start at `docs/index.md` for progressive disclosure before opening individual documents.
|
||||
- **When new durable technical knowledge emerges** (architecture decisions, flows, non-obvious mechanisms), capture it as an OKF concept document in `docs/` — markdown with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp` (recommended).
|
||||
- **Maintain the bundle**: add every new document to `docs/index.md`, note changes in `docs/log.md` (grouped by ISO date). Use standard RELATIVE markdown links between docs — never the OKF `/`-prefixed bundle-absolute form (GitHub resolves those from the repo root and navigation breaks). `index.md` and `log.md` are reserved filenames.
|
||||
- Documents are English, structural markdown over prose (`# Schema`, `# Examples`, `# Citations` sections where applicable).
|
||||
Contributor/AI-facing docs (customer docs live in the `autopilotmonitor-docs` repo). Start at `docs/index.md` before opening individual files.
|
||||
|
||||
- New durable knowledge (architecture decisions, flows, non-obvious mechanisms) → an OKF concept doc with YAML frontmatter: `type` (mandatory), plus `title`, `description`, `resource`, `tags`, `timestamp`.
|
||||
- Register it in `docs/index.md`, note the change in `docs/log.md` (ISO date). `index.md` and `log.md` are reserved names.
|
||||
- English, structural markdown (`# Schema`, `# Examples`, `# Citations`). Links between docs are RELATIVE — never `/`-prefixed, that breaks GitHub navigation.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: nuget
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
dotnet:
|
||||
patterns: ['*']
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/Web/autopilot-monitor-web
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
web:
|
||||
patterns: ['*']
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/McpServer/autopilot-monitor-mcp
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
mcp:
|
||||
patterns: ['*']
|
||||
update-types: [minor, patch]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
@@ -0,0 +1,116 @@
|
||||
name: CI
|
||||
|
||||
# Runs the full test suite on every pull request and every push to main.
|
||||
# The deploy workflows build but do not test — this is the gate that decides
|
||||
# whether a change is allowed to be correct.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
dotnet:
|
||||
name: .NET tests (backend + agent)
|
||||
# windows-latest is required: the agent test projects target net48.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore AutopilotMonitor.sln
|
||||
|
||||
- name: Build
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release --no-restore
|
||||
|
||||
- name: Backend tests
|
||||
run: dotnet test src/Backend/AutopilotMonitor.Functions.Tests --configuration Release --no-build --verbosity quiet
|
||||
|
||||
- name: Agent V2 Core tests
|
||||
run: dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests --configuration Release --no-build --verbosity quiet
|
||||
|
||||
- name: Decision Core tests
|
||||
run: dotnet test src/Agent/AutopilotMonitor.DecisionCore.Tests --configuration Release --no-build --verbosity quiet
|
||||
|
||||
- name: Summary Dialog tests
|
||||
run: dotnet test src/Agent/AutopilotMonitor.SummaryDialog.Tests --configuration Release --no-build --verbosity quiet
|
||||
|
||||
web:
|
||||
name: Web (typecheck + tests)
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/Web/autopilot-monitor-web
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: src/Web/autopilot-monitor-web/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
mcp:
|
||||
name: MCP server tests
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/McpServer/autopilot-monitor-mcp
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: src/McpServer/autopilot-monitor-mcp/package-lock.json
|
||||
|
||||
- run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
guardrails-in-sync:
|
||||
name: Generated guardrails match rules/guardrails.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Regenerate
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
- name: Fail if the generated files drifted
|
||||
run: |
|
||||
if ! git diff --quiet; then
|
||||
echo "::error::Generated files are out of date. Run 'node rules/scripts/combine.js' and commit the result."
|
||||
git diff --stat
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,58 @@
|
||||
name: CodeQL
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
schedule:
|
||||
# Weekly, so advisories published after a merge still surface.
|
||||
- cron: '17 3 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze ${{ matrix.language }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# csharp needs the net48 agent projects to build, so Windows.
|
||||
- language: csharp
|
||||
runner: windows-latest
|
||||
build-mode: manual
|
||||
- language: javascript-typescript
|
||||
runner: ubuntu-latest
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
if: matrix.language == 'csharp'
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Build
|
||||
if: matrix.build-mode == 'manual'
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
@@ -21,6 +21,22 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
# The published customer documentation lives in its own PUBLIC repo and is
|
||||
# baked into the image as the search_docs corpus (see the Dockerfile). No
|
||||
# token is needed. Checking it out here rather than vendoring it keeps the
|
||||
# docs single-sourced — but it also means EDITING THE DOCS DOES NOT UPDATE A
|
||||
# RUNNING SERVER; this workflow must be re-run. /health reports the SHA
|
||||
# captured below so that staleness is checkable.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
path: mcp-docs
|
||||
|
||||
- name: Resolve docs commit
|
||||
id: docs
|
||||
run: echo "sha=$(git -C mcp-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@v3
|
||||
with:
|
||||
@@ -35,6 +51,7 @@ jobs:
|
||||
run: |
|
||||
docker build \
|
||||
-f src/McpServer/autopilot-monitor-mcp/Dockerfile \
|
||||
--build-arg DOCS_COMMIT=${{ steps.docs.outputs.sha }} \
|
||||
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:${{ env.IMAGE_TAG }} \
|
||||
-t ${{ env.ACR_NAME }}.azurecr.io/${{ env.CONTAINER_APP_NAME }}:latest \
|
||||
.
|
||||
|
||||
+5
-2
@@ -117,8 +117,11 @@ scripts/Deployment/
|
||||
# Migration tooling (local only, vorerst) — cross-tenant storage/cert migration scripts
|
||||
scripts/Migration/
|
||||
|
||||
# Infra (local only)
|
||||
infra/
|
||||
# Infra: tracked. The Bicep templates carry no credentials — every secret is a
|
||||
# @secure() parameter with an empty default, injected at deploy time, and image
|
||||
# pull runs on managed identity. Keep it that way: never commit a resolved secret
|
||||
# value or a *.parameters.json holding one.
|
||||
infra/*.parameters.json
|
||||
|
||||
# M2 — local raw session fixtures before anonymization (plan §4 M2)
|
||||
tests/fixtures/enrollment-sessions-local/
|
||||
|
||||
@@ -34,7 +34,7 @@ For detailed information about the system architecture, components, and data flo
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation is available at **[autopilotmonitor.com/docs](https://www.autopilotmonitor.com/docs)**
|
||||
Full admin documentation is available at **[docs.autopilotmonitor.com](https://docs.autopilotmonitor.com)**
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+1
-1
@@ -7,6 +7,6 @@ How the on-device agent works:
|
||||
* [Logs, Persistence & Crash Recovery](logs-and-persistence.md) - every persisted file and why it exists; signal log vs spool; snapshot/journal; the replay-based recovery flow and quarantine.
|
||||
* [Hello Wizard Un-Skip](hello-wizard-unskip.md) - why a single "WHfB disabled" policy read never decides completion: the HelloWizardStarted signal (prevention + cure) and the HelloTracker confirmation second read.
|
||||
* [Autopilot ZTD Diagnostics](autopilot-ztd-diagnostics.md) - Windows' own diagnostic surfaces for the profile-download flow: ModernDeployment event IDs (807/815/164/...), the Diagnostics\Autopilot registry key, deployment-service endpoints, and the known-issue error-code map.
|
||||
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: 2800 watcher with cross-restart watermark, Shell-Core RebootCoalescing corroboration, ANALYZE-ESP-005 advisory.
|
||||
* [MDM Reboot Coalescing](mdm-reboot-coalescing.md) - attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated 2800 observations (neutral, flush-time watermark) + ANALYZE-ESP-005 gated on an actually-observed reboot.
|
||||
|
||||
Reading order for newcomers: overview → decision engine → persistence.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: Concept
|
||||
title: MDM Reboot Coalescing — attributing the mid-ESP reboot and second sign-in to policy URIs
|
||||
description: How the agent detects device-assigned MDM policies that force a coalesced reboot during ESP DeviceSetup (DM-Enterprise EventID 2800 + Shell-Core 62407 RebootCoalescing), and the ANALYZE-ESP-005 advisory that makes the invisible "second sign-in" fixable.
|
||||
description: How the agent observes reboot-required MDM policy flags (DM-Enterprise EventID 2800, aggregated) and how ANALYZE-ESP-005 attributes an actually-observed enrollment reboot to them — including why the raw events stay neutral and why the Shell-Core RebootCoalescing match was removed.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals
|
||||
tags:
|
||||
- agent
|
||||
@@ -17,91 +17,82 @@ timestamp: 2026-07-20T00:00:00+02:00
|
||||
Device-assigned MDM policies whose CSP URIs match the OS reboot-required catalog
|
||||
(`HKLM\SOFTWARE\Microsoft\Provisioning\SyncML\RebootRequiredURIs`) force a **coalesced
|
||||
reboot at the end of ESP DeviceSetup** — the user sees an unexpected restart and a
|
||||
**second sign-in screen** before AccountSetup. This is expected OS behavior, invisible in
|
||||
the Intune console; most admins simply live with it, not knowing that reassigning the
|
||||
profiles to user groups removes the reboot entirely. Source research: Patch My PC,
|
||||
**second sign-in screen** before AccountSetup. Invisible in the Intune console; most
|
||||
admins live with it, not knowing that reassigning the profiles to user groups removes the
|
||||
reboot entirely. Source research: Patch My PC,
|
||||
["Avoid the second sign-in screen after Autopilot"](https://patchmypc.com/blog/autopilot-unexpected-reboot-autopilot-second-login-screen/).
|
||||
|
||||
The session already counts reboots (`system_reboot_detected` → `session.rebootCount`);
|
||||
this feature adds the **cause attribution**.
|
||||
|
||||
# Signal chain
|
||||
# Verified signal facts (session b2e890c1, 2026-07-20)
|
||||
|
||||
1. **EventID 2800**, `Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin`
|
||||
— logged by the SyncML engine once per policy URI that matched the reboot-required
|
||||
catalog, during device-policy application in DeviceSetup.
|
||||
2. **EventID 62407**, `Microsoft-Windows-Shell-Core/Operational`, description containing
|
||||
`RebootCoalescing` — ESP/CloudExperienceHost initiating the coalesced reboot at the end
|
||||
of DeviceSetup.
|
||||
3. The reboot kills the agent; the post-reboot restart re-observes both channels via
|
||||
backfill (the .evtx persists across the reboot).
|
||||
* **EventID 2800**, `Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin`,
|
||||
one record per URI, verified description shape:
|
||||
`The following URI has triggered a reboot: (./Device/Vendor/MSFT/Policy/Config/ServiceControlManager/SvchostProcessMitigation).`
|
||||
Records arrive in sub-second bursts during policy sync.
|
||||
* **Timing semantics (the critical nuance)**: 2800 fires at EVERY policy sync. Only URIs
|
||||
applied **during ESP DeviceSetup** are coalesced into the forced mid-ESP restart. In the
|
||||
verified session the flags fired during AccountSetup (after DeviceSetup completed) —
|
||||
**no reboot occurred**, and `omadmRebootRequiredFlag` read `false`. A detector that
|
||||
claims "reboot incoming" on every 2800 is therefore wrong.
|
||||
* **Shell-Core 62407 "RebootCoalescing" — REMOVED as a signal.** The token appears in the
|
||||
ROUTINE bootstrap marker on every enrollment:
|
||||
`Name: 'CommercialOOBE_BootstrapStatusCategory_SubcategoryProcessing_Started', Value: '{"message":"BootstrapStatus: Starting subcategory DeviceSetup.RebootCoalescing...","errorCode":0}'`
|
||||
— the ESP merely *processes* the RebootCoalescing subcategory (which checks whether a
|
||||
reboot is needed). A substring match on it is a false positive by construction. If a
|
||||
real coalesced-reboot record is ever captured on the E2E VM, a precise pattern can be
|
||||
added then.
|
||||
|
||||
# Detectors
|
||||
# Detector — `MdmRebootPolicyTracker` (`MdmRebootPolicyWatcherHost`)
|
||||
|
||||
## `MdmRebootPolicyTracker` (new collector, `MdmRebootPolicyWatcherHost`)
|
||||
Live `EventLogWatcher` on the DM-Enterprise Admin channel (EventID 2800) + startup
|
||||
backfill (default 60 min lookback). Records are **buffered and debounced (10 s)**, then
|
||||
flushed as **ONE aggregated `mdm_policy_reboot_required` event** per burst:
|
||||
|
||||
Structural clone of `WindowsUpdateTracker`: live `EventLogWatcher` on the DM-Enterprise
|
||||
Admin channel (EventID 2800) + startup backfill (default 60 min lookback) + **cross-restart
|
||||
RecordId watermark** (`mdm-reboot-watermark.json`). The watermark is mandatory here: the
|
||||
coalesced reboot restarts the agent, and without it every reboot-forcing URI would be
|
||||
re-emitted after the very reboot it caused.
|
||||
* `Severity=Info`, neutral message ("Windows flagged N MDM policy URIs as
|
||||
reboot-required: … (+N more)") — the raw event never claims a reboot.
|
||||
* `Data`: `rebootUris` (distinct, sorted, capped 20), `uriCount` (uncapped distinct),
|
||||
`firstRebootUri` (record-time order, drives rule interpolation), `recordCount`,
|
||||
`unparsedCount`/`sampleDescription` (extraction diagnostics), `backfilled` (all-backfill),
|
||||
fail-soft `omadmRebootRequiredFlag` probe of
|
||||
`HKLM\SOFTWARE\Microsoft\Provisioning\OMADM\SyncML\RebootRequired` (null = unknown).
|
||||
* `Timestamp` = earliest record time (backfilled bursts land at their historical instant);
|
||||
`ImmediateUpload=true` (one event per burst is cheap, and a DeviceSetup burst precedes a
|
||||
reboot that kills the agent).
|
||||
* **Watermark is persisted at FLUSH, not at claim** (`mdm-reboot-watermark.json`): if the
|
||||
coalesced reboot kills the process before the debounced emit, nothing is marked — the
|
||||
post-restart backfill re-reads and emits the burst. A flushed burst is never repeated.
|
||||
|
||||
Per 2800 record it emits one `mdm_policy_reboot_required` event (Warning,
|
||||
`ImmediateUpload=true` — the announced reboot will kill the agent, flush now) with
|
||||
`Data.rebootUri` (the CSP URI), `recordId`, `backfilled`, `description` (truncated 1000),
|
||||
and a fail-soft `omadmRebootRequiredFlag` corroboration probe of
|
||||
`HKLM\SOFTWARE\Microsoft\Provisioning\OMADM\SyncML\RebootRequired` (null = unknown, never
|
||||
blocks the emit). Per-URI emission (not aggregated) is deliberate: an aggregate would need
|
||||
a flush point that races the reboot kill.
|
||||
URI extraction: EventData values starting with `./` preferred, permissive regex over the
|
||||
description as fallback (trailing punctuation trimmed — the verified text wraps the URI in
|
||||
parens and ends with a period). Unparsed records still count into the aggregate.
|
||||
|
||||
**Tolerant matching**: the exact 2800 message text is unverified — emission is gated on
|
||||
the EventID only. URI extraction prefers structured EventData values starting with `./`
|
||||
and falls back to a permissive regex over the description (trailing punctuation trimmed).
|
||||
Events with an unparseable URI are still emitted (timeline-visible, rule stays silent, and
|
||||
the captured description tells us how to fix the extraction). Exact texts are validated
|
||||
post-deploy on the E2E VM and recorded here.
|
||||
|
||||
Config (internal steering only, no admin UI — this is an agent-native collector, NOT an
|
||||
Config (internal steering only, no admin UI — agent-native collector, NOT an
|
||||
`IAgentAnalyzer`): `Collectors.MdmRebootPolicyWatcherEnabled` (default true),
|
||||
`Collectors.MdmRebootPolicyBackfillLookbackMinutes` (default 60). ConfigVersion 35.
|
||||
|
||||
## `ShellCoreTracker` — `RebootCoalescing` branch
|
||||
|
||||
The already-watched 62407 stream gets a fourth branch (checked AFTER
|
||||
WhiteGlove/failure/exiting so their semantics stay untouched): description containing
|
||||
`RebootCoalescing` → one `esp_reboot_coalescing` event (Warning, ImmediateUpload), pure
|
||||
informational — no `FinalizingSetup` transition, no C# event raise, no DecisionCore
|
||||
involvement.
|
||||
|
||||
**Deliberate deviation**: unlike the other 62407 branches, the backfill path DOES emit
|
||||
this event. The live emit races the very reboot that kills the process; the post-restart
|
||||
5-min backfill re-emission is the only reliable delivery. A live+backfill cross-process
|
||||
duplicate is possible and accepted (fire-once bool resets per process; both copies carry
|
||||
the identical historical timestamp; the backfill copy is marked `backfilled:true`; the
|
||||
analyze rule uses exists-semantics). The shared 5-min lookback is NOT enlarged — the
|
||||
rule's required condition is the watermarked 2800 event; coalescing is optional
|
||||
corroboration only.
|
||||
|
||||
# Advisory — ANALYZE-ESP-005
|
||||
|
||||
"MDM Policy Forced a Mid-ESP Reboot (Second Sign-In)", severity `warning`,
|
||||
`markSessionAsFailedDefault: false`. Required: `mdm_policy_reboot_required.rebootUri`
|
||||
exists (first URI interpolates as `{{rebootUri}}`). Optional corroboration:
|
||||
`esp_reboot_coalescing` observed (+20 confidence) and ≥2 reboot URIs (+10) on base 70 /
|
||||
threshold 70. Remediation: map each URI to its Intune profile and reassign device → user
|
||||
groups (or exclude the Autopilot device group), or accept + communicate the restart.
|
||||
Because `{{token}}` interpolation is scalar-only, the explanation points at the timeline
|
||||
for the complete URI list — each URI is its own event.
|
||||
The reboot **claim** lives here, and only here. Required conditions (BOTH):
|
||||
|
||||
1. `mdm_policy_reboot_required.firstRebootUri` exists (→ `{{firstRebootUri}}` interpolation)
|
||||
2. `system_reboot_detected` count ≥ 1 — **an actually-observed reboot**
|
||||
|
||||
Policy flags alone (e.g. AccountSetup user-policy sync) stay advisory-silent. Optional
|
||||
confidence: `uriCount ≥ 2` (+10), `omadmRebootRequiredFlag == true` (+15) on base 70 /
|
||||
threshold 70. Remediation: map URIs to Intune profiles, reassign device → user groups (or
|
||||
exclude the Autopilot device group), or accept + communicate the restart.
|
||||
|
||||
# Non-goals / invariants
|
||||
|
||||
* The new event types are **deliberately distinct from `system_reboot_detected`** — the
|
||||
backend RebootCount matches that string exactly in two places
|
||||
(`EventIngestProcessor.Classification` + the terminal recount); attribution events must
|
||||
never inflate the reboot count.
|
||||
* Event type **deliberately distinct from `system_reboot_detected`** — the backend
|
||||
RebootCount matches that string exactly in two places
|
||||
(`EventIngestProcessor.Classification` + terminal recount); attribution must never
|
||||
inflate the reboot count.
|
||||
* No session-level field / SessionInfoCard change in this iteration (follow-up candidate).
|
||||
* The `RebootRequiredURIs` catalog itself is not read (format unverified); only the 2800
|
||||
events (which carry the actually-triggered URIs) are consumed.
|
||||
* The `RebootRequiredURIs` catalog itself is not read; only the 2800 events (which carry
|
||||
the actually-triggered URIs) are consumed.
|
||||
|
||||
# Citations
|
||||
|
||||
|
||||
+7
-2
@@ -18,16 +18,21 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
|
||||
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
|
||||
* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: DM-Enterprise 2800 watcher with cross-restart watermark, Shell-Core RebootCoalescing corroboration, ANALYZE-ESP-005 advisory.
|
||||
* [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot.
|
||||
|
||||
# Rules
|
||||
|
||||
* [Gather Rule Phase Scoping & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch) and emitMode "on_change" result-dedup — the anti-spam pair for interval rules.
|
||||
* [Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change](rules/gather-rule-phase-scoping.md) - Restricting gather rules to enrollment phases (activePhases / activeFromPhase sticky latch), one-shot collection at a phase's start/end (phase_change / phase_exit), and emitMode "on_change" result-dedup.
|
||||
* [Gather Rule Guardrails](rules/gather-rule-guardrails.md) - Why the allowlists in rules/guardrails.json are enforced on the agent and nowhere else, which hard blocks survive unrestricted mode, and the rule that every collector must call a guard and emit security_warning on refusal.
|
||||
|
||||
# Backend
|
||||
|
||||
* [Business Timestamps](backend/business-timestamps.md) - Why the Azure Tables system Timestamp is never authoritative (migrations reset it), and the OccurredUtc + RowKey-decode compensation for AuditLogs/OpsEvents/Events after the 2026-07-18 migration.
|
||||
|
||||
# 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.
|
||||
|
||||
# Security & Identity
|
||||
|
||||
* [MCP OAuth Flow](mcp-oauth-flow.md) - Who authenticates where when connecting an AI client to the MCP server; two identities, three parties plus Entra ID.
|
||||
|
||||
+19
@@ -1,7 +1,26 @@
|
||||
# Log
|
||||
|
||||
## 2026-07-22
|
||||
|
||||
* **Creation**: Added `rules/gather-rule-guardrails.md` — the gather-rule allowlists are enforced only on the agent (the backend validates no `collectorType`, `target`, or `parameters`), so a collector that forgets to call its guard *is* the vulnerability. Written because two had: `LogParserCollector` read any path in `rule.Target`, bypassing even the `C:\Users` hard block that every other file collector honours, and `EventLogCollector` read any channel including `Security` — while the customer-facing documentation already promised both were allowlisted. Both now guard, with `logparser` checking the directory for wildcard targets (`Path.GetFullPath` throws on `*`) and re-checking each resolved path. Added an event-log channel allowlist (`eventLogChannels`) plus hard-blocked channels held in C# rather than JSON, so a parse error cannot lift them. Also fixed the command timeout, which was inert: `ReadToEnd()` blocks until the pipe closes, so `WaitForExit(30000)` was only reached after the process had already exited and a hung command pinned its worker forever — now read asynchronously and killed at the timeout.
|
||||
|
||||
* **Update**: Added `.github/workflows/ci.yml` — the full suite (backend, agent V2 Core, DecisionCore, SummaryDialog, web, MCP) now runs on every pull request and every push to main, plus a job that fails if the generated guardrail files drift from `rules/guardrails.json`. Previously **no** workflow ran tests on a PR: the deploy workflows build without testing, and `build-agent.yml` tested only `DecisionCore.Tests` on manual dispatch — while `trust/security-faq.md` already told customers that automated tests run in CI. Added CodeQL (C# and TypeScript) and Dependabot (nuget, npm ×2, actions). Marked `OfficeBinaryWatcherTests` `[Collection("SerialThreading")]`: its bounded re-probe races the shared ThreadPool and failed once under full-suite load.
|
||||
|
||||
## 2026-07-21
|
||||
|
||||
* **Update**: `search_docs` and `search_knowledge` now state in their tool descriptions that queries must be in English. Both corpora and `all-MiniLM-L6-v2` are English-only, and the failure is total rather than gradual — a German question returns zero results, not weak ones. The caller is a language model, so translating the question before the call and answering in the user's language costs nothing; a multilingual embedding model would be the alternative but is much larger and would have to be measured against the eval harness first, since it changes English retrieval too.
|
||||
|
||||
* **Update**: Added `scripts/eval-docs-search.ts`, an offline retrieval evaluation harness (2465 queries in three classes: chunk headings as a regression detector, rare identifiers, and hand-written questions as the reality anchor), and used it to close a third `search_docs` gap found during the live test. The keyword fallback only fired when semantic search returned *fewer* than `topK`; a pasted identifier like `WOW6432Node` instead returned three chunks at ~0.29 that did not contain the word while the one chunk that did was never considered. It now also fires on a weak semantic head (<0.35), letting exact matches take the front while keeping one semantic result. The threshold is measured, not guessed: 0.35 fixes 335 queries and breaks 0 (identifier recall 66.2% → 81.1%), while 0.40 fixes more but starts demoting hand-written questions. The measurement also resized the problem — identifier queries were only succeeding 66.2% of the time, so the reported case was one of roughly 750.
|
||||
|
||||
* **Update**: Closed the two measured recall gaps in `search_docs` (`mcp/docs-corpus.md`). (1) **Token windows** — 87 of 324 chunks exceeded the model's 256-token context and 13% of all tokens never reached the ranking; a character budget cannot fix this (the corpus ranges 2.43–5.81 chars/token), so long text is now embedded as 250-token windows with 40 overlap and scored by its BEST window (`embeddings: number[][]`, max not mean). Token coverage 87% → 100%; "proxy configuration blocking app downloads" went from absent to 0.559 at rank 1; the ten reference queries kept byte-identical scores. Costs +31% vectors, index 2.63 → 3.12 MB. The rules corpus gained the same treatment (128 rules → 172 vectors). (2) **Keyword fallback** — when the semantic pass returns fewer than `topK`, results are topped up via literal `lexicalMatch` on distinctive query terms, requiring two matching terms for corroboration (one incidental substring match returned a page about LLM providers for a sourdough query). Fuzzy matching was measured and rejected: at a threshold low enough to surface "RealmJoin" it invents 5 hits for "SCEPman", which the corpus never mentions. Also sharpened the tool description with the measured context argument for preferring it over fetching the docs site.
|
||||
|
||||
* **Creation**: Added `mcp/docs-corpus.md` (new `mcp/` section) — the MCP server gains `search_docs`, a third semantic corpus built from the published customer documentation repository (`autopilotmonitor-docs`, checked out into the Docker build context by the deploy workflow). Documents the h2/h3 + `<details>` chunking with breadcrumb prefixes, the two deliberate size exceptions (tables never split, unbroken changelog lists split at line level), the CRLF precondition (`.` does not match `\r` in JS — headings silently stop matching, which produced 286 wrong-but-plausible chunks before it was found), the optional `docs` section in `search-index.json` and its validation asymmetry, embedding rounding to 6 decimals, and the cross-repo coupling: a docs edit reaches clients only after an MCP redeploy, made checkable via `DOCS_COMMIT` on `/health`. Container resources raised to 0.5 vCPU / 1.0 GiB for the cold-start query-embedder load.
|
||||
|
||||
## 2026-07-20
|
||||
|
||||
* **Update**: `rules/gather-rule-phase-scoping.md` — the gather-rule form now shows the phase-scope and emit-mode controls only where they can change behavior (`supportsPhaseScope` / `supportsEmitMode`), nulls hidden-control state out of the payload, and rejects a scope mode selected but left unfilled. Offering all three "when" controls unconditionally allowed combinations that could only suppress a one-shot rule, plus a silent no-op state.
|
||||
* **Update**: Extended `rules/gather-rule-phase-scoping.md` with the `phase_exit` trigger — the closing bookend to `phase_change`, for one-shot collection when a phase is LEFT (e.g. "state at the end of Account Setup"). Documents the evaluation-before-phase-advance rule (triggerPhase match and scope gate see the phase being left), firing into `Failed`, the disjoint `exit:` dedup key space, and why no ConfigVersion bump is needed (no new field — old agents simply never fire the rule).
|
||||
* **Update**: Reworked `agent/mdm-reboot-coalescing.md` after the first real enrollment (session b2e890c1): 2800 records are now **aggregated per burst** into ONE neutral Info event (`rebootUris[]`/`uriCount`/`firstRebootUri`; watermark persisted at flush so a pre-emit reboot kill loses nothing); the raw event no longer claims a reboot — ANALYZE-ESP-005 is gated on an actually-observed `system_reboot_detected`. **Removed** the Shell-Core `esp_reboot_coalescing` event entirely: the "RebootCoalescing" token appears in the routine `SubcategoryProcessing_Started` bootstrap marker on every enrollment (false positive by construction). Verified 2800 description shape recorded.
|
||||
* **Creation**: Added `agent/mdm-reboot-coalescing.md` — the agent now attributes the "unexpected mid-ESP reboot + second sign-in" pattern (PMPC research) to the device-assigned policy URIs that forced it: new `MdmRebootPolicyTracker` (DM-Enterprise EventID 2800, per-URI `mdm_policy_reboot_required` events, cross-restart RecordId watermark), a `RebootCoalescing` branch in `ShellCoreTracker` (`esp_reboot_coalescing`, backfill-emitting by design), and the `ANALYZE-ESP-005` advisory (reassign device → user groups). ConfigVersion 35; event types deliberately distinct from `system_reboot_detected` so RebootCount stays uninflated.
|
||||
|
||||
* **Creation**: Added `backend/business-timestamps.md` (new `backend/` section) — the 2026-07-18 migration reset every row's system Timestamp, exposing that AuditLogs/OpsEvents/Events had (unknowingly) displayed and filtered on it all along; documents the `OccurredUtc` column, the RowKey decoders and RowKey-range date/retention clauses in `BusinessTimestamp`, the GUID-guard trap, the audit/ops backfill endpoint, and the "system Timestamp is never authoritative" migration rule.
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
---
|
||||
type: Concept
|
||||
title: MCP Docs Corpus — search_docs and the Cross-Repo Build Coupling
|
||||
description: How the published customer documentation becomes a third semantic search corpus in the MCP server — CRLF-safe heading chunking, build-time embedding, the corpus hash guard, and why editing the docs does not update a running server.
|
||||
resource: /src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts
|
||||
tags:
|
||||
- mcp
|
||||
- search
|
||||
- embeddings
|
||||
- documentation
|
||||
- build
|
||||
timestamp: 2026-07-21T19:30:00+02:00
|
||||
---
|
||||
|
||||
# Purpose
|
||||
|
||||
The MCP server answers two different kinds of question, and they need two
|
||||
different corpora.
|
||||
|
||||
* *"Why did this enrollment fail?"* → analysis rules, gather rules, IME log
|
||||
patterns. That is `search_knowledge`, backed by `knowledge-base.ts`.
|
||||
* *"How do I deploy the agent?" / "Where is my data stored?"* → the published
|
||||
customer documentation. That is `search_docs`, backed by `docs-corpus.ts`.
|
||||
|
||||
They are deliberately **separate indexes**. Merged into one, they would compete
|
||||
for the same `topK` and dilute each other: a rules query would surface prose
|
||||
pages, and a product question would surface rule definitions.
|
||||
|
||||
# Schema
|
||||
|
||||
## Corpus source
|
||||
|
||||
The corpus is the `autopilotmonitor-docs` repository — a public OKF bundle
|
||||
published at `docs.autopilotmonitor.com`. It is **not** vendored into this repo.
|
||||
The deploy workflow checks it out into `mcp-docs/` of the Docker build context;
|
||||
the Dockerfile copies it to `/app/docs` in both the build and the final stage
|
||||
(byte-identical, or the boot-time corpus hash rejects the precomputed index).
|
||||
|
||||
`SUMMARY.md` and `log.md` are skipped as navigation scaffolding. `index.md` is
|
||||
kept — it is the documentation map and answers "what documentation exists" — but
|
||||
gets no `url`, because the bundle deliberately keeps it out of `SUMMARY.md` and
|
||||
GitBook therefore never renders it.
|
||||
|
||||
## Chunking
|
||||
|
||||
A whole page cannot be one document: `trust/security-faq.md` is 36 KB, while
|
||||
MiniLM-L6-v2 truncates at ~256 tokens. An un-chunked page would silently embed
|
||||
only its first paragraph.
|
||||
|
||||
Chunks are cut at **h2/h3 headings**, which in this bundle are also the semantic
|
||||
boundaries — `security-faq.md` carries 37 h3s, one per question.
|
||||
`<details><summary>Q</summary>` blocks (used by `troubleshooting/faq.md`) are
|
||||
treated the same way, with the summary as the section title. Each chunk is
|
||||
prefixed with a `Page Title › Section › Subsection` breadcrumb so a hit lifted
|
||||
out of the middle of a page is self-describing to both the embedder and the
|
||||
reader.
|
||||
|
||||
Two deliberate exceptions to the ~1500-character budget:
|
||||
|
||||
* **Markdown tables are never split.** A table broken across chunks leaves
|
||||
`| --- |` rows with no header — unreadable in a result and meaningless to
|
||||
embed. Tables carry real answers here (the role matrix, the settings
|
||||
reference), so an over-long table is allowed to exceed the budget.
|
||||
* **Unbroken lists split at line level.** The changelog pages are one bullet
|
||||
list per release with no blank lines, so paragraph splitting cannot touch
|
||||
them; left whole, everything past the model's window would be truncated away
|
||||
and become unsearchable.
|
||||
|
||||
Short sections are **kept**, not filtered. A one-line answer ("Is it free?" →
|
||||
"Yes, the Community plan is free") is often the most valuable hit, and the
|
||||
breadcrumb supplies the context the short body lacks. Only structural residue
|
||||
(under 25 alphanumeric characters) is dropped.
|
||||
|
||||
## CRLF is a correctness precondition
|
||||
|
||||
The docs repo is authored on Windows and ships CRLF. In JavaScript `.` does not
|
||||
match `\r`, so a pattern like `/^(#{2,3})\s+(.*)$/` matches **nothing** on a CRLF
|
||||
file: `(.*)` stops before the `\r` and `$` never reaches end-of-string.
|
||||
|
||||
This was observed during development, and the failure mode is what makes it
|
||||
dangerous: no crash, no error — headings and frontmatter simply stop being
|
||||
recognized and every page silently degrades to unlabelled paragraph splits. The
|
||||
first working build produced 286 plausible-looking chunks that were almost all
|
||||
wrong; after normalizing, `security-faq.md` went from 29 blind splits to 46 real
|
||||
question sections.
|
||||
|
||||
`normalizeNewlines()` runs before any parsing. Do not remove it, and do not
|
||||
"fix" a future parsing bug here by adding `\r?` to one pattern.
|
||||
|
||||
## Token windows: why a chunk can hold several vectors
|
||||
|
||||
Chunking at heading boundaries keeps sections coherent, but it does not bound the
|
||||
result by *tokens* — and it cannot. Measured on this corpus the ratio ranges from
|
||||
**2.43 to 5.81 characters per token**, a factor of 2.4, so no character budget maps
|
||||
reliably onto the model's 256-token window. A budget safe for the worst case
|
||||
(~620 characters) would shred prose and tables alike.
|
||||
|
||||
Left alone, 87 of 324 chunks exceeded the window and **13% of all tokens never
|
||||
reached the ranking** — silently, since the tokenizer truncates without complaint.
|
||||
The effect was concrete: in `built-in-rules.md :: Apps` (734 tokens), the strings
|
||||
`ANALYZE-APP-013`, `ANALYZE-OFFICE-001`, `ANALYZE-CORR-003` and `Click-to-Run` all
|
||||
sit past the cutoff, so *"proxy configuration blocking app downloads"* did not
|
||||
retrieve that section at all.
|
||||
|
||||
So the *chunk* stays whole and the *embedding* is split instead:
|
||||
`VectorSearchProvider.index()` cuts long text into 250-token windows with 40 tokens
|
||||
of overlap and embeds each. A document therefore carries `embeddings: number[][]`,
|
||||
and `search()` scores it by its **best** window.
|
||||
|
||||
- **Max, not mean.** Averaging window vectors would blur a long section's several
|
||||
topics into one mediocre direction — the same dilution that makes large context
|
||||
windows worse than chunking. Max keeps a document findable by any one thing it
|
||||
discusses.
|
||||
- **The tokenizer is a build-time dependency only.** `index()` runs during the
|
||||
Docker build; production boots from precomputed vectors and never loads it. It is
|
||||
also only the vocabulary, not the ONNX weights (~93 ms).
|
||||
- Cost: 324 chunks → 424 vectors (+31%), index 2.63 → 3.12 MB, and 424 instead of
|
||||
324 dot products per query — still microseconds.
|
||||
- The rules corpus benefits too, unplanned: 128 rules now carry 172 vectors.
|
||||
|
||||
Measured effect: token coverage 87% → **100%**, *"Office Click-to-Run install never
|
||||
finished"* 0.318 → **0.442**, *"proxy configuration blocking app downloads"* absent
|
||||
→ **0.559 at rank 1**. The ten reference queries kept byte-identical scores — their
|
||||
chunks are short, hence one window, hence the same vector as before.
|
||||
|
||||
## Keyword fallback
|
||||
|
||||
Semantic ranking alone leaves a gap: proper nouns embed poorly. `RealmJoin` occurs
|
||||
in 3 chunks yet scores under the threshold.
|
||||
|
||||
Fuzzy matching is the wrong remedy, and the corpus says so. At a threshold low
|
||||
enough to surface `RealmJoin` (0.28), Fuse also returns 5 hits for `SCEPman` — a
|
||||
word this corpus contains **zero** times. Literal containment has neither problem.
|
||||
|
||||
So when the semantic pass returns fewer than `topK`, `search_docs` tops the list up
|
||||
via `lexicalMatch`, the substring scan every provider already exposes:
|
||||
|
||||
- Query terms are filtered to distinctive ones (≥4 characters, non-stopword, max 6).
|
||||
- A hit needs **corroboration**: at least two matching terms, unless the query has
|
||||
only one. Without this rule, "how do I bake sourdough bread" matched a page about
|
||||
LLM providers, because "bake" is a substring of "baked into the image".
|
||||
- Hits are **appended, never merged**: they carry no cosine similarity, so semantic
|
||||
results keep the head of the list and keyword hits fill the tail, flagged
|
||||
`matchType: "keyword"` with `keywordFallback` in the response.
|
||||
|
||||
### Language
|
||||
|
||||
The corpus and `all-MiniLM-L6-v2` are both English-only, and the failure is total
|
||||
rather than gradual: "wo werden meine Daten gespeichert" returns **zero** results,
|
||||
not weak ones, because the query vector lands nowhere near any English chunk.
|
||||
|
||||
The fix is a sentence in the tool description telling the caller to query in English
|
||||
and answer in the user's language — the caller is a language model, so translating
|
||||
the question before the call costs nothing. A multilingual embedding model would
|
||||
handle it natively but is substantially larger and would need to be measured against
|
||||
`scripts/eval-docs-search.ts` first, since it changes English retrieval too.
|
||||
`search_knowledge` carries the same note for the same reason.
|
||||
|
||||
### When it fires
|
||||
|
||||
Two triggers, and the second one exists because the first was not enough:
|
||||
|
||||
1. **Fewer than `topK` semantic hits** — the obvious case.
|
||||
2. **A weak semantic head** (best score below `WEAK_SEMANTIC_SCORE`, 0.35). Topping
|
||||
up only a short list misses the more common failure: a pasted identifier like
|
||||
`WOW6432Node` returns three chunks at ~0.29 that do not contain the word, while
|
||||
the single chunk that does never gets considered. When nothing semantic is
|
||||
convincing, exact matches take the front — keeping at least one semantic result,
|
||||
so a weak-but-correct hit is never pushed out entirely.
|
||||
|
||||
The threshold was chosen by measurement, not intuition. `scripts/eval-docs-search.ts`
|
||||
scores strategies over 2465 queries in three classes — every chunk heading
|
||||
(known-item, a regression detector), rare identifiers (the class this fallback
|
||||
serves), and hand-written questions (the reality anchor, not derived from the
|
||||
corpus):
|
||||
|
||||
| strategy | known-item | literal | handwritten MRR | fixed | broken |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| append-only (previous) | 71.9% | 66.2% | 1.000 | — | — |
|
||||
| weak < 0.30 | 71.9% | 72.6% | 1.000 | 142 | 0 |
|
||||
| **weak < 0.35** | **73.7%** | **81.1%** | **1.000** | **335** | **0** |
|
||||
| weak < 0.40 | 76.3% | 88.2% | 0.944 | 502 | 3 |
|
||||
| weak < 0.45 | 77.2% | 93.0% | 0.889 | 613 | 5 |
|
||||
|
||||
0.35 is strictly dominant: better in every class, worse in none. Higher thresholds
|
||||
keep repairing more identifier queries but start demoting hand-written questions —
|
||||
the class that most resembles real use — so the gain is not free past that point.
|
||||
|
||||
The measurement also corrected the size of the problem: identifier queries were
|
||||
succeeding only 66.2% of the time, so `WOW6432Node` was not an outlier but one of
|
||||
roughly 750 such failures.
|
||||
|
||||
## Build-time embedding
|
||||
|
||||
Like the rules corpus, docs are embedded once at Docker build time by
|
||||
`precompute-embeddings.js <rulesDir> <outFile> [docsDir]`, into a third
|
||||
`PrecomputedSection` (`docs`) of `search-index.json`. The container never embeds
|
||||
a corpus at boot — only the incoming query.
|
||||
|
||||
The `docs` section is **optional** in the file format, which is what makes a
|
||||
local checkout without the docs repo work: `validatePrecomputedIndex` skips it
|
||||
when the booting server loaded zero doc chunks. The asymmetry is intentional —
|
||||
if the server *has* a docs corpus and the file does *not*, that is staleness and
|
||||
is rejected like any other mismatch.
|
||||
|
||||
Embeddings are rounded to 6 decimals on serialize. A float64 component
|
||||
serializes to ~20 characters of JSON, so 384 dims cost ~7.8 KB per vector;
|
||||
rounding roughly halves the index file, which is `JSON.parse`d on every
|
||||
scale-to-zero cold start. The induced L2 norm error (~1e-5 over 384 dims) sits
|
||||
two orders of magnitude inside `UNIT_NORM_EPSILON`.
|
||||
|
||||
## Registration
|
||||
|
||||
`search_docs` is registered for **every role**, unlike the platform tools: this
|
||||
is public documentation carrying no tenant data, and a tenant user asking "how do
|
||||
I deploy the agent" has as much business here as a Global Admin. It is skipped
|
||||
entirely when the corpus is empty, so a doc-less build advertises no broken tool.
|
||||
|
||||
# Cold start
|
||||
|
||||
`minReplicas: 0` means every idle period ends in a cold start someone waits through.
|
||||
Measured from outside: **24.5 s** to first byte after `ScaledToZero`, against **60 ms**
|
||||
warm.
|
||||
|
||||
Where those seconds go could not be answered from outside the process. Log Analytics
|
||||
stamps every console line of one ingestion batch with the same time, so the boot
|
||||
sequence collapses to a single instant however it is queried. The server therefore
|
||||
times itself — `[boot +Nms]` marks from `process.uptime()`, which includes Node's own
|
||||
startup and module loading.
|
||||
|
||||
Measured in production on a real cold start (31.5 s to first byte that time):
|
||||
|
||||
```
|
||||
+244ms node started (startup + module loading)
|
||||
+431ms corpora read from disk (+187ms)
|
||||
+491ms index read, validated, hydrated (+60ms <- for 3.12 MB)
|
||||
+504ms listening
|
||||
+677ms query embedder warm (background)
|
||||
```
|
||||
|
||||
**The application accounts for half a second of it.** The other ~31 s — 98% — is
|
||||
Container Apps activation: KEDA reacting to the request, scheduling a pod, mounting
|
||||
the 236 MB image, starting the container. The container also boots *faster* than a
|
||||
development laptop (504 ms against 1273 ms), so nothing here is CPU-starved.
|
||||
|
||||
Three conclusions, and two of them overturn assumptions this file previously stated:
|
||||
|
||||
- **Index size is irrelevant to startup.** 3.12 MB hydrates in 60 ms. Growing the
|
||||
corpus is effectively free, and the embedding rounding earns its keep on file size
|
||||
alone, not on boot time.
|
||||
- **Raising the container to 0.5 vCPU was not justified by cold start.** The stated
|
||||
reason was the embedding model load; prebaked, that is 173 ms and runs in the
|
||||
background, where only the first *search* could ever wait on it. The extra vCPU may
|
||||
still help concurrent requests, but the cold-start argument does not survive
|
||||
measurement.
|
||||
- **The only real lever is `minReplicas: 1`**, which removes activation entirely at
|
||||
roughly 7× the monthly free consumption grant. Shrinking the image would shave the
|
||||
mount, but that is a fraction of platform time, not the bulk of it.
|
||||
|
||||
# Cross-repo coupling
|
||||
|
||||
**Editing the documentation does not update a running MCP server.** The corpus is
|
||||
baked into the image, so only re-running the *Deploy MCP Server* workflow picks
|
||||
up doc changes.
|
||||
|
||||
That coupling is made checkable rather than memorable: the build passes the docs
|
||||
checkout SHA as `--build-arg DOCS_COMMIT`, and `/health` reports
|
||||
`docs: { commit, chunks, sections }`. Comparing that commit against
|
||||
`git rev-parse HEAD` in `autopilotmonitor-docs` answers "is the deployed
|
||||
documentation stale?" directly.
|
||||
|
||||
Automatic redeploy on a docs push was considered and rejected: it would need a
|
||||
PAT secret in the docs repo and would turn every typo fix into a container
|
||||
deployment.
|
||||
|
||||
# Examples
|
||||
|
||||
Retrieval measured against the real bundle (323 chunks, all-MiniLM-L6-v2):
|
||||
|
||||
```
|
||||
"where is my data stored" → 0.566 troubleshooting/faq.md :: General › Where is my data stored?
|
||||
"how do I deploy the agent with Intune" → 0.819 troubleshooting/faq.md :: Setup & Agent › How do I deploy the agent?
|
||||
"which roles exist and what can they do" → 0.500 trust/security-faq.md :: Identity and Access › What roles exist?
|
||||
"how do I set up Teams notifications" → 0.592 reference/settings.md :: Tenant › Notifications
|
||||
"what firewall URLs do I need to allow" → 0.374 reference/network-endpoints.md :: (intro)
|
||||
```
|
||||
|
||||
The 0.25 `minScore` default is tuned to that observed band — short keyword
|
||||
queries on all-MiniLM score low, and a relevant-but-marginal hit lands around
|
||||
0.34.
|
||||
|
||||
# Citations
|
||||
|
||||
* `/src/McpServer/autopilot-monitor-mcp/src/docs-corpus.ts` — loader, frontmatter
|
||||
parser, chunker
|
||||
* `/src/McpServer/autopilot-monitor-mcp/src/tools/search.ts` — `search_docs`
|
||||
registration
|
||||
* `/src/McpServer/autopilot-monitor-mcp/src/precomputed-index.ts` — `docs`
|
||||
section and its validation asymmetry
|
||||
* `/src/McpServer/autopilot-monitor-mcp/Dockerfile` — `mcp-docs/` copy,
|
||||
`DOCS_COMMIT`
|
||||
* `/.github/workflows/deploy-mcp.yml` — docs checkout and SHA capture
|
||||
* `/src/McpServer/autopilot-monitor-mcp/scripts/eval-docs-search.ts` — retrieval
|
||||
evaluation harness; run it before changing anything about ranking
|
||||
* [MCP OAuth Flow](../mcp-oauth-flow.md) — the other MCP concept document
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Gather Rule Guardrails
|
||||
description: >-
|
||||
How gather-rule collectors are fenced in — the allowlists in rules/guardrails.json,
|
||||
the hard blocks that survive unrestricted mode, and why every collector must call a
|
||||
guard rather than trusting rule.Target.
|
||||
resource: src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather
|
||||
tags: [gather-rules, security, guardrails, agent]
|
||||
timestamp: 2026-07-22
|
||||
---
|
||||
|
||||
# Concept
|
||||
|
||||
A gather rule is authored by a **tenant administrator** through `POST /api/rules/gather` and executed by the agent as **SYSTEM** on an enrolling device. The backend does not validate `collectorType`, `target`, or `parameters` at all — it only enforces scope/emit-mode fields, a 1 MB body cap, and that the rule lands in the caller's own tenant partition. Everything that decides *what a rule may reach* is therefore enforced **on the agent**, in `GatherRuleGuards`.
|
||||
|
||||
This is the security boundary. The portal's `ValidationIndicator` is a convenience: it does not block submit, and the API is reachable directly.
|
||||
|
||||
# Schema
|
||||
|
||||
Allowlists come from `rules/guardrails.json`, embedded into the agent assembly as
|
||||
`AutopilotMonitor.Agent.V2.Core.Resources.guardrails.json` (see the `EmbeddedResource`
|
||||
item in `AutopilotMonitor.Agent.V2.Core.csproj`) and regenerated into the portal's
|
||||
`utils/guardrails.generated.ts` by `node rules/scripts/combine.js`.
|
||||
|
||||
| Guard | Source list | Match rule |
|
||||
| --- | --- | --- |
|
||||
| `IsRegistryPathAllowed` | `registryPrefixes` | prefix, boundary `\` or end |
|
||||
| `IsFilePathAllowed` | `filePrefixes` | `Path.GetFullPath` first, then prefix + boundary |
|
||||
| `IsWmiQueryAllowed` | `wmiQueryPrefixes` | prefix, boundary whitespace or end |
|
||||
| `IsCommandAllowed` | `allowedCommands` | **exact** match, trimmed — never prefix |
|
||||
| `IsEventLogChannelAllowed` | `eventLogChannels` | prefix, boundary `/` or end |
|
||||
|
||||
**Hard blocks live in code, not in the JSON**, so a parse error cannot lift them — on a
|
||||
parse failure the allowlists fall back to empty and everything is blocked (fail-closed),
|
||||
but the hard blocks must hold regardless of configuration:
|
||||
|
||||
* `BlockedUsersPrefix` (`C:\Users`) and `AdditionalHardBlockedPathPrefixes`
|
||||
(`C:\Windows\System32\config`) — exception only for `%LOGGED_ON_USER_PROFILE%`
|
||||
under `AppData\Local` / `AppData\Roaming`.
|
||||
* `HardBlockedCommandPatterns` — download, user/group creation, boot config,
|
||||
persistence, destructive operations; plus `MaxCommandLength`.
|
||||
* `HardBlockedEventLogChannels` — `Security` (audit trail of user behaviour) and the
|
||||
PowerShell channels (script-block logging routinely contains secrets in clear text).
|
||||
|
||||
`rules/guardrails.json` mirrors the blocked lists (`blockedFilePrefixes`,
|
||||
`blockedEventLogChannels`) **for display only**. Enforcement is the C# constant.
|
||||
|
||||
# Examples
|
||||
|
||||
Every collector must call its guard and return `context.EmitSecurityWarning(...)` on
|
||||
refusal — that emits the `security_warning` event that makes a block visible in the
|
||||
timeline instead of failing silently.
|
||||
|
||||
```csharp
|
||||
// FileCollector / JsonCollector / XmlCollector
|
||||
if (!GatherRuleGuards.IsFilePathAllowed(filePath, context.UnrestrictedMode, userProfilePath))
|
||||
return context.EmitSecurityWarning(rule, "file", filePath);
|
||||
```
|
||||
|
||||
Two collectors were added to this contract on 2026-07-22 after shipping without it:
|
||||
|
||||
* **`LogParserCollector`** read any path in `rule.Target`, bypassing even the `C:\Users`
|
||||
hard block. It cannot guard `rule.Target` directly — a wildcard filename makes
|
||||
`Path.GetFullPath` throw — so it guards **the directory** when the filename contains
|
||||
`*`/`?`, and re-guards **each resolved path** after wildcard expansion (a junction can
|
||||
surface a file the directory check did not cover).
|
||||
* **`EventLogCollector`** read any channel, including `Security`.
|
||||
|
||||
`ImeLogPathOverride` comes only from the local `--ime-log-path` CLI flag, never from
|
||||
remote config. The operator using it is already a local admin, so it relaxes the
|
||||
allowlist exactly the way unrestricted mode does — hard blocks still apply.
|
||||
|
||||
# Citations
|
||||
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleGuards.cs`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleContext.cs` — `EmitSecurityWarning`
|
||||
* `src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Gather/GatherRuleCollectorGuardTests.cs`
|
||||
* `src/Backend/AutopilotMonitor.Functions/Functions/Rules/GatherRulesFunction.cs` — what the backend does *not* validate
|
||||
* `rules/guardrails.json`, `rules/scripts/combine.js`
|
||||
* Customer-facing counterpart: `autopilotmonitor-docs/rules/gather-rules.md` (§ Security guardrails)
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: Concept
|
||||
title: Gather Rule Phase Scoping & Emit-on-Change
|
||||
description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase) and how emitMode "on_change" suppresses repeated identical results — the anti-spam pair for interval rules.
|
||||
title: Gather Rule Phase Scoping, One-Shot Triggers & Emit-on-Change
|
||||
description: How gather rules are restricted to enrollment phases (activePhases / activeFromPhase), collect once at a phase boundary (phase_change / phase_exit), and how emitMode "on_change" suppresses repeated identical results.
|
||||
resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/Gather/GatherRuleExecutor.cs
|
||||
tags:
|
||||
- agent
|
||||
@@ -35,6 +35,35 @@ Backend validation (`GatherRulesFunction.ValidateScopeAndEmitMode`) rejects `Unk
|
||||
numeric tokens, unknown emit modes, and both scope fields set at once (400). The agent
|
||||
defensively prefers `activePhases` if both arrive anyway.
|
||||
|
||||
# One-shot collection at phase boundaries
|
||||
|
||||
Scoping answers "while *may* this rule run". The trigger answers "when does it fire". For
|
||||
one-shot collection the two phase triggers are the bookends of a phase:
|
||||
|
||||
| Trigger | Fires |
|
||||
|---|---|
|
||||
| `phase_change` | Once when `triggerPhase` is **entered** (dedup key `{ruleId}\|{phase}`). |
|
||||
| `phase_exit` | Once when `triggerPhase` is **left** (dedup key `exit:{ruleId}\|{phase}`). |
|
||||
|
||||
Empty `triggerPhase` means every transition. Both keys live in the same
|
||||
`_phaseRulesExecuted` set; the `exit:` prefix keeps the two key spaces disjoint, so an
|
||||
enter-rule and an exit-rule on the same phase never cannibalise each other's slot.
|
||||
|
||||
`phase_exit` semantics that are easy to get wrong:
|
||||
|
||||
* It is evaluated **before** `_currentPhase` advances, so both the `triggerPhase` match and
|
||||
the phase-scope gate see the phase being **left**. A rule scoped to `DeviceSetup` therefore
|
||||
still fires on its own exit, even though the phase being entered is outside that scope.
|
||||
* A transition into `Failed` **does** fire the exit rules of the failing phase — capturing
|
||||
state at the failure boundary is the point.
|
||||
* Exit of `Unknown` never fires (nothing was entered yet), and a repeated identical phase
|
||||
signal is not a transition.
|
||||
* An exit rule on `Complete` only fires if something transitions *away* from Complete. For
|
||||
"at the end of enrollment", use `phase_change` on `Complete` or `on_event` with
|
||||
`enrollment_complete`.
|
||||
* Old agents do not know the trigger value and simply never fire the rule (silent no-op) —
|
||||
which is why `phase_exit` needs no ConfigVersion bump: no new field is transported.
|
||||
|
||||
# Scope semantics (agent, `GatherRuleExecutor`)
|
||||
|
||||
* Scope applies to **all trigger types**:
|
||||
@@ -78,6 +107,16 @@ defensively prefers `activePhases` if both arrive anyway.
|
||||
`always`. The scope UI is a three-way mode (always / during phases / from phase) backed by
|
||||
a canonical `GATHER_PHASES` list — the same list replaced the free-text `triggerPhase`
|
||||
input, whose placeholder used to suggest invalid tokens.
|
||||
* **Controls are shown only where they change behavior** (`supportsPhaseScope` /
|
||||
`supportsEmitMode` in `types.ts`). Offering all three "when" controls unconditionally
|
||||
produced meaningless combinations — a scope on a `phase_change` rule naming a concrete
|
||||
phase can only ever suppress the single firing, and `on_change` has no second result to
|
||||
compare against. Hidden controls are nulled by `buildScopeFields` so state from an earlier
|
||||
trigger choice cannot leak into the payload, and `validateScopeSelection` rejects a scope
|
||||
mode that was selected but left unfilled (which previously serialised to null and silently
|
||||
ran the rule everywhere while the form still read "From a phase onwards").
|
||||
Startup is the deliberate exception: a scope there defers the one-shot rather than
|
||||
suppressing it, so the scope control stays while emit mode is hidden.
|
||||
* The custom-rule toggle PUT (`{enabled, isBuiltIn, isCommunity}`) previously full-replaced
|
||||
the stored rule and wiped Title/Target/Trigger — `GatherRuleService.UpdateRuleAsync` now
|
||||
merges toggle-style partials (empty Title/CollectorType/Target) into the existing row and
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
// Autopilot-Monitor MCP Server — Azure Container App Infrastructure
|
||||
//
|
||||
// Reconciled against the live deployment on 2026-07-21 (rg-autopilotmonitor-prd-gwc,
|
||||
// Germany West Central). The template had drifted behind two changes made directly
|
||||
// in Azure: the EU cutover (registry/API renamed) and the ACR hardening (admin user
|
||||
// disabled, image pull moved to the app's system-assigned identity).
|
||||
//
|
||||
// !! BEFORE RE-DEPLOYING THIS TEMPLATE, READ THIS !!
|
||||
// The live app carries an ingress custom-domain binding for mcp.autopilotmonitor.com
|
||||
// (SNI-enabled, with a managed certificate). Custom domains are NOT modelled below,
|
||||
// because the certificate is bound out-of-band. A full template deployment would
|
||||
// therefore REMOVE that binding and take the public MCP endpoint offline.
|
||||
// For routine changes (CPU/memory, env vars, image) prefer the surgical CLI form:
|
||||
// az containerapp update -n autopilotmonitor-mcp -g rg-autopilotmonitor-prd-gwc --cpu 0.5 --memory 1.0Gi
|
||||
// Treat this file as the documented desired state and as a rebuild-from-scratch
|
||||
// recipe — not as a routinely-applied deployment.
|
||||
//
|
||||
// On a FROM-SCRATCH deploy the AcrPull assignment below is created only after the
|
||||
// Container App exists (it needs the app's principalId), so the very first revision
|
||||
// can fail to pull its image. Re-run the deployment, or restart the revision, once
|
||||
// the role assignment has propagated. This does not affect updates to a live app.
|
||||
//
|
||||
// Deploy (two-stage; required to set the OAuth public URL safely):
|
||||
// 1. First deploy without mcpPublicUrl:
|
||||
// az deployment group create \
|
||||
// --resource-group <rg-name> \
|
||||
// --template-file infra/mcp-server.bicep \
|
||||
// --parameters apiUrl=https://autopilotmonitor-api.azurewebsites.net \
|
||||
// entraClientSecret=<secret-value>
|
||||
// This emits `containerAppUrl` as an output (e.g. https://autopilotmonitor-mcp.<env-suffix>.<region>.azurecontainerapps.io).
|
||||
//
|
||||
// 2. Re-deploy with that URL pinned, so the OAuth proxy never trusts caller-
|
||||
// supplied X-Forwarded-Host / Host headers when building issuer / WWW-
|
||||
// Authenticate / redirect_uri metadata:
|
||||
// --parameters mcpPublicUrl=<containerAppUrl from step 1>
|
||||
//
|
||||
// When mcpPublicUrl is set, the MCP server uses it verbatim and ignores any
|
||||
// forwarded-headers from the request. Without it, the server logs a startup
|
||||
// warning and falls back to header-derived URLs (acceptable for local dev,
|
||||
// not for production).
|
||||
|
||||
@description('Azure region for all resources')
|
||||
param location string = resourceGroup().location
|
||||
|
||||
@description('Name of the Container App')
|
||||
param containerAppName string = 'autopilotmonitor-mcp'
|
||||
|
||||
@description('Name of the Container App Environment')
|
||||
param environmentName string = 'autopilotmonitor-env'
|
||||
|
||||
@description('Name of the Log Analytics Workspace (dedicated for Container Apps)')
|
||||
param logAnalyticsName string = 'autopilotmonitor-container-logs'
|
||||
|
||||
@description('Name of the Azure Container Registry')
|
||||
param acrName string = 'autopilotmonitoreuacr'
|
||||
|
||||
@description('Backend API URL')
|
||||
param apiUrl string = 'https://autopilotmonitor-api-eu.azurewebsites.net'
|
||||
|
||||
@description('Container image tag')
|
||||
param imageTag string = 'latest'
|
||||
|
||||
@description('Entra ID app registration client_id used for the OAuth proxy. Public value (appears in JWT aud claim); not a secret.')
|
||||
param entraClientId string = '1a400946-62c1-4ab4-aa37-f730ac89704d'
|
||||
|
||||
@secure()
|
||||
@description('Entra ID client secret for OAuth token exchange')
|
||||
param entraClientSecret string = ''
|
||||
|
||||
@description('Comma-separated allowlist of redirect_uri hosts for the OAuth proxy. Loopback is always allowed. Empty = built-in default (Anthropic, OpenAI, Google).')
|
||||
param mcpAllowedRedirectHosts string = ''
|
||||
|
||||
@description('Public URL of the MCP server, used verbatim for OAuth issuer / WWW-Authenticate / redirect metadata. REQUIRED in production: the container refuses to boot without it (host-spoofing defense — see config.ts). Set on the second deploy with the value of the containerAppUrl output from the first deploy. The stage-1 deploy is safe because minReplicas=0 means no container runs until this is pinned. Empty falls back to X-Forwarded-Host headers only outside production (NODE_ENV != production).')
|
||||
param mcpPublicUrl string = ''
|
||||
|
||||
@secure()
|
||||
// NOTE: avoid characters outside cp1252 here (this one used to say a Unicode
|
||||
// >=). `az bicep build --stdout` writes through the Windows console codec and
|
||||
// dies with a UnicodeEncodeError on anything it cannot map. Em dashes are fine.
|
||||
@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
|
||||
|
||||
// --- Azure Container Registry ---
|
||||
|
||||
// Admin user is DISABLED: a registry-wide username/password pair is a static,
|
||||
// non-rotating credential that would also have to live in a Container App secret.
|
||||
// The app pulls with its system-assigned identity + AcrPull instead (see below).
|
||||
resource acr 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
|
||||
name: acrName
|
||||
location: location
|
||||
sku: {
|
||||
name: 'Basic'
|
||||
}
|
||||
properties: {
|
||||
adminUserEnabled: false
|
||||
}
|
||||
}
|
||||
|
||||
// AcrPull for the Container App's system-assigned identity — this is what replaces
|
||||
// the admin credentials. Without it the app cannot pull its own image.
|
||||
//
|
||||
// NOTE: this assignment exists on the live registry but could not be enumerated
|
||||
// during reconciliation (the CLI session lacks Microsoft.Authorization read), so it
|
||||
// is reproduced here from the documented pattern rather than read back from Azure.
|
||||
// Verify with:
|
||||
// az role assignment list --scope <acr-id> --assignee <containerApp principalId>
|
||||
var acrPullRoleId = '7f951dda-4ed3-4680-a7ca-43fe172d538d'
|
||||
|
||||
resource acrPull 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
|
||||
scope: acr
|
||||
name: guid(acr.id, containerAppName, acrPullRoleId)
|
||||
properties: {
|
||||
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', acrPullRoleId)
|
||||
principalId: containerApp.identity.principalId
|
||||
principalType: 'ServicePrincipal'
|
||||
}
|
||||
}
|
||||
|
||||
// --- Log Analytics Workspace (required by Container App Environment) ---
|
||||
|
||||
resource logAnalytics 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
|
||||
name: logAnalyticsName
|
||||
location: location
|
||||
properties: {
|
||||
sku: {
|
||||
name: 'PerGB2018'
|
||||
}
|
||||
retentionInDays: 30
|
||||
}
|
||||
}
|
||||
|
||||
// --- Container App Environment ---
|
||||
|
||||
resource environment 'Microsoft.App/managedEnvironments@2024-03-01' = {
|
||||
name: environmentName
|
||||
location: location
|
||||
properties: {
|
||||
appLogsConfiguration: {
|
||||
destination: 'log-analytics'
|
||||
logAnalyticsConfiguration: {
|
||||
customerId: logAnalytics.properties.customerId
|
||||
sharedKey: logAnalytics.listKeys().primarySharedKey
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Container App ---
|
||||
|
||||
resource containerApp 'Microsoft.App/containerApps@2024-03-01' = {
|
||||
name: containerAppName
|
||||
location: location
|
||||
// System-assigned identity: the credential the app uses to pull from the ACR
|
||||
// (see the AcrPull assignment above). Replaces the former admin-user secret.
|
||||
identity: {
|
||||
type: 'SystemAssigned'
|
||||
}
|
||||
properties: {
|
||||
managedEnvironmentId: environment.id
|
||||
configuration: {
|
||||
ingress: {
|
||||
external: true
|
||||
targetPort: 8080
|
||||
transport: 'auto'
|
||||
allowInsecure: false
|
||||
}
|
||||
// 'system' = pull with the app's system-assigned identity. No username, no
|
||||
// password secret — nothing to rotate and nothing to leak from the app config.
|
||||
registries: [
|
||||
{
|
||||
server: acr.properties.loginServer
|
||||
identity: 'system'
|
||||
}
|
||||
]
|
||||
secrets: [
|
||||
{
|
||||
name: 'entra-client-secret'
|
||||
value: entraClientSecret
|
||||
}
|
||||
{
|
||||
name: 'oauth-state-signing-key'
|
||||
value: oAuthStateSigningKey
|
||||
}
|
||||
]
|
||||
activeRevisionsMode: 'Single'
|
||||
}
|
||||
template: {
|
||||
containers: [
|
||||
{
|
||||
name: 'mcp-server'
|
||||
image: '${acr.properties.loginServer}/${containerAppName}:${imageTag}'
|
||||
// 0.5 vCPU, but NOT for the reason originally given. It was raised from
|
||||
// 0.25 to shorten the scale-to-zero cold start, on the assumption that
|
||||
// loading the ONNX embedding model dominated it. Measurement disproved
|
||||
// that: the app boots in 504 ms in production (see the [boot] marks in
|
||||
// the container log and docs/mcp/docs-corpus.md), of which the prebaked
|
||||
// embedder is 173 ms in the background. Of a 31 s cold start, ~98% is
|
||||
// Container Apps activation — pod scheduling, image mount, container
|
||||
// start — which no CPU setting touches.
|
||||
//
|
||||
// It stays at 0.5 because it plausibly helps CONCURRENT requests
|
||||
// (maxReplicas is 1, so one replica serves up to 10 in-flight calls) and
|
||||
// because at scale-to-zero the container bills only while active, making
|
||||
// the difference negligible. If cold start ever needs to actually go
|
||||
// away, the only lever is minReplicas: 1. Container Apps accepts fixed
|
||||
// CPU/memory pairs, so memory moves with the CPU.
|
||||
resources: {
|
||||
cpu: json('0.5')
|
||||
memory: '1.0Gi'
|
||||
}
|
||||
env: [
|
||||
{ name: 'AUTOPILOT_API_URL', value: apiUrl }
|
||||
{ name: 'PORT', value: '8080' }
|
||||
{ name: 'AUTOPILOT_ENTRA_CLIENT_ID', value: entraClientId }
|
||||
{ name: 'AUTOPILOT_ENTRA_CLIENT_SECRET', secretRef: 'entra-client-secret' }
|
||||
{ name: 'MCP_ALLOWED_REDIRECT_HOSTS', value: mcpAllowedRedirectHosts }
|
||||
{ name: 'MCP_PUBLIC_URL', value: mcpPublicUrl }
|
||||
{ name: 'OAuthStateSigningKey', secretRef: 'oauth-state-signing-key' }
|
||||
{ name: 'MCP_TOOL_LOGGING', value: toolLoggingEnabled ? 'true' : 'false' }
|
||||
]
|
||||
probes: [
|
||||
{
|
||||
type: 'Liveness'
|
||||
httpGet: {
|
||||
path: '/health'
|
||||
port: 8080
|
||||
}
|
||||
periodSeconds: 30
|
||||
initialDelaySeconds: 10
|
||||
}
|
||||
{
|
||||
type: 'Readiness'
|
||||
httpGet: {
|
||||
path: '/health'
|
||||
port: 8080
|
||||
}
|
||||
periodSeconds: 10
|
||||
initialDelaySeconds: 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
scale: {
|
||||
minReplicas: 0
|
||||
maxReplicas: 1
|
||||
rules: [
|
||||
{
|
||||
name: 'http-scaler'
|
||||
http: {
|
||||
metadata: {
|
||||
concurrentRequests: '10'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Outputs ---
|
||||
|
||||
output containerAppUrl string = 'https://${containerApp.properties.configuration.ingress.fqdn}'
|
||||
output mcpEndpoint string = 'https://${containerApp.properties.configuration.ingress.fqdn}/mcp'
|
||||
output acrLoginServer string = acr.properties.loginServer
|
||||
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ESP-005",
|
||||
"title": "MDM Policy Forced a Mid-ESP Reboot (Second Sign-In)",
|
||||
"description": "Detects the 'unexpected reboot + second sign-in screen' pattern: one or more device-assigned MDM policies matched the OS reboot-required catalog during ESP DeviceSetup, and Windows coalesced them into a restart at the end of DeviceSetup — forcing the user through a second sign-in before AccountSetup. Attributes the reboot to the specific CSP policy URIs so the profiles can be reassigned.",
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
@@ -15,37 +15,48 @@
|
||||
"signal": "reboot_forcing_policy_uri",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "rebootUri",
|
||||
"dataField": "firstRebootUri",
|
||||
"operator": "exists",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "reboot_uri_count",
|
||||
"signal": "reboot_observed",
|
||||
"source": "event_count",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"eventType": "system_reboot_detected",
|
||||
"operator": "count_gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "multiple_reboot_uris",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "uriCount",
|
||||
"operator": "gte",
|
||||
"value": "2",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "esp_coalesced_reboot_observed",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_reboot_coalescing",
|
||||
"operator": "exists",
|
||||
"signal": "omadm_reboot_flag_set",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "omadmRebootRequiredFlag",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{ "signal": "esp_coalesced_reboot_observed", "condition": "exists", "weight": 20 },
|
||||
{ "signal": "mdm_policy_reboot_required", "condition": "count >= 2", "weight": 10 }
|
||||
{ "signal": "multiple_reboot_uris", "condition": "exists", "weight": 10 },
|
||||
{ "signal": "omadm_reboot_flag_set", "condition": "exists", "weight": 15 }
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "During ESP DeviceSetup, a device-assigned MDM policy matched the OS reboot-required catalog and Windows scheduled a **coalesced reboot** at the end of DeviceSetup — the user saw an unexpected restart and a **second sign-in screen** before AccountSetup continued.\n\nFirst reboot-forcing policy URI: `{{rebootUri}}`\n\nWhen several policies force a reboot they are coalesced into one restart; **each** reboot-forcing policy appears as its own `mdm_policy_reboot_required` event on the session timeline — check the timeline for the complete URI list.\n\nThis is expected OS behavior, not a failure — but it degrades the enrollment experience, confuses users, and is invisible in the Intune console.",
|
||||
"explanation": "This session **rebooted during enrollment**, and Windows flagged one or more device-assigned MDM policies as **reboot-required** — the classic cause of the coalesced mid-ESP restart and the unexpected **second sign-in screen** before AccountSetup.\n\nFirst reboot-forcing policy URI: `{{firstRebootUri}}`\n\nThe complete URI list is in the `mdm_policy_reboot_required` timeline event (`rebootUris`). Policies applied DURING ESP DeviceSetup are coalesced into one forced restart at the end of DeviceSetup; the same flags raised later (e.g. user-policy sync in AccountSetup) have no immediate effect — this advisory only fires when a reboot was actually observed.\n\nThis is expected OS behavior, not a failure — but it degrades the enrollment experience, confuses users, and is invisible in the Intune console.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Reassign the reboot-forcing policies to user groups",
|
||||
"steps": [
|
||||
"Open each `mdm_policy_reboot_required` timeline event and note the CSP URI (e.g. `./Device/Vendor/MSFT/...`)",
|
||||
"Open the `mdm_policy_reboot_required` timeline event and note the CSP URIs in `rebootUris` (e.g. `./Device/Vendor/MSFT/...`)",
|
||||
"Map each URI to its Intune profile (Settings Catalog / configuration profile) — the URI path names the CSP area",
|
||||
"Reassign those profiles from device groups to user groups so they apply during/after AccountSetup instead of DeviceSetup",
|
||||
"Alternatively, exclude the Autopilot device group from the profile and deliver the setting post-enrollment"
|
||||
|
||||
+27
-16
@@ -1788,10 +1788,10 @@
|
||||
{
|
||||
"ruleId": "ANALYZE-ESP-005",
|
||||
"title": "MDM Policy Forced a Mid-ESP Reboot (Second Sign-In)",
|
||||
"description": "Detects the 'unexpected reboot + second sign-in screen' pattern: one or more device-assigned MDM policies matched the OS reboot-required catalog during ESP DeviceSetup, and Windows coalesced them into a restart at the end of DeviceSetup — forcing the user through a second sign-in before AccountSetup. Attributes the reboot to the specific CSP policy URIs so the profiles can be reassigned.",
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "single",
|
||||
@@ -1801,45 +1801,56 @@
|
||||
"signal": "reboot_forcing_policy_uri",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "rebootUri",
|
||||
"dataField": "firstRebootUri",
|
||||
"operator": "exists",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "reboot_uri_count",
|
||||
"signal": "reboot_observed",
|
||||
"source": "event_count",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"eventType": "system_reboot_detected",
|
||||
"operator": "count_gte",
|
||||
"value": "1",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "multiple_reboot_uris",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "uriCount",
|
||||
"operator": "gte",
|
||||
"value": "2",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "esp_coalesced_reboot_observed",
|
||||
"source": "event_type",
|
||||
"eventType": "esp_reboot_coalescing",
|
||||
"operator": "exists",
|
||||
"signal": "omadm_reboot_flag_set",
|
||||
"source": "event_data",
|
||||
"eventType": "mdm_policy_reboot_required",
|
||||
"dataField": "omadmRebootRequiredFlag",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "esp_coalesced_reboot_observed",
|
||||
"signal": "multiple_reboot_uris",
|
||||
"condition": "exists",
|
||||
"weight": 20
|
||||
"weight": 10
|
||||
},
|
||||
{
|
||||
"signal": "mdm_policy_reboot_required",
|
||||
"condition": "count >= 2",
|
||||
"weight": 10
|
||||
"signal": "omadm_reboot_flag_set",
|
||||
"condition": "exists",
|
||||
"weight": 15
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "During ESP DeviceSetup, a device-assigned MDM policy matched the OS reboot-required catalog and Windows scheduled a **coalesced reboot** at the end of DeviceSetup — the user saw an unexpected restart and a **second sign-in screen** before AccountSetup continued.\n\nFirst reboot-forcing policy URI: `{{rebootUri}}`\n\nWhen several policies force a reboot they are coalesced into one restart; **each** reboot-forcing policy appears as its own `mdm_policy_reboot_required` event on the session timeline — check the timeline for the complete URI list.\n\nThis is expected OS behavior, not a failure — but it degrades the enrollment experience, confuses users, and is invisible in the Intune console.",
|
||||
"explanation": "This session **rebooted during enrollment**, and Windows flagged one or more device-assigned MDM policies as **reboot-required** — the classic cause of the coalesced mid-ESP restart and the unexpected **second sign-in screen** before AccountSetup.\n\nFirst reboot-forcing policy URI: `{{firstRebootUri}}`\n\nThe complete URI list is in the `mdm_policy_reboot_required` timeline event (`rebootUris`). Policies applied DURING ESP DeviceSetup are coalesced into one forced restart at the end of DeviceSetup; the same flags raised later (e.g. user-policy sync in AccountSetup) have no immediate effect — this advisory only fires when a reboot was actually observed.\n\nThis is expected OS behavior, not a failure — but it degrades the enrollment experience, confuses users, and is invisible in the Intune console.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Reassign the reboot-forcing policies to user groups",
|
||||
"steps": [
|
||||
"Open each `mdm_policy_reboot_required` timeline event and note the CSP URI (e.g. `./Device/Vendor/MSFT/...`)",
|
||||
"Open the `mdm_policy_reboot_required` timeline event and note the CSP URIs in `rebootUris` (e.g. `./Device/Vendor/MSFT/...`)",
|
||||
"Map each URI to its Intune profile (Settings Catalog / configuration profile) — the URI path names the CSP area",
|
||||
"Reassign those profiles from device groups to user groups so they apply during/after AccountSetup instead of DeviceSetup",
|
||||
"Alternatively, exclude the Autopilot device group from the profile and deliver the setting post-enrollment"
|
||||
|
||||
@@ -109,6 +109,79 @@
|
||||
"SELECT * FROM Win32_LogicalDisk",
|
||||
"SELECT * FROM SoftwareLicensingProduct"
|
||||
],
|
||||
"eventLogChannels": [
|
||||
{
|
||||
"category": "Core Windows logs",
|
||||
"channels": [
|
||||
"Application",
|
||||
"System",
|
||||
"Setup",
|
||||
"Microsoft-Windows-Kernel-Boot",
|
||||
"Microsoft-Windows-Diagnostics-Performance"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "MDM / Enrollment",
|
||||
"channels": [
|
||||
"Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider",
|
||||
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider",
|
||||
"Microsoft-Windows-Provisioning-Diagnostics-Provider",
|
||||
"Microsoft-Windows-AAD",
|
||||
"Microsoft-Windows-User Device Registration"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "ESP / Shell / Apps",
|
||||
"channels": [
|
||||
"Microsoft-Windows-Shell-Core",
|
||||
"Microsoft-Windows-AppXDeployment",
|
||||
"Microsoft-Windows-AppXDeploymentServer",
|
||||
"Microsoft-Windows-AppReadiness",
|
||||
"Microsoft-Windows-Store"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Security posture / Crypto",
|
||||
"channels": [
|
||||
"Microsoft-Windows-BitLocker-API",
|
||||
"Microsoft-Windows-BitLocker-DrivePreparationTool",
|
||||
"Microsoft-Windows-TPM-WMI",
|
||||
"Microsoft-Windows-CertificateServicesClient-Lifecycle-System"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Update / Servicing",
|
||||
"channels": [
|
||||
"Microsoft-Windows-WindowsUpdateClient"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Logon / Session",
|
||||
"channels": [
|
||||
"Microsoft-Windows-Winlogon",
|
||||
"Microsoft-Windows-User Profile Service",
|
||||
"Microsoft-Windows-GroupPolicy",
|
||||
"Microsoft-Windows-TaskScheduler"
|
||||
]
|
||||
},
|
||||
{
|
||||
"category": "Network",
|
||||
"channels": [
|
||||
"Microsoft-Windows-NetworkProfile",
|
||||
"Microsoft-Windows-Dhcp-Client",
|
||||
"Microsoft-Windows-DNS-Client",
|
||||
"Microsoft-Windows-NCSI",
|
||||
"Microsoft-Windows-WLAN-AutoConfig",
|
||||
"Microsoft-Windows-Time-Service"
|
||||
]
|
||||
}
|
||||
],
|
||||
"blockedEventLogChannels": [
|
||||
"Security",
|
||||
"Microsoft-Windows-PowerShell",
|
||||
"Windows PowerShell",
|
||||
"Microsoft-Windows-Sysmon"
|
||||
],
|
||||
"allowedCommands": [
|
||||
{
|
||||
"category": "TPM and Security",
|
||||
|
||||
@@ -80,8 +80,8 @@
|
||||
},
|
||||
"trigger": {
|
||||
"type": "string",
|
||||
"description": "When to collect data.",
|
||||
"enum": ["startup", "phase_change", "interval", "on_event"]
|
||||
"description": "When to collect data. \"phase_change\" fires once when triggerPhase is entered, \"phase_exit\" once when it is left.",
|
||||
"enum": ["startup", "phase_change", "phase_exit", "interval", "on_event"]
|
||||
},
|
||||
"intervalSeconds": {
|
||||
"type": "integer",
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
"triggerPhase": {
|
||||
"type": "string",
|
||||
"description": "Phase to trigger on (only used when trigger = \"phase_change\")."
|
||||
"description": "Phase to trigger on (used when trigger = \"phase_change\" or \"phase_exit\"). Empty = every phase transition."
|
||||
},
|
||||
"triggerEventType": {
|
||||
"type": "string",
|
||||
|
||||
@@ -91,6 +91,7 @@ if (fs.existsSync(guardrailsPath)) {
|
||||
// Flatten categorized lists for validation
|
||||
const flatRegistryPrefixes = guardrails.registryPrefixes.flatMap((g) => g.prefixes);
|
||||
const flatCommands = guardrails.allowedCommands.flatMap((g) => g.commands);
|
||||
const flatEventLogChannels = guardrails.eventLogChannels.flatMap((g) => g.channels);
|
||||
|
||||
const ts = `/**
|
||||
* AUTO-GENERATED from rules/guardrails.json — DO NOT EDIT.
|
||||
@@ -114,6 +115,10 @@ export const COMMAND_CATEGORIES: readonly GuardrailCategory[] = [
|
||||
${categorizedArray(guardrails.allowedCommands, 'commands')}
|
||||
];
|
||||
|
||||
export const EVENT_LOG_CHANNEL_CATEGORIES: readonly GuardrailCategory[] = [
|
||||
${categorizedArray(guardrails.eventLogChannels, 'channels')}
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flat arrays (for validation logic)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -141,10 +146,18 @@ ${flatArray(guardrails.diagnosticsPathPrefixes)}
|
||||
export const BLOCKED_FILE_PREFIXES: readonly string[] = [
|
||||
${flatArray(guardrails.blockedFilePrefixes)}
|
||||
];
|
||||
|
||||
export const ALLOWED_EVENT_LOG_CHANNELS: readonly string[] = [
|
||||
${flatArray(flatEventLogChannels)}
|
||||
];
|
||||
|
||||
export const BLOCKED_EVENT_LOG_CHANNELS: readonly string[] = [
|
||||
${flatArray(guardrails.blockedEventLogChannels)}
|
||||
];
|
||||
`;
|
||||
|
||||
fs.writeFileSync(guardrailsOutput, ts, 'utf8');
|
||||
console.log(`guardrails.generated.ts: ${flatRegistryPrefixes.length} registry, ${flatCommands.length} commands, ${guardrails.filePrefixes.length} file prefixes`);
|
||||
console.log(`guardrails.generated.ts: ${flatRegistryPrefixes.length} registry, ${flatCommands.length} commands, ${guardrails.filePrefixes.length} file prefixes, ${flatEventLogChannels.length} event log channels`);
|
||||
} else {
|
||||
console.warn('guardrails.json not found — skipping guardrails generation');
|
||||
}
|
||||
|
||||
@@ -56,6 +56,10 @@
|
||||
PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars.
|
||||
|
||||
.CHANGELOG
|
||||
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
|
||||
@@ -80,7 +84,7 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip",
|
||||
[string]$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$VersionJsonName = "version.json",
|
||||
@@ -93,7 +97,7 @@ param(
|
||||
)
|
||||
|
||||
# Script version (bump on meaningful changes; see .CHANGELOG above)
|
||||
$ScriptVersion = "2.1"
|
||||
$ScriptVersion = "2.2"
|
||||
|
||||
# Configuration - Everything in ProgramData for easy cleanup
|
||||
$AgentBasePath = "$env:ProgramData\AutopilotMonitor"
|
||||
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// Allowlist enforcement for the two collectors that used to bypass it:
|
||||
/// <see cref="LogParserCollector"/> read any path the rule named — including
|
||||
/// C:\Users, whose hard block every other file collector honours — and
|
||||
/// <see cref="EventLogCollector"/> read any channel, including Security.
|
||||
/// A tenant admin can author both through the rules API, so the agent is the
|
||||
/// only place this can be enforced.
|
||||
/// </summary>
|
||||
public sealed class GatherRuleCollectorGuardTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
|
||||
// %TEMP% itself lives under C:\Users, which is hard-blocked — the positive
|
||||
// cases need a directory the guard can actually admit, so they run beside
|
||||
// the test assembly instead.
|
||||
private readonly string _outsideUsers = Path.Combine(
|
||||
AppContext.BaseDirectory, "guard-tests-" + Guid.NewGuid().ToString("N"));
|
||||
|
||||
public GatherRuleCollectorGuardTests() => Directory.CreateDirectory(_outsideUsers);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_tmp.Dispose();
|
||||
try { Directory.Delete(_outsideUsers, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
|
||||
private GatherRuleContext Context(bool unrestricted = false, string imeLogPathOverride = null)
|
||||
=> new GatherRuleContext(
|
||||
new AgentLogger(_tmp.Path, AgentLogLevel.Info),
|
||||
"sess", "tenant",
|
||||
evt => _events.Add(evt),
|
||||
imeLogPathOverride,
|
||||
new LogFilePositionTracker())
|
||||
{
|
||||
UnrestrictedMode = unrestricted
|
||||
};
|
||||
|
||||
private static GatherRule Rule(string collectorType, string target,
|
||||
Dictionary<string, string> parameters = null) => new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-GUARD-TEST",
|
||||
Title = "guard test",
|
||||
CollectorType = collectorType,
|
||||
Target = target,
|
||||
Parameters = parameters,
|
||||
Trigger = "startup",
|
||||
OutputEventType = "guard_test",
|
||||
};
|
||||
|
||||
private EnrollmentEvent SingleSecurityWarning()
|
||||
=> Assert.Single(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Event log channel allowlist
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("System")]
|
||||
[InlineData("Application")]
|
||||
[InlineData("Setup")]
|
||||
[InlineData("Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin")]
|
||||
[InlineData("Microsoft-Windows-AAD/Operational")]
|
||||
[InlineData("Microsoft-Windows-Shell-Core/Operational")]
|
||||
public void EventLogChannel_OnAllowlist_IsAllowed(string channel)
|
||||
=> Assert.True(GatherRuleGuards.IsEventLogChannelAllowed(channel));
|
||||
|
||||
[Theory]
|
||||
[InlineData("Security")]
|
||||
[InlineData("Microsoft-Windows-PowerShell/Operational")]
|
||||
[InlineData("Windows PowerShell")]
|
||||
[InlineData("Microsoft-Windows-Sysmon/Operational")]
|
||||
public void EventLogChannel_HardBlocked_StaysBlockedEvenUnrestricted(string channel)
|
||||
{
|
||||
Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(channel));
|
||||
Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(channel, unrestrictedMode: true));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Microsoft-Windows-AADSomethingElse/Operational")] // prefix spoofing
|
||||
[InlineData("Microsoft-Windows-TerminalServices-LocalSessionManager/Operational")]
|
||||
[InlineData("")]
|
||||
public void EventLogChannel_OffAllowlist_IsBlocked(string channel)
|
||||
=> Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(channel));
|
||||
|
||||
[Fact]
|
||||
public void EventLogChannel_Null_IsBlocked()
|
||||
=> Assert.False(GatherRuleGuards.IsEventLogChannelAllowed(null));
|
||||
|
||||
[Fact]
|
||||
public void EventLogChannel_OffAllowlist_IsAllowedInUnrestrictedMode()
|
||||
=> Assert.True(GatherRuleGuards.IsEventLogChannelAllowed(
|
||||
"Microsoft-Windows-TerminalServices-LocalSessionManager/Operational",
|
||||
unrestrictedMode: true));
|
||||
|
||||
[Fact]
|
||||
public void EventLogCollector_SecurityChannel_EmitsSecurityWarningAndReturnsNoData()
|
||||
{
|
||||
var result = new EventLogCollector().Execute(Rule("eventlog", "Security"), Context());
|
||||
|
||||
Assert.Empty(result);
|
||||
var warning = SingleSecurityWarning();
|
||||
Assert.Equal(true, warning.Data["blocked"]);
|
||||
Assert.Equal("Security", warning.Data["target"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EventLogCollector_SecurityChannel_StaysBlockedInUnrestrictedMode()
|
||||
{
|
||||
var result = new EventLogCollector().Execute(
|
||||
Rule("eventlog", "Security"), Context(unrestricted: true));
|
||||
|
||||
Assert.Empty(result);
|
||||
SingleSecurityWarning();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Log parser file allowlist
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private static Dictionary<string, string> TextPattern() => new Dictionary<string, string>
|
||||
{
|
||||
["pattern"] = "(?<line>.+)",
|
||||
["format"] = "text",
|
||||
["trackPosition"] = "false",
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void LogParser_UserProfilePath_IsBlockedAndEmitsSecurityWarning()
|
||||
{
|
||||
const string target = @"C:\Users\Public\Documents\secret.txt";
|
||||
|
||||
var result = new LogParserCollector().Execute(
|
||||
Rule("logparser", target, TextPattern()), Context());
|
||||
|
||||
Assert.Null(result);
|
||||
var warning = SingleSecurityWarning();
|
||||
Assert.Equal(target, warning.Data["target"]);
|
||||
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_UserProfilePath_StaysBlockedInUnrestrictedMode()
|
||||
{
|
||||
// C:\Users is a hard block — unrestricted mode must not lift it.
|
||||
new LogParserCollector().Execute(
|
||||
Rule("logparser", @"C:\Users\Public\Documents\secret.txt", TextPattern()),
|
||||
Context(unrestricted: true));
|
||||
|
||||
SingleSecurityWarning();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_OffAllowlistPath_IsBlockedEvenWhenFileExists()
|
||||
{
|
||||
var path = _tmp.File("evidence.log");
|
||||
File.WriteAllText(path, "line one\nline two\n");
|
||||
|
||||
var result = new LogParserCollector().Execute(
|
||||
Rule("logparser", path, TextPattern()), Context());
|
||||
|
||||
Assert.Null(result);
|
||||
SingleSecurityWarning();
|
||||
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_WildcardTarget_IsBlockedByItsDirectory()
|
||||
{
|
||||
// Path.GetFullPath rejects '*', so the guard has to check the directory.
|
||||
File.WriteAllText(_tmp.File("evidence.log"), "line one\n");
|
||||
|
||||
new LogParserCollector().Execute(
|
||||
Rule("logparser", Path.Combine(_tmp.Path, "*.log"), TextPattern()), Context());
|
||||
|
||||
SingleSecurityWarning();
|
||||
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_UnrestrictedMode_ReadsPathOffTheAllowlist()
|
||||
{
|
||||
var path = Path.Combine(_outsideUsers, "evidence.log");
|
||||
File.WriteAllText(path, "line one\nline two\n");
|
||||
|
||||
new LogParserCollector().Execute(
|
||||
Rule("logparser", path, TextPattern()), Context(unrestricted: true));
|
||||
|
||||
Assert.DoesNotContain(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
|
||||
Assert.Equal(2, _events.Count(e => e.EventType == "guard_test"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_LocalImeLogPathOverride_RelaxesTheAllowlist()
|
||||
{
|
||||
// --ime-log-path is a local CLI flag, never remote config: the operator
|
||||
// using it is already a local admin, so it relaxes the allowlist the way
|
||||
// unrestricted mode does.
|
||||
File.WriteAllText(Path.Combine(_outsideUsers, "AppWorkload.log"), "line one\n");
|
||||
|
||||
new LogParserCollector().Execute(
|
||||
Rule("logparser", @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log",
|
||||
TextPattern()),
|
||||
Context(imeLogPathOverride: _outsideUsers));
|
||||
|
||||
Assert.DoesNotContain(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
|
||||
Assert.Single(_events, e => e.EventType == "guard_test");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Command execution
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void Command_OffAllowlist_IsBlockedAndNeverRuns()
|
||||
{
|
||||
var result = new CommandCollector().Execute(
|
||||
Rule("command_allowlisted", "Get-Content C:\\Windows\\System32\\config\\SAM"), Context());
|
||||
|
||||
Assert.Equal(true, result["blocked"]);
|
||||
Assert.False(result.ContainsKey("output"));
|
||||
SingleSecurityWarning();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Command_HardBlockedPattern_StaysBlockedInUnrestrictedMode()
|
||||
{
|
||||
var result = new CommandCollector().Execute(
|
||||
Rule("command_allowlisted", "Invoke-WebRequest https://example.invalid/x"),
|
||||
Context(unrestricted: true));
|
||||
|
||||
Assert.Equal(true, result["blocked"]);
|
||||
SingleSecurityWarning();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Command_ThatHangs_IsKilledAtTheTimeoutRatherThanPinningTheWorker()
|
||||
{
|
||||
// Regression: reading stdout synchronously blocked until the pipe closed,
|
||||
// so WaitForExit's timeout was never reached and a hung command pinned the
|
||||
// worker indefinitely. Unrestricted mode is needed to run an ad-hoc command.
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var result = new CommandCollector().Execute(
|
||||
Rule("command_allowlisted", "Start-Sleep -Seconds 120"), Context(unrestricted: true));
|
||||
sw.Stop();
|
||||
|
||||
Assert.Equal(true, result["timed_out"]);
|
||||
Assert.Equal(-1, result["exit_code"]);
|
||||
// The 30s timeout plus drain margin — nowhere near the command's 120s.
|
||||
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(60),
|
||||
$"expected the hung command to be killed at the timeout; took {sw.Elapsed}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LogParser_ImeLogPathOverride_StillHonoursTheUsersHardBlock()
|
||||
{
|
||||
new LogParserCollector().Execute(
|
||||
Rule("logparser", @"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\AppWorkload.log",
|
||||
TextPattern()),
|
||||
Context(imeLogPathOverride: @"C:\Users\Public\Documents"));
|
||||
|
||||
SingleSecurityWarning();
|
||||
}
|
||||
}
|
||||
}
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Gather
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>phase_exit</c> trigger: a one-shot collection when a phase is LEFT — the closing
|
||||
/// bookend to <c>phase_change</c> (which fires on ENTER). The distinguishing invariant is that
|
||||
/// both the TriggerPhase match and the phase-scope gate must evaluate against the phase being
|
||||
/// left, i.e. before the executor's current-phase state moves forward.
|
||||
/// Rules use the absent-registry-path pattern so no real system state is touched.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // rules execute on the shared ThreadPool
|
||||
public sealed class GatherRuleExecutorPhaseExitTests : IDisposable
|
||||
{
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly List<EnrollmentEvent> _events = new List<EnrollmentEvent>();
|
||||
private readonly object _eventsGate = new object();
|
||||
private readonly GatherRuleExecutor _executor;
|
||||
|
||||
private const string AbsentPath =
|
||||
"HKLM\\SOFTWARE\\AutopilotMonitorTests\\DefinitelyAbsent_9c4d2a7f-0000-0000-0000-000000000000";
|
||||
|
||||
public GatherRuleExecutorPhaseExitTests()
|
||||
{
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_executor = new GatherRuleExecutor(
|
||||
"sess", "tenant",
|
||||
evt => { lock (_eventsGate) _events.Add(evt); },
|
||||
logger)
|
||||
{
|
||||
UnrestrictedMode = true
|
||||
};
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_executor.Dispose();
|
||||
_tmp.Dispose();
|
||||
}
|
||||
|
||||
private int EventCount { get { lock (_eventsGate) return _events.Count; } }
|
||||
|
||||
private bool WaitForEventCount(int count, int timeoutMs = 10000)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
if (EventCount >= count) return true;
|
||||
Thread.Sleep(25);
|
||||
}
|
||||
return EventCount >= count;
|
||||
}
|
||||
|
||||
private static GatherRule ExitRule(
|
||||
string id, string triggerPhase, List<string> activePhases = null, string fromPhase = null)
|
||||
=> new GatherRule
|
||||
{
|
||||
RuleId = id,
|
||||
Title = id,
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "phase_exit",
|
||||
TriggerPhase = triggerPhase,
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
ActivePhases = activePhases,
|
||||
ActiveFromPhase = fromPhase,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void FiresOnceWhenTheNamedPhaseIsLeft_NotWhenItIsEntered()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-001", "AccountSetup") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
// Entering AccountSetup must NOT fire an exit rule.
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
// Leaving it does.
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Assert.True(WaitForEventCount(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DoesNotRefireWhenThePhaseIsLeftTwice()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-002", "AccountSetup") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Assert.True(WaitForEventCount(1));
|
||||
|
||||
// A phase can be re-entered (ESP re-reporting); the one-shot slot stays consumed.
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Thread.Sleep(400);
|
||||
Assert.Equal(1, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EmptyTriggerPhase_FiresOnEveryExit()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-003", "") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup); // exit of Unknown — must NOT fire
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup
|
||||
Assert.True(WaitForEventCount(1));
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup
|
||||
Assert.True(WaitForEventCount(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeverFiresOnExitOfUnknown()
|
||||
{
|
||||
// Before the first phase signal there is no phase to leave.
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-004", "") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.Start);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RepeatedSamePhaseSignal_IsNotAnExit()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-005", "") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // same phase — no transition
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiresOnTransitionIntoFailed_CapturingStateAtTheFailurePoint()
|
||||
{
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-006", "AccountSetup") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.Failed);
|
||||
|
||||
Assert.True(WaitForEventCount(1), "leaving a phase into Failed must still snapshot it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FiresOnExitOfCompleteOnly_WhenCompleteIsActuallyLeft()
|
||||
{
|
||||
// "End of enrollment" is better served by phase_change on Complete / on_event; an exit
|
||||
// rule on Complete only fires if a later transition happens at all.
|
||||
_executor.UpdateRules(new List<GatherRule> { ExitRule("GATHER-PX-007", "Complete") });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.FinalizingSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.Complete);
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ScopeGate_EvaluatesAgainstThePhaseBeingLeft()
|
||||
{
|
||||
// Scope = only during DeviceSetup. Leaving DeviceSetup must fire (the rule is still in
|
||||
// scope at that instant) even though the NEW phase is outside the scope.
|
||||
_executor.UpdateRules(new List<GatherRule>
|
||||
{
|
||||
ExitRule("GATHER-PX-010", "DeviceSetup", activePhases: new List<string> { "DeviceSetup" })
|
||||
});
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
|
||||
Assert.True(WaitForEventCount(1),
|
||||
"the scope gate must see the phase being left, not the one being entered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OutOfScopeExit_DoesNotFire_AndDoesNotConsumeTheOneShotSlot()
|
||||
{
|
||||
// Scope = only during AccountSetup; the rule exits DeviceSetup while out of scope.
|
||||
var rule = ExitRule("GATHER-PX-011", "", activePhases: new List<string> { "AccountSetup" });
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.DeviceSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup); // exit of DeviceSetup: out of scope
|
||||
Thread.Sleep(300);
|
||||
Assert.Equal(0, EventCount);
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser); // exit of AccountSetup: in scope
|
||||
Assert.True(WaitForEventCount(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitAndChangeRulesForTheSamePhase_BothFireIndependently()
|
||||
{
|
||||
// The dedup key spaces must not collide: same ruleId shape, same phase, different trigger.
|
||||
var enter = new GatherRule
|
||||
{
|
||||
RuleId = "GATHER-PX-020",
|
||||
Title = "enter",
|
||||
CollectorType = "registry",
|
||||
Target = AbsentPath,
|
||||
Trigger = "phase_change",
|
||||
TriggerPhase = "AccountSetup",
|
||||
OutputEventType = "gather_test",
|
||||
Enabled = true,
|
||||
};
|
||||
var exit = ExitRule("GATHER-PX-021", "AccountSetup");
|
||||
_executor.UpdateRules(new List<GatherRule> { enter, exit });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
Assert.True(WaitForEventCount(1)); // enter fired
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
Assert.True(WaitForEventCount(2)); // exit fired
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExitRuleWithEmitOnChange_StillEmitsItsFirstResult()
|
||||
{
|
||||
var rule = ExitRule("GATHER-PX-030", "AccountSetup");
|
||||
rule.EmitMode = "on_change";
|
||||
_executor.UpdateRules(new List<GatherRule> { rule });
|
||||
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AccountSetup);
|
||||
_executor.OnPhaseChanged(EnrollmentPhase.AppsUser);
|
||||
|
||||
Assert.True(WaitForEventCount(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -17,6 +17,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Office
|
||||
/// cadence and stops at its bound, is idempotent, the initial scan raises when binaries already
|
||||
/// exist, a filesystem create raises, and Dispose cancels the re-probe.
|
||||
/// </summary>
|
||||
[Collection("SerialThreading")] // the bounded re-probe runs on the shared ThreadPool
|
||||
public sealed class OfficeBinaryWatcherTests
|
||||
{
|
||||
private static readonly string[] CoreBinaries = { "WINWORD.EXE", "EXCEL.EXE", "POWERPNT.EXE", "OUTLOOK.EXE" };
|
||||
|
||||
-195
@@ -1,195 +0,0 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||
{
|
||||
/// <summary>
|
||||
/// Shell-Core 62407 "RebootCoalescing" branch — ESP-initiated coalesced reboot (MDM policies
|
||||
/// forced a mid-ESP restart → second sign-in). Pure informational corroboration for the
|
||||
/// MdmRebootPolicyTracker's per-URI 2800 attribution: no FinalizingSetup transition, no C#
|
||||
/// event raise, and — as a DELIBERATE deviation from the other 62407 backfill branches — the
|
||||
/// backfill path DOES emit (the live emit races the very reboot that kills the process).
|
||||
/// </summary>
|
||||
public sealed class ShellCoreTrackerRebootCoalescingTests
|
||||
{
|
||||
private static readonly DateTime ClockNow = new DateTime(2026, 7, 20, 10, 5, 0, DateTimeKind.Utc);
|
||||
private const string CoalescingDescription =
|
||||
"CloudExperienceHost Web App Event 2. Name: 'CommercialOOBE_DeviceSetup_RebootCoalescing', Value: '...'.";
|
||||
|
||||
private static (ShellCoreTracker tracker, FakeSignalIngressSink sink) MakeTracker(TempDirectory tmp)
|
||||
{
|
||||
var sink = new FakeSignalIngressSink();
|
||||
var logger = new AgentLogger(tmp.Path, AgentLogLevel.Info);
|
||||
var tracker = new ShellCoreTracker(
|
||||
sessionId: "S1",
|
||||
tenantId: "T1",
|
||||
post: new InformationalEventPost(sink, new VirtualClock(ClockNow)),
|
||||
logger: logger,
|
||||
helloTracker: null);
|
||||
return (tracker, sink);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FakeSignalIngressSink.PostedSignal> ByType(FakeSignalIngressSink sink, string eventType) =>
|
||||
sink.Posted.Where(p =>
|
||||
p.Kind == DecisionSignalKind.InformationalEvent
|
||||
&& p.Payload != null
|
||||
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
|
||||
&& et == eventType).ToList();
|
||||
|
||||
private static IReadOnlyDictionary<string, object> Data(FakeSignalIngressSink.PostedSignal s) =>
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62407_RebootCoalescing_EmitsWarning_ImmediateUpload()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var (tracker, sink) = MakeTracker(tmp);
|
||||
using (tracker)
|
||||
{
|
||||
var sourceTime = ClockNow.AddSeconds(-5);
|
||||
tracker.ProcessEvent(
|
||||
eventId: ShellCoreTracker.EventId_ShellCore_WebAppEvent,
|
||||
description: CoalescingDescription,
|
||||
timestamp: sourceTime,
|
||||
providerName: "Microsoft-Windows-Shell-Core",
|
||||
isBackfill: false);
|
||||
|
||||
var s = Assert.Single(ByType(sink, Constants.EventTypes.EspRebootCoalescing));
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
Assert.Equal(sourceTime, s.OccurredAtUtc);
|
||||
Assert.Equal(CoalescingDescription, Data(s)["description"]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62407_RebootCoalescing_FiresOncePerRun()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var (tracker, sink) = MakeTracker(tmp);
|
||||
using (tracker)
|
||||
{
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEvent,
|
||||
CoalescingDescription, ClockNow, "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEvent,
|
||||
CoalescingDescription, ClockNow.AddSeconds(1), "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
|
||||
Assert.Single(ByType(sink, Constants.EventTypes.EspRebootCoalescing));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62407_RebootCoalescing_DoesNotTriggerEspSemantics()
|
||||
{
|
||||
// The coalescing record is corroboration only — it must not trip the ESP-exit /
|
||||
// FinalizingSetup / failure machinery.
|
||||
using var tmp = new TempDirectory();
|
||||
var (tracker, sink) = MakeTracker(tmp);
|
||||
using (tracker)
|
||||
{
|
||||
var raised = new List<string>();
|
||||
tracker.FinalizingSetupPhaseTriggered += (_, reason) => raised.Add($"finalizing:{reason}");
|
||||
tracker.EspExited += (_, _) => raised.Add("espExited");
|
||||
tracker.EspFailureDetected += (_, type) => raised.Add($"failure:{type}");
|
||||
tracker.WhiteGloveCompleted += (_, _) => raised.Add("whiteGlove");
|
||||
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEvent,
|
||||
CoalescingDescription, ClockNow, "Microsoft-Windows-Shell-Core", isBackfill: false);
|
||||
|
||||
Assert.Empty(raised);
|
||||
Assert.False(tracker.IsEspExitedForTest);
|
||||
Assert.Empty(ByType(sink, Constants.EventTypes.EspExiting));
|
||||
Assert.Empty(ByType(sink, Constants.EventTypes.EspFailure));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_62407_ExistingBranches_Unaffected()
|
||||
{
|
||||
// Ordering regression guard: WhiteGlove/failure/exiting still classify as before —
|
||||
// the RebootCoalescing check comes AFTER them.
|
||||
using var tmp = new TempDirectory();
|
||||
var (tracker, sink) = MakeTracker(tmp);
|
||||
using (tracker)
|
||||
{
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEvent,
|
||||
"Name: 'CommercialOOBE_ESPProgress_Page_Exiting'", ClockNow, "p", isBackfill: false);
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEvent,
|
||||
"Name: 'CommercialOOBE_ESPProgress_WhiteGlove_Success'", ClockNow, "p", isBackfill: false);
|
||||
|
||||
Assert.Single(ByType(sink, Constants.EventTypes.EspExiting));
|
||||
Assert.Single(ByType(sink, Constants.EventTypes.WhiteGloveComplete));
|
||||
Assert.Empty(ByType(sink, Constants.EventTypes.EspRebootCoalescing));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_RebootCoalescing_Emits_WithBackfilledTrue_AndHistoricalTimestamp()
|
||||
{
|
||||
// Deliberate deviation from the other 62407 backfill branches: after the coalesced
|
||||
// reboot restarts the agent, the backfill re-emission is the only reliable delivery.
|
||||
using var tmp = new TempDirectory();
|
||||
var (tracker, sink) = MakeTracker(tmp);
|
||||
using (tracker)
|
||||
{
|
||||
var historical = ClockNow.AddMinutes(-3);
|
||||
tracker.HandleBackfillRecord(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, CoalescingDescription, historical);
|
||||
|
||||
var s = Assert.Single(ByType(sink, Constants.EventTypes.EspRebootCoalescing));
|
||||
Assert.Equal(historical, s.OccurredAtUtc);
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_RebootCoalescing_AfterLiveFire_DoesNotDoubleEmit_SameProcess()
|
||||
{
|
||||
// Same-process dedup via the fire-once flag. (Cross-process live+backfill duplication
|
||||
// is possible and accepted — exists-semantics rule, identical historical timestamp.)
|
||||
using var tmp = new TempDirectory();
|
||||
var (tracker, sink) = MakeTracker(tmp);
|
||||
using (tracker)
|
||||
{
|
||||
tracker.ProcessEvent(ShellCoreTracker.EventId_ShellCore_WebAppEvent,
|
||||
CoalescingDescription, ClockNow, "p", isBackfill: false);
|
||||
tracker.HandleBackfillRecord(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, CoalescingDescription, ClockNow.AddMinutes(-1));
|
||||
|
||||
Assert.Single(ByType(sink, Constants.EventTypes.EspRebootCoalescing));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfill_RebootCoalescing_DoesNotFallThroughToExitOrFailureHandling()
|
||||
{
|
||||
using var tmp = new TempDirectory();
|
||||
var (tracker, sink) = MakeTracker(tmp);
|
||||
using (tracker)
|
||||
{
|
||||
var raised = new List<string>();
|
||||
tracker.FinalizingSetupPhaseTriggered += (_, reason) => raised.Add($"finalizing:{reason}");
|
||||
tracker.EspExited += (_, _) => raised.Add("espExited");
|
||||
tracker.EspFailureDetected += (_, type) => raised.Add($"failure:{type}");
|
||||
|
||||
tracker.HandleBackfillRecord(
|
||||
ShellCoreTracker.EventId_ShellCore_WebAppEvent, CoalescingDescription, ClockNow.AddMinutes(-2));
|
||||
|
||||
Assert.Empty(raised);
|
||||
Assert.False(tracker.IsEspExitedForTest);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+176
-75
@@ -15,16 +15,22 @@ using Xunit;
|
||||
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// MdmRebootPolicyTracker — attributes the "unexpected reboot + second sign-in" pattern to the
|
||||
/// device-assigned policy URIs that forced the coalesced reboot (DM-Enterprise EventID 2800).
|
||||
/// Drives the primitive <see cref="MdmRebootPolicyTracker.ProcessEvent"/> test seam (no real
|
||||
/// EventRecord, which is abstract + Windows-only), mirroring the WindowsUpdateTracker pattern.
|
||||
/// MdmRebootPolicyTracker — aggregates DM-Enterprise EventID 2800 bursts into ONE neutral
|
||||
/// <c>mdm_policy_reboot_required</c> event (session b2e890c1 rework: per-record events were
|
||||
/// spam, and the raw event must not CLAIM a reboot — ANALYZE-ESP-005 does, gated on an
|
||||
/// observed system_reboot_detected). Drives the primitive ProcessEvent seam + explicit
|
||||
/// FlushPending (debounce 0 = manual-flush mode; no timers, no SerialThreading needed).
|
||||
/// The registry probe is always overridden — tests never touch the live registry.
|
||||
/// </summary>
|
||||
public sealed class MdmRebootPolicyTrackerTests : IDisposable
|
||||
{
|
||||
private static readonly DateTime At = new DateTime(2026, 7, 20, 9, 30, 0, DateTimeKind.Utc);
|
||||
private const string SampleUri = "./Device/Vendor/MSFT/Policy/Config/DeviceGuard/EnableVirtualizationBasedSecurity";
|
||||
private static readonly DateTime At = new DateTime(2026, 7, 20, 16, 20, 57, DateTimeKind.Utc);
|
||||
private const string UriSvchost = "./Device/Vendor/MSFT/Policy/Config/ServiceControlManager/SvchostProcessMitigation";
|
||||
private const string UriSystemGuard = "./Device/Vendor/MSFT/Policy/Config/DeviceGuard/ConfigureSystemGuardLaunch";
|
||||
|
||||
// Verified real 2800 description shape (session b2e890c1, 2026-07-20).
|
||||
private const string RealDescription =
|
||||
"The following URI has triggered a reboot: (./Device/Vendor/MSFT/Policy/Config/ServiceControlManager/SvchostProcessMitigation).";
|
||||
|
||||
private readonly TempDirectory _tmp = new TempDirectory();
|
||||
private readonly FakeSignalIngressSink _sink;
|
||||
@@ -34,21 +40,28 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public MdmRebootPolicyTrackerTests()
|
||||
{
|
||||
_sink = new FakeSignalIngressSink();
|
||||
_tracker = MakeTracker(stateDirectory: null);
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private MdmRebootPolicyTracker MakeTracker(string? stateDirectory)
|
||||
{
|
||||
var post = new InformationalEventPost(_sink, new VirtualClock(At));
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
_tracker = new MdmRebootPolicyTracker(
|
||||
var tracker = new MdmRebootPolicyTracker(
|
||||
sessionId: "sess-mdm",
|
||||
tenantId: "tenant-mdm",
|
||||
post: post,
|
||||
logger: logger,
|
||||
backfillEnabled: false,
|
||||
stateDirectory: null); // in-memory dedup for most tests
|
||||
_tracker.RebootRequiredFlagProbe = () => null; // registry untouched by default
|
||||
stateDirectory: stateDirectory,
|
||||
debounceMilliseconds: 0); // manual-flush mode — tests call FlushPending explicitly
|
||||
tracker.RebootRequiredFlagProbe = () => null; // registry untouched by default
|
||||
return tracker;
|
||||
}
|
||||
|
||||
public void Dispose() => _tmp.Dispose();
|
||||
|
||||
private void Emit(string? rebootUri = SampleUri, string? description = null, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null)
|
||||
private void Buffer(string? rebootUri, long? recordId = null, bool isBackfill = false, DateTime? timeCreatedUtc = null, string? description = null)
|
||||
{
|
||||
_tracker.ProcessEvent(
|
||||
eventId: MdmRebootPolicyTracker.EventId_PolicyRebootRequired,
|
||||
@@ -70,104 +83,192 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
(IReadOnlyDictionary<string, object>)s.TypedPayload!;
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_EmitsWarning_WithUri_AndImmediateUpload()
|
||||
public void Burst_FlushesAsOneAggregatedEvent_InfoSeverity_ImmediateUpload()
|
||||
{
|
||||
Emit();
|
||||
Buffer(UriSvchost, timeCreatedUtc: At);
|
||||
Buffer(UriSystemGuard, timeCreatedUtc: At.AddMilliseconds(120));
|
||||
_tracker.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
Assert.Equal("Warning", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("Info", s.Payload![SignalPayloadKeys.Severity]);
|
||||
Assert.Equal("true", s.Payload![SignalPayloadKeys.ImmediateUpload]);
|
||||
|
||||
var data = Data(s);
|
||||
Assert.Equal(SampleUri, data["rebootUri"]);
|
||||
Assert.Equal(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, data["windowsEventId"]);
|
||||
Assert.Equal(MdmRebootPolicyTracker.Channel, data["eventLogChannel"]);
|
||||
Assert.Equal(2, data["uriCount"]);
|
||||
Assert.Equal(2, data["recordCount"]);
|
||||
Assert.Equal(UriSvchost, data["firstRebootUri"]);
|
||||
var uris = Assert.IsType<List<string>>(data["rebootUris"]);
|
||||
Assert.Equal(2, uris.Count);
|
||||
Assert.Contains(UriSvchost, uris);
|
||||
Assert.Contains(UriSystemGuard, uris);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProcessEvent_NoUri_StillEmits_WithoutRebootUriField()
|
||||
public void Message_IsNeutralObservation_NoRebootClaim()
|
||||
{
|
||||
// Tolerant contract: an unparsed URI must stay timeline-visible (with the captured
|
||||
// description telling us how to fix the extraction) — the analyze rule keys on
|
||||
// "rebootUri exists" and simply stays silent.
|
||||
Emit(rebootUri: null, description: "Some unexpected 2800 wording");
|
||||
// Session b2e890c1: flags raised during AccountSetup have no effect — the raw event
|
||||
// must state the observation only, never a future consequence.
|
||||
Buffer(UriSvchost);
|
||||
_tracker.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
var message = s.Payload![SignalPayloadKeys.Message];
|
||||
Assert.Contains("flagged", message);
|
||||
Assert.DoesNotContain("will restart", message);
|
||||
Assert.DoesNotContain("second sign-in", message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FlushPending_Empty_EmitsNothing()
|
||||
{
|
||||
_tracker.FlushPending();
|
||||
Assert.Empty(Emitted());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AggregateTimestamp_IsEarliestRecordTime()
|
||||
{
|
||||
var early = At.AddMinutes(-30);
|
||||
Buffer(UriSystemGuard, timeCreatedUtc: At, isBackfill: true);
|
||||
Buffer(UriSvchost, timeCreatedUtc: early, isBackfill: true);
|
||||
_tracker.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
Assert.Equal(early, s.OccurredAtUtc);
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
// firstRebootUri follows record-time order, not buffer order.
|
||||
Assert.Equal(UriSvchost, Data(s)["firstRebootUri"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Backfilled_False_WhenAnyRecordIsLive()
|
||||
{
|
||||
Buffer(UriSvchost, isBackfill: true);
|
||||
Buffer(UriSystemGuard, isBackfill: false);
|
||||
_tracker.FlushPending();
|
||||
|
||||
Assert.Equal(false, Data(Assert.Single(Emitted()))["backfilled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateUris_AreDistinctInAggregate()
|
||||
{
|
||||
Buffer(UriSvchost);
|
||||
Buffer(UriSvchost);
|
||||
_tracker.FlushPending();
|
||||
|
||||
var data = Data(Assert.Single(Emitted()));
|
||||
Assert.Equal(1, data["uriCount"]);
|
||||
Assert.Equal(2, data["recordCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnparsedRecords_CountedWithSampleDescription_StillEmitted()
|
||||
{
|
||||
Buffer(rebootUri: null, description: "Some unexpected 2800 wording");
|
||||
_tracker.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
var data = Data(s);
|
||||
Assert.False(data.ContainsKey("rebootUri"));
|
||||
Assert.Equal("Some unexpected 2800 wording", data["description"]);
|
||||
Assert.False(data.ContainsKey("rebootUris"));
|
||||
Assert.False(data.ContainsKey("firstRebootUri"));
|
||||
Assert.Equal(0, data["uriCount"]);
|
||||
Assert.Equal(1, data["unparsedCount"]);
|
||||
Assert.Equal("Some unexpected 2800 wording", data["sampleDescription"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfilledEvent_UsesEventTimeForTimelineTimestamp_AndMarksBackfilled()
|
||||
public void SameRecordId_BufferedTwice_CountsOnce()
|
||||
{
|
||||
var eventTime = At.AddMinutes(-25); // policy applied well before agent start
|
||||
Emit(isBackfill: true, timeCreatedUtc: eventTime);
|
||||
Buffer(UriSvchost, recordId: 42);
|
||||
Buffer(UriSvchost, recordId: 42);
|
||||
_tracker.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
Assert.Equal(eventTime, s.OccurredAtUtc);
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
Assert.Equal(1, Data(Assert.Single(Emitted()))["recordCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SameRecordId_ProcessedTwice_EmitsOnce()
|
||||
public void RecordAfterFlush_GoesIntoNextAggregate()
|
||||
{
|
||||
Emit(recordId: 42);
|
||||
Emit(recordId: 42);
|
||||
|
||||
Assert.Single(Emitted());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfillEventWithLowerRecordId_AfterLiveEvent_IsStillEmitted()
|
||||
{
|
||||
// Same contract as WindowsUpdateTracker: the live watcher is armed BEFORE the backfill
|
||||
// scan, so a live record with a higher RecordId can arrive first — the older,
|
||||
// never-emitted backfill record must not be suppressed by a high-water mark.
|
||||
Emit(recordId: 100);
|
||||
Emit(recordId: 50, isBackfill: true);
|
||||
Buffer(UriSvchost, recordId: 10);
|
||||
_tracker.FlushPending();
|
||||
Buffer(UriSystemGuard, recordId: 11);
|
||||
_tracker.FlushPending();
|
||||
|
||||
Assert.Equal(2, Emitted().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BackfillRecordWithLowerRecordId_AfterLiveRecord_IsStillBuffered()
|
||||
{
|
||||
// Live watcher is armed BEFORE the backfill scan — an older, never-seen backfill
|
||||
// record must not be suppressed by a high-water mark.
|
||||
Buffer(UriSvchost, recordId: 100);
|
||||
Buffer(UriSystemGuard, recordId: 50, isBackfill: true);
|
||||
_tracker.FlushPending();
|
||||
|
||||
Assert.Equal(2, Data(Assert.Single(Emitted()))["recordCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NegativeRecordId_IsNeverDeduped()
|
||||
{
|
||||
Emit(recordId: -1);
|
||||
Emit(recordId: -1);
|
||||
Buffer(UriSvchost, recordId: -1);
|
||||
Buffer(UriSvchost, recordId: -1);
|
||||
_tracker.FlushPending();
|
||||
|
||||
Assert.Equal(2, Emitted().Count);
|
||||
Assert.Equal(2, Data(Assert.Single(Emitted()))["recordCount"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_PersistsAcrossTrackerInstances()
|
||||
public void Watermark_PersistedAtFlush_SkipsReReadAcrossInstances()
|
||||
{
|
||||
// The coalesced reboot restarts the agent and the post-reboot backfill re-reads the
|
||||
// same 2800 records — the persisted watermark must make that re-read a no-op.
|
||||
var post = new InformationalEventPost(_sink, new VirtualClock(At));
|
||||
var logger = new AgentLogger(_tmp.Path, AgentLogLevel.Info);
|
||||
|
||||
var first = new MdmRebootPolicyTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
|
||||
first.RebootRequiredFlagProbe = () => null;
|
||||
// The coalesced reboot restarts the agent; the post-reboot backfill re-reads the same
|
||||
// records — a FLUSHED record must be skipped by the next instance.
|
||||
var first = MakeTracker(stateDirectory: _tmp.Path);
|
||||
first.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: SampleUri, formattedDescription: null, isBackfill: false);
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: false);
|
||||
first.FlushPending();
|
||||
|
||||
var second = new MdmRebootPolicyTracker("s", "t", post, logger, backfillEnabled: false, stateDirectory: _tmp.Path);
|
||||
second.RebootRequiredFlagProbe = () => null;
|
||||
var second = MakeTracker(stateDirectory: _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
second.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: SampleUri, formattedDescription: null, isBackfill: true);
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: true);
|
||||
second.FlushPending();
|
||||
|
||||
Assert.Single(Emitted());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Watermark_NotPersisted_WhenBufferedButNeverFlushed()
|
||||
{
|
||||
// Process killed by the very reboot the records announce: nothing was flushed, so the
|
||||
// next instance must re-read and emit the records.
|
||||
var first = MakeTracker(stateDirectory: _tmp.Path);
|
||||
first.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: false);
|
||||
// no FlushPending — simulated reboot kill
|
||||
|
||||
var second = MakeTracker(stateDirectory: _tmp.Path);
|
||||
second.LoadWatermark();
|
||||
second.ProcessEvent(MdmRebootPolicyTracker.EventId_PolicyRebootRequired, recordId: 500,
|
||||
timeCreatedUtc: At, rebootUri: UriSvchost, formattedDescription: null, isBackfill: true);
|
||||
second.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
Assert.Equal(true, Data(s)["backfilled"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebootRequiredFlagProbe_True_AddsField_Null_OmitsIt()
|
||||
{
|
||||
_tracker.RebootRequiredFlagProbe = () => true;
|
||||
Emit(recordId: 1);
|
||||
Buffer(UriSvchost, recordId: 1);
|
||||
_tracker.FlushPending();
|
||||
|
||||
_tracker.RebootRequiredFlagProbe = () => null;
|
||||
Emit(recordId: 2);
|
||||
Buffer(UriSystemGuard, recordId: 2);
|
||||
_tracker.FlushPending();
|
||||
|
||||
var emitted = Emitted();
|
||||
Assert.Equal(2, emitted.Count);
|
||||
@@ -179,10 +280,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
public void RebootRequiredFlagProbe_Throwing_IsFailSoft()
|
||||
{
|
||||
_tracker.RebootRequiredFlagProbe = () => throw new InvalidOperationException("boom");
|
||||
Emit();
|
||||
Buffer(UriSvchost);
|
||||
_tracker.FlushPending();
|
||||
|
||||
var s = Assert.Single(Emitted());
|
||||
Assert.False(Data(s).ContainsKey("omadmRebootRequiredFlag"));
|
||||
Assert.False(Data(Assert.Single(Emitted())).ContainsKey("omadmRebootRequiredFlag"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -194,20 +295,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
|
||||
// ------------------------------------------------------------------ URI extraction
|
||||
|
||||
[Fact]
|
||||
public void ExtractRebootUri_PrefersEventDataValue_OverDescription()
|
||||
public void ExtractRebootUri_FromVerifiedRealDescription()
|
||||
{
|
||||
var eventData = new Dictionary<string, string> { ["Data0"] = " " + SampleUri + " " };
|
||||
var uri = MdmRebootPolicyTracker.ExtractRebootUri(eventData, "unrelated ./Other/Uri text");
|
||||
Assert.Equal(SampleUri, uri);
|
||||
// Real 2800 text wraps the URI in parentheses and ends with a period — both must be
|
||||
// excluded from the extracted URI.
|
||||
var uri = MdmRebootPolicyTracker.ExtractRebootUri(null, RealDescription);
|
||||
Assert.Equal(UriSvchost, uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractRebootUri_RegexFallback_FromDescription()
|
||||
public void ExtractRebootUri_PrefersEventDataValue_OverDescription()
|
||||
{
|
||||
var uri = MdmRebootPolicyTracker.ExtractRebootUri(
|
||||
new Dictionary<string, string> { ["Data0"] = "not a uri" },
|
||||
$"The following URI has triggered a reboot: {SampleUri}.");
|
||||
Assert.Equal(SampleUri, uri);
|
||||
var eventData = new Dictionary<string, string> { ["Data0"] = " " + UriSystemGuard + " " };
|
||||
var uri = MdmRebootPolicyTracker.ExtractRebootUri(eventData, RealDescription);
|
||||
Assert.Equal(UriSystemGuard, uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+96
-2
@@ -17,13 +17,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
FakeTelemetryTransport transport,
|
||||
out EventSequenceCounter counter,
|
||||
string sessionId = "sess-1",
|
||||
string tenantId = "tenant-1")
|
||||
string tenantId = "tenant-1",
|
||||
Func<bool>? traceEventsEnabled = null)
|
||||
{
|
||||
var tmp = new TempDirectory();
|
||||
counter = new EventSequenceCounter(new EventSequencePersistence(tmp.File("seq.json")));
|
||||
return new TelemetryEventEmitter(transport, counter, sessionId, tenantId);
|
||||
return new TelemetryEventEmitter(transport, counter, sessionId, tenantId, traceEventsEnabled);
|
||||
}
|
||||
|
||||
private static EnrollmentEvent NewTraceEvent(string eventType = "outbound_ip") =>
|
||||
new EnrollmentEvent
|
||||
{
|
||||
EventType = eventType,
|
||||
Severity = EventSeverity.Trace,
|
||||
Source = "test",
|
||||
Timestamp = At,
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = "trace message",
|
||||
};
|
||||
|
||||
private static EnrollmentEvent NewTestEvent(string eventType = "enrollment_complete") =>
|
||||
new EnrollmentEvent
|
||||
{
|
||||
@@ -49,6 +61,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
Assert.Equal("sess-1", evt.SessionId);
|
||||
Assert.Equal("tenant-1", evt.TenantId);
|
||||
|
||||
Assert.NotNull(item);
|
||||
Assert.Equal(TelemetryItemKind.Event, item.Kind);
|
||||
Assert.Equal("tenant-1_sess-1", item.PartitionKey);
|
||||
Assert.Equal(evt.RowKey, item.RowKey);
|
||||
@@ -174,5 +187,86 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Telemetry.Events
|
||||
Assert.Equal("test-reason", (string?)data["reason"]);
|
||||
Assert.Equal("Strong", (string?)data["wgConfidence"]);
|
||||
}
|
||||
|
||||
// ============================================================ Trace-event gate
|
||||
|
||||
[Fact]
|
||||
public void Trace_event_is_dropped_when_gate_is_off()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var emitter = Build(transport, out _, traceEventsEnabled: () => false);
|
||||
|
||||
var item = emitter.Emit(NewTraceEvent());
|
||||
|
||||
Assert.Null(item);
|
||||
Assert.Equal(0, transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trace_event_flows_when_gate_is_on()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var emitter = Build(transport, out _, traceEventsEnabled: () => true);
|
||||
|
||||
Assert.NotNull(emitter.Emit(NewTraceEvent()));
|
||||
Assert.Equal(1, transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Trace_event_flows_when_no_gate_is_wired()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var emitter = Build(transport, out _);
|
||||
|
||||
Assert.NotNull(emitter.Emit(NewTraceEvent()));
|
||||
Assert.Equal(1, transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Gate_off_does_not_suppress_non_trace_events()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var emitter = Build(transport, out _, traceEventsEnabled: () => false);
|
||||
|
||||
foreach (var severity in new[] { EventSeverity.Info, EventSeverity.Warning, EventSeverity.Error })
|
||||
{
|
||||
var evt = NewTestEvent();
|
||||
evt.Severity = severity;
|
||||
Assert.NotNull(emitter.Emit(evt));
|
||||
}
|
||||
|
||||
Assert.Equal(3, transport.EnqueueCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dropped_trace_event_does_not_consume_a_sequence_number()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var traceAllowed = true;
|
||||
// ReSharper disable once AccessToModifiedClosure — deliberate: the gate is read
|
||||
// per event, mirroring a mid-session remote-config merge.
|
||||
var emitter = Build(transport, out _, traceEventsEnabled: () => traceAllowed);
|
||||
|
||||
emitter.Emit(NewTestEvent()); // seq 1
|
||||
traceAllowed = false;
|
||||
emitter.Emit(NewTraceEvent()); // dropped — must not burn seq 2
|
||||
emitter.Emit(NewTestEvent()); // seq 2
|
||||
|
||||
Assert.Equal(2, transport.EnqueueCount);
|
||||
var first = JObject.Parse(transport.Enqueued[0].PayloadJson);
|
||||
var second = JObject.Parse(transport.Enqueued[1].PayloadJson);
|
||||
Assert.Equal(1, (long)first["Sequence"]!);
|
||||
Assert.Equal(2, (long)second["Sequence"]!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Throwing_gate_fails_open_so_diagnostics_are_never_silently_lost()
|
||||
{
|
||||
var transport = new FakeTelemetryTransport();
|
||||
var emitter = Build(transport, out _, traceEventsEnabled: () => throw new InvalidOperationException("boom"));
|
||||
|
||||
Assert.NotNull(emitter.Emit(NewTraceEvent()));
|
||||
Assert.Equal(1, transport.EnqueueCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+200
-91
@@ -4,6 +4,7 @@ using System.Diagnostics.Eventing.Reader;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Xml.Linq;
|
||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||
@@ -15,31 +16,25 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// Watches <c>Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider/Admin</c> for
|
||||
/// EventID 2800 — logged once per device-assigned MDM policy whose CSP URI matches the OS
|
||||
/// reboot-required catalog — and forwards each as a <c>mdm_policy_reboot_required</c> event
|
||||
/// carrying the reboot-forcing URI.
|
||||
/// EventID 2800 — logged once per MDM policy URI that matches the OS reboot-required catalog —
|
||||
/// and emits ONE aggregated <c>mdm_policy_reboot_required</c> event per burst carrying the URI
|
||||
/// list (<c>rebootUris</c>, <c>uriCount</c>, <c>firstRebootUri</c>).
|
||||
/// <para>
|
||||
/// This attributes the "unexpected reboot + second sign-in screen" pattern (PMPC research):
|
||||
/// device-assigned policies applied during ESP DeviceSetup can demand a reboot; Windows
|
||||
/// coalesces them into one restart at the end of DeviceSetup, forcing the user through a second
|
||||
/// sign-in before AccountSetup. Admins are blind to this in the Intune console — the attributed
|
||||
/// URIs make it actionable (reassign the profiles to user groups and the reboot disappears).
|
||||
/// Verified against session b2e890c1 (2026-07-20): the 2800 description reads
|
||||
/// <c>The following URI has triggered a reboot: (./Device/...).</c> and records arrive in
|
||||
/// sub-second bursts during policy sync — hence the debounce aggregation instead of per-record
|
||||
/// events. CRITICAL semantics: only URIs applied DURING ESP DeviceSetup cause the coalesced
|
||||
/// mid-ESP reboot ("second sign-in", PMPC research); later syncs (AccountSetup and beyond)
|
||||
/// merely flag the requirement with no forced reboot. The event is therefore a NEUTRAL
|
||||
/// observation (Info) — the reboot claim lives in ANALYZE-ESP-005, which requires an
|
||||
/// actually-observed <c>system_reboot_detected</c> before attributing anything.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Backfill.</b> Device policies can apply before the agent starts, so a startup backfill
|
||||
/// scans the lookback window. The .evtx persists across the coalesced reboot, so the
|
||||
/// post-reboot agent restart re-reads the same records — which is why the
|
||||
/// <b>cross-restart RecordId watermark</b> (WindowsUpdateTracker pattern) is mandatory here:
|
||||
/// without it every reboot-forcing URI would be re-emitted after the very reboot it caused.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Tolerant matching.</b> The exact 2800 message text is unverified (validated post-deploy on
|
||||
/// the E2E VM): emission is gated on the EventID only, never on message wording. URI extraction
|
||||
/// prefers structured EventData values that look like an OMA-DM URI (<c>./…</c>) and falls back
|
||||
/// to a permissive regex over the formatted description. Events with an unparseable URI are
|
||||
/// still emitted (with the captured description) so the timeline shows them and the captured
|
||||
/// text tells us how to fix the extraction — the analyze rule keys on <c>rebootUri exists</c>
|
||||
/// and simply stays silent for those.
|
||||
/// <b>Backfill + watermark.</b> Device policies can apply before the agent starts, and a
|
||||
/// genuine coalesced reboot kills the agent BEFORE the debounced emit — in both cases the
|
||||
/// post-(re)start backfill re-reads the .evtx and emits the aggregate then (historical
|
||||
/// timestamps). The cross-restart RecordId watermark is persisted only at successful flush, so
|
||||
/// a pre-emit death loses nothing and a completed emit is never repeated.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class MdmRebootPolicyTracker : IDisposable
|
||||
@@ -49,12 +44,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
internal const string WatermarkStateFileName = "mdm-reboot-watermark.json";
|
||||
|
||||
/// <summary>Cap for the emitted <c>rebootUris</c> list — <c>uriCount</c> always carries the uncapped distinct total.</summary>
|
||||
internal const int MaxUrisPerEvent = 20;
|
||||
|
||||
/// <summary>Default quiet-period before a burst of 2800 records is flushed as one event.</summary>
|
||||
internal const int DefaultDebounceMilliseconds = 10000;
|
||||
|
||||
/// <summary>Registry key whose <c>RebootRequired</c> value/subkey flags a pending OMA-DM coalesced reboot.</summary>
|
||||
internal const string OmadmSyncMlKeyPath = @"SOFTWARE\Microsoft\Provisioning\OMADM\SyncML";
|
||||
|
||||
// Permissive OMA-DM URI shape: "./Device/Vendor/MSFT/...", "./User/...", "./Vendor/...".
|
||||
// Kept deliberately broad — the exact 2800 text is unverified; post-deploy E2E validates
|
||||
// and tightens this against real records if needed.
|
||||
// Permissive OMA-DM URI shape ("./Device/Vendor/MSFT/...", "./User/..."). The verified 2800
|
||||
// text wraps the URI in parentheses — ')' is deliberately outside the class so the match
|
||||
// stops cleanly; trailing sentence punctuation is trimmed after the match.
|
||||
private static readonly Regex UriPattern = new Regex(
|
||||
@"\./[A-Za-z0-9_\-./\[\]{}%~+]+", RegexOptions.Compiled);
|
||||
|
||||
@@ -65,16 +66,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private readonly bool _backfillEnabled;
|
||||
private readonly int _backfillLookbackMinutes;
|
||||
private readonly string _stateDirectory;
|
||||
private readonly int _debounceMilliseconds;
|
||||
|
||||
private EventLogWatcher _watcher;
|
||||
private Timer _debounceTimer;
|
||||
|
||||
// Dedup — same contract as WindowsUpdateTracker (see its field docs): immutable
|
||||
// cross-restart boundary from disk + intra-run HashSet (NOT a high-water mark, because the
|
||||
// live watcher is armed before the backfill scan and can deliver higher RecordIds first).
|
||||
private sealed class PendingRecord
|
||||
{
|
||||
public long RecordId;
|
||||
public DateTime TimeUtc;
|
||||
public string Uri; // null when extraction failed
|
||||
public string Description; // may be null
|
||||
public bool IsBackfill;
|
||||
}
|
||||
|
||||
// Pending burst buffer + dedup state, all guarded by _stateLock. Same claim contract as
|
||||
// WindowsUpdateTracker (immutable cross-restart boundary + intra-run HashSet), with ONE
|
||||
// deliberate difference: the watermark is persisted at FLUSH time, not at claim time —
|
||||
// records buffered but never flushed (process killed by the very reboot they announce)
|
||||
// must be re-read and emitted by the post-restart backfill.
|
||||
private readonly List<PendingRecord> _pending = new List<PendingRecord>();
|
||||
private long _restartWatermark = -1;
|
||||
private readonly HashSet<long> _seenThisRun = new HashSet<long>();
|
||||
private long _maxEmittedRecordId = -1;
|
||||
private readonly object _watermarkLock = new object();
|
||||
private long _maxPersistedRecordId = -1;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Fail-soft corroboration probe for the OMA-DM RebootRequired flag — null = unknown
|
||||
@@ -89,7 +104,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
AgentLogger logger,
|
||||
bool backfillEnabled = true,
|
||||
int backfillLookbackMinutes = 60,
|
||||
string stateDirectory = null)
|
||||
string stateDirectory = null,
|
||||
int debounceMilliseconds = DefaultDebounceMilliseconds)
|
||||
{
|
||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||
@@ -98,6 +114,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
_backfillEnabled = backfillEnabled;
|
||||
_backfillLookbackMinutes = backfillLookbackMinutes;
|
||||
_stateDirectory = stateDirectory != null ? Environment.ExpandEnvironmentVariables(stateDirectory) : null;
|
||||
_debounceMilliseconds = debounceMilliseconds;
|
||||
}
|
||||
|
||||
public void Start()
|
||||
@@ -106,13 +123,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
StartWatcher();
|
||||
|
||||
if (_backfillEnabled && _backfillLookbackMinutes > 0)
|
||||
{
|
||||
BackfillRecentEvents();
|
||||
// The backfill batch is complete — flush immediately instead of waiting out the
|
||||
// debounce (live records that raced in are simply included).
|
||||
FlushPending();
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.Info("MdmRebootPolicy backfill disabled by config");
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
// Flush any un-emitted burst before tearing down (normal agent shutdown path).
|
||||
try { FlushPending(); }
|
||||
catch (Exception ex) { _logger.Warning($"MdmRebootPolicy flush on stop failed: {ex.Message}"); }
|
||||
|
||||
var timer = Interlocked.Exchange(ref _debounceTimer, null);
|
||||
if (timer != null)
|
||||
{
|
||||
try { timer.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
if (_watcher == null) return;
|
||||
try
|
||||
{
|
||||
@@ -168,7 +202,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
$"*[System[(EventID={EventId_PolicyRebootRequired})]]";
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Event processing
|
||||
// Event processing — buffer + debounce
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void OnEventRecordWritten(object sender, EventRecordWrittenEventArgs e)
|
||||
@@ -212,9 +246,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core processing extracted to primitive inputs so tests can drive it without synthesizing
|
||||
/// an abstract, Windows-only <see cref="EventRecord"/>. Mirrors the WindowsUpdateTracker
|
||||
/// test-seam pattern.
|
||||
/// Buffers one 2800 record into the pending burst (primitive-input test seam, mirroring the
|
||||
/// WindowsUpdateTracker pattern). The aggregated event is emitted by <see cref="FlushPending"/>
|
||||
/// — after the debounce quiet-period, at backfill completion, or at <see cref="Stop"/>.
|
||||
/// </summary>
|
||||
internal void ProcessEvent(
|
||||
int eventId,
|
||||
@@ -224,53 +258,155 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
string formattedDescription,
|
||||
bool isBackfill)
|
||||
{
|
||||
if (!MarkProcessed(recordId))
|
||||
return; // already emitted (cross-restart or duplicate delivery)
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (recordId >= 0)
|
||||
{
|
||||
// Already emitted in a prior run, or already buffered/emitted this run.
|
||||
if (recordId <= _restartWatermark || !_seenThisRun.Add(recordId))
|
||||
return;
|
||||
}
|
||||
|
||||
_pending.Add(new PendingRecord
|
||||
{
|
||||
RecordId = recordId,
|
||||
TimeUtc = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
Uri = string.IsNullOrEmpty(rebootUri) ? null : rebootUri,
|
||||
Description = formattedDescription,
|
||||
IsBackfill = isBackfill,
|
||||
});
|
||||
}
|
||||
|
||||
ArmDebounce();
|
||||
}
|
||||
|
||||
private void ArmDebounce()
|
||||
{
|
||||
if (_debounceMilliseconds <= 0) return; // manual-flush mode (tests; backfill/Stop flush explicitly)
|
||||
|
||||
var timer = _debounceTimer;
|
||||
if (timer == null)
|
||||
{
|
||||
var created = new Timer(_ =>
|
||||
{
|
||||
try { FlushPending(); }
|
||||
catch (Exception ex) { _logger.Warning($"MdmRebootPolicy debounce flush failed: {ex.Message}"); }
|
||||
}, null, Timeout.Infinite, Timeout.Infinite);
|
||||
|
||||
timer = Interlocked.CompareExchange(ref _debounceTimer, created, null) ?? created;
|
||||
if (!ReferenceEquals(timer, created))
|
||||
{
|
||||
try { created.Dispose(); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
try { timer.Change(_debounceMilliseconds, Timeout.Infinite); }
|
||||
catch (ObjectDisposedException) { /* raced Stop() — Stop's flush covers the buffer */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the pending burst as ONE aggregated <c>mdm_policy_reboot_required</c> event and
|
||||
/// persists the watermark. No-op when nothing is pending. Internal for tests.
|
||||
/// </summary>
|
||||
internal void FlushPending()
|
||||
{
|
||||
List<PendingRecord> batch;
|
||||
long watermarkToPersist = -1;
|
||||
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_pending.Count == 0) return;
|
||||
batch = new List<PendingRecord>(_pending);
|
||||
_pending.Clear();
|
||||
|
||||
foreach (var r in batch)
|
||||
{
|
||||
if (r.RecordId > _maxPersistedRecordId)
|
||||
_maxPersistedRecordId = r.RecordId;
|
||||
}
|
||||
watermarkToPersist = _maxPersistedRecordId;
|
||||
}
|
||||
|
||||
if (watermarkToPersist >= 0) PersistWatermark(watermarkToPersist);
|
||||
|
||||
batch.Sort((a, b) => a.TimeUtc.CompareTo(b.TimeUtc));
|
||||
|
||||
var distinctUris = batch
|
||||
.Where(r => r.Uri != null)
|
||||
.Select(r => r.Uri)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var firstUri = batch.FirstOrDefault(r => r.Uri != null)?.Uri;
|
||||
var unparsedCount = batch.Count(r => r.Uri == null);
|
||||
var allBackfill = batch.All(r => r.IsBackfill);
|
||||
var earliest = batch[0].TimeUtc;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "windowsEventId", eventId },
|
||||
{ "backfilled", isBackfill },
|
||||
{ "windowsEventId", EventId_PolicyRebootRequired },
|
||||
{ "eventLogChannel", Channel },
|
||||
{ "recordCount", batch.Count },
|
||||
{ "uriCount", distinctUris.Count },
|
||||
{ "backfilled", allBackfill },
|
||||
};
|
||||
if (recordId >= 0) data["recordId"] = recordId;
|
||||
if (timeCreatedUtc.HasValue) data["eventTime"] = timeCreatedUtc.Value.ToString("o");
|
||||
if (!string.IsNullOrEmpty(rebootUri)) data["rebootUri"] = rebootUri;
|
||||
if (!string.IsNullOrEmpty(formattedDescription))
|
||||
if (distinctUris.Count > 0)
|
||||
{
|
||||
data["description"] = formattedDescription.Length > 1000
|
||||
? formattedDescription.Substring(0, 1000) + "…"
|
||||
: formattedDescription;
|
||||
data["rebootUris"] = distinctUris
|
||||
.OrderBy(u => u, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(MaxUrisPerEvent)
|
||||
.ToList();
|
||||
data["firstRebootUri"] = firstUri;
|
||||
}
|
||||
if (unparsedCount > 0)
|
||||
{
|
||||
data["unparsedCount"] = unparsedCount;
|
||||
var sample = batch.FirstOrDefault(r => r.Uri == null && !string.IsNullOrEmpty(r.Description))?.Description;
|
||||
if (sample != null)
|
||||
data["sampleDescription"] = sample.Length > 500 ? sample.Substring(0, 500) + "…" : sample;
|
||||
}
|
||||
|
||||
var flag = SafeProbeRebootRequiredFlag();
|
||||
if (flag.HasValue) data["omadmRebootRequiredFlag"] = flag.Value;
|
||||
|
||||
string message;
|
||||
if (distinctUris.Count == 1)
|
||||
message = $"Windows flagged an MDM policy URI as reboot-required: {firstUri}";
|
||||
else if (distinctUris.Count > 1)
|
||||
message = $"Windows flagged {distinctUris.Count} MDM policy URIs as reboot-required: {firstUri} (+{distinctUris.Count - 1} more)";
|
||||
else
|
||||
message = $"Windows flagged {batch.Count} MDM policy record(s) as reboot-required (URI not parsed — see sampleDescription)";
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
// The record's own time, not UtcNow: backfilled pre-agent (and post-reboot re-read)
|
||||
// records must land on the timeline at when the policy actually demanded the reboot.
|
||||
Timestamp = timeCreatedUtc ?? DateTime.UtcNow,
|
||||
// Earliest record time, not UtcNow: backfilled (and post-reboot re-read) bursts
|
||||
// must land on the timeline at when the policies were actually flagged.
|
||||
Timestamp = earliest,
|
||||
EventType = Constants.EventTypes.MdmPolicyRebootRequired,
|
||||
Severity = EventSeverity.Warning,
|
||||
// Neutral observation — reboot-required flags outside ESP DeviceSetup have no
|
||||
// immediate effect. The reboot CLAIM is ANALYZE-ESP-005's job, gated on an
|
||||
// actually-observed system_reboot_detected.
|
||||
Severity = EventSeverity.Info,
|
||||
Source = "MdmRebootPolicyWatcher",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = !string.IsNullOrEmpty(rebootUri)
|
||||
? $"MDM policy requires a reboot during ESP: {rebootUri}"
|
||||
: "MDM policy requires a reboot during ESP (URI not parsed from event — see description)",
|
||||
Message = message,
|
||||
Data = data,
|
||||
// The reboot this event announces will kill the agent — flush now.
|
||||
// Cheap (one event per burst) and still time-critical: a burst DURING DeviceSetup
|
||||
// precedes a coalesced reboot that kills the agent.
|
||||
ImmediateUpload = true,
|
||||
});
|
||||
|
||||
_logger.Info($"MdmRebootPolicy: flushed {batch.Count} record(s) as one event " +
|
||||
$"(uris={distinctUris.Count}, unparsed={unparsedCount}, backfilled={allBackfill})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the reboot-forcing OMA-DM URI. Preference order: (1) a structured EventData
|
||||
/// value that starts with <c>./</c> (the CSP-URI shape), (2) a permissive regex over the
|
||||
/// formatted description. Returns null when nothing URI-like is found — the caller still
|
||||
/// emits, just without the <c>rebootUri</c> field.
|
||||
/// value that starts with <c>./</c>, (2) a permissive regex over the formatted description
|
||||
/// (verified shape: <c>The following URI has triggered a reboot: (./Device/...).</c>).
|
||||
/// Returns null when nothing URI-like is found — the record still counts into the
|
||||
/// aggregate (<c>unparsedCount</c> + <c>sampleDescription</c>).
|
||||
/// </summary>
|
||||
internal static string ExtractRebootUri(Dictionary<string, string> eventData, string description)
|
||||
{
|
||||
@@ -289,8 +425,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var match = UriPattern.Match(description);
|
||||
if (match.Success)
|
||||
{
|
||||
// The permissive pattern can swallow sentence punctuation trailing the URI
|
||||
// ("...Security."). OMA-DM URI segments never end in punctuation — trim it.
|
||||
// The permissive pattern can swallow sentence punctuation trailing the URI.
|
||||
// OMA-DM URI segments never end in punctuation — trim it.
|
||||
var candidate = match.Value.TrimEnd('.', ',', ';', ':', '\'', '"', ')');
|
||||
if (candidate.Length > 2) return candidate;
|
||||
}
|
||||
@@ -349,8 +485,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the pending-reboot flag under <see cref="OmadmSyncMlKeyPath"/>. The exact shape is
|
||||
/// unverified (PMPC research names the key, not the value type), so both a value and a
|
||||
/// Reads the pending-reboot flag under <see cref="OmadmSyncMlKeyPath"/>. Both a value and a
|
||||
/// subkey named <c>RebootRequired</c> count as "pending". Fail-soft: null = unknown
|
||||
/// (key absent / unreadable), matching the EspSkipConfigurationProbe convention.
|
||||
/// </summary>
|
||||
@@ -424,44 +559,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Watermark dedup (cross-restart) — WindowsUpdateTracker pattern verbatim
|
||||
// Watermark dedup (cross-restart) — persisted at flush, not at claim
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private bool IsAlreadyProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return false; // no RecordId → cannot dedup, let it through
|
||||
lock (_watermarkLock)
|
||||
lock (_stateLock)
|
||||
{
|
||||
return recordId <= _restartWatermark || _seenThisRun.Contains(recordId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Atomically claims <paramref name="recordId"/>. Returns true if this is the first time we
|
||||
/// see it (caller should emit), false if it was already processed — either in a PRIOR run
|
||||
/// (at/below the restart watermark) or already this run (in the seen-set). Records without
|
||||
/// a RecordId (-1) are always emitted, never tracked or persisted.
|
||||
/// </summary>
|
||||
private bool MarkProcessed(long recordId)
|
||||
{
|
||||
if (recordId < 0) return true;
|
||||
|
||||
long toPersist = -1;
|
||||
lock (_watermarkLock)
|
||||
{
|
||||
if (recordId <= _restartWatermark || !_seenThisRun.Add(recordId))
|
||||
return false;
|
||||
|
||||
if (recordId > _maxEmittedRecordId)
|
||||
{
|
||||
_maxEmittedRecordId = recordId;
|
||||
toPersist = _maxEmittedRecordId;
|
||||
}
|
||||
}
|
||||
if (toPersist >= 0) PersistWatermark(toPersist);
|
||||
return true;
|
||||
}
|
||||
|
||||
internal void LoadWatermark()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_stateDirectory)) return;
|
||||
@@ -475,10 +584,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
var state = JsonConvert.DeserializeObject<WatermarkState>(json);
|
||||
if (state != null)
|
||||
{
|
||||
lock (_watermarkLock)
|
||||
lock (_stateLock)
|
||||
{
|
||||
_restartWatermark = state.LastRecordId;
|
||||
_maxEmittedRecordId = state.LastRecordId;
|
||||
_maxPersistedRecordId = state.LastRecordId;
|
||||
}
|
||||
_logger.Info($"MdmRebootPolicy watermark loaded: lastRecordId={state.LastRecordId} (persisted {state.PersistedUtc:O})");
|
||||
}
|
||||
|
||||
+6
-81
@@ -16,9 +16,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
/// CommercialOOBE_ESPProgress_Page_Exiting — normal ESP exit
|
||||
/// CommercialOOBE_ESPProgress_WhiteGlove_Success — WhiteGlove complete
|
||||
/// CommercialOOBE_ESPProgress_Failure/_Timeout/_Abort/WhiteGlove_Failed — ESP failure
|
||||
/// "RebootCoalescing" (DeviceSetup) — ESP-initiated coalesced reboot
|
||||
/// (MDM policies forced a mid-ESP restart → second sign-in; corroborates the
|
||||
/// MdmRebootPolicyTracker's per-URI 2800 attribution events)
|
||||
///
|
||||
/// NOTE (session b2e890c1, 2026-07-20): a bare "RebootCoalescing" substring match on 62407 was
|
||||
/// tried and REMOVED — the token appears in the ROUTINE SubcategoryProcessing_Started marker
|
||||
/// ("Starting subcategory DeviceSetup.RebootCoalescing...") on every enrollment, so it proves
|
||||
/// nothing about an actual coalesced reboot. Policy-reboot attribution lives entirely in
|
||||
/// MdmRebootPolicyTracker (EventID 2800) + the ANALYZE-ESP-005 rule gated on an observed reboot.
|
||||
///
|
||||
/// Raises <see cref="FinalizingSetupPhaseTriggered"/>, <see cref="WhiteGloveCompleted"/>,
|
||||
/// and <see cref="EspFailureDetected"/>. Cross-notifies the <see cref="HelloTracker"/>
|
||||
@@ -50,7 +53,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
private bool _espExitDetected;
|
||||
private bool _whiteGloveDetected;
|
||||
private bool _helloWizardStartDetected;
|
||||
private bool _rebootCoalescingDetected;
|
||||
private readonly object _stateLock = new object();
|
||||
|
||||
/// <summary>
|
||||
@@ -251,27 +253,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
|
||||
_logger.Info("ESP phase exit detected - detected via Shell-Core event 62407");
|
||||
}
|
||||
else if (description.IndexOf("RebootCoalescing", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
// ESP-initiated coalesced reboot (end of DeviceSetup): MDM policies forced a
|
||||
// restart, the user will see a second sign-in screen. Checked AFTER the
|
||||
// WhiteGlove/failure/exiting patterns so their semantics stay untouched even
|
||||
// if a description ever carried both tokens. Fire-once per process run — the
|
||||
// reboot restarts the agent, and the fresh run re-observes it via backfill.
|
||||
lock (_stateLock)
|
||||
{
|
||||
if (_rebootCoalescingDetected) return;
|
||||
_rebootCoalescingDetected = true;
|
||||
}
|
||||
|
||||
eventType = Constants.EventTypes.EspRebootCoalescing;
|
||||
severity = EventSeverity.Warning;
|
||||
message = "ESP initiated a coalesced reboot (RebootCoalescing) — device will restart and show a second sign-in";
|
||||
// No FinalizingSetup transition, no C# event raise — pure informational
|
||||
// corroboration for the MdmRebootPolicyTracker's per-URI attribution.
|
||||
|
||||
_logger.Warning("ESP coalesced reboot detected via Shell-Core event 62407 (RebootCoalescing)");
|
||||
}
|
||||
else
|
||||
{
|
||||
return;
|
||||
@@ -446,29 +427,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
return;
|
||||
}
|
||||
|
||||
if (description.IndexOf("RebootCoalescing", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
// DELIBERATE deviation from the other 62407 backfill branches (which only raise C#
|
||||
// events): this is a pure informational type with no reducer/subscriber — the
|
||||
// emission IS the signal, and the live emit races the very reboot that kills the
|
||||
// process. Re-emitting after the restart is the only reliable delivery path. A
|
||||
// live+backfill cross-process duplicate is possible (no watermark here); accepted:
|
||||
// the analyze rule uses exists-semantics, both copies carry the identical historical
|
||||
// timestamp, and the backfill copy is marked backfilled:true.
|
||||
bool shouldEmit;
|
||||
lock (_stateLock)
|
||||
{
|
||||
shouldEmit = !_rebootCoalescingDetected;
|
||||
_rebootCoalescingDetected = true;
|
||||
}
|
||||
if (shouldEmit)
|
||||
{
|
||||
_logger.Info($"Backfill: ESP coalesced reboot (RebootCoalescing) found in recent Shell-Core logs (originalAt={occurredAtUtc:o})");
|
||||
EmitRebootCoalescing(description, occurredAtUtc, isBackfill: true);
|
||||
}
|
||||
return; // a RebootCoalescing record is not an ESP exit/failure — stop here
|
||||
}
|
||||
|
||||
if (EspExitingPattern.IsMatch(description))
|
||||
{
|
||||
bool shouldNotify = false;
|
||||
@@ -507,39 +465,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the <c>esp_reboot_coalescing</c> event with the standard Shell-Core eventData
|
||||
/// shape. Used by the backfill path (the live path flows through the shared emit tail in
|
||||
/// <see cref="ProcessEvent"/>); <paramref name="isBackfill"/> adds the
|
||||
/// <c>backfilled</c> marker that distinguishes an accepted post-restart re-emission.
|
||||
/// </summary>
|
||||
private void EmitRebootCoalescing(string description, DateTime occurredAtUtc, bool isBackfill)
|
||||
{
|
||||
var eventData = new Dictionary<string, object>
|
||||
{
|
||||
{ "windowsEventId", EventId_ShellCore_WebAppEvent },
|
||||
{ "providerName", "" },
|
||||
{ "description", description },
|
||||
{ "eventLogChannel", ShellCoreEventLogChannel },
|
||||
{ "eventTime", occurredAtUtc.ToString("o") },
|
||||
{ "backfilled", isBackfill }
|
||||
};
|
||||
|
||||
_post.Emit(new EnrollmentEvent
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
TenantId = _tenantId,
|
||||
Timestamp = occurredAtUtc,
|
||||
EventType = Constants.EventTypes.EspRebootCoalescing,
|
||||
Severity = EventSeverity.Warning,
|
||||
Source = "EspAndHelloTracker",
|
||||
Phase = EnrollmentPhase.Unknown,
|
||||
Message = "ESP initiated a coalesced reboot (RebootCoalescing) — device will restart and show a second sign-in",
|
||||
Data = eventData,
|
||||
ImmediateUpload = true
|
||||
});
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Pattern helpers
|
||||
// =====================================================================
|
||||
|
||||
+39
-4
@@ -12,6 +12,24 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
{
|
||||
public string CollectorType => "command_allowlisted";
|
||||
|
||||
private const int CommandTimeoutMs = 30000;
|
||||
|
||||
/// <summary>
|
||||
/// Returns what the reader captured, or an empty string if it did not finish
|
||||
/// (a killed process can leave the read faulted or still pending).
|
||||
/// </summary>
|
||||
private static string DrainOrEmpty(System.Threading.Tasks.Task<string> readTask)
|
||||
{
|
||||
try
|
||||
{
|
||||
return readTask.Wait(2000) ? readTask.Result : string.Empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, object> Execute(GatherRule rule, GatherRuleContext context)
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
@@ -81,12 +99,29 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
using (var process = Process.Start(psi))
|
||||
{
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
var error = process.StandardError.ReadToEnd();
|
||||
process.WaitForExit(30000); // 30 second timeout
|
||||
// Read asynchronously: a synchronous ReadToEnd() blocks until the pipe
|
||||
// closes, which only happens when the process exits — the WaitForExit
|
||||
// timeout below would never be reached and a hung command would pin
|
||||
// this worker forever.
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync();
|
||||
var errorTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
var exited = process.WaitForExit(CommandTimeoutMs);
|
||||
if (!exited)
|
||||
{
|
||||
context.Logger.Warning(
|
||||
$"Command timed out after {CommandTimeoutMs / 1000}s, killing: {command} (Rule: {rule.RuleId})");
|
||||
try { process.Kill(); } catch { /* already gone */ }
|
||||
// Give the readers a moment to drain once the pipes close.
|
||||
try { process.WaitForExit(5000); } catch { /* best effort */ }
|
||||
data["timed_out"] = true;
|
||||
}
|
||||
|
||||
data["command"] = command;
|
||||
data["exit_code"] = process.ExitCode;
|
||||
data["exit_code"] = exited ? process.ExitCode : -1;
|
||||
|
||||
var output = DrainOrEmpty(outputTask);
|
||||
var error = DrainOrEmpty(errorTask);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
|
||||
+4
@@ -17,6 +17,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
if (string.IsNullOrEmpty(logName))
|
||||
return data;
|
||||
|
||||
// Guard: only allow enrollment-relevant channels
|
||||
if (!GatherRuleGuards.IsEventLogChannelAllowed(logName, context.UnrestrictedMode))
|
||||
return context.EmitSecurityWarning(rule, "eventlog", logName);
|
||||
|
||||
// Optional: escalate severity when matching entries are found
|
||||
string severityIfFound = null;
|
||||
rule.Parameters?.TryGetValue("severityIfFound", out severityIfFound);
|
||||
|
||||
+33
-2
@@ -34,11 +34,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
if (filePath == null)
|
||||
return null; // Token present but no user logged on — skip silently
|
||||
|
||||
// Apply IME log path override if set
|
||||
// Apply IME log path override if set. The override comes from the local
|
||||
// --ime-log-path flag only (never from remote config), so the operator
|
||||
// running it is already a local admin — it relaxes the allowlist the same
|
||||
// way unrestricted mode does, while the hard blocks below still apply.
|
||||
var localOverride = false;
|
||||
if (!string.IsNullOrEmpty(context.ImeLogPathOverride))
|
||||
{
|
||||
var fileName = Path.GetFileName(filePath);
|
||||
filePath = Path.Combine(context.ImeLogPathOverride, fileName);
|
||||
localOverride = true;
|
||||
}
|
||||
|
||||
var relaxAllowlist = context.UnrestrictedMode || localOverride;
|
||||
|
||||
// Guard: only allow enrollment-relevant file paths. Checked before resolution
|
||||
// so a blocked target is reported even when no file matches it. A wildcard
|
||||
// target is checked by its directory — Path.GetFullPath rejects '*' and '?'.
|
||||
var guardPath = HasWildcard(Path.GetFileName(filePath))
|
||||
? Path.GetDirectoryName(filePath)
|
||||
: filePath;
|
||||
if (!GatherRuleGuards.IsFilePathAllowed(guardPath, relaxAllowlist, userProfilePath))
|
||||
{
|
||||
context.EmitSecurityWarning(rule, "logparser", filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get parameters
|
||||
@@ -87,18 +106,30 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather.Collectors
|
||||
|
||||
foreach (var resolvedPath in resolvedPaths)
|
||||
{
|
||||
// Re-check each resolved path: wildcard expansion can surface files
|
||||
// reached through a junction that the directory check did not cover.
|
||||
if (!GatherRuleGuards.IsFilePathAllowed(resolvedPath, relaxAllowlist, userProfilePath))
|
||||
{
|
||||
context.EmitSecurityWarning(rule, "logparser", resolvedPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
ProcessLogFile(resolvedPath, rule, pattern, trackPosition, maxLines, isTextMode, context);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasWildcard(string fileNamePart)
|
||||
=> !string.IsNullOrEmpty(fileNamePart) &&
|
||||
(fileNamePart.Contains("*") || fileNamePart.Contains("?"));
|
||||
|
||||
private static List<string> ResolveLogPaths(string filePath, string ruleId, GatherRuleContext context)
|
||||
{
|
||||
var fileNamePart = Path.GetFileName(filePath);
|
||||
|
||||
// No wildcards — single file
|
||||
if (!fileNamePart.Contains("*") && !fileNamePart.Contains("?"))
|
||||
if (!HasWildcard(fileNamePart))
|
||||
{
|
||||
if (File.Exists(filePath))
|
||||
return new List<string> { filePath };
|
||||
|
||||
+48
-1
@@ -176,12 +176,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
/// <summary>
|
||||
/// Called when a phase change event occurs - executes rules triggered by phase changes
|
||||
/// (<c>phase_change</c> on entering the new phase, <c>phase_exit</c> on leaving the old one).
|
||||
/// </summary>
|
||||
public void OnPhaseChanged(EnrollmentPhase newPhase)
|
||||
{
|
||||
var phaseName = newPhase.ToString();
|
||||
|
||||
// Update the phase + from-phase latches FIRST so deferred startup activation and
|
||||
// phase_exit runs FIRST and against the phase being LEFT: both the TriggerPhase match
|
||||
// and the scope gate must see the old phase, so this block has to complete before
|
||||
// _currentPhase moves below. A transition INTO Failed still fires the exit rules of the
|
||||
// phase that failed — that snapshot is the whole point at a failure boundary.
|
||||
List<GatherRule> exitRules = null;
|
||||
lock (_scopeLock)
|
||||
{
|
||||
var previousPhase = _currentPhase;
|
||||
if (previousPhase != EnrollmentPhase.Unknown && previousPhase != newPhase)
|
||||
{
|
||||
var previousPhaseName = previousPhase.ToString();
|
||||
exitRules = new List<GatherRule>();
|
||||
|
||||
foreach (var rule in _activeRules)
|
||||
{
|
||||
if (rule.Trigger != "phase_exit") continue;
|
||||
if (!string.IsNullOrEmpty(rule.TriggerPhase)
|
||||
&& !string.Equals(rule.TriggerPhase, previousPhaseName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
// Scope gate before the dedup add — an out-of-scope pass must not consume
|
||||
// the once-per-(rule, phase) slot. IsRuleInScopeLocked still sees the old phase.
|
||||
if (!IsRuleInScopeLocked(rule)) continue;
|
||||
|
||||
// "exit:" prefix keeps this key space disjoint from the phase_change keys.
|
||||
if (!_phaseRulesExecuted.Add($"exit:{rule.RuleId}|{previousPhaseName}"))
|
||||
{
|
||||
_logger.Debug($"Phase-exit rule {rule.RuleId} already executed for exit of {previousPhaseName}, skipping");
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.Info($"Phase exit triggered rule {rule.RuleId} (left phase: {previousPhaseName}, now: {phaseName})");
|
||||
exitRules.Add(rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (exitRules != null)
|
||||
{
|
||||
foreach (var rule in exitRules)
|
||||
{
|
||||
var exitRule = rule;
|
||||
ThreadPool.QueueUserWorkItem(_ => ExecuteRule(exitRule));
|
||||
}
|
||||
}
|
||||
|
||||
// Update the phase + from-phase latches so deferred startup activation and
|
||||
// the phase_change rules below all evaluate against the NEW phase.
|
||||
List<GatherRule> deferredStartup;
|
||||
lock (_scopeLock)
|
||||
|
||||
+55
@@ -33,6 +33,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
public static readonly IReadOnlyList<string> AllowedFilePrefixes;
|
||||
public static readonly IReadOnlyList<string> AllowedWmiQueryPrefixes;
|
||||
public static readonly IReadOnlyCollection<string> AllowedCommands;
|
||||
public static readonly IReadOnlyList<string> AllowedEventLogChannels;
|
||||
|
||||
// Hard blocks: never allowed, even in unrestricted mode
|
||||
private static readonly string BlockedUsersPrefix = Path.GetFullPath(@"C:\Users");
|
||||
@@ -51,6 +52,19 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
// Hard limit: maximum command length (even in unrestricted mode)
|
||||
private const int MaxCommandLength = 2000;
|
||||
|
||||
// Event log channels that are never readable, even in unrestricted mode.
|
||||
// Security carries the audit trail of user behaviour; the PowerShell channels
|
||||
// carry script-block logging, which routinely contains secrets in clear text.
|
||||
// Mirrored in rules/guardrails.json ("blockedEventLogChannels") for the portal
|
||||
// to display — enforcement lives here so a parse error cannot lift it.
|
||||
private static readonly string[] HardBlockedEventLogChannels = new[]
|
||||
{
|
||||
"Security",
|
||||
"Microsoft-Windows-PowerShell",
|
||||
"Windows PowerShell",
|
||||
"Microsoft-Windows-Sysmon",
|
||||
};
|
||||
|
||||
// Hard-blocked command patterns: never allowed, even in unrestricted mode.
|
||||
// These prevent privilege escalation, persistence, and data exfiltration.
|
||||
private static readonly string[] HardBlockedCommandPatterns = new[]
|
||||
@@ -85,6 +99,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
|
||||
var commands = FlattenCategorized(obj, "allowedCommands", "commands");
|
||||
AllowedCommands = new HashSet<string>(commands, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
AllowedEventLogChannels = FlattenCategorized(obj, "eventLogChannels", "channels");
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -98,6 +114,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
AllowedFilePrefixes = new List<string>();
|
||||
AllowedWmiQueryPrefixes = new List<string>();
|
||||
AllowedCommands = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
AllowedEventLogChannels = new List<string>();
|
||||
}
|
||||
|
||||
private static string LoadEmbeddedGuardrails()
|
||||
@@ -257,6 +274,44 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.Gather
|
||||
(trimmed.Length == prefix.Length || char.IsWhiteSpace(trimmed[prefix.Length])));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the event log channel matches an allowed entry with
|
||||
/// boundary matching (next char must be '/' or end of string), so that
|
||||
/// "Microsoft-Windows-AAD" admits "Microsoft-Windows-AAD/Operational"
|
||||
/// but not "Microsoft-Windows-AADSomethingElse".
|
||||
/// </summary>
|
||||
public static bool IsEventLogChannelAllowed(string channel)
|
||||
=> IsEventLogChannelAllowed(channel, unrestrictedMode: false);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the event log channel is allowed.
|
||||
/// When unrestrictedMode is true, all channels are allowed except the
|
||||
/// hard-blocked ones (audit trail and script-block logging).
|
||||
/// </summary>
|
||||
public static bool IsEventLogChannelAllowed(string channel, bool unrestrictedMode)
|
||||
{
|
||||
if (string.IsNullOrEmpty(channel))
|
||||
return false;
|
||||
|
||||
var trimmed = channel.Trim();
|
||||
|
||||
// Hard blocks apply even in unrestricted mode
|
||||
foreach (var blocked in HardBlockedEventLogChannels)
|
||||
{
|
||||
if (MatchesChannel(trimmed, blocked))
|
||||
return false;
|
||||
}
|
||||
|
||||
if (unrestrictedMode)
|
||||
return true;
|
||||
|
||||
return AllowedEventLogChannels.Any(allowed => MatchesChannel(trimmed, allowed));
|
||||
}
|
||||
|
||||
private static bool MatchesChannel(string channel, string prefix)
|
||||
=> channel.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) &&
|
||||
(channel.Length == prefix.Length || channel[prefix.Length] == '/');
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the command (trimmed) exactly matches an entry in the allowlist.
|
||||
/// Exact matching is intentional — no prefix spoofing possible.
|
||||
|
||||
@@ -86,6 +86,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// Production always passes null → Start() creates the real DeadlineScheduler.
|
||||
private readonly IDeadlineScheduler? _schedulerOverride;
|
||||
|
||||
// Live accessor for TenantConfiguration.SendTraceEvents — read per event by the
|
||||
// TelemetryEventEmitter so a mid-session remote-config merge takes effect. Null
|
||||
// (tests / legacy callers) means trace events always flow.
|
||||
private readonly Func<bool>? _traceEventsEnabled;
|
||||
|
||||
// Max-lifetime watchdog (M4.6.α). Timer is armed in Start() when _agentMaxLifetime != null.
|
||||
private System.Threading.Timer? _maxLifetimeTimer;
|
||||
private int _terminatedFired;
|
||||
@@ -166,7 +171,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
TimeSpan? terminalDrainTimeout = null,
|
||||
TimeSpan? agentMaxLifetime = null,
|
||||
int uploadBatchSize = 100,
|
||||
IDeadlineScheduler? schedulerOverride = null)
|
||||
IDeadlineScheduler? schedulerOverride = null,
|
||||
Func<bool>? traceEventsEnabled = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
@@ -206,6 +212,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
_uploadBatchSize = uploadBatchSize;
|
||||
|
||||
_schedulerOverride = schedulerOverride;
|
||||
_traceEventsEnabled = traceEventsEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -402,7 +409,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
||||
// strukturelle Abhängigkeit erlischt damit, Architektur-Baseline-Test gate
|
||||
// shrinks to exactly two permitted callers.
|
||||
_eventSequenceCounter = new EventSequenceCounter(_eventSequencePersistence);
|
||||
var eventEmitter = new TelemetryEventEmitter(_transport, _eventSequenceCounter, _sessionId, _tenantId);
|
||||
var eventEmitter = new TelemetryEventEmitter(
|
||||
_transport, _eventSequenceCounter, _sessionId, _tenantId, _traceEventsEnabled);
|
||||
_signalEmitter = new TelemetrySignalEmitter(_transport, _sessionId, _tenantId);
|
||||
_transitionEmitter = new TelemetryTransitionEmitter(_transport, _sessionId, _tenantId);
|
||||
_timelineEmitter = new EventTimelineEmitter(eventEmitter);
|
||||
|
||||
@@ -30,12 +30,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
private readonly string _sessionId;
|
||||
private readonly string _tenantId;
|
||||
private readonly string _partitionKey;
|
||||
private readonly Func<bool>? _traceEventsEnabled;
|
||||
|
||||
public TelemetryEventEmitter(
|
||||
ITelemetryTransport transport,
|
||||
EventSequenceCounter sequenceCounter,
|
||||
string sessionId,
|
||||
string tenantId)
|
||||
string tenantId,
|
||||
Func<bool>? traceEventsEnabled = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sessionId)) throw new ArgumentException("SessionId is mandatory.", nameof(sessionId));
|
||||
if (string.IsNullOrEmpty(tenantId)) throw new ArgumentException("TenantId is mandatory.", nameof(tenantId));
|
||||
@@ -44,13 +46,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
_sessionId = sessionId;
|
||||
_tenantId = tenantId;
|
||||
_partitionKey = $"{tenantId}_{sessionId}";
|
||||
_traceEventsEnabled = traceEventsEnabled;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Weist Sequence zu, serialisiert im Legacy-Wire-Format und enqueued via Transport.
|
||||
/// Thread-safe (EventSequenceCounter + ITelemetryTransport.Enqueue beide unter Lock).
|
||||
/// <para>
|
||||
/// Returns <c>null</c> when the event was suppressed by the trace-event gate — the
|
||||
/// only drop path. Both permitted callers ignore the return value.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal TelemetryItem Emit(EnrollmentEvent evt)
|
||||
internal TelemetryItem? Emit(EnrollmentEvent evt)
|
||||
{
|
||||
if (evt == null) throw new ArgumentNullException(nameof(evt));
|
||||
if (string.IsNullOrEmpty(evt.EventType))
|
||||
@@ -58,6 +65,16 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
throw new ArgumentException("EnrollmentEvent.EventType is mandatory.", nameof(evt));
|
||||
}
|
||||
|
||||
// Trace-event gate (TenantConfiguration.SendTraceEvents, default on). Dropped
|
||||
// before the sequence counter advances, so suppression leaves no gaps in the
|
||||
// per-session sequence and the timeline stays contiguous. Evaluated per event
|
||||
// rather than captured at construction: remote config is merged into the live
|
||||
// AgentConfiguration mid-session, and the gate must follow it.
|
||||
if (evt.Severity == EventSeverity.Trace && !TraceEventsEnabled())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
evt.Sequence = _sequenceCounter.Next();
|
||||
if (string.IsNullOrEmpty(evt.SessionId)) evt.SessionId = _sessionId;
|
||||
if (string.IsNullOrEmpty(evt.TenantId)) evt.TenantId = _tenantId;
|
||||
@@ -77,5 +94,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Telemetry.Events
|
||||
|
||||
return _transport.Enqueue(draft);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fail-open: no gate wired (legacy ctor / tests) or a throwing accessor means trace
|
||||
/// events flow. The gate suppresses diagnostics, so a broken accessor must not be able
|
||||
/// to silently blind the timeline.
|
||||
/// </summary>
|
||||
private bool TraceEventsEnabled()
|
||||
{
|
||||
if (_traceEventsEnabled == null) return true;
|
||||
try
|
||||
{
|
||||
return _traceEventsEnabled();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,10 @@ namespace AutopilotMonitor.Agent.V2.Runtime
|
||||
whiteGloveSealingPatternIds: whiteGloveSealingPatternIds,
|
||||
drainInterval: drainInterval,
|
||||
agentMaxLifetime: agentMaxLifetime,
|
||||
uploadBatchSize: uploadBatchSize))
|
||||
uploadBatchSize: uploadBatchSize,
|
||||
// Live read: remote config is merged into agentConfig mid-session, so the
|
||||
// trace-event gate must follow it rather than snapshot at construction.
|
||||
traceEventsEnabled: () => agentConfig.SendTraceEvents))
|
||||
{
|
||||
using (var shutdown = new ManualResetEventSlim(false))
|
||||
using (var shutdownComplete = new ManualResetEventSlim(false))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ContactEmail is the address enforcement actions and service notices are sent to.
|
||||
/// The seed must never overwrite a value the tenant owns — that is the whole contract.
|
||||
/// </summary>
|
||||
public class TenantContactEmailTests
|
||||
{
|
||||
[Fact]
|
||||
public void ContactEmail_defaults_to_null_so_absence_is_distinguishable_from_empty()
|
||||
{
|
||||
var config = new TenantConfiguration();
|
||||
Assert.Null(config.ContactEmail);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ContactEmail_survives_a_round_trip_through_the_model()
|
||||
{
|
||||
var config = new TenantConfiguration { ContactEmail = "it-operations@contoso.com" };
|
||||
Assert.Equal("it-operations@contoso.com", config.ContactEmail);
|
||||
}
|
||||
}
|
||||
+2
@@ -336,6 +336,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "LastUpdated", config.LastUpdated },
|
||||
{ "UpdatedBy", config.UpdatedBy },
|
||||
{ "OnboardedBy", config.OnboardedBy },
|
||||
{ "ContactEmail", config.ContactEmail },
|
||||
{ "Disabled", config.Disabled },
|
||||
{ "DisabledReason", config.DisabledReason },
|
||||
{ "DisabledUntil", config.DisabledUntil },
|
||||
@@ -432,6 +433,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow,
|
||||
UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown",
|
||||
OnboardedBy = entity.GetString("OnboardedBy"),
|
||||
ContactEmail = entity.GetString("ContactEmail"),
|
||||
Disabled = entity.GetBoolean("Disabled") ?? false,
|
||||
DisabledReason = entity.GetString("DisabledReason"),
|
||||
DisabledUntil = entity.GetDateTime("DisabledUntil"),
|
||||
|
||||
@@ -517,6 +517,52 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// Removes distress reports older than 14 days. Distress data is unverified
|
||||
/// and low-volume; short retention keeps Table Storage lean.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// One-off reconciliation for tenants onboarded before <c>ContactEmail</c> existed:
|
||||
/// copies the preview notification address they already supplied into their tenant
|
||||
/// configuration, but only where no contact address is set yet.
|
||||
/// <para>
|
||||
/// Deliberately never overwrites an existing value — a tenant that has edited its
|
||||
/// contact address owns it, and a maintenance run must not undo that. Going forward
|
||||
/// the seed happens when the notification address is saved, so this converges to a
|
||||
/// no-op and can be removed once every existing tenant has been covered.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <returns>Number of tenants that received a contact address.</returns>
|
||||
private async Task<int> BackfillTenantContactEmailsAsync()
|
||||
{
|
||||
var filled = 0;
|
||||
try
|
||||
{
|
||||
var configs = await _tenantConfigService.GetAllConfigurationsAsync();
|
||||
foreach (var config in configs)
|
||||
{
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.TenantId))
|
||||
continue;
|
||||
if (!string.IsNullOrWhiteSpace(config.ContactEmail))
|
||||
continue;
|
||||
|
||||
var email = await _previewWhitelistService.GetNotificationEmailAsync(config.TenantId);
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
continue;
|
||||
|
||||
config.ContactEmail = email.Trim();
|
||||
await _tenantConfigService.SaveConfigurationAsync(config);
|
||||
filled++;
|
||||
}
|
||||
|
||||
if (filled > 0)
|
||||
_logger.LogInformation("Backfilled contact address for {Count} tenant(s)", filled);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Housekeeping, not load-bearing: a failure here must not fail the whole run.
|
||||
_logger.LogWarning(ex, "Tenant contact address backfill failed");
|
||||
}
|
||||
|
||||
return filled;
|
||||
}
|
||||
|
||||
private async Task CleanupOldDistressReportsAsync()
|
||||
{
|
||||
const int retentionDays = 14;
|
||||
|
||||
@@ -32,6 +32,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
public bool DataCleanupExecuted { get; set; }
|
||||
public bool PlatformStatsRecomputed { get; set; }
|
||||
public int DevicesBlockedForExcessiveData { get; set; }
|
||||
public int ContactEmailsBackfilled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -64,6 +65,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
private readonly IPoisonQueueProbe _poisonQueueProbe;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly PreviewWhitelistService _previewWhitelistService;
|
||||
private readonly ILogger<MaintenanceService> _logger;
|
||||
|
||||
private const string PlatformStatsAliasFileName = "platform-stats.json";
|
||||
@@ -92,6 +94,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
IPoisonQueueProbe poisonQueueProbe,
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
PreviewWhitelistService previewWhitelistService,
|
||||
ILogger<MaintenanceService> logger)
|
||||
{
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
@@ -115,6 +118,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_poisonQueueProbe = poisonQueueProbe;
|
||||
_configuration = configuration;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_previewWhitelistService = previewWhitelistService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -214,6 +218,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
// StoreSessionAsync Replace-mode IndexRowKey bug (now fixed).
|
||||
await _maintenanceRepo.CleanupGhostSessionIndexEntriesAsync();
|
||||
|
||||
// One-off: give tenants onboarded before ContactEmail existed the contact
|
||||
// address they already supplied as a preview notification email. New tenants
|
||||
// are seeded at the point that address is saved, so this converges to a no-op
|
||||
// and can be dropped once every existing tenant has been covered.
|
||||
result.ContactEmailsBackfilled = await BackfillTenantContactEmailsAsync();
|
||||
|
||||
// Mirror the timer path: check embedded Intune cert bundle for
|
||||
// expiring members so manual triggers also exercise the watcher.
|
||||
await CheckEmbeddedCertExpiryAsync();
|
||||
|
||||
@@ -102,10 +102,44 @@ public class PreviewWhitelistService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves (or clears) the notification email for a tenant.
|
||||
/// Saves (or clears) the notification email for a tenant, and seeds the tenant's
|
||||
/// contact address from it the first time one is given.
|
||||
/// </summary>
|
||||
public async Task SaveNotificationEmailAsync(string tenantId, string? email)
|
||||
{
|
||||
await _configRepo.SaveNotificationEmailAsync(tenantId, email);
|
||||
await SeedContactEmailAsync(tenantId, email);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-way seed: copies the preview notification address into
|
||||
/// <see cref="TenantConfiguration.ContactEmail"/> only while that field is still empty.
|
||||
/// Once the tenant owns a contact address — seeded or edited in the portal — this never
|
||||
/// touches it again, so a later change here cannot silently overwrite the tenant's choice.
|
||||
/// Best-effort: the notification email has already been persisted by the caller, and a
|
||||
/// failure to seed must not fail that write.
|
||||
/// </summary>
|
||||
private async Task SeedContactEmailAsync(string tenantId, string? email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
|
||||
if (config == null || !string.IsNullOrWhiteSpace(config.ContactEmail))
|
||||
return;
|
||||
|
||||
config.ContactEmail = email.Trim();
|
||||
await _configRepo.SaveTenantConfigurationAsync(config);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Seeded tenant contact address for {TenantId} from the preview notification email", tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Could not seed tenant contact address for {TenantId} — the notification email was still saved", tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,22 @@ COPY rules/analyze/ ./rules/analyze/
|
||||
COPY rules/gather/ ./rules/gather/
|
||||
COPY rules/ime-log-patterns/ ./rules/ime-log-patterns/
|
||||
|
||||
# Published customer documentation (autopilotmonitor-docs) — a SEPARATE public
|
||||
# repo that the deploy workflow checks out into ./mcp-docs of the build context.
|
||||
# Same byte-identical requirement as the rules above.
|
||||
#
|
||||
# This COPY is deliberately strict: if the workflow's docs checkout is ever
|
||||
# removed, the build must fail loudly rather than quietly ship an image whose
|
||||
# search_docs tool simply does not exist. For a local `docker build`, clone the
|
||||
# bundle first:
|
||||
# git clone --depth 1 https://github.com/autopilotmonitor/autopilotmonitor-docs mcp-docs
|
||||
COPY mcp-docs/ ./docs/
|
||||
|
||||
# Commit SHA of that docs checkout. Surfaced on /health so a running server can be
|
||||
# diffed against the docs repo HEAD — the bundle changes independently of this
|
||||
# repo, so a stale deploy is otherwise invisible.
|
||||
ARG DOCS_COMMIT=unknown
|
||||
|
||||
# Pre-bake the ~23 MB embedding model AND precompute the search index at image
|
||||
# build time. Embedding the static corpus at boot cost 35-55s of ONNX inference
|
||||
# on the 0.25 vCPU container (measured); hydrating precomputed vectors is
|
||||
@@ -35,7 +51,7 @@ RUN mkdir -p "$HF_CACHE_DIR" /app/search-index && \
|
||||
if [ "$PREBAKE_MODEL" = "1" ]; then \
|
||||
ok=0; \
|
||||
for i in 1 2 3 4 5; do \
|
||||
node dist/precompute-embeddings.js ./rules /app/search-index/search-index.json && { ok=1; break; }; \
|
||||
node dist/precompute-embeddings.js ./rules /app/search-index/search-index.json ./docs && { ok=1; break; }; \
|
||||
echo "embedding precompute attempt $i/5 failed; retrying in 15s"; \
|
||||
sleep 15; \
|
||||
done && \
|
||||
@@ -66,8 +82,16 @@ COPY rules/analyze/ ./rules/analyze/
|
||||
COPY rules/gather/ ./rules/gather/
|
||||
COPY rules/ime-log-patterns/ ./rules/ime-log-patterns/
|
||||
|
||||
# Customer documentation corpus — copied from the build stage rather than the
|
||||
# context so it is provably the same bytes the embeddings were computed over.
|
||||
COPY --from=build /app/docs/ ./docs/
|
||||
|
||||
ARG DOCS_COMMIT=unknown
|
||||
ENV DOCS_COMMIT=${DOCS_COMMIT}
|
||||
|
||||
ENV PORT=8080
|
||||
ENV RULES_DIR=/app/rules
|
||||
ENV DOCS_DIR=/app/docs
|
||||
ENV SEARCH_INDEX_PATH=/app/search-index/search-index.json
|
||||
EXPOSE 8080
|
||||
|
||||
|
||||
+52
-22
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "autopilot-monitor-mcp",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "autopilot-monitor-mcp",
|
||||
"version": "1.5.0",
|
||||
"version": "1.5.1",
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
@@ -1730,20 +1730,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "^3.1.2",
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"debug": "^4.4.3",
|
||||
"http-errors": "^2.0.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"iconv-lite": "^0.7.2",
|
||||
"on-finished": "^2.4.1",
|
||||
"qs": "^6.14.1",
|
||||
"raw-body": "^3.0.1",
|
||||
"type-is": "^2.0.1"
|
||||
"qs": "^6.15.2",
|
||||
"raw-body": "^3.0.2",
|
||||
"type-is": "^2.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -1753,6 +1753,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/boolean": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
|
||||
@@ -3230,9 +3243,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/protobufjs": {
|
||||
"version": "7.6.4",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz",
|
||||
"integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==",
|
||||
"version": "7.6.5",
|
||||
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz",
|
||||
"integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -3674,9 +3687,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.16",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
|
||||
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
|
||||
"version": "7.5.20",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz",
|
||||
"integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/fs-minipass": "^4.0.0",
|
||||
@@ -3781,17 +3794,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
|
||||
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
|
||||
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"content-type": "^1.0.5",
|
||||
"content-type": "^2.0.0",
|
||||
"media-typer": "^1.1.0",
|
||||
"mime-types": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is/node_modules/content-type": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
|
||||
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Offline retrieval evaluation for search_docs.
|
||||
*
|
||||
* Exists because a single anecdote is not evidence. The WOW6432Node case showed
|
||||
* that the keyword fallback misses when semantic search returns weak-but-present
|
||||
* results — but "it fixes that one query" says nothing about how many queries a fix
|
||||
* BREAKS. This harness answers that: it scores several ranking strategies over the
|
||||
* same query set and reports, per class, how many queries each strategy fixes and
|
||||
* how many it regresses relative to the deployed behaviour.
|
||||
*
|
||||
* Three query classes, deliberately different in character:
|
||||
*
|
||||
* known-item Every chunk's own heading as the query, expecting any chunk with
|
||||
* that heading back. Easy by construction — its value is as a
|
||||
* REGRESSION detector, not an absolute score. If a strategy drops
|
||||
* these, it is breaking retrieval of things the corpus plainly names.
|
||||
* literal Rare identifiers (few chunks, >=6 chars) as single-word queries,
|
||||
* expecting the chunks that literally contain them. This is the class
|
||||
* WOW6432Node belongs to and the one the fallback exists for.
|
||||
* handwritten Real questions a human would ask, with the page they should reach.
|
||||
* The reality anchor: unlike the other two, these are not derived from
|
||||
* the corpus and share little vocabulary with it.
|
||||
*
|
||||
* Embeddings are computed ONCE per query and every strategy is a pure function over
|
||||
* the same semantic + literal candidate lists, so a threshold sweep costs no extra
|
||||
* inference.
|
||||
*
|
||||
* Usage: npx tsx scripts/eval-docs-search.ts [docsDir]
|
||||
*/
|
||||
|
||||
import { loadDocsCorpus } from '../src/docs-corpus.js';
|
||||
import { VectorSearchProvider } from '../src/vector-search-provider.js';
|
||||
import { extractDistinctiveTerms } from '../src/tools/search.js';
|
||||
import type { SearchDocument, SearchResult } from '../src/search-provider.js';
|
||||
|
||||
const DOCS_DIR = process.argv[2] ?? 'c:/Code/GitHubRepos/autopilotmonitor-docs';
|
||||
const TOP_K = 3;
|
||||
const MIN_SCORE = 0.25;
|
||||
|
||||
interface EvalQuery {
|
||||
klass: 'known-item' | 'literal' | 'handwritten';
|
||||
query: string;
|
||||
/** Any of these ids counts as correct. */
|
||||
expected: Set<string>;
|
||||
}
|
||||
|
||||
// ── Query set ────────────────────────────────────────────────
|
||||
|
||||
function buildQueries(docs: SearchDocument[]): EvalQuery[] {
|
||||
const queries: EvalQuery[] = [];
|
||||
|
||||
// known-item: heading → chunks carrying that heading.
|
||||
const byHeading = new Map<string, Set<string>>();
|
||||
for (const d of docs) {
|
||||
const h = d.metadata.heading as string | null;
|
||||
if (!h) continue;
|
||||
const leaf = h.split('›').pop()!.trim();
|
||||
if (leaf.length < 8) continue;
|
||||
if (!byHeading.has(leaf)) byHeading.set(leaf, new Set());
|
||||
byHeading.get(leaf)!.add(d.id);
|
||||
}
|
||||
for (const [heading, ids] of byHeading) {
|
||||
queries.push({ klass: 'known-item', query: heading, expected: ids });
|
||||
}
|
||||
|
||||
// literal: rare identifier-ish tokens → chunks literally containing them.
|
||||
const occurrences = new Map<string, Set<string>>();
|
||||
for (const d of docs) {
|
||||
for (const t of new Set(d.text.toLowerCase().match(/[a-z][a-z0-9_-]{5,}/g) ?? [])) {
|
||||
if (!occurrences.has(t)) occurrences.set(t, new Set());
|
||||
occurrences.get(t)!.add(d.id);
|
||||
}
|
||||
}
|
||||
for (const [token, ids] of occurrences) {
|
||||
if (ids.size > 3) continue; // not rare enough to be a known-item probe
|
||||
// Expected set is every chunk literally containing it, not just the tokenized hits.
|
||||
const containing = new Set(docs.filter((d) => d.text.toLowerCase().includes(token)).map((d) => d.id));
|
||||
queries.push({ klass: 'literal', query: token, expected: containing });
|
||||
}
|
||||
|
||||
// handwritten: the reality anchor.
|
||||
const byPath = (path: string) => new Set(docs.filter((d) => d.metadata.path === path).map((d) => d.id));
|
||||
const handwritten: Array<[string, string]> = [
|
||||
['how do I deploy the agent with Intune', 'troubleshooting/faq.md'],
|
||||
['where is my data stored', 'troubleshooting/faq.md'],
|
||||
['which role is allowed to delete a session', 'portal-guide/dashboard-and-sessions.md'],
|
||||
['how do I set up Teams notifications', 'reference/settings.md'],
|
||||
['what firewall URLs do I need to allow', 'reference/network-endpoints.md'],
|
||||
['is Autopilot Monitor free', 'troubleshooting/faq.md'],
|
||||
['how long is my data retained before deletion', 'trust/security-faq.md'],
|
||||
['what is a gather rule and when does it run', 'rules/gather-rules.md'],
|
||||
['what can helpdesk staff see in the progress portal', 'portal-guide/progress-portal.md'],
|
||||
['can an MSP access my tenant', 'trust/security-faq.md'],
|
||||
['Office Click-to-Run install never finished', 'rules/analyze-rules/built-in-rules.md'],
|
||||
['proxy configuration blocking app downloads', 'rules/analyze-rules/built-in-rules.md'],
|
||||
];
|
||||
for (const [query, path] of handwritten) {
|
||||
queries.push({ klass: 'handwritten', query, expected: byPath(path) });
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
// ── Strategies ───────────────────────────────────────────────
|
||||
|
||||
interface Candidates {
|
||||
semantic: SearchResult[]; // sorted desc, unfiltered
|
||||
literal: SearchResult[]; // corroborated, ranked by term count
|
||||
}
|
||||
|
||||
type Strategy = (c: Candidates) => SearchResult[];
|
||||
|
||||
/** Deployed behaviour: semantic, topped up by literal only when short of topK. */
|
||||
const baseline: Strategy = ({ semantic, literal }) => {
|
||||
const res = semantic.filter((s) => s.score >= MIN_SCORE).slice(0, TOP_K);
|
||||
if (res.length < TOP_K) {
|
||||
const have = new Set(res.map((r) => r.id));
|
||||
for (const l of literal) {
|
||||
if (res.length >= TOP_K) break;
|
||||
if (have.has(l.id)) continue;
|
||||
have.add(l.id);
|
||||
res.push(l);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* Candidate fix: additionally treat a WEAK semantic head as unconvincing and let
|
||||
* exact literal matches take the front, keeping at least one semantic result so a
|
||||
* weak-but-right hit is never fully displaced.
|
||||
*/
|
||||
function weakTrigger(threshold: number): Strategy {
|
||||
return ({ semantic, literal }) => {
|
||||
const res = semantic.filter((s) => s.score >= MIN_SCORE).slice(0, TOP_K);
|
||||
const have = new Set(res.map((r) => r.id));
|
||||
const fresh = literal.filter((l) => !have.has(l.id));
|
||||
if (res.length < TOP_K) {
|
||||
for (const l of fresh) {
|
||||
if (res.length >= TOP_K) break;
|
||||
res.push(l);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
if (fresh.length > 0 && (res[0]?.score ?? 0) < threshold) {
|
||||
return [...fresh.slice(0, TOP_K - 1), ...res].slice(0, TOP_K);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Scoring ──────────────────────────────────────────────────
|
||||
|
||||
interface ClassStats {
|
||||
n: number;
|
||||
hit: number; // recall@TOP_K
|
||||
mrr: number;
|
||||
}
|
||||
|
||||
function score(results: SearchResult[], expected: Set<string>): { hit: boolean; rr: number } {
|
||||
const rank = results.findIndex((r) => expected.has(r.id));
|
||||
return { hit: rank >= 0, rr: rank >= 0 ? 1 / (rank + 1) : 0 };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const docs = await loadDocsCorpus(DOCS_DIR);
|
||||
console.error(`corpus: ${docs.length} chunks`);
|
||||
const index = new VectorSearchProvider();
|
||||
await index.index(docs);
|
||||
console.error(`indexed: ${index.vectorCount} vectors\n`);
|
||||
|
||||
const queries = buildQueries(docs);
|
||||
const counts = queries.reduce<Record<string, number>>((a, q) => ({ ...a, [q.klass]: (a[q.klass] ?? 0) + 1 }), {});
|
||||
console.error(`queries: ${queries.length} (${Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(', ')})`);
|
||||
console.error('embedding queries…');
|
||||
|
||||
// One inference pass; strategies are pure functions over these candidates.
|
||||
const candidates: Candidates[] = [];
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
const q = queries[i];
|
||||
const semantic = await index.search(q.query, { topK: 10, minScore: -1 });
|
||||
const terms = extractDistinctiveTerms(q.query);
|
||||
let literal: SearchResult[] = [];
|
||||
if (terms.length > 0) {
|
||||
const required = Math.min(2, terms.length);
|
||||
literal = index
|
||||
.lexicalMatch(terms)
|
||||
.map((hit) => ({ hit, matched: terms.filter((t) => hit.text.toLowerCase().includes(t)).length }))
|
||||
.filter((x) => x.matched >= required)
|
||||
.sort((a, b) => b.matched - a.matched)
|
||||
.map((x) => x.hit);
|
||||
}
|
||||
candidates.push({ semantic, literal });
|
||||
if ((i + 1) % 200 === 0) console.error(` ${i + 1}/${queries.length}`);
|
||||
}
|
||||
|
||||
const strategies: Array<[string, Strategy]> = [
|
||||
['baseline (deployed)', baseline],
|
||||
['weak<0.30', weakTrigger(0.30)],
|
||||
['weak<0.35', weakTrigger(0.35)],
|
||||
['weak<0.40', weakTrigger(0.40)],
|
||||
['weak<0.45', weakTrigger(0.45)],
|
||||
];
|
||||
|
||||
// Per-query correctness under the baseline, for the fixed/broken delta.
|
||||
const baseHit = queries.map((q, i) => score(baseline(candidates[i]), q.expected).hit);
|
||||
|
||||
console.log(`\n${'strategy'.padEnd(20)} ${'class'.padEnd(12)} ${'n'.padStart(5)} ${'recall@3'.padStart(9)} ${'MRR'.padStart(7)} ${'fixed'.padStart(6)} ${'broken'.padStart(7)}`);
|
||||
console.log('-'.repeat(75));
|
||||
|
||||
for (const [name, strat] of strategies) {
|
||||
const stats = new Map<string, ClassStats>();
|
||||
let fixed = 0;
|
||||
let broken = 0;
|
||||
queries.forEach((q, i) => {
|
||||
const { hit, rr } = score(strat(candidates[i]), q.expected);
|
||||
const s = stats.get(q.klass) ?? { n: 0, hit: 0, mrr: 0 };
|
||||
s.n++;
|
||||
if (hit) s.hit++;
|
||||
s.mrr += rr;
|
||||
stats.set(q.klass, s);
|
||||
if (hit && !baseHit[i]) fixed++;
|
||||
if (!hit && baseHit[i]) broken++;
|
||||
});
|
||||
for (const [klass, s] of [...stats].sort()) {
|
||||
console.log(
|
||||
`${name.padEnd(20)} ${klass.padEnd(12)} ${String(s.n).padStart(5)} ` +
|
||||
`${((s.hit / s.n) * 100).toFixed(1).padStart(8)}% ${(s.mrr / s.n).toFixed(3).padStart(7)} ` +
|
||||
`${'—'.padStart(6)} ${'—'.padStart(7)}`,
|
||||
);
|
||||
}
|
||||
console.log(`${name.padEnd(20)} ${'TOTAL'.padEnd(12)} ${String(queries.length).padStart(5)} ${''.padStart(9)} ${''.padStart(7)} ${String(fixed).padStart(6)} ${String(broken).padStart(7)}`);
|
||||
console.log('-'.repeat(75));
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Unit tests for the customer-documentation corpus loader.
|
||||
*
|
||||
* The bundle is authored on Windows and ships CRLF, which is the single most
|
||||
* dangerous property of this input: in JavaScript `.` does not match `\r`, so a
|
||||
* pattern like /^(#{2,3})\s+(.*)$/ silently matches NOTHING on a CRLF file. The
|
||||
* failure is not a crash — headings and frontmatter just stop being recognized
|
||||
* and every page degrades to unlabelled paragraph splits. Several tests below
|
||||
* therefore run the CRLF form on purpose.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadDocsCorpus, parseFrontmatter, docUrl, slugify, docSections } from '../docs-corpus.js';
|
||||
|
||||
async function withBundle<T>(
|
||||
files: Record<string, string>,
|
||||
fn: (root: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'docs-corpus-'));
|
||||
try {
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = join(root, rel);
|
||||
await mkdir(join(full, '..'), { recursive: true });
|
||||
await writeFile(full, content, 'utf-8');
|
||||
}
|
||||
return await fn(root);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
describe('parseFrontmatter', () => {
|
||||
it('reads scalars, folded blocks and inline sequences', () => {
|
||||
const { frontmatter, body } = parseFrontmatter(
|
||||
['---', 'type: Concept', 'tags: [security, gdpr]', 'description: >-', ' First line', ' second line', '---', '', '# Title', 'Body.'].join('\n'),
|
||||
);
|
||||
expect(frontmatter.type).toBe('Concept');
|
||||
expect(frontmatter.tags).toEqual(['security', 'gdpr']);
|
||||
expect(frontmatter.description).toBe('First line second line');
|
||||
expect(body).toContain('# Title');
|
||||
expect(body).not.toContain('type: Concept');
|
||||
});
|
||||
|
||||
it('reads block sequences', () => {
|
||||
const { frontmatter } = parseFrontmatter(['---', 'type: Guide', 'tags:', ' - agent', ' - intune', '---', 'x'].join('\n'));
|
||||
expect(frontmatter.tags).toEqual(['agent', 'intune']);
|
||||
});
|
||||
|
||||
it('parses CRLF frontmatter identically to LF', () => {
|
||||
const lf = ['---', 'type: Concept', 'tags: [a, b]', '---', '', '# T', 'x'].join('\n');
|
||||
expect(parseFrontmatter(lf.replace(/\n/g, '\r\n'))).toEqual(parseFrontmatter(lf));
|
||||
});
|
||||
|
||||
it('returns the whole input as body when there is no frontmatter', () => {
|
||||
const { frontmatter, body } = parseFrontmatter('# Just a title\n\nText.');
|
||||
expect(frontmatter).toEqual({ type: undefined, title: undefined, description: undefined, tags: undefined });
|
||||
expect(body).toBe('# Just a title\n\nText.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('docUrl', () => {
|
||||
it('maps a page path to its published URL', () => {
|
||||
expect(docUrl('trust/security-faq.md')).toBe('https://docs.autopilotmonitor.com/trust/security-faq');
|
||||
});
|
||||
|
||||
it('collapses README.md to its folder', () => {
|
||||
expect(docUrl('rules/analyze-rules/README.md')).toBe('https://docs.autopilotmonitor.com/rules/analyze-rules');
|
||||
expect(docUrl('README.md')).toBe('https://docs.autopilotmonitor.com');
|
||||
});
|
||||
|
||||
it('has no URL for index.md — GitBook never publishes the OKF catalog', () => {
|
||||
expect(docUrl('index.md')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('slugify', () => {
|
||||
it('strips markdown and punctuation', () => {
|
||||
expect(slugify('Is the device\'s **IP address** stored?')).toBe('is-the-device-s-ip-address-stored');
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadDocsCorpus', () => {
|
||||
const page = (fm: string, body: string) => `---\n${fm}\n---\n\n${body}`;
|
||||
|
||||
it('splits on h2/h3 headings and carries a breadcrumb', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'concepts/roles.md': page(
|
||||
'type: Concept\ntags: [roles]',
|
||||
['# Roles & Permissions', '', 'Intro prose that is long enough to survive the minimum-length filter for chunks.', '', '## Tenant roles', '', 'A tenant admin can do everything, including managing team members and enabling admin mode.', '', '### Operator', '', 'An operator monitors day to day and cannot perform destructive operations at all.'].join('\n'),
|
||||
),
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
|
||||
const headings = chunks.map((c) => c.metadata.heading);
|
||||
expect(headings).toContain('Tenant roles');
|
||||
expect(headings).toContain('Tenant roles › Operator');
|
||||
// The page title is prepended to every chunk so a mid-page hit is self-describing.
|
||||
expect(chunks.every((c) => c.text.startsWith('Roles & Permissions'))).toBe(true);
|
||||
expect(chunks[0].metadata.section).toBe('concepts');
|
||||
expect(chunks[0].metadata.tags).toEqual(['roles']);
|
||||
});
|
||||
|
||||
it('produces the SAME chunks for CRLF input as for LF', async () => {
|
||||
const body = ['# Guide', '', 'Intro paragraph long enough to be kept as its own indexed chunk here.', '', '## Step one', '', 'Do the first thing, which needs a sentence long enough to pass the minimum.', '', '## Step two', '', 'Then do the second thing, also written out at a reasonable length for this.'].join('\n');
|
||||
const src = page('type: Guide\ntags: [setup]', body);
|
||||
|
||||
const lf = await withBundle({ 'getting-started/g.md': src }, (r) => loadDocsCorpus(r));
|
||||
const crlf = await withBundle({ 'getting-started/g.md': src.replace(/\n/g, '\r\n') }, (r) => loadDocsCorpus(r));
|
||||
|
||||
expect(crlf).toEqual(lf);
|
||||
expect(lf.map((c) => c.metadata.heading)).toEqual([null, 'Step one', 'Step two']);
|
||||
expect(lf.some((c) => c.text.includes('\r'))).toBe(false);
|
||||
});
|
||||
|
||||
it('treats <details>/<summary> blocks as sections', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'troubleshooting/faq.md': page(
|
||||
'type: FAQ\ntags: [faq]',
|
||||
['# FAQ', '', '## General', '', '<details>', '', '<summary>Is Autopilot Monitor free?</summary>', '', 'Yes, the Community plan is free and stays free for everyone who wants to use it.', '', '</details>'].join('\n'),
|
||||
),
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
expect(chunks.map((c) => c.metadata.heading)).toContain('General › Is Autopilot Monitor free?');
|
||||
});
|
||||
|
||||
it('never emits a heading from inside a fenced code block', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'reference/cli.md': page(
|
||||
'type: Reference\ntags: [cli]',
|
||||
['# CLI', '', '## Usage', '', '```powershell', '# This is a shell comment, not a heading', '## Neither is this', '```', '', 'Explanatory prose after the block so the section is comfortably long enough.'].join('\n'),
|
||||
),
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
expect(chunks.map((c) => c.metadata.heading)).toEqual(['Usage']);
|
||||
});
|
||||
|
||||
it('skips SUMMARY.md and log.md but keeps index.md', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'SUMMARY.md': '# Table of contents\n\n* [A](a.md)\n',
|
||||
'log.md': '# Log\n\nSome change record entry that is long enough to be a chunk on its own.\n',
|
||||
'index.md': page('okf_version: "0.1"', '# Bundle\n\nThe documentation map listing every page available in this bundle here.'),
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
const paths = [...new Set(chunks.map((c) => c.metadata.path))];
|
||||
expect(paths).toEqual(['index.md']);
|
||||
// index.md is indexed for its content but is not a published page.
|
||||
expect(chunks[0].metadata.url).toBeNull();
|
||||
});
|
||||
|
||||
it('splits an oversized unbroken list so its tail is not truncated away', async () => {
|
||||
const items = Array.from({ length: 60 }, (_, i) => `* Release note number ${i} describing a change that shipped in this release.`);
|
||||
const chunks = await withBundle(
|
||||
{ 'changelog/platform.md': page('type: Changelog\ntags: [changelog]', ['# Platform Changelog', '', '## July 2026', '', ...items].join('\n')) },
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((c) => c.text.length <= 1700)).toBe(true);
|
||||
// The final item must still be reachable — that is the whole point of splitting.
|
||||
expect(chunks.some((c) => c.text.includes('number 59'))).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps a markdown table intact rather than splitting mid-table', async () => {
|
||||
const rows = Array.from({ length: 40 }, (_, i) => `| Setting ${i} | A reasonably wordy description of what this particular setting controls. |`);
|
||||
const chunks = await withBundle(
|
||||
{ 'reference/settings.md': page('type: Reference\ntags: [settings]', ['# Settings', '', '## All settings', '', '| Name | Description |', '| --- | --- |', ...rows].join('\n')) },
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
const withTable = chunks.filter((c) => c.text.includes('| Setting '));
|
||||
expect(withTable).toHaveLength(1);
|
||||
expect(withTable[0].text).toContain('| Setting 39 |');
|
||||
});
|
||||
|
||||
it('assigns unique, stable ids across repeated loads', async () => {
|
||||
const files = {
|
||||
'a/one.md': page('type: Guide\ntags: [x]', '# One\n\n## Same\n\nFirst body text that is long enough to be indexed as a chunk.\n\n## Same\n\nSecond body text that is also long enough to be indexed here.'),
|
||||
};
|
||||
const first = await withBundle(files, (r) => loadDocsCorpus(r));
|
||||
const second = await withBundle(files, (r) => loadDocsCorpus(r));
|
||||
expect(new Set(first.map((c) => c.id)).size).toBe(first.length);
|
||||
expect(first.map((c) => c.id)).toEqual(second.map((c) => c.id));
|
||||
});
|
||||
|
||||
it('returns an empty corpus for a missing directory instead of throwing', async () => {
|
||||
await expect(loadDocsCorpus(join(tmpdir(), 'definitely-not-here-9d2f'))).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('docSections', () => {
|
||||
it('lists distinct top-level areas, sorted', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'trust/a.md': '# A\n\nSome prose that is definitely long enough to become an indexed chunk.',
|
||||
'concepts/b.md': '# B\n\nMore prose that is definitely long enough to become an indexed chunk.',
|
||||
'root.md': '# R\n\nRoot level prose that is long enough to become an indexed chunk too.',
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
expect(docSections(chunks)).toEqual(['concepts', 'general', 'trust']);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,19 @@ const MODEL = 'Xenova/all-MiniLM-L6-v2';
|
||||
const doc = (id: string, text: string, metadata: Record<string, unknown> = {}): SearchDocument =>
|
||||
({ id, text, metadata });
|
||||
|
||||
/**
|
||||
* Fake embeddings still have to satisfy the real contract: indexPrecomputed()
|
||||
* asserts L2-unit norm, because cosineSimilarity is a bare dot product that would
|
||||
* otherwise return silently wrong scores. Normalize here rather than hand-writing
|
||||
* unit vectors, so a test can pass any direction it finds readable.
|
||||
*/
|
||||
const unit = (v: number[]): number[] => {
|
||||
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
|
||||
return v.map((x) => x / norm);
|
||||
};
|
||||
|
||||
const withEmbedding = (d: SearchDocument, embedding: number[] = [0.1, 0.2, 0.3]): PrecomputedDocument =>
|
||||
({ ...d, embedding });
|
||||
({ ...d, embeddings: [unit(embedding)] });
|
||||
|
||||
const KB_DOCS = [doc('rule-1', 'TPM not ready', { type: 'analyze-rule' }), doc('rule-2', 'ESP timeout', { type: 'analyze-rule' })];
|
||||
const ET_DOCS = [doc('et-1', 'bitlocker status', { eventType: 'bitlocker_status' })];
|
||||
@@ -99,14 +110,73 @@ describe('validatePrecomputedIndex', () => {
|
||||
|
||||
it('rejects inconsistent embedding dimensions', () => {
|
||||
const f = validFile();
|
||||
f.knowledgeBase.entries[1].embedding = [0.1, 0.2]; // 2 dims vs 3
|
||||
f.knowledgeBase.entries[1].embeddings = [[0.1, 0.2]]; // 2 dims vs 3
|
||||
expect(validatePrecomputedIndex(f, MODEL, KB_DOCS, ET_DOCS)).toMatchObject({
|
||||
ok: false, reason: expect.stringContaining('dimensions'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('docs section (optional third corpus)', () => {
|
||||
const DOC_CHUNKS = [doc('docs:trust/security-faq.md#intro', 'Where is my data stored', { type: 'doc', section: 'trust' })];
|
||||
|
||||
const withDocs = (): PrecomputedIndexFile => ({
|
||||
...validFile(),
|
||||
docs: { docsHash: hashDocs(DOC_CHUNKS), entries: DOC_CHUNKS.map((d) => withEmbedding(d)) },
|
||||
});
|
||||
|
||||
it('returns the docs entries when the corpus matches', () => {
|
||||
const result = validatePrecomputedIndex(withDocs(), MODEL, KB_DOCS, ET_DOCS, DOC_CHUNKS);
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(result.ok && result.docs).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('rejects the whole file when the docs corpus changed since the build', () => {
|
||||
const stale = [doc('docs:trust/security-faq.md#intro', 'DIFFERENT TEXT', { type: 'doc', section: 'trust' })];
|
||||
expect(validatePrecomputedIndex(withDocs(), MODEL, KB_DOCS, ET_DOCS, stale)).toMatchObject({
|
||||
ok: false, reason: expect.stringContaining('docs'),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when the server has docs but the image shipped none — that is staleness, not absence', () => {
|
||||
expect(validatePrecomputedIndex(validFile(), MODEL, KB_DOCS, ET_DOCS, DOC_CHUNKS)).toMatchObject({
|
||||
ok: false, reason: expect.stringContaining('docs'),
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a file WITHOUT a docs section when the server has no docs corpus', () => {
|
||||
// A local checkout has no docs repo. That must not throw away the rules and
|
||||
// event-type vectors too — recomputing those is the expensive half.
|
||||
const result = validatePrecomputedIndex(validFile(), MODEL, KB_DOCS, ET_DOCS, []);
|
||||
expect(result).toMatchObject({ ok: true });
|
||||
expect(result.ok && result.docs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VectorSearchProvider serialize/hydrate roundtrip', () => {
|
||||
it('rounds embeddings on serialize but stays well inside the unit-norm tolerance', () => {
|
||||
const p = new VectorSearchProvider();
|
||||
const raw = unit([0.123456789012345, -0.987654321098765, 0.5555555555555]);
|
||||
p.indexPrecomputed([{ ...doc('d1', 'text'), embeddings: [raw] }]);
|
||||
|
||||
const [out] = p.serialize();
|
||||
// Rounded to 6 decimals — the point of the exercise (halves the index JSON).
|
||||
expect(out.embeddings[0].every((v) => v === Number(v.toFixed(6)))).toBe(true);
|
||||
expect(out.embeddings[0]).not.toEqual(raw);
|
||||
|
||||
const norm = Math.sqrt(out.embeddings[0].reduce((s, x) => s + x * x, 0));
|
||||
expect(Math.abs(norm - 1)).toBeLessThan(1e-5);
|
||||
// Re-hydrating the rounded vectors must not trip the assertion.
|
||||
expect(() => new VectorSearchProvider().indexPrecomputed(p.serialize())).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a non-unit embedding on hydrate rather than skewing every score silently', () => {
|
||||
const p = new VectorSearchProvider();
|
||||
expect(() => p.indexPrecomputed([{ ...doc('d1', 'text'), embeddings: [[0.1, 0.2, 0.3]] }]))
|
||||
.toThrow(/not L2-unit-normalized/);
|
||||
});
|
||||
|
||||
|
||||
it('indexPrecomputed restores exactly what serialize exported, without the model', () => {
|
||||
const source = new VectorSearchProvider();
|
||||
source.indexPrecomputed(KB_DOCS.map((d, i) => withEmbedding(d, [i + 0.5, i + 1.5])));
|
||||
|
||||
@@ -20,16 +20,62 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { registerTools } from '../tools.js';
|
||||
import type { SearchProvider } from '../search-provider.js';
|
||||
|
||||
function registeredToolNames(ga: boolean, strictGa: boolean = ga, delegated: boolean = false): string[] {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
// knowledgeBase / eventTypeIndex are optional — pass undefined so this stays a
|
||||
// pure unit test with no search-provider or backend dependency.
|
||||
registerTools(server, undefined, undefined, ga, strictGa, delegated);
|
||||
registerTools(server, undefined, undefined, undefined, ga, strictGa, delegated);
|
||||
const internal = server as unknown as { _registeredTools: Record<string, unknown> };
|
||||
return Object.keys(internal._registeredTools);
|
||||
}
|
||||
|
||||
/** Minimal stand-in for an indexed docs corpus — registration only checks `size`. */
|
||||
function stubDocsIndex(size = 3): SearchProvider {
|
||||
return {
|
||||
name: 'stub',
|
||||
semanticCapable: true,
|
||||
size,
|
||||
index: async () => {},
|
||||
search: async () => [],
|
||||
};
|
||||
}
|
||||
|
||||
function namesWithDocs(ga: boolean, strictGa: boolean = ga, delegated: boolean = false): string[] {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, { vector: stubDocsIndex(), sections: ['concepts', 'trust'] }, ga, strictGa, delegated);
|
||||
const internal = server as unknown as { _registeredTools: Record<string, unknown> };
|
||||
return Object.keys(internal._registeredTools);
|
||||
}
|
||||
|
||||
describe('search_docs registration', () => {
|
||||
it('is NOT registered when no documentation corpus was baked into the image', () => {
|
||||
expect(registeredToolNames(true)).not.toContain('search_docs');
|
||||
});
|
||||
|
||||
it('is registered when a corpus is present — for EVERY role, including a plain tenant user', () => {
|
||||
// Published product documentation carries no tenant data, so unlike the
|
||||
// platform tools it is deliberately ungated. A tenant user asking "how do I
|
||||
// deploy the agent" has exactly as much business here as a Global Admin.
|
||||
expect(namesWithDocs(true, true)).toContain('search_docs');
|
||||
expect(namesWithDocs(false, false)).toContain('search_docs');
|
||||
expect(namesWithDocs(false, false, true)).toContain('search_docs');
|
||||
});
|
||||
|
||||
it('is skipped for an empty corpus, so a doc-less build advertises no broken tool', () => {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, { vector: stubDocsIndex(0), sections: [] }, true, true, false);
|
||||
const internal = server as unknown as { _registeredTools: Record<string, unknown> };
|
||||
expect(Object.keys(internal._registeredTools)).not.toContain('search_docs');
|
||||
});
|
||||
|
||||
it('keeps the catalog alphabetically sorted once it joins', () => {
|
||||
const names = namesWithDocs(true);
|
||||
expect(names).toEqual([...names].sort());
|
||||
});
|
||||
});
|
||||
|
||||
describe('tool catalog ordering', () => {
|
||||
it('lists tools in alphabetical order for a Global Admin', () => {
|
||||
const names = registeredToolNames(true);
|
||||
|
||||
@@ -27,7 +27,7 @@ const MANAGED = 'aaaa-1111-managed';
|
||||
/** Build a server for the given caller shape and return a tool's raw handler by name. */
|
||||
function handlerFor(name: string, opts: { ga?: boolean; strictGa?: boolean; delegated?: boolean }): ToolHandler {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, opts.ga ?? false, opts.strictGa ?? (opts.ga ?? false), opts.delegated ?? false);
|
||||
registerTools(server, undefined, undefined, undefined, opts.ga ?? false, opts.strictGa ?? (opts.ga ?? false), opts.delegated ?? false);
|
||||
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
|
||||
const tool = registry[name];
|
||||
if (!tool) throw new Error(`tool ${name} not registered for this caller shape`);
|
||||
|
||||
@@ -29,7 +29,7 @@ type ToolHandler = (args: Record<string, unknown>, extra: unknown) => Promise<{
|
||||
/** Build a strict-GA server and return a tool's raw handler by name. */
|
||||
function gaHandler(name: string): ToolHandler {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, /*ga*/ true, /*strictGa*/ true, /*delegated*/ false);
|
||||
registerTools(server, undefined, undefined, undefined, /*ga*/ true, /*strictGa*/ true, /*delegated*/ false);
|
||||
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
|
||||
const tool = registry[name];
|
||||
if (!tool) throw new Error(`tool ${name} not registered for strict-GA caller`);
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Unit tests for the search_docs handler — specifically the keyword fallback.
|
||||
*
|
||||
* Semantic search misses exact proper nouns that embed poorly. Measured on the real
|
||||
* corpus, "RealmJoin" occurs in 3 chunks yet scores below the semantic threshold,
|
||||
* while fuzzy matching at a threshold low enough to surface it also returns 5 hits
|
||||
* for "SCEPman", a word the corpus does not contain at all. The fallback therefore
|
||||
* uses literal containment, and the contract under test is:
|
||||
* appended after semantic hits, flagged, never re-sorted together, never invented.
|
||||
*
|
||||
* The provider is a stub: no model, no backend, no corpus on disk.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { registerTools } from '../tools.js';
|
||||
import { extractDistinctiveTerms } from '../tools/search.js';
|
||||
import type { SearchDocument, SearchProvider, SearchResult } from '../search-provider.js';
|
||||
|
||||
type ToolHandler = (args: Record<string, unknown>) => Promise<{ content: Array<{ text: string }> }>;
|
||||
|
||||
const chunk = (id: string, section: string, text = 'body'): SearchResult => ({
|
||||
id,
|
||||
text,
|
||||
metadata: { title: id, heading: null, section, path: `${section}/x.md`, url: null, tags: [] },
|
||||
score: 0.5,
|
||||
});
|
||||
|
||||
/**
|
||||
* Provider returning fixed lists, ignoring the query. `lexical` omitted entirely
|
||||
* models a provider that cannot expose document text (lexicalMatch is optional).
|
||||
*/
|
||||
function stubProvider(semantic: SearchResult[], lexical?: SearchResult[]): SearchProvider {
|
||||
return {
|
||||
name: 'stub',
|
||||
semanticCapable: true,
|
||||
size: Math.max(semantic.length, 1),
|
||||
index: async (_docs: SearchDocument[]) => {},
|
||||
search: async () => semantic,
|
||||
...(lexical ? { lexicalMatch: () => lexical } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function searchDocsHandler(provider: SearchProvider): ToolHandler {
|
||||
const server = new McpServer({ name: 'test', version: '0.0.0' });
|
||||
registerTools(server, undefined, undefined, { vector: provider, sections: ['trust', 'reference'] }, true, true, false);
|
||||
const registry = (server as unknown as { _registeredTools: Record<string, { handler: ToolHandler }> })._registeredTools;
|
||||
return registry.search_docs.handler;
|
||||
}
|
||||
|
||||
async function run(handler: ToolHandler, args: Record<string, unknown>) {
|
||||
const res = await handler(args);
|
||||
return JSON.parse(res.content[0].text) as {
|
||||
resultCount: number;
|
||||
keywordFallback?: { used: boolean; added: number };
|
||||
results: Array<{ id: string; matchType?: string; section: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('extractDistinctiveTerms', () => {
|
||||
it('keeps product and setting names, drops filler', () => {
|
||||
expect(extractDistinctiveTerms('how does the RealmJoin watcher work'))
|
||||
.toEqual(['realmjoin', 'watcher', 'work']);
|
||||
});
|
||||
|
||||
it('drops words too short to be distinctive', () => {
|
||||
expect(extractDistinctiveTerms('who can do it')).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps identifier-shaped tokens intact', () => {
|
||||
expect(extractDistinctiveTerms('what is CollectorIdleTimeoutMinutes'))
|
||||
.toContain('collectoridletimeoutminutes');
|
||||
});
|
||||
|
||||
it('deduplicates and caps the term list', () => {
|
||||
const terms = extractDistinctiveTerms('alpha alpha beta gamma delta epsilon zeta eta theta');
|
||||
expect(new Set(terms).size).toBe(terms.length);
|
||||
expect(terms.length).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search_docs keyword fallback', () => {
|
||||
it('does not fire when semantic search already fills topK', async () => {
|
||||
const handler = searchDocsHandler(
|
||||
stubProvider([chunk('sem-1', 'trust'), chunk('sem-2', 'trust')], [chunk('kw-1', 'trust')]),
|
||||
);
|
||||
|
||||
const body = await run(handler, { query: 'notifications setup', topK: 2 });
|
||||
|
||||
expect(body.resultCount).toBe(2);
|
||||
expect(body.keywordFallback).toBeUndefined();
|
||||
expect(body.results.every((r) => r.matchType === undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('tops up a short result set and flags the added hits', async () => {
|
||||
const both = 'the realmjoin watcher toggle';
|
||||
const handler = searchDocsHandler(
|
||||
stubProvider([chunk('sem-1', 'trust')], [chunk('kw-1', 'trust', both), chunk('kw-2', 'trust', both)]),
|
||||
);
|
||||
|
||||
const body = await run(handler, { query: 'realmjoin watcher', topK: 3 });
|
||||
|
||||
expect(body.resultCount).toBe(3);
|
||||
expect(body.keywordFallback).toEqual({ used: true, added: 2 });
|
||||
// Semantic keeps the head; keyword fills the tail. Never interleaved — literal
|
||||
// hits carry no cosine similarity to sort against.
|
||||
expect(body.results.map((r) => r.matchType)).toEqual([undefined, 'keyword', 'keyword']);
|
||||
});
|
||||
|
||||
it('ranks fallback hits by how many query terms they contain', async () => {
|
||||
const handler = searchDocsHandler(
|
||||
stubProvider([], [
|
||||
chunk('two-terms', 'trust', 'mentions realmjoin and the watcher'),
|
||||
chunk('three-terms', 'trust', 'mentions realmjoin, the watcher and its toggle'),
|
||||
]),
|
||||
);
|
||||
|
||||
const body = await run(handler, { query: 'realmjoin watcher toggle', topK: 2 });
|
||||
|
||||
expect(body.results.map((r) => r.id)).toEqual(['three-terms', 'two-terms']);
|
||||
});
|
||||
|
||||
it('requires corroboration: one incidental term match is not a hit', async () => {
|
||||
// "bake" is a substring of "baked into the image" — matching ANY single term
|
||||
// would return a page about LLM providers for a sourdough question. Measured
|
||||
// on the real corpus before the corroboration rule was added.
|
||||
const handler = searchDocsHandler(
|
||||
stubProvider([], [chunk('incidental', 'trust', 'the corpus is baked into the image')]),
|
||||
);
|
||||
|
||||
const body = await run(handler, { query: 'how do I bake sourdough bread', topK: 3 });
|
||||
|
||||
expect(body.resultCount).toBe(0);
|
||||
});
|
||||
|
||||
it('never duplicates a document already returned semantically', async () => {
|
||||
const shared = chunk('same-doc', 'trust', 'realmjoin');
|
||||
const handler = searchDocsHandler(stubProvider([shared], [shared, chunk('kw-1', 'trust', 'realmjoin')]));
|
||||
|
||||
const body = await run(handler, { query: 'realmjoin', topK: 3 });
|
||||
|
||||
expect(body.results.map((r) => r.id)).toEqual(['same-doc', 'kw-1']);
|
||||
expect(body.keywordFallback).toEqual({ used: true, added: 1 });
|
||||
});
|
||||
|
||||
it('honours the section filter for fallback hits too', async () => {
|
||||
const handler = searchDocsHandler(
|
||||
stubProvider([], [chunk('kw-other', 'reference', 'realmjoin'), chunk('kw-match', 'trust', 'realmjoin')]),
|
||||
);
|
||||
|
||||
const body = await run(handler, { query: 'realmjoin', topK: 3, section: 'trust' });
|
||||
|
||||
expect(body.results.map((r) => r.id)).toEqual(['kw-match']);
|
||||
expect(body.results.every((r) => r.section === 'trust')).toBe(true);
|
||||
});
|
||||
|
||||
it('lets exact matches take the front when the semantic head is weak', async () => {
|
||||
// The WOW6432Node case: semantic fills topK with chunks scoring ~0.29 that do
|
||||
// not contain the term, while the one chunk that does never gets considered.
|
||||
// Measured over 2465 queries, acting on a weak head fixed 335 and broke 0.
|
||||
const weak = (id: string) => ({ ...chunk(id, 'trust'), score: 0.29 });
|
||||
const handler = searchDocsHandler(
|
||||
stubProvider(
|
||||
[weak('weak-1'), weak('weak-2'), weak('weak-3')],
|
||||
[chunk('exact', 'trust', 'mentions wow6432node verbatim')],
|
||||
),
|
||||
);
|
||||
|
||||
const body = await run(handler, { query: 'wow6432node', topK: 3 });
|
||||
|
||||
expect(body.results[0].id).toBe('exact');
|
||||
expect(body.results[0].matchType).toBe('keyword');
|
||||
// At least one semantic result survives — a weak-but-correct hit is never
|
||||
// pushed out entirely.
|
||||
expect(body.results.map((r) => r.id)).toContain('weak-1');
|
||||
expect(body.keywordFallback).toEqual({ used: true, added: 1 });
|
||||
});
|
||||
|
||||
it('leaves a confident semantic ranking untouched', async () => {
|
||||
const strong = (id: string) => ({ ...chunk(id, 'trust'), score: 0.72 });
|
||||
const handler = searchDocsHandler(
|
||||
stubProvider(
|
||||
[strong('sem-1'), strong('sem-2'), strong('sem-3')],
|
||||
[chunk('exact', 'trust', 'mentions wow6432node verbatim')],
|
||||
),
|
||||
);
|
||||
|
||||
const body = await run(handler, { query: 'wow6432node', topK: 3 });
|
||||
|
||||
expect(body.results.map((r) => r.id)).toEqual(['sem-1', 'sem-2', 'sem-3']);
|
||||
expect(body.keywordFallback).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns nothing when neither pass matches — no invented results', async () => {
|
||||
const handler = searchDocsHandler(stubProvider([], []));
|
||||
|
||||
const body = await run(handler, { query: 'how do I bake sourdough bread', topK: 5 });
|
||||
|
||||
expect(body.resultCount).toBe(0);
|
||||
expect(body.results).toEqual([]);
|
||||
expect(body.keywordFallback).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips the fallback when the query has no distinctive terms', async () => {
|
||||
const handler = searchDocsHandler(stubProvider([], [chunk('kw-1', 'trust')]));
|
||||
|
||||
const body = await run(handler, { query: 'who can do it', topK: 3 });
|
||||
|
||||
expect(body.resultCount).toBe(0);
|
||||
});
|
||||
|
||||
it('works with a provider that exposes no lexicalMatch at all', async () => {
|
||||
const handler = searchDocsHandler(stubProvider([chunk('sem-1', 'trust')]));
|
||||
|
||||
const body = await run(handler, { query: 'realmjoin watcher', topK: 3 });
|
||||
|
||||
expect(body.resultCount).toBe(1);
|
||||
expect(body.keywordFallback).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
* assertUnitNorm directly with canned vectors — no ML model load required.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { assertUnitNorm } from '../vector-search-provider.js';
|
||||
import { assertUnitNorm, splitIntoWindows, VectorSearchProvider, embed } from '../vector-search-provider.js';
|
||||
|
||||
/** Build an L2-unit vector of the given dimension (all components equal). */
|
||||
function unitVector(dim: number): number[] {
|
||||
@@ -46,3 +46,62 @@ describe('assertUnitNorm', () => {
|
||||
expect(() => assertUnitNorm([5, 0], 'index:doc-42')).toThrow(/index:doc-42/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitIntoWindows', () => {
|
||||
it('returns short text unchanged as a single window', async () => {
|
||||
const text = 'Retention is per tenant and configurable, default 90 days.';
|
||||
await expect(splitIntoWindows(text)).resolves.toEqual([text]);
|
||||
});
|
||||
|
||||
it('splits text beyond the model context into several overlapping windows', async () => {
|
||||
// ~1200 distinct words — comfortably past the 250-token window.
|
||||
const long = Array.from({ length: 1200 }, (_, i) => `token${i}`).join(' ');
|
||||
const windows = await splitIntoWindows(long);
|
||||
|
||||
expect(windows.length).toBeGreaterThan(1);
|
||||
// Overlap: consecutive windows must share content, or a sentence sitting on a
|
||||
// boundary would be split across two vectors and match neither well.
|
||||
const tailOfFirst = windows[0].split(/\s+/).slice(-10);
|
||||
expect(tailOfFirst.some((w) => windows[1].includes(w))).toBe(true);
|
||||
// Coverage is the whole point: the last token must survive somewhere.
|
||||
expect(windows.some((w) => w.includes('token1199'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VectorSearchProvider scoring across windows', () => {
|
||||
const unit3 = (v: number[]) => {
|
||||
const n = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
|
||||
return v.map((x) => x / n);
|
||||
};
|
||||
|
||||
it('scores a document by its BEST window, not by its first', async () => {
|
||||
// Uses the real embedder to obtain a query vector, then plants that exact
|
||||
// direction as a document's SECOND window. Before windowing, content past the
|
||||
// first 250 tokens could not be matched at all — this pins that it now can.
|
||||
const query = 'delivery optimization download timeout';
|
||||
const target = await embed(query);
|
||||
const decoy = target.map((_, i) => (i === 0 ? 1 : 0)); // unit, but unrelated direction
|
||||
|
||||
const p = new VectorSearchProvider();
|
||||
p.indexPrecomputed([
|
||||
{ id: 'match-in-second-window', text: 'long doc', metadata: {}, embeddings: [decoy, target] },
|
||||
{ id: 'first-window-only', text: 'short doc', metadata: {}, embeddings: [decoy] },
|
||||
]);
|
||||
|
||||
const results = await p.search(query, { topK: 2, minScore: 0.5 });
|
||||
expect(results[0].id).toBe('match-in-second-window');
|
||||
// Cosine with itself is 1 — proof the second window, not the first, set the score.
|
||||
expect(results[0].score).toBeCloseTo(1, 5);
|
||||
expect(p.vectorCount).toBe(3);
|
||||
expect(p.size).toBe(2);
|
||||
});
|
||||
|
||||
it('counts vectors separately from documents', () => {
|
||||
const p = new VectorSearchProvider();
|
||||
p.indexPrecomputed([
|
||||
{ id: 'a', text: 'a', metadata: {}, embeddings: [unit3([1, 0, 0]), unit3([0, 1, 0]), unit3([0, 0, 1])] },
|
||||
]);
|
||||
expect(p.size).toBe(1);
|
||||
expect(p.vectorCount).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Loads the published customer documentation bundle (autopilotmonitor-docs) and
|
||||
* chunks it into SearchDocuments for semantic search via the `search_docs` tool.
|
||||
*
|
||||
* This is the product-knowledge corpus — "how do I deploy the agent", "where is
|
||||
* my data stored", "which roles exist". It is deliberately SEPARATE from the
|
||||
* rules corpus in knowledge-base.ts: rules answer "why did this session fail",
|
||||
* docs answer "how does the product work". Indexed together they would compete
|
||||
* for the same topK and dilute each other.
|
||||
*
|
||||
* Like the rules corpus, docs are baked into the image and embedded at Docker
|
||||
* build time (see precompute-embeddings.ts) — the 0.25-0.5 vCPU container never
|
||||
* embeds a corpus at boot.
|
||||
*
|
||||
* Chunking matters more here than for rules: a rule is already a short, self-
|
||||
* contained record, whereas a doc page runs to 36 KB (trust/security-faq.md).
|
||||
* MiniLM-L6-v2 truncates at ~256 tokens, so a whole-page embedding would silently
|
||||
* represent only the first paragraph. We therefore split on heading boundaries,
|
||||
* which in this bundle are also the semantic boundaries (one h3 = one question).
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import { join, relative, sep } from 'node:path';
|
||||
import type { SearchDocument } from './search-provider.js';
|
||||
|
||||
/** Published base URL of the bundle — used to build a citable link per chunk. */
|
||||
const DOCS_BASE_URL = 'https://docs.autopilotmonitor.com';
|
||||
|
||||
/**
|
||||
* Upper bound on chunk text. MiniLM-L6-v2's 256-token window is roughly 1000-1200
|
||||
* characters of English prose; going much beyond that means the tail of the chunk
|
||||
* contributes nothing to the embedding. We allow a little slack over that so a
|
||||
* coherent section (or an indivisible table) is not split for the sake of a few
|
||||
* characters — the overflow is only ever the part that gets truncated.
|
||||
*/
|
||||
const MAX_CHUNK_CHARS = 1500;
|
||||
|
||||
/**
|
||||
* A trailing fragment shorter than this is folded back into the preceding chunk
|
||||
* when splitting an oversized section, so a split never leaves a stranded line.
|
||||
*/
|
||||
const MIN_CHUNK_CHARS = 120;
|
||||
|
||||
/**
|
||||
* Minimum body length for a chunk to be indexed at all, EXCLUDING the breadcrumb
|
||||
* header. Set low on purpose: a short section is very often the most valuable
|
||||
* kind of hit ("Is Autopilot Monitor free?" → "Yes — the Community plan is free
|
||||
* and stays free"), and the breadcrumb already supplies the context that a short
|
||||
* body lacks. This filter only exists to drop structural residue — a stray rule
|
||||
* line, an empty section left behind by stripped GitBook markers.
|
||||
*/
|
||||
const MIN_BODY_CHARS = 25;
|
||||
|
||||
/**
|
||||
* Structural/meta files that are not documentation content.
|
||||
* `SUMMARY.md` is the GitBook table of contents and `log.md` is the OKF change
|
||||
* log — both are navigation scaffolding whose text would match queries about
|
||||
* every topic at once. `index.md` (the OKF bundle catalog) is deliberately KEPT:
|
||||
* it is the documentation map and answers "what documentation exists".
|
||||
*/
|
||||
const SKIP_FILES = new Set(['SUMMARY.md', 'log.md']);
|
||||
|
||||
const SKIP_DIRS = new Set(['.git', '.github', '.gitbook', 'node_modules']);
|
||||
|
||||
// ── Frontmatter ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalize CRLF/CR to LF before ANY parsing.
|
||||
*
|
||||
* Not cosmetic — a correctness precondition. The docs repo is authored on
|
||||
* Windows and ships CRLF, and in JavaScript `.` does not match `\r`. A pattern
|
||||
* like /^(#{2,3})\s+(.*)$/ therefore fails on every single heading of a CRLF
|
||||
* file: `(.*)` stops before the `\r` and `$` never reaches end-of-string. The
|
||||
* observable symptom is silent, not a crash — headings and frontmatter keys
|
||||
* simply stop being recognized and the page degrades to unlabelled paragraph
|
||||
* splits. Normalizing once here is the root fix; the alternative is remembering
|
||||
* `\r?` in every pattern in this file forever.
|
||||
*/
|
||||
function normalizeNewlines(text: string): string {
|
||||
return text.replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
export interface DocFrontmatter {
|
||||
type?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal YAML frontmatter parser covering exactly the forms this bundle uses:
|
||||
* plain scalars, folded blocks (`description: >-`), inline sequences
|
||||
* (`tags: [a, b]`) and block sequences (`- a`). A full YAML parser would be a new
|
||||
* runtime dependency in a hardened, dependency-minimal container for the sake of
|
||||
* four flat keys.
|
||||
*
|
||||
* Anything it cannot parse is skipped rather than thrown — a malformed header
|
||||
* must not cost us the page body, which is the part being searched.
|
||||
*/
|
||||
export function parseFrontmatter(input: string): { frontmatter: DocFrontmatter; body: string } {
|
||||
const raw = normalizeNewlines(input);
|
||||
if (!raw.startsWith('---')) return { frontmatter: {}, body: raw };
|
||||
const end = raw.indexOf('\n---', 3);
|
||||
if (end === -1) return { frontmatter: {}, body: raw };
|
||||
|
||||
const header = raw.slice(raw.indexOf('\n') + 1, end);
|
||||
const body = raw.slice(raw.indexOf('\n', end + 1) + 1);
|
||||
const fm: Record<string, string | string[]> = {};
|
||||
|
||||
const lines = header.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const match = /^([A-Za-z_][\w-]*):\s*(.*)$/.exec(lines[i]);
|
||||
if (!match) continue;
|
||||
const key = match[1];
|
||||
let value = match[2].trim();
|
||||
|
||||
// Folded / literal block scalar: consume the following indented lines.
|
||||
if (value === '>-' || value === '>' || value === '|' || value === '|-') {
|
||||
const parts: string[] = [];
|
||||
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
|
||||
parts.push(lines[++i].trim());
|
||||
}
|
||||
fm[key] = parts.join(' ');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block sequence: `tags:` followed by `- item` lines.
|
||||
if (value === '') {
|
||||
const items: string[] = [];
|
||||
while (i + 1 < lines.length && /^\s*-\s+/.test(lines[i + 1])) {
|
||||
items.push(lines[++i].replace(/^\s*-\s+/, '').trim().replace(/^["']|["']$/g, ''));
|
||||
}
|
||||
if (items.length > 0) fm[key] = items;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Inline sequence: `[a, b, c]`.
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
fm[key] = value
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
||||
.filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
|
||||
fm[key] = value.replace(/^["']|["']$/g, '');
|
||||
}
|
||||
|
||||
const asString = (v: string | string[] | undefined): string | undefined =>
|
||||
typeof v === 'string' ? v : undefined;
|
||||
const asArray = (v: string | string[] | undefined): string[] | undefined =>
|
||||
Array.isArray(v) ? v : typeof v === 'string' && v ? [v] : undefined;
|
||||
|
||||
return {
|
||||
frontmatter: {
|
||||
type: asString(fm.type),
|
||||
title: asString(fm.title),
|
||||
description: asString(fm.description),
|
||||
tags: asArray(fm.tags),
|
||||
},
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Markdown normalization ───────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip GitBook-specific block syntax while keeping the prose inside it.
|
||||
* `{% hint style="info" %}` wrappers carry no meaning for retrieval but do add
|
||||
* tokens that dilute the embedding; the sentence inside them frequently IS the
|
||||
* answer, so the content is preserved and only the markers removed.
|
||||
*/
|
||||
function stripGitbookSyntax(text: string): string {
|
||||
return text
|
||||
.replace(/^\s*\{%\s*(end)?(hint|tabs|tab|content-ref|embed|code)[^%]*%\}\s*$/gim, '')
|
||||
.replace(/^\s*\{%\s*end\w+\s*%\}\s*$/gim, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** URL/anchor-safe slug from heading text. */
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[`*_[\]()]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public GitBook URL for a bundle-relative path, or undefined when the file is
|
||||
* not published as a page.
|
||||
*
|
||||
* `index.md` is an OKF reserved file that the bundle deliberately keeps out of
|
||||
* SUMMARY.md, so GitBook never renders it — emitting a link would hand callers a
|
||||
* 404. Its content is still indexed (it is the documentation map); it just has no
|
||||
* citable URL.
|
||||
*/
|
||||
export function docUrl(relPath: string): string | undefined {
|
||||
const posix = relPath.split(sep).join('/');
|
||||
if (posix === 'index.md') return undefined;
|
||||
const withoutExt = posix.replace(/\.md$/, '');
|
||||
const path = withoutExt === 'README' ? '' : withoutExt.replace(/\/README$/, '');
|
||||
return path ? `${DOCS_BASE_URL}/${path}` : DOCS_BASE_URL;
|
||||
}
|
||||
|
||||
// ── Sectioning ───────────────────────────────────────────────
|
||||
|
||||
interface Section {
|
||||
/** Heading trail below the page title, e.g. ['Data Processing', 'What data…']. */
|
||||
breadcrumb: string[];
|
||||
/** Anchor of the deepest markdown heading, if this section came from one. */
|
||||
anchor?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a page body into heading-delimited sections.
|
||||
*
|
||||
* Handles both structures the bundle uses: markdown h2/h3 headings (the common
|
||||
* case — trust/security-faq.md carries 37 h3s, one per question) and
|
||||
* `<details><summary>Q</summary>…</details>` blocks (troubleshooting/faq.md),
|
||||
* where the summary is the question and therefore the natural section title.
|
||||
*/
|
||||
function splitIntoSections(body: string): Section[] {
|
||||
const sections: Section[] = [];
|
||||
const lines = body.split('\n');
|
||||
|
||||
let h2: string | null = null;
|
||||
let h3: string | null = null;
|
||||
let detailsTitle: string | null = null;
|
||||
let buffer: string[] = [];
|
||||
let inFence = false;
|
||||
|
||||
const flush = (anchorSource: string | null) => {
|
||||
const content = buffer.join('\n').trim();
|
||||
buffer = [];
|
||||
if (!content) return;
|
||||
const breadcrumb = [h2, detailsTitle ?? h3].filter((s): s is string => Boolean(s));
|
||||
sections.push({
|
||||
breadcrumb,
|
||||
anchor: anchorSource ? slugify(anchorSource) : undefined,
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
// Never interpret markdown inside a fenced code block as structure.
|
||||
if (/^\s*```/.test(line)) {
|
||||
inFence = !inFence;
|
||||
buffer.push(line);
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
buffer.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const summary = /^\s*<summary>(.*?)<\/summary>\s*$/i.exec(line);
|
||||
if (summary) {
|
||||
flush(detailsTitle ?? h3 ?? h2);
|
||||
detailsTitle = summary[1].replace(/<[^>]+>/g, '').trim();
|
||||
continue;
|
||||
}
|
||||
if (/^\s*<\/details>\s*$/i.test(line)) {
|
||||
flush(null);
|
||||
detailsTitle = null;
|
||||
continue;
|
||||
}
|
||||
if (/^\s*<details>\s*$/i.test(line)) continue;
|
||||
|
||||
const heading = /^(#{2,3})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
flush(detailsTitle ?? h3 ?? h2);
|
||||
detailsTitle = null;
|
||||
const text = heading[2].replace(/[*_`]/g, '').trim();
|
||||
if (heading[1] === '##') {
|
||||
h2 = text;
|
||||
h3 = null;
|
||||
} else {
|
||||
h3 = text;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
buffer.push(line);
|
||||
}
|
||||
flush(detailsTitle ?? h3 ?? h2);
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an oversized section at paragraph boundaries.
|
||||
*
|
||||
* Markdown tables are kept intact: a table split across chunks leaves a body of
|
||||
* `| --- |` rows with no header, which is both unreadable in a result and
|
||||
* meaningless to embed. Tables carry real answers in this bundle (the role matrix
|
||||
* in concepts/roles-and-permissions.md is one), so an over-long table is allowed
|
||||
* to exceed MAX_CHUNK_CHARS rather than be broken.
|
||||
*/
|
||||
function splitOversized(content: string): string[] {
|
||||
if (content.length <= MAX_CHUNK_CHARS) return [content];
|
||||
|
||||
const blocks = content.split(/\n\s*\n/);
|
||||
const chunks: string[] = [];
|
||||
let current: string[] = [];
|
||||
let currentLen = 0;
|
||||
|
||||
for (const block of blocks) {
|
||||
const isTable = /^\s*\|/.test(block);
|
||||
if (currentLen > 0 && currentLen + block.length > MAX_CHUNK_CHARS && !isTable) {
|
||||
chunks.push(current.join('\n\n'));
|
||||
current = [];
|
||||
currentLen = 0;
|
||||
}
|
||||
current.push(block);
|
||||
currentLen += block.length + 2;
|
||||
}
|
||||
if (current.length > 0) chunks.push(current.join('\n\n'));
|
||||
|
||||
// A single block can still exceed the budget on its own — the changelog pages
|
||||
// are one unbroken bullet list per release with no blank lines, so paragraph
|
||||
// splitting cannot touch them. Left whole, everything past the model's ~256
|
||||
// tokens would be truncated away and become unsearchable, so fall back to
|
||||
// packing whole LINES. Contiguous table rows stay together (see above).
|
||||
const final: string[] = [];
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.length <= MAX_CHUNK_CHARS) {
|
||||
final.push(chunk);
|
||||
continue;
|
||||
}
|
||||
let buf: string[] = [];
|
||||
let len = 0;
|
||||
for (const line of chunk.split('\n')) {
|
||||
const isTableRow = /^\s*\|/.test(line);
|
||||
if (len > 0 && len + line.length > MAX_CHUNK_CHARS && !isTableRow) {
|
||||
final.push(buf.join('\n'));
|
||||
buf = [];
|
||||
len = 0;
|
||||
}
|
||||
buf.push(line);
|
||||
len += line.length + 1;
|
||||
}
|
||||
if (buf.length > 0) final.push(buf.join('\n'));
|
||||
}
|
||||
|
||||
// Fold a runt tail back into its predecessor.
|
||||
if (final.length > 1 && final[final.length - 1].length < MIN_CHUNK_CHARS) {
|
||||
const tail = final.pop()!;
|
||||
final[final.length - 1] += `\n\n${tail}`;
|
||||
}
|
||||
return final;
|
||||
}
|
||||
|
||||
// ── File walking ─────────────────────────────────────────────
|
||||
|
||||
async function walkMarkdown(root: string, dir = root): Promise<string[]> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true, encoding: 'utf-8' });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
files.push(...(await walkMarkdown(root, join(dir, entry.name))));
|
||||
} else if (entry.name.endsWith('.md') && !SKIP_FILES.has(entry.name)) {
|
||||
files.push(relative(root, join(dir, entry.name)));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load and chunk the customer documentation bundle at `docsRoot`.
|
||||
*
|
||||
* Returns an empty array when the directory is absent or holds no markdown —
|
||||
* a local checkout without the docs repo is a supported state, and the caller
|
||||
* then simply does not register `search_docs`. A missing corpus must never be
|
||||
* a boot failure.
|
||||
*/
|
||||
export async function loadDocsCorpus(docsRoot: string): Promise<SearchDocument[]> {
|
||||
const files = (await walkMarkdown(docsRoot)).sort();
|
||||
const docs: SearchDocument[] = [];
|
||||
const usedIds = new Set<string>();
|
||||
|
||||
for (const relPath of files) {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await readFile(join(docsRoot, relPath), 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(raw);
|
||||
const posixPath = relPath.split(sep).join('/');
|
||||
|
||||
// Title: frontmatter wins (the technical bundle carries it), then the page's
|
||||
// own h1 (the customer bundle's only source), then the filename.
|
||||
const h1 = /^#\s+(.+)$/m.exec(body);
|
||||
const title =
|
||||
frontmatter.title ??
|
||||
h1?.[1].replace(/[*_`]/g, '').trim() ??
|
||||
posixPath.replace(/\.md$/, '').split('/').pop()!;
|
||||
|
||||
// Top-level directory doubles as the bundle's section taxonomy
|
||||
// (getting-started, concepts, portal-guide, …). Root files are 'general'.
|
||||
const section = posixPath.includes('/') ? posixPath.split('/')[0] : 'general';
|
||||
const url = docUrl(relPath);
|
||||
|
||||
// Drop the h1 line itself — it is carried in every chunk's header instead.
|
||||
const withoutH1 = body.replace(/^#\s+.+$/m, '');
|
||||
const sections = splitIntoSections(stripGitbookSyntax(withoutH1));
|
||||
|
||||
for (const sec of sections) {
|
||||
for (const piece of splitOversized(sec.content)) {
|
||||
const trail = [title, ...sec.breadcrumb].join(' › ');
|
||||
// The breadcrumb is prepended to the embedded text so a chunk lifted out
|
||||
// of the middle of a page still states which page and section it is from —
|
||||
// both for the embedding and for a human reading the tool result.
|
||||
if (piece.replace(/[^A-Za-z0-9]/g, '').length < MIN_BODY_CHARS) continue;
|
||||
const text = `${trail}\n\n${piece}`.trim();
|
||||
|
||||
const base = `docs:${posixPath}#${slugify(sec.breadcrumb.join('-')) || 'intro'}`;
|
||||
let id = base;
|
||||
for (let n = 2; usedIds.has(id); n++) id = `${base}-${n}`;
|
||||
usedIds.add(id);
|
||||
|
||||
docs.push({
|
||||
id,
|
||||
text,
|
||||
metadata: {
|
||||
type: 'doc',
|
||||
title,
|
||||
section,
|
||||
path: posixPath,
|
||||
heading: sec.breadcrumb.join(' › ') || null,
|
||||
docType: frontmatter.type ?? null,
|
||||
tags: frontmatter.tags ?? [],
|
||||
url: url ? (sec.anchor ? `${url}#${sec.anchor}` : url) : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return docs;
|
||||
}
|
||||
|
||||
/** Distinct top-level sections present in a loaded corpus — drives the tool's filter enum. */
|
||||
export function docSections(docs: SearchDocument[]): string[] {
|
||||
return [...new Set(docs.map((d) => String(d.metadata.section)))].sort();
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { registerTools } from './tools.js';
|
||||
import { registerResources } from './resources.js';
|
||||
import { registerPrompts } from './prompts.js';
|
||||
import { loadKnowledgeDocs } from './knowledge-base.js';
|
||||
import { loadDocsCorpus, docSections } from './docs-corpus.js';
|
||||
import { createSearchProvider, resolveBackend } from './search-factory.js';
|
||||
import type { SearchBackend, SearchProvider } from './search-provider.js';
|
||||
import { MODEL_NAME, VectorSearchProvider, embed } from './vector-search-provider.js';
|
||||
@@ -24,9 +25,34 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf-8')) as { version: string };
|
||||
const SERVER_VERSION: string = pkg.version;
|
||||
|
||||
/**
|
||||
* Boot stopwatch. minReplicas=0 means every idle period ends in a cold start a user
|
||||
* actually waits through — measured at 24.5s to first byte, against 60ms warm.
|
||||
* Where those seconds go was NOT answerable from outside: Log Analytics stamps all
|
||||
* console lines of one ingestion batch with the same time, so the boot sequence
|
||||
* collapses to a single instant no matter how it is queried.
|
||||
*
|
||||
* So the server times itself. `process.uptime()` counts from process start, which
|
||||
* includes Node's own startup and module loading — the part an in-process timer
|
||||
* started at the top of this file would miss.
|
||||
*/
|
||||
const bootMark = (): string => `+${(process.uptime() * 1000).toFixed(0)}ms`;
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '8080', 10);
|
||||
const RULES_DIR = process.env.RULES_DIR ?? resolve(__dirname, '..', '..', '..', '..', 'rules');
|
||||
|
||||
// Published customer documentation bundle (autopilotmonitor-docs), baked into the
|
||||
// image by the deploy workflow. Unset in a plain local checkout — the docs live in
|
||||
// a separate repo — in which case the corpus is empty and search_docs is simply not
|
||||
// registered. Point DOCS_DIR at a local clone to develop against it.
|
||||
const DOCS_DIR = process.env.DOCS_DIR ?? '';
|
||||
|
||||
// Commit of the docs bundle this image was built from. The docs repo changes
|
||||
// independently of this one, so a deployed image can silently serve stale
|
||||
// documentation; surfacing the SHA on /health makes that checkable instead of a
|
||||
// thing to remember.
|
||||
const DOCS_COMMIT = process.env.DOCS_COMMIT ?? 'unknown';
|
||||
|
||||
// Surface the MCP_PUBLIC_URL state at boot. In production a missing pin is a
|
||||
// hard boot failure (config.ts throws — host-spoofing defense); this dev-only
|
||||
// notice flags the forwarded-header fallback. Two-stage deploy expectation:
|
||||
@@ -46,13 +72,18 @@ console.error(`[startup] Backend API base URL: ${API_BASE_URL}`);
|
||||
|
||||
// --- Load shared knowledge base (reused across all sessions) ---
|
||||
|
||||
console.error('Loading knowledge base documents…');
|
||||
console.error(`[boot ${bootMark()}] node started, loading corpora from disk…`);
|
||||
const docs = await loadKnowledgeDocs(RULES_DIR);
|
||||
const eventTypeDocs = buildEventTypeSearchDocs();
|
||||
const docsDocs = DOCS_DIR ? await loadDocsCorpus(DOCS_DIR) : [];
|
||||
if (DOCS_DIR && docsDocs.length === 0) {
|
||||
console.error(`[startup] DOCS_DIR=${DOCS_DIR} yielded no documents — search_docs will NOT be registered.`);
|
||||
}
|
||||
|
||||
async function buildSearchIndexes(backend?: SearchBackend): Promise<{
|
||||
knowledgeBase: SearchProvider;
|
||||
eventTypeIndex: SearchProvider;
|
||||
docsIndex: SearchProvider | undefined;
|
||||
}> {
|
||||
const kb = await createSearchProvider(backend);
|
||||
await kb.index(docs);
|
||||
@@ -61,7 +92,15 @@ async function buildSearchIndexes(backend?: SearchBackend): Promise<{
|
||||
// the embedder singleton with the knowledge base, so this adds ~no memory.
|
||||
const et = await createSearchProvider(backend);
|
||||
await et.index(eventTypeDocs);
|
||||
return { knowledgeBase: kb, eventTypeIndex: et };
|
||||
// Product documentation — a third corpus, deliberately not merged into the
|
||||
// knowledge base (rules answer "why did this fail", docs answer "how does this
|
||||
// work"; sharing one topK would dilute both).
|
||||
let dx: SearchProvider | undefined;
|
||||
if (docsDocs.length > 0) {
|
||||
dx = await createSearchProvider(backend);
|
||||
await dx.index(docsDocs);
|
||||
}
|
||||
return { knowledgeBase: kb, eventTypeIndex: et, docsIndex: dx };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +111,12 @@ async function buildSearchIndexes(backend?: SearchBackend): Promise<{
|
||||
*/
|
||||
const SEARCH_INDEX_PATH = process.env.SEARCH_INDEX_PATH ?? resolve(__dirname, '..', 'search-index.json');
|
||||
|
||||
function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventTypeIndex: SearchProvider } | null {
|
||||
function tryLoadPrecomputedIndexes(): {
|
||||
knowledgeBase: SearchProvider;
|
||||
eventTypeIndex: SearchProvider;
|
||||
docsIndex: SearchProvider | undefined;
|
||||
} | null {
|
||||
console.error(`[boot ${bootMark()}] corpora loaded, reading precomputed index…`);
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(SEARCH_INDEX_PATH, 'utf-8');
|
||||
@@ -81,7 +125,7 @@ function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventType
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const validated = validatePrecomputedIndex(JSON.parse(raw), MODEL_NAME, docs, eventTypeDocs);
|
||||
const validated = validatePrecomputedIndex(JSON.parse(raw), MODEL_NAME, docs, eventTypeDocs, docsDocs);
|
||||
if (!validated.ok) {
|
||||
console.error(`[startup] Precomputed search index rejected (${validated.reason}) — computing embeddings at boot.`);
|
||||
return null;
|
||||
@@ -90,7 +134,13 @@ function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventType
|
||||
kb.indexPrecomputed(validated.knowledgeBase);
|
||||
const et = new VectorSearchProvider();
|
||||
et.indexPrecomputed(validated.eventTypes);
|
||||
return { knowledgeBase: kb, eventTypeIndex: et };
|
||||
let dx: SearchProvider | undefined;
|
||||
if (validated.docs.length > 0) {
|
||||
const provider = new VectorSearchProvider();
|
||||
provider.indexPrecomputed(validated.docs);
|
||||
dx = provider;
|
||||
}
|
||||
return { knowledgeBase: kb, eventTypeIndex: et, docsIndex: dx };
|
||||
} catch (err) {
|
||||
console.error('[startup] Failed to read precomputed search index — computing embeddings at boot:', err);
|
||||
return null;
|
||||
@@ -106,27 +156,44 @@ function tryLoadPrecomputedIndexes(): { knowledgeBase: SearchProvider; eventType
|
||||
console.error(`Initializing search provider (${docs.length} documents)…`);
|
||||
let knowledgeBase: SearchProvider;
|
||||
let eventTypeIndex: SearchProvider;
|
||||
let docsIndex: SearchProvider | undefined;
|
||||
const precomputed = (await resolveBackend()) === 'vector' ? tryLoadPrecomputedIndexes() : null;
|
||||
if (precomputed) {
|
||||
({ knowledgeBase, eventTypeIndex } = precomputed);
|
||||
({ knowledgeBase, eventTypeIndex, docsIndex } = precomputed);
|
||||
// Serving precomputed vectors does not need the embedder — only incoming
|
||||
// queries do. Warm it in the background so neither boot nor the readiness
|
||||
// probe waits on the model load; a search arriving first awaits the same
|
||||
// memoized load instead of failing.
|
||||
embed('embedder warmup').then(
|
||||
() => console.error('Query embedder warm.'),
|
||||
() => console.error(`[boot ${bootMark()}] Query embedder warm (background; only the first SEARCH waits on this).`),
|
||||
(err) => console.error('[startup] Query embedder warmup failed — semantic ranking degrades until it loads:', err),
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
({ knowledgeBase, eventTypeIndex } = await buildSearchIndexes());
|
||||
({ knowledgeBase, eventTypeIndex, docsIndex } = await buildSearchIndexes());
|
||||
} catch (err) {
|
||||
console.error('[startup] Search index initialization failed (embedding model unavailable?) — falling back to the keyword (fuse) backend:', err);
|
||||
({ knowledgeBase, eventTypeIndex } = await buildSearchIndexes('fuse'));
|
||||
({ knowledgeBase, eventTypeIndex, docsIndex } = await buildSearchIndexes('fuse'));
|
||||
}
|
||||
}
|
||||
console.error(`Search provider ready: ${knowledgeBase.name} — ${knowledgeBase.size} documents indexed${precomputed ? ' (precomputed)' : ''}.`);
|
||||
console.error(`Event-type index ready: ${eventTypeIndex.name} — ${eventTypeIndex.size} types indexed${precomputed ? ' (precomputed)' : ''}.`);
|
||||
if (docsIndex) {
|
||||
// Vectors exceed chunks whenever a chunk spans several token windows — surfacing
|
||||
// both makes a regression in the windowing visible in the boot log.
|
||||
const vectors = (docsIndex as { vectorCount?: number }).vectorCount;
|
||||
console.error(
|
||||
`[boot ${bootMark()}] Docs index ready: ${docsIndex.name} — ${docsIndex.size} chunks` +
|
||||
`${vectors ? ` / ${vectors} vectors` : ''} indexed${precomputed ? ' (precomputed)' : ''} ` +
|
||||
`(docs commit ${DOCS_COMMIT}).`,
|
||||
);
|
||||
} else {
|
||||
console.error('Docs index: not available — search_docs is not registered.');
|
||||
}
|
||||
|
||||
// Computed once: the tool catalog is rebuilt per request, and this is a pure
|
||||
// function of the (immutable) corpus.
|
||||
const DOCS = docsIndex ? { vector: docsIndex, sections: docSections(docsDocs) } : undefined;
|
||||
|
||||
// Server-level guidance. The host surfaces this once per connection, so it is
|
||||
// the right home for cross-cutting strategy that would otherwise be duplicated
|
||||
@@ -154,6 +221,11 @@ function buildInstructions(ga: boolean, delegated: boolean, managedTenants: stri
|
||||
'Autopilot-Monitor is a READ-ONLY telemetry server for Windows Autopilot enrollment sessions.',
|
||||
'',
|
||||
'Investigating one session: call get_session_summary FIRST (status, filtered timeline, stats, rule analysis in one call), then drill in.',
|
||||
...(docsIndex
|
||||
? ['Product questions ("how do I…", "what does X mean", "where is my data stored"): use search_docs — the ' +
|
||||
'published customer documentation. search_knowledge is a DIFFERENT corpus (analysis rules and IME log ' +
|
||||
'patterns) and answers why an enrollment failed, not how the product works.']
|
||||
: []),
|
||||
'Searching events: use search_events (hybrid keyword+semantic ranking; depth="fast" then "deep" for exhaustive recall) for ranked hits, or get_session_events / query_raw_events for the raw unranked stream.',
|
||||
'Counting / aggregating: pass a lean `fields=` projection and use `agentVersionPrefix=`/`imeAgentVersionPrefix=` sweeps to stay under the per-response size cap.',
|
||||
'Pagination: when a response carries `nextLink`, pass that whole string back as `continuation`; stop when it is absent. Results are never silently truncated.',
|
||||
@@ -173,7 +245,7 @@ function createMcpServer(ga: boolean, strictGa: boolean, delegated: boolean, man
|
||||
{ name: 'Autopilot-Monitor', version: SERVER_VERSION },
|
||||
{ instructions: buildInstructions(ga, delegated, managedTenants, homeTenantId) },
|
||||
);
|
||||
registerTools(s, knowledgeBase, eventTypeIndex, ga, strictGa, delegated);
|
||||
registerTools(s, knowledgeBase, eventTypeIndex, DOCS, ga, strictGa, delegated);
|
||||
registerResources(s);
|
||||
// A delegated caller has no platform scope, so prompts get the tenant-user surface (ga=false) —
|
||||
// the cross-tenant prompt wording would be misleading for a tenant-bounded MSP user.
|
||||
@@ -227,8 +299,19 @@ app.use(createOAuthRouter());
|
||||
// The version is the same value already advertised in the MCP handshake
|
||||
// (createMcpServer → { name, version }), so surfacing it here leaks nothing
|
||||
// new; it lets the backend health dashboard show the deployed MCP build.
|
||||
//
|
||||
// The docs block exists for one reason: the documentation bundle lives in its own
|
||||
// repo and is baked in at image build time, so editing the docs does NOT update a
|
||||
// running server — only a redeploy does. Reporting the bundle's commit and chunk
|
||||
// count turns "did we remember to redeploy?" into a diff against
|
||||
// `git rev-parse HEAD` in autopilotmonitor-docs. Both values are public
|
||||
// (the bundle is a public repo published at docs.autopilotmonitor.com).
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'healthy', version: SERVER_VERSION });
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
version: SERVER_VERSION,
|
||||
docs: { commit: DOCS_COMMIT, chunks: docsIndex?.size ?? 0, sections: docSections(docsDocs) },
|
||||
});
|
||||
});
|
||||
|
||||
// Access guard for /mcp — validates JWT, checks backend whitelist, enforces rate limits
|
||||
@@ -348,7 +431,9 @@ const bodyErrorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
app.use(bodyErrorHandler);
|
||||
|
||||
const server = app.listen(PORT, '0.0.0.0', () => {
|
||||
console.error(`Autopilot-Monitor MCP Server running on port ${PORT}`);
|
||||
// The number a user actually waits for on a cold start, minus the platform's
|
||||
// own activation (pod scheduling, container start), which is not visible here.
|
||||
console.error(`[boot ${bootMark()}] Autopilot-Monitor MCP Server listening on port ${PORT} — ready to serve.`);
|
||||
});
|
||||
|
||||
// --- Graceful shutdown ---
|
||||
|
||||
@@ -7,18 +7,19 @@
|
||||
* 0.25 vCPU container. Running the real pipeline here also doubles as the
|
||||
* model prefetch into HF_CACHE_DIR (no HuggingFace CDN dependency at runtime).
|
||||
*
|
||||
* Usage: node dist/precompute-embeddings.js <rulesDir> <outFile>
|
||||
* Usage: node dist/precompute-embeddings.js <rulesDir> <outFile> [docsDir]
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { loadKnowledgeDocs } from './knowledge-base.js';
|
||||
import { loadDocsCorpus } from './docs-corpus.js';
|
||||
import { buildEventTypeSearchDocs } from './resource-catalog.js';
|
||||
import { MODEL_NAME, VectorSearchProvider } from './vector-search-provider.js';
|
||||
import { hashDocs, type PrecomputedIndexFile } from './precomputed-index.js';
|
||||
|
||||
const [rulesDir, outFile] = process.argv.slice(2);
|
||||
const [rulesDir, outFile, docsDir] = process.argv.slice(2);
|
||||
if (!rulesDir || !outFile) {
|
||||
console.error('usage: node dist/precompute-embeddings.js <rulesDir> <outFile>');
|
||||
console.error('usage: node dist/precompute-embeddings.js <rulesDir> <outFile> [docsDir]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -31,6 +32,17 @@ if (knowledgeDocs.length === 0) {
|
||||
}
|
||||
const eventTypeDocs = buildEventTypeSearchDocs();
|
||||
|
||||
// Docs are optional: the production image always passes docsDir (the workflow
|
||||
// checks the docs repo out into the build context), but a local build without it
|
||||
// must still produce a usable index rather than fail. Warn loudly instead —
|
||||
// a silently doc-less production image would leave search_docs unregistered.
|
||||
const docsDocs = docsDir ? await loadDocsCorpus(docsDir) : [];
|
||||
if (docsDir && docsDocs.length === 0) {
|
||||
console.error(`WARNING: docs dir ${docsDir} yielded no documents — search_docs will not be registered`);
|
||||
} else if (!docsDir) {
|
||||
console.error('no docsDir argument — building an index without the documentation corpus');
|
||||
}
|
||||
|
||||
const knowledgeBase = new VectorSearchProvider();
|
||||
await knowledgeBase.index(knowledgeDocs);
|
||||
const eventTypeIndex = new VectorSearchProvider();
|
||||
@@ -41,7 +53,15 @@ const file: PrecomputedIndexFile = {
|
||||
knowledgeBase: { docsHash: hashDocs(knowledgeDocs), entries: knowledgeBase.serialize() },
|
||||
eventTypes: { docsHash: hashDocs(eventTypeDocs), entries: eventTypeIndex.serialize() },
|
||||
};
|
||||
|
||||
if (docsDocs.length > 0) {
|
||||
const docsIndex = new VectorSearchProvider();
|
||||
await docsIndex.index(docsDocs);
|
||||
file.docs = { docsHash: hashDocs(docsDocs), entries: docsIndex.serialize() };
|
||||
}
|
||||
|
||||
writeFileSync(outFile, JSON.stringify(file));
|
||||
console.error(
|
||||
`precomputed embeddings: ${knowledgeDocs.length} knowledge docs + ${eventTypeDocs.length} event types (${MODEL_NAME}) → ${outFile}`,
|
||||
`precomputed embeddings: ${knowledgeDocs.length} knowledge docs + ${eventTypeDocs.length} event types + ` +
|
||||
`${docsDocs.length} doc chunks (${MODEL_NAME}) → ${outFile}`,
|
||||
);
|
||||
|
||||
@@ -29,6 +29,12 @@ export interface PrecomputedIndexFile {
|
||||
model: string;
|
||||
knowledgeBase: PrecomputedSection;
|
||||
eventTypes: PrecomputedSection;
|
||||
/**
|
||||
* Chunked customer documentation (see docs-corpus.ts). Optional in the file
|
||||
* format: a build without the docs repo checked out ships no docs section, and
|
||||
* that must not invalidate the other two.
|
||||
*/
|
||||
docs?: PrecomputedSection;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -51,7 +57,12 @@ export function hashDocs(docs: SearchDocument[]): string {
|
||||
}
|
||||
|
||||
export type PrecomputedValidation =
|
||||
| { ok: true; knowledgeBase: PrecomputedDocument[]; eventTypes: PrecomputedDocument[] }
|
||||
| {
|
||||
ok: true;
|
||||
knowledgeBase: PrecomputedDocument[];
|
||||
eventTypes: PrecomputedDocument[];
|
||||
docs: PrecomputedDocument[];
|
||||
}
|
||||
| { ok: false; reason: string };
|
||||
|
||||
function validateSection(
|
||||
@@ -67,12 +78,20 @@ function validateSection(
|
||||
if (section.entries.length !== sourceDocs.length) {
|
||||
return `${name}: entry count ${section.entries.length} != corpus size ${sourceDocs.length}`;
|
||||
}
|
||||
const dims = section.entries[0]?.embedding?.length ?? 0;
|
||||
// Each entry carries one vector per token window (see splitIntoWindows) — usually
|
||||
// one, more for long documents. Dimensionality must be uniform across every window
|
||||
// of every entry: a ragged file would otherwise produce silently wrong dot products.
|
||||
const dims = section.entries[0]?.embeddings?.[0]?.length ?? 0;
|
||||
if (dims === 0) return `${name}: first entry has no embedding`;
|
||||
for (const e of section.entries) {
|
||||
if (typeof e.id !== 'string' || typeof e.text !== 'string') return `${name}: entry missing id/text`;
|
||||
if (!Array.isArray(e.embedding) || e.embedding.length !== dims) {
|
||||
return `${name}: inconsistent embedding dimensions`;
|
||||
if (!Array.isArray(e.embeddings) || e.embeddings.length === 0) {
|
||||
return `${name}: entry ${e.id} has no embeddings`;
|
||||
}
|
||||
for (const vec of e.embeddings) {
|
||||
if (!Array.isArray(vec) || vec.length !== dims) {
|
||||
return `${name}: inconsistent embedding dimensions`;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -88,6 +107,7 @@ export function validatePrecomputedIndex(
|
||||
expectedModel: string,
|
||||
knowledgeDocs: SearchDocument[],
|
||||
eventTypeDocs: SearchDocument[],
|
||||
docsDocs: SearchDocument[] = [],
|
||||
): PrecomputedValidation {
|
||||
if (parsed === null || typeof parsed !== 'object') return { ok: false, reason: 'not an object' };
|
||||
const file = parsed as Partial<PrecomputedIndexFile>;
|
||||
@@ -98,5 +118,24 @@ export function validatePrecomputedIndex(
|
||||
if (kbError) return { ok: false, reason: kbError };
|
||||
const etError = validateSection(file.eventTypes, 'eventTypes', eventTypeDocs);
|
||||
if (etError) return { ok: false, reason: etError };
|
||||
return { ok: true, knowledgeBase: file.knowledgeBase!.entries, eventTypes: file.eventTypes!.entries };
|
||||
|
||||
// Docs are an OPTIONAL corpus: a checkout without the docs repo loads zero doc
|
||||
// documents, and the matching image ships no docs section. Only validate when
|
||||
// the booting server actually has a docs corpus to check against — otherwise a
|
||||
// dev build would throw away the rules and event-type vectors too, which is the
|
||||
// expensive half. When the server HAS docs but the file does not, that is a
|
||||
// genuine staleness signal and must be rejected like any other mismatch.
|
||||
let docs: PrecomputedDocument[] = [];
|
||||
if (docsDocs.length > 0) {
|
||||
const docsError = validateSection(file.docs, 'docs', docsDocs);
|
||||
if (docsError) return { ok: false, reason: docsError };
|
||||
docs = file.docs!.entries;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
knowledgeBase: file.knowledgeBase!.entries,
|
||||
eventTypes: file.eventTypes!.entries,
|
||||
docs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'esp_failure_settle_started',
|
||||
'esp_failure_settle_recovered',
|
||||
'esp_exiting',
|
||||
'esp_reboot_coalescing',
|
||||
'mdm_policy_reboot_required',
|
||||
'esp_config_detected',
|
||||
'completion_check',
|
||||
|
||||
@@ -90,3 +90,16 @@ export function scanLexical(documents: SearchDocument[], needles: string[]): Sea
|
||||
// ── Provider identifiers ─────────────────────────────────────
|
||||
|
||||
export type SearchBackend = 'vector' | 'fuse';
|
||||
|
||||
/**
|
||||
* What `search_docs` runs on, as one value rather than a growing tail of positional
|
||||
* parameters through registerTools → registerSearchTools.
|
||||
*
|
||||
* The keyword fallback needs no second provider: it uses `vector.lexicalMatch`,
|
||||
* the literal-containment scan every provider already exposes.
|
||||
*/
|
||||
export interface DocsSearchBundle {
|
||||
vector: SearchProvider;
|
||||
/** Top-level documentation areas present in the corpus — the `section` filter hint. */
|
||||
sections: string[];
|
||||
}
|
||||
|
||||
@@ -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 type { DocsSearchBundle } from './search-provider.js';
|
||||
|
||||
/**
|
||||
* Registers the tool catalog for a single request, tailored to the caller's role.
|
||||
@@ -26,12 +27,13 @@ export function registerTools(
|
||||
server: McpServer,
|
||||
knowledgeBase: SearchProvider | undefined,
|
||||
eventTypeIndex: SearchProvider | undefined,
|
||||
docs: DocsSearchBundle | undefined,
|
||||
ga: boolean,
|
||||
strictGa: boolean = ga,
|
||||
delegated: boolean = false,
|
||||
): void {
|
||||
registerSessionTools(server, ga, delegated);
|
||||
registerSearchTools(server, knowledgeBase, eventTypeIndex, ga, delegated);
|
||||
registerSearchTools(server, knowledgeBase, eventTypeIndex, docs, ga, delegated);
|
||||
registerAdminTools(server, ga, strictGa, delegated);
|
||||
sortToolCatalog(server);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,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 type { SearchProvider } from '../search-provider.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';
|
||||
import { ALL_EVENT_TYPES } from '../resource-catalog.js';
|
||||
@@ -959,12 +959,49 @@ export function diversifyBySession<T extends { event: EventEntry }>(
|
||||
return picked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terms from a natural-language query that are worth matching literally.
|
||||
*
|
||||
* Only distinctive words: short and common ones ("how", "the", "does") appear in
|
||||
* nearly every chunk, so matching on them would return the whole corpus in
|
||||
* arbitrary order. What is left is the vocabulary literal matching is actually good
|
||||
* at — product names, setting names, identifiers — which is exactly what embeddings
|
||||
* are bad at.
|
||||
*/
|
||||
const KEYWORD_STOPWORDS = new Set([
|
||||
'about', 'after', 'again', 'also', 'been', 'before', 'being', 'between', 'both', 'can',
|
||||
'could', 'does', 'doing', 'during', 'each', 'from', 'have', 'having', 'here', 'how',
|
||||
'into', 'just', 'more', 'most', 'much', 'must', 'need', 'only', 'other', 'over',
|
||||
'same', 'should', 'some', 'such', 'than', 'that', 'their', 'them', 'then', 'there',
|
||||
'these', 'they', 'this', 'those', 'through', 'under', 'until', 'very', 'were', 'what',
|
||||
'when', 'where', 'which', 'while', 'will', 'with', 'would', 'your',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Below this best-hit cosine score, the semantic pass is treated as having found
|
||||
* nothing convincing, and exact keyword matches are allowed to take the front.
|
||||
* Chosen by measurement, not intuition — see scripts/eval-docs-search.ts.
|
||||
*/
|
||||
export const WEAK_SEMANTIC_SCORE = 0.35;
|
||||
|
||||
export function extractDistinctiveTerms(query: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
for (const raw of query.toLowerCase().split(/[^a-z0-9_.-]+/)) {
|
||||
const term = raw.replace(/^[.-]+|[.-]+$/g, '');
|
||||
if (term.length < 4 || KEYWORD_STOPWORDS.has(term)) continue;
|
||||
seen.add(term);
|
||||
if (seen.size >= 6) break;
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
// ── Registration ────────────────────────────────────────────────────────
|
||||
|
||||
export function registerSearchTools(
|
||||
server: McpServer,
|
||||
knowledgeBase: SearchProvider | undefined,
|
||||
eventTypeIndex: SearchProvider | undefined,
|
||||
docs: DocsSearchBundle | undefined,
|
||||
ga: boolean,
|
||||
delegated: boolean = false,
|
||||
): void {
|
||||
@@ -1099,6 +1136,8 @@ export function registerSearchTools(
|
||||
'Use natural language queries like "app install timeout", "BitLocker issues", "detection script failure". ' +
|
||||
'Returns the most relevant rules and patterns ranked by similarity. ' +
|
||||
'Great for finding remediation steps, understanding error patterns, or discovering relevant diagnostic rules. ' +
|
||||
'QUERY IN ENGLISH: the rules and the embedding model are English-only, so a query in another language ' +
|
||||
'matches nothing at all. Translate the user\'s question first and answer them in their own language. ' +
|
||||
'ERROR CODES: a query containing an HRESULT/Win32 hex code (e.g. "0x87D1041C", "0x80070002") also triggers a ' +
|
||||
'literal substring fallback — any rule that names the code verbatim is returned regardless of minScore, since ' +
|
||||
'such opaque codes embed poorly and the semantic score alone would miss them. Those hits are flagged `matchType: "error-code"`.',
|
||||
@@ -1176,4 +1215,173 @@ export function registerSearchTools(
|
||||
})
|
||||
);
|
||||
|
||||
// Tool 11: search_docs — semantic search over the published customer documentation.
|
||||
//
|
||||
// Registered for EVERY role, unlike the platform tools: this is public product
|
||||
// documentation (docs.autopilotmonitor.com), carries no tenant data, and a
|
||||
// normal tenant user has exactly as much business asking "how do I deploy the
|
||||
// agent" as a Global Admin. It is skipped entirely when no corpus was baked
|
||||
// into the image, so a doc-less build advertises no broken tool.
|
||||
if (docs && docs.vector.size > 0) {
|
||||
const docsIndex = docs.vector;
|
||||
server.registerTool(
|
||||
'search_docs',
|
||||
{
|
||||
title: 'Search Product Documentation',
|
||||
description:
|
||||
'PRODUCT DOCUMENTATION SEARCH — the published Autopilot Monitor customer documentation ' +
|
||||
'(docs.autopilotmonitor.com), chunked by section and ranked semantically. ' +
|
||||
'Use this for questions about how the PRODUCT works rather than what a specific enrollment did: ' +
|
||||
'setup and onboarding, agent deployment, portal features, roles and permissions, settings, ' +
|
||||
'notifications, network endpoints, security/privacy/data-residency, plans, and troubleshooting guidance. ' +
|
||||
'PREFER THIS OVER FETCHING OR WEB-SEARCHING THE DOCS SITE: it returns the matching SECTIONS ' +
|
||||
'(~1k tokens for topK=3) instead of whole pages (~9k tokens raw, ~600k rendered), it is the exact ' +
|
||||
'version this server ships rather than a cached or third-party copy, and it matches on meaning — ' +
|
||||
'"where is my data stored" finds the page that says "Germany West Central" with no shared words, ' +
|
||||
'which keyword or web search cannot do. ' +
|
||||
'Each result carries the page `title`, its `heading` breadcrumb and a citable `url` — quote the url when ' +
|
||||
'answering so the user can verify. ' +
|
||||
'If semantic matching returns too few hits, exact keyword matches are APPENDED (flagged ' +
|
||||
'`matchType: "keyword"`, and their scores are on a different scale — rank order across the two is ' +
|
||||
'not comparable). ' +
|
||||
'QUERY IN ENGLISH: the documentation and the embedding model are English-only, so a query in another ' +
|
||||
'language matches nothing at all (it returns zero results rather than poor ones). Translate the ' +
|
||||
'user\'s question first and answer them in their own language. ' +
|
||||
'NOT the same corpus as search_knowledge: that one holds analysis/gather rules and IME log patterns and ' +
|
||||
'explains why an enrollment failed. Rule of thumb — "how does X work?" → search_docs; ' +
|
||||
'"why did this session fail?" → search_knowledge or get_session_summary.',
|
||||
inputSchema: {
|
||||
query: z.string().describe('Natural language question (e.g. "how do I deploy the agent with Intune", "where is my data stored", "which roles exist")'),
|
||||
topK: z.coerce.number().min(1).max(20).optional().default(5).describe('Number of results to return (1-20, default 5)'),
|
||||
section: z.string().optional()
|
||||
.describe('Restrict to one documentation area, matching the bundle\'s top-level folder ' +
|
||||
`(one of: ${docs.sections.join(', ')}). Omit to search everything.`),
|
||||
minScore: z.coerce.number().min(0).max(1).optional().default(0.25)
|
||||
.describe('Minimum similarity score (0-1, default 0.25). Short keyword queries score low on ' +
|
||||
'all-MiniLM embeddings, so the default is tuned to keep the relevant-but-marginal band.'),
|
||||
},
|
||||
annotations: READ_ONLY,
|
||||
},
|
||||
async (args) => withToolTelemetry('search_docs', async () => {
|
||||
try {
|
||||
const { query, topK, section, minScore } = args;
|
||||
|
||||
// Over-fetch before filtering by section, exactly as search_knowledge does
|
||||
// for `type` — filtering a topK-sized result set would otherwise return
|
||||
// fewer than topK hits whenever the top matches sit in other sections.
|
||||
const fetchK = section ? topK * 4 : topK;
|
||||
let results = await docsIndex.search(query, { topK: fetchK, minScore });
|
||||
|
||||
// Same rationale as search_knowledge: opaque HRESULT/Win32 tokens embed
|
||||
// poorly, and the troubleshooting pages name them verbatim.
|
||||
const needles = extractErrorCodeNeedles(query);
|
||||
const errorCodeHitIds = new Set<string>();
|
||||
if (needles.length > 0 && docsIndex.lexicalMatch) {
|
||||
const byId = new Map(results.map((r) => [r.id, r] as const));
|
||||
for (const hit of docsIndex.lexicalMatch(needles)) {
|
||||
errorCodeHitIds.add(hit.id);
|
||||
const existing = byId.get(hit.id);
|
||||
if (!existing || hit.score > existing.score) byId.set(hit.id, hit);
|
||||
}
|
||||
results = [...byId.values()].sort((a, b) => b.score - a.score);
|
||||
}
|
||||
|
||||
if (section) {
|
||||
results = results.filter((r) => r.metadata.section === section);
|
||||
}
|
||||
results = results.slice(0, topK);
|
||||
|
||||
// Keyword fallback: exact, literal term matching to top up a short result set.
|
||||
//
|
||||
// Proper nouns embed poorly — measured on this corpus, "RealmJoin" appears in
|
||||
// 3 chunks yet scores below the semantic threshold. Fuzzy matching is the wrong
|
||||
// remedy: at a threshold low enough to surface "RealmJoin" (0.28), Fuse also
|
||||
// returns 5 hits for "SCEPman", a word that appears in the corpus zero times.
|
||||
// Literal containment has neither problem — it finds the first and returns
|
||||
// nothing for the second.
|
||||
//
|
||||
// APPENDED, never merged: these carry no cosine similarity, so semantic hits
|
||||
// keep the head of the list and keyword hits fill the tail, flagged for the
|
||||
// caller. Ranked among themselves by how many query terms they contain.
|
||||
const keywordHitIds = new Set<string>();
|
||||
const terms = extractDistinctiveTerms(query);
|
||||
// A weak semantic head means "nothing here really matches" — measured, that
|
||||
// is where exact matches belong in front. Topping up only when the list is
|
||||
// SHORT misses this: a pasted identifier like "WOW6432Node" returns three
|
||||
// chunks at 0.29 that do not contain the word, while the one chunk that does
|
||||
// never gets considered. Over 2465 evaluation queries, acting on a weak head
|
||||
// below this threshold fixed 335 and broke 0 (see scripts/eval-docs-search.ts);
|
||||
// raising it to 0.40 fixed more but started demoting hand-written questions,
|
||||
// so it stays here.
|
||||
const semanticIsWeak = (results[0]?.score ?? 0) < WEAK_SEMANTIC_SCORE;
|
||||
if ((results.length < topK || semanticIsWeak) && terms.length > 0 && docsIndex.lexicalMatch) {
|
||||
// lexicalMatch is ANY-of-needles, which alone is far too generous: for
|
||||
// "how do I bake sourdough bread" the single word "bake" matches "baked
|
||||
// into the image" and a page about LLM providers comes back as a hit.
|
||||
// A multi-term query must therefore corroborate — at least two of its
|
||||
// terms in the same chunk. A single-term query ("RealmJoin") still needs
|
||||
// just that one, which is the case this fallback exists for.
|
||||
const required = Math.min(2, terms.length);
|
||||
const seen = new Set(results.map((r) => r.id));
|
||||
const ranked = docsIndex
|
||||
.lexicalMatch(terms)
|
||||
.map((hit) => {
|
||||
const haystack = hit.text.toLowerCase();
|
||||
return { hit, matched: terms.filter((t) => haystack.includes(t)).length };
|
||||
})
|
||||
.filter(({ matched }) => matched >= required)
|
||||
.sort((a, b) => b.matched - a.matched);
|
||||
|
||||
const fresh = ranked
|
||||
.map(({ hit }) => hit)
|
||||
.filter((hit) => !seen.has(hit.id) && (!section || hit.metadata.section === section));
|
||||
|
||||
if (results.length < topK) {
|
||||
// Free slots: append, semantic keeps the head.
|
||||
for (const hit of fresh) {
|
||||
if (results.length >= topK) break;
|
||||
keywordHitIds.add(hit.id);
|
||||
results.push(hit);
|
||||
}
|
||||
} else if (semanticIsWeak && fresh.length > 0) {
|
||||
// No free slots, but nothing semantic is convincing. Exact matches go to
|
||||
// the front — while keeping at least one semantic result, so a
|
||||
// weak-but-correct hit is never pushed out entirely.
|
||||
for (const hit of fresh.slice(0, topK - 1)) keywordHitIds.add(hit.id);
|
||||
results = [...fresh.slice(0, topK - 1), ...results].slice(0, topK);
|
||||
}
|
||||
}
|
||||
|
||||
const formatted = results.map((r) => ({
|
||||
id: r.id,
|
||||
score: Math.round(r.score * 1000) / 1000,
|
||||
title: r.metadata.title,
|
||||
heading: r.metadata.heading,
|
||||
section: r.metadata.section,
|
||||
path: r.metadata.path,
|
||||
url: r.metadata.url,
|
||||
tags: r.metadata.tags,
|
||||
content: r.text,
|
||||
matchType: errorCodeHitIds.has(r.id)
|
||||
? ('error-code' as const)
|
||||
: keywordHitIds.has(r.id)
|
||||
? ('keyword' as const)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return toolResultText({
|
||||
query,
|
||||
searchBackend: docsIndex.name,
|
||||
...(section ? { section } : {}),
|
||||
resultCount: formatted.length,
|
||||
...(needles.length > 0 ? { errorCodeFallback: { codes: needles, matchedCount: errorCodeHitIds.size } } : {}),
|
||||
...(keywordHitIds.size > 0 ? { keywordFallback: { used: true, added: keywordHitIds.size } } : {}),
|
||||
results: formatted,
|
||||
}, MAX_RESULT_SIZE_CHARS.small);
|
||||
} catch (error: unknown) {
|
||||
return toolError('search_docs', args, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,68 @@ function getEmbedder(): Promise<FeatureExtractionPipeline> {
|
||||
return embedderPromise;
|
||||
}
|
||||
|
||||
// ── Token windowing ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The model's context window is 256 tokens INCLUDING the [CLS]/[SEP] specials, so
|
||||
* windows carry at most 250 content tokens.
|
||||
*
|
||||
* Anything past the window is silently dropped by the tokenizer — not an error, just
|
||||
* a vector that represents the opening of a document and nothing else. Measured on
|
||||
* the docs corpus, 87 of 324 chunks exceeded it and 13% of all tokens never reached
|
||||
* the ranking. Splitting long text into several windows and scoring by the best one
|
||||
* (see search()) puts every token back in play without shrinking chunks, which would
|
||||
* break up the tables and breadcrumbs the chunker deliberately keeps whole.
|
||||
*/
|
||||
const WINDOW_TOKENS = 250;
|
||||
|
||||
/**
|
||||
* Overlap between consecutive windows. A sentence that straddles a boundary would
|
||||
* otherwise be split across two vectors and match neither well.
|
||||
*/
|
||||
const WINDOW_OVERLAP_TOKENS = 40;
|
||||
|
||||
// Tokenizer only — NOT the ONNX weights. Needed exclusively on the indexing path
|
||||
// (build time); the production boot hydrates precomputed vectors and never calls
|
||||
// index(), so this stays unloaded there. Measured cold load: ~93 ms.
|
||||
let tokenizerPromise: Promise<{
|
||||
(text: string, opts?: object): Promise<{ input_ids: { data: BigInt64Array | Int32Array } }>;
|
||||
decode(ids: number[], opts?: object): string;
|
||||
}> | null = null;
|
||||
|
||||
function getTokenizer() {
|
||||
if (!tokenizerPromise) {
|
||||
tokenizerPromise = (async () => {
|
||||
const { AutoTokenizer, env } = await import('@huggingface/transformers');
|
||||
if (process.env.HF_CACHE_DIR) env.cacheDir = process.env.HF_CACHE_DIR;
|
||||
return (await AutoTokenizer.from_pretrained(MODEL_NAME)) as never;
|
||||
})().catch((err) => {
|
||||
tokenizerPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return tokenizerPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split `text` into overlapping token windows, returned as decoded strings.
|
||||
* Short text yields exactly one window (the original string, untouched).
|
||||
*/
|
||||
export async function splitIntoWindows(text: string): Promise<string[]> {
|
||||
const tokenizer = await getTokenizer();
|
||||
const encoded = await tokenizer(text, { add_special_tokens: false });
|
||||
const ids = Array.from(encoded.input_ids.data as ArrayLike<bigint | number>, Number);
|
||||
if (ids.length <= WINDOW_TOKENS) return [text];
|
||||
|
||||
const stride = WINDOW_TOKENS - WINDOW_OVERLAP_TOKENS;
|
||||
const windows: string[] = [];
|
||||
for (let start = 0; start < ids.length; start += stride) {
|
||||
windows.push(tokenizer.decode(ids.slice(start, start + WINDOW_TOKENS), { skip_special_tokens: true }));
|
||||
if (start + WINDOW_TOKENS >= ids.length) break;
|
||||
}
|
||||
return windows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every embedding this module produces MUST be L2-unit-normalized: cosineSimilarity is a
|
||||
* bare dot product (see below) that silently returns wrong scores for non-unit inputs. We
|
||||
@@ -56,6 +118,13 @@ function getEmbedder(): Promise<FeatureExtractionPipeline> {
|
||||
*/
|
||||
const UNIT_NORM_EPSILON = 1e-3;
|
||||
|
||||
/**
|
||||
* Decimal places kept when serializing embeddings to the precomputed index.
|
||||
* Six is comfortably below the tolerance above while roughly halving the file
|
||||
* size — see VectorSearchProvider.serialize().
|
||||
*/
|
||||
const SERIALIZED_PRECISION = 6;
|
||||
|
||||
export function assertUnitNorm(vec: number[], context: string): void {
|
||||
let sumSq = 0;
|
||||
for (let i = 0; i < vec.length; i++) sumSq += vec[i] * vec[i];
|
||||
@@ -98,9 +167,16 @@ function cosineSimilarity(a: number[], b: number[]): number {
|
||||
|
||||
// ── Provider implementation ──────────────────────────────────
|
||||
|
||||
/** An indexed document with its embedding — the unit of build-time serialization. */
|
||||
/**
|
||||
* An indexed document with its embeddings — the unit of build-time serialization.
|
||||
*
|
||||
* One vector per token window (see splitIntoWindows). Short documents — which is
|
||||
* every rule and every event type — have exactly one, so the array is not an
|
||||
* optimization for the common case but the single uniform shape: no branch, no
|
||||
* "primary vs extra" special case to get wrong.
|
||||
*/
|
||||
export interface PrecomputedDocument extends SearchDocument {
|
||||
embedding: number[];
|
||||
embeddings: number[][];
|
||||
}
|
||||
|
||||
export class VectorSearchProvider implements SearchProvider {
|
||||
@@ -119,12 +195,38 @@ export class VectorSearchProvider implements SearchProvider {
|
||||
* from disk is milliseconds. The embedder is still loaded lazily for queries.
|
||||
*/
|
||||
indexPrecomputed(docs: PrecomputedDocument[]): void {
|
||||
// The serialized vectors are rounded (see serialize()) and arrive from a file
|
||||
// this process did not produce. Spot-check the first one: cosineSimilarity is
|
||||
// a bare dot product, so a non-unit vector here would silently skew every
|
||||
// score rather than fail. One check is enough — the whole section is written
|
||||
// by a single serialize() call, so they stand or fall together.
|
||||
if (docs.length > 0 && docs[0].embeddings.length > 0) {
|
||||
assertUnitNorm(docs[0].embeddings[0], 'indexPrecomputed');
|
||||
}
|
||||
this.documents.push(...docs);
|
||||
}
|
||||
|
||||
/** Export the indexed documents with embeddings for build-time serialization. */
|
||||
/** Total vectors held — exceeds `size` whenever documents span several windows. */
|
||||
get vectorCount(): number {
|
||||
return this.documents.reduce((n, d) => n + d.embeddings.length, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the indexed documents with embeddings for build-time serialization.
|
||||
*
|
||||
* Embeddings are rounded to SERIALIZED_PRECISION decimals. A float64 component
|
||||
* serializes to ~20 characters of JSON ("0.05234159901738167"), so 384 dims
|
||||
* cost ~7.8 KB per document; rounding roughly halves that, and the index file
|
||||
* is JSON.parse'd on every scale-to-zero cold start. The precision loss is
|
||||
* irrelevant to ranking: the induced L2 norm error is ~1e-5 across 384 dims,
|
||||
* two orders of magnitude inside UNIT_NORM_EPSILON, and far below the score
|
||||
* gaps that separate ranked results.
|
||||
*/
|
||||
serialize(): PrecomputedDocument[] {
|
||||
return this.documents;
|
||||
return this.documents.map((doc) => ({
|
||||
...doc,
|
||||
embeddings: doc.embeddings.map((vec) => vec.map((v) => Number(v.toFixed(SERIALIZED_PRECISION)))),
|
||||
}));
|
||||
}
|
||||
|
||||
async index(docs: SearchDocument[]): Promise<void> {
|
||||
@@ -134,14 +236,21 @@ export class VectorSearchProvider implements SearchProvider {
|
||||
const batch = docs.slice(i, i + batchSize);
|
||||
const embeddings = await Promise.all(
|
||||
batch.map(async (d) => {
|
||||
const out = await model(d.text, { pooling: 'mean', normalize: true });
|
||||
const vec = Array.from(out.data as Float32Array);
|
||||
assertUnitNorm(vec, `index:${d.id}`);
|
||||
return vec;
|
||||
// One vector per token window: text longer than the model's context would
|
||||
// otherwise be truncated to its opening and the rest never ranked at all.
|
||||
const windows = await splitIntoWindows(d.text);
|
||||
return Promise.all(
|
||||
windows.map(async (window, w) => {
|
||||
const out = await model(window, { pooling: 'mean', normalize: true });
|
||||
const vec = Array.from(out.data as Float32Array);
|
||||
assertUnitNorm(vec, `index:${d.id}#w${w}`);
|
||||
return vec;
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
for (let j = 0; j < batch.length; j++) {
|
||||
this.documents.push({ ...batch[j], embedding: embeddings[j] });
|
||||
this.documents.push({ ...batch[j], embeddings: embeddings[j] });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,12 +259,18 @@ export class VectorSearchProvider implements SearchProvider {
|
||||
const { topK = 5, minScore = 0.3 } = options;
|
||||
const queryEmbedding = await embed(query);
|
||||
|
||||
const scored = this.documents.map((doc) => ({
|
||||
id: doc.id,
|
||||
text: doc.text,
|
||||
metadata: doc.metadata,
|
||||
score: cosineSimilarity(queryEmbedding, doc.embedding),
|
||||
}));
|
||||
// Score a document by its BEST window, not by an average. Averaging would blur a
|
||||
// long document's several topics into one mediocre vector — the exact dilution
|
||||
// that makes big context windows worse than chunking. Max keeps a document
|
||||
// findable by any one of the things it actually talks about.
|
||||
const scored = this.documents.map((doc) => {
|
||||
let best = -Infinity;
|
||||
for (const vec of doc.embeddings) {
|
||||
const score = cosineSimilarity(queryEmbedding, vec);
|
||||
if (score > best) best = score;
|
||||
}
|
||||
return { id: doc.id, text: doc.text, metadata: doc.metadata, score: best };
|
||||
});
|
||||
|
||||
return scored
|
||||
.filter((r) => r.score >= minScore)
|
||||
|
||||
@@ -405,17 +405,17 @@ namespace AutopilotMonitor.Shared
|
||||
public const string OsBuildChanged = "os_build_changed"; // OS build (CurrentBuild.UBR) differs across an agent restart — an update was installed during enrollment
|
||||
public const string WindowsUpdateChannelCensus = "windows_update_channel_census"; // One-shot EventID histogram of WU Client + UpdateOrchestrator channels when os_build_changed but zero targeted WU events captured
|
||||
|
||||
// MDM-policy-forced coalesced reboot during ESP (MdmRebootPolicyTracker + ShellCoreTracker;
|
||||
// PMPC "unexpected reboot / second sign-in" research). Device-assigned policies whose CSP
|
||||
// URIs match the OS reboot-required catalog force a coalesced reboot at the end of
|
||||
// DeviceSetup — the user sees an unexpected restart and a second sign-in screen before
|
||||
// AccountSetup. 2800 (DeviceManagement-Enterprise-Diagnostics-Provider/Admin) fires once
|
||||
// per reboot-forcing URI; Shell-Core 62407 "RebootCoalescing" records ESP initiating the
|
||||
// reboot. Deliberately DISTINCT from system_reboot_detected: the backend RebootCount
|
||||
// matches that string exactly (EventIngestProcessor.Classification + terminal recount) —
|
||||
// these attribution events must never inflate the reboot count.
|
||||
public const string MdmPolicyRebootRequired = "mdm_policy_reboot_required"; // DM-Enterprise EventID 2800 — Data.rebootUri = CSP URI that demands the reboot (one event per URI)
|
||||
public const string EspRebootCoalescing = "esp_reboot_coalescing"; // Shell-Core 62407 "RebootCoalescing" — ESP-initiated coalesced reboot imminent (second sign-in follows)
|
||||
// MDM reboot-required policy flags (MdmRebootPolicyTracker; PMPC "unexpected reboot /
|
||||
// second sign-in" research). DM-Enterprise EventID 2800 fires once per policy URI that
|
||||
// matches the OS reboot-required catalog ("The following URI has triggered a reboot:
|
||||
// (./Device/...)."; verified session b2e890c1). Only URIs applied DURING ESP DeviceSetup
|
||||
// cause the coalesced mid-ESP reboot — later syncs (AccountSetup+) merely flag, no forced
|
||||
// reboot. The tracker therefore emits ONE aggregated Info event per burst with the URI
|
||||
// list; the reboot CLAIM lives in ANALYZE-ESP-005, gated on an actually-observed
|
||||
// system_reboot_detected. Deliberately DISTINCT from system_reboot_detected: the backend
|
||||
// RebootCount matches that string exactly (EventIngestProcessor.Classification + terminal
|
||||
// recount) — this attribution event must never inflate the reboot count.
|
||||
public const string MdmPolicyRebootRequired = "mdm_policy_reboot_required"; // Aggregated per burst — Data.rebootUris[] + uriCount + firstRebootUri
|
||||
|
||||
// TEMPORARY: shadow SM rollout verification — remove when CompletionStateMachine is promoted to primary
|
||||
public const string ShadowDiscrepancy = "shadow_discrepancy";
|
||||
|
||||
@@ -44,6 +44,23 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public string? OnboardedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Address used to reach this tenant about the service itself — a technical problem,
|
||||
/// a security matter, or a change that needs an administrator's attention. Editable by
|
||||
/// the tenant's own admins under Settings → Tenant → Contact.
|
||||
/// <para>
|
||||
/// Seeded once at onboarding from the Private Preview notification address if one was
|
||||
/// given, and never re-synced afterwards: from that point the value belongs to the
|
||||
/// tenant, and a later edit must not be overwritten by the onboarding source.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Purpose-limited by design — service communication only. It is never used for
|
||||
/// marketing and never disclosed. Null means we have no way to reach this tenant,
|
||||
/// which is why enforcement actions cannot promise prior warning.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string? ContactEmail { get; set; }
|
||||
|
||||
// ===== TENANT STATUS =====
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -107,7 +107,11 @@ namespace AutopilotMonitor.Shared.Models
|
||||
// ===== WHEN TO COLLECT =====
|
||||
|
||||
/// <summary>
|
||||
/// Trigger type: "startup", "phase_change", "interval", "on_event"
|
||||
/// Trigger type: "startup", "phase_change", "phase_exit", "interval", "on_event".
|
||||
/// <para>
|
||||
/// "phase_change" fires once when the phase in <see cref="TriggerPhase"/> is ENTERED,
|
||||
/// "phase_exit" once when it is LEFT — the two one-shot bookends of a phase.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string Trigger { get; set; } = default!;
|
||||
|
||||
@@ -117,8 +121,9 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public int? IntervalSeconds { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Phase to trigger on (only used when Trigger = "phase_change")
|
||||
/// e.g., "Identity", "MdmEnrollment", "AppInstallation"
|
||||
/// Phase to trigger on (used when Trigger = "phase_change" or "phase_exit").
|
||||
/// Canonical tokens are the <see cref="EnrollmentPhase"/> enum names, e.g.
|
||||
/// "DeviceSetup", "AccountSetup", "Complete". Empty = every phase transition.
|
||||
/// </summary>
|
||||
public string TriggerPhase { get; set; } = default!;
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ const FEATURES = [
|
||||
{
|
||||
title: "Diagnostics Collection",
|
||||
description:
|
||||
"Trigger on-demand diagnostic uploads directly from the portal. Collect ETL logs, event logs, IME logs, and system information from any enrolled device without local access.",
|
||||
"Collect agent logs, IME logs, agent state, and device information as a ZIP bundle at the end of an enrollment — then download it from the session view without touching the device.",
|
||||
color: "green",
|
||||
bullets: ["On-demand ZIP bundle upload", "ETL, event & IME log collection", "Configurable log path rules"],
|
||||
bullets: ["Configurable upload: off, always, or on failure", "Agent, IME & device information bundle", "Configurable additional log paths"],
|
||||
},
|
||||
{
|
||||
title: "Detailed Event Timeline",
|
||||
@@ -40,7 +40,7 @@ const FEATURES = [
|
||||
{
|
||||
title: "Audit Logging & Compliance",
|
||||
description:
|
||||
"Complete audit trail of all administrative actions and configuration changes. Meet compliance requirements with detailed, tamper-evident records and configurable data retention.",
|
||||
"Complete audit trail of all administrative actions and configuration changes. Meet compliance requirements with detailed, tenant-scoped records and configurable data retention.",
|
||||
color: "red",
|
||||
bullets: ["Admin action history", "Configurable retention policies", "Tenant-scoped audit log"],
|
||||
},
|
||||
@@ -138,7 +138,7 @@ export default function AboutPage() {
|
||||
{
|
||||
title: "MSPs & Enterprise Teams",
|
||||
description:
|
||||
"Autopilot Monitor supports multi-tenant deployments — each customer tenant runs its own isolated instance. MSPs with login access to a customer environment can use the portal for that tenant independently.",
|
||||
"Autopilot Monitor is a multi-tenant service with strict per-tenant data isolation — telemetry, configuration, and diagnostics are partitioned and access-scoped to each tenant. MSPs can be granted delegated read access across several customer tenants from a single login.",
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.title} className="rounded-lg bg-blue-50 border border-blue-100 p-4">
|
||||
@@ -165,7 +165,7 @@ export default function AboutPage() {
|
||||
{ step: "2", text: "The bootstrapper installs the Autopilot Monitor Agent on each enrolling device." },
|
||||
{ step: "3", text: "The agent captures live enrollment events and uploads them to the backend pipeline." },
|
||||
{ step: "4", text: "The portal displays real-time session data, analyze rule results, and fleet health metrics." },
|
||||
{ step: "5", text: "On completion, the agent optionally self-destructs and uploads a diagnostics bundle." },
|
||||
{ step: "5", text: "On completion, the agent uploads a diagnostics bundle if configured, then removes itself." },
|
||||
].map((item) => (
|
||||
<li key={item.step} className="flex items-start gap-3 text-sm text-gray-700">
|
||||
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-blue-600 text-white text-xs font-bold">
|
||||
@@ -189,7 +189,7 @@ export default function AboutPage() {
|
||||
{
|
||||
title: "Backend",
|
||||
items: [
|
||||
"Azure Functions (.NET 8 Isolated) — serverless, scalable API",
|
||||
"Azure Functions (.NET 10 Isolated, Flex Consumption) — serverless, scalable API",
|
||||
"Azure Table Storage — high-throughput event ingestion",
|
||||
"Azure Blob Storage — diagnostics and log bundle storage",
|
||||
"Azure SignalR Service — real-time push to the portal",
|
||||
@@ -198,10 +198,10 @@ export default function AboutPage() {
|
||||
{
|
||||
title: "Portal (Web Frontend)",
|
||||
items: [
|
||||
"Next.js 18 + TypeScript — fast, server-rendered React app",
|
||||
"Next.js 15 (React 18) + TypeScript — fast, server-rendered React app",
|
||||
"Microsoft Entra ID (MSAL) — secure authentication",
|
||||
"Role-based access control (Admin / Operator)",
|
||||
"Multi-tenant architecture",
|
||||
"Role-based access control (Admin / Operator / Viewer)",
|
||||
"Multi-tenant architecture with delegated MSP access",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -209,16 +209,16 @@ export default function AboutPage() {
|
||||
items: [
|
||||
".NET binary — lightweight, low-overhead monitoring",
|
||||
"Runs via scheduled task (no Windows service — easy, residue-free removal)",
|
||||
"Deployed via Intune bootstrapper script (Win32 app)",
|
||||
"Client certificate authentication",
|
||||
"Optional self-destruct on enrollment completion",
|
||||
"Deployed via an Intune platform script (PowerShell bootstrapper)",
|
||||
"Mutual TLS using the existing Intune MDM device certificate",
|
||||
"Self-destruct on enrollment completion (on by default — removes task and files)",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Integrations",
|
||||
items: [
|
||||
"Microsoft Intune — agent deployment target",
|
||||
"Microsoft Teams — enrollment failure notifications",
|
||||
"Microsoft Teams, Slack & generic JSON webhooks — start, success, failure and SLA alerts",
|
||||
"Intune Management Extension (IME) log — event source for log pattern detection",
|
||||
"WMI & Registry — extended data gather rules",
|
||||
],
|
||||
@@ -249,11 +249,17 @@ export default function AboutPage() {
|
||||
extended by the wider Windows Autopilot community.
|
||||
</p>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
Autopilot Monitor was created by{" "}
|
||||
Autopilot Monitor was created and is maintained by{" "}
|
||||
<strong>Oliver Kieselbach</strong>, a Microsoft MVP and long-time contributor to the Windows
|
||||
Autopilot and Microsoft Intune community. The project is driven by real-world enterprise
|
||||
deployment experience and community feedback.
|
||||
</p>
|
||||
<p className="text-gray-700 leading-relaxed mb-4">
|
||||
The hosted service is operated by <strong>glueckkanja AG</strong> — see the{" "}
|
||||
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:text-blue-800 underline">Imprint</a> for company
|
||||
details and the <Link href="/terms" className="text-blue-600 hover:text-blue-800 underline">Terms of Use</Link>{" "}
|
||||
for what each plan does and does not commit to.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 mt-5">
|
||||
<a
|
||||
href="https://github.com/okieselbach/Autopilot-Monitor"
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface TenantConfiguration {
|
||||
domainName: string;
|
||||
lastUpdated: string;
|
||||
updatedBy: string;
|
||||
/** Tenant-maintained contact address for service matters. Read-only here. */
|
||||
contactEmail?: string | null;
|
||||
disabled: boolean;
|
||||
disabledReason?: string;
|
||||
disabledUntil?: string;
|
||||
@@ -694,6 +696,13 @@ export function TenantManagementSection({
|
||||
<p className="text-xs text-indigo-600 mt-2">
|
||||
The email is saved and sent in one step. Also sent automatically on approval if set.
|
||||
</p>
|
||||
{editingTenant.contactEmail && (
|
||||
<p className="text-xs text-indigo-700 mt-2 pt-2 border-t border-indigo-200">
|
||||
<span className="font-medium">Tenant contact:</span>{" "}
|
||||
<span className="font-mono">{editingTenant.contactEmail}</span>
|
||||
<span className="text-indigo-500"> — maintained by the tenant, for service matters only</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device API Rate Limit override */}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
EMPTY_FORM,
|
||||
NewRuleForm,
|
||||
buildScopeFields,
|
||||
firesExactlyOnce,
|
||||
supportsEmitMode,
|
||||
supportsPhaseScope,
|
||||
validateScopeSelection,
|
||||
withDerivedScopeMode,
|
||||
} from "../types";
|
||||
|
||||
const form = (over: Partial<NewRuleForm>): NewRuleForm => ({ ...EMPTY_FORM, ...over });
|
||||
|
||||
/**
|
||||
* The gather-rule form offers three independent "when" controls (trigger, phase scope, emit
|
||||
* mode) and not every combination is meaningful: a trigger naming a concrete phase already
|
||||
* pins the single firing, so a scope could only ever suppress it and emit-dedup has nothing
|
||||
* to compare against. These predicates decide which controls are shown, so they also decide
|
||||
* what the payload may contain.
|
||||
*/
|
||||
describe("which controls apply to a trigger", () => {
|
||||
it("treats startup and concrete-phase triggers as one-shot", () => {
|
||||
expect(firesExactlyOnce(form({ trigger: "startup" }))).toBe(true);
|
||||
expect(firesExactlyOnce(form({ trigger: "phase_change", triggerPhase: "AccountSetup" }))).toBe(true);
|
||||
expect(firesExactlyOnce(form({ trigger: "phase_exit", triggerPhase: "FinalizingSetup" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("treats repeating triggers as multi-shot", () => {
|
||||
expect(firesExactlyOnce(form({ trigger: "interval" }))).toBe(false);
|
||||
expect(firesExactlyOnce(form({ trigger: "on_event", triggerEventType: "app_install_failed" }))).toBe(false);
|
||||
// "Any phase" fires on every transition
|
||||
expect(firesExactlyOnce(form({ trigger: "phase_change", triggerPhase: "" }))).toBe(false);
|
||||
expect(firesExactlyOnce(form({ trigger: "phase_exit", triggerPhase: "" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("hides phase scope only where the trigger already names the phase", () => {
|
||||
expect(supportsPhaseScope(form({ trigger: "phase_change", triggerPhase: "AccountSetup" }))).toBe(false);
|
||||
expect(supportsPhaseScope(form({ trigger: "phase_exit", triggerPhase: "AccountSetup" }))).toBe(false);
|
||||
|
||||
expect(supportsPhaseScope(form({ trigger: "phase_change", triggerPhase: "" }))).toBe(true);
|
||||
expect(supportsPhaseScope(form({ trigger: "interval" }))).toBe(true);
|
||||
expect(supportsPhaseScope(form({ trigger: "on_event" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps phase scope for startup — it defers the one-shot instead of suppressing it", () => {
|
||||
expect(supportsPhaseScope(form({ trigger: "startup" }))).toBe(true);
|
||||
expect(supportsEmitMode(form({ trigger: "startup" }))).toBe(false);
|
||||
});
|
||||
|
||||
it("hides emit mode for every one-shot trigger", () => {
|
||||
expect(supportsEmitMode(form({ trigger: "phase_change", triggerPhase: "AccountSetup" }))).toBe(false);
|
||||
expect(supportsEmitMode(form({ trigger: "interval" }))).toBe(true);
|
||||
expect(supportsEmitMode(form({ trigger: "phase_exit", triggerPhase: "" }))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateScopeSelection", () => {
|
||||
it("rejects a scope mode that was selected but left unfilled", () => {
|
||||
// The trap: the dropdown reads "From a phase onwards" while the payload would send null,
|
||||
// so the rule would silently run everywhere.
|
||||
expect(validateScopeSelection(form({ trigger: "interval", scopeMode: "from", activeFromPhase: "" })))
|
||||
.toMatch(/Active From Phase/);
|
||||
expect(validateScopeSelection(form({ trigger: "interval", scopeMode: "during", activePhases: [] })))
|
||||
.toMatch(/Active Phases/);
|
||||
});
|
||||
|
||||
it("accepts a filled scope and the always mode", () => {
|
||||
expect(validateScopeSelection(form({ trigger: "interval", scopeMode: "from", activeFromPhase: "AccountSetup" }))).toBeNull();
|
||||
expect(validateScopeSelection(form({ trigger: "interval", scopeMode: "during", activePhases: ["AppsUser"] }))).toBeNull();
|
||||
expect(validateScopeSelection(form({ trigger: "interval", scopeMode: "always" }))).toBeNull();
|
||||
});
|
||||
|
||||
it("does not block a trigger whose scope control is hidden", () => {
|
||||
// Stale scope state from an earlier trigger choice must not make the form unsavable.
|
||||
expect(validateScopeSelection(form({
|
||||
trigger: "phase_change", triggerPhase: "AccountSetup", scopeMode: "from", activeFromPhase: "",
|
||||
}))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildScopeFields", () => {
|
||||
it("sends the selected scope and emit mode for a repeating trigger", () => {
|
||||
expect(buildScopeFields(form({
|
||||
trigger: "interval", scopeMode: "from", activeFromPhase: "AccountSetup", emitMode: "on_change",
|
||||
}))).toEqual({ activePhases: null, activeFromPhase: "AccountSetup", emitMode: "on_change" });
|
||||
|
||||
expect(buildScopeFields(form({
|
||||
trigger: "interval", scopeMode: "during", activePhases: ["DeviceSetup", "AppsDevice"], emitMode: "always",
|
||||
}))).toEqual({ activePhases: ["DeviceSetup", "AppsDevice"], activeFromPhase: null, emitMode: "always" });
|
||||
});
|
||||
|
||||
it("drops hidden-control state instead of leaking it into the payload", () => {
|
||||
// User configures a scoped interval rule, then switches to a concrete-phase trigger:
|
||||
// the now-hidden scope and emit values must not be persisted.
|
||||
const switched = form({
|
||||
trigger: "phase_change",
|
||||
triggerPhase: "FinalizingSetup",
|
||||
scopeMode: "during",
|
||||
activePhases: ["AccountSetup"],
|
||||
emitMode: "on_change",
|
||||
});
|
||||
|
||||
expect(buildScopeFields(switched)).toEqual({
|
||||
activePhases: null, activeFromPhase: null, emitMode: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a deferring scope on a startup rule but drops its emit mode", () => {
|
||||
expect(buildScopeFields(form({
|
||||
trigger: "startup", scopeMode: "from", activeFromPhase: "AccountSetup", emitMode: "on_change",
|
||||
}))).toEqual({ activePhases: null, activeFromPhase: "AccountSetup", emitMode: null });
|
||||
});
|
||||
|
||||
it("treats an unfilled scope mode as unrestricted", () => {
|
||||
expect(buildScopeFields(form({ trigger: "interval", scopeMode: "during", activePhases: [] })).activePhases).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("withDerivedScopeMode", () => {
|
||||
it("derives the UI-only scopeMode from rule-shaped JSON", () => {
|
||||
expect(withDerivedScopeMode(form({ activePhases: ["AccountSetup"], activeFromPhase: "" })).scopeMode).toBe("during");
|
||||
expect(withDerivedScopeMode(form({ activePhases: [], activeFromPhase: "DeviceSetup" })).scopeMode).toBe("from");
|
||||
expect(withDerivedScopeMode(form({ activePhases: [], activeFromPhase: "" })).scopeMode).toBe("always");
|
||||
});
|
||||
|
||||
it("coerces unknown or missing emit modes to always", () => {
|
||||
expect(withDerivedScopeMode(form({ emitMode: "sometimes" })).emitMode).toBe("always");
|
||||
expect(withDerivedScopeMode(form({ emitMode: "on_change" })).emitMode).toBe("on_change");
|
||||
});
|
||||
|
||||
it("survives non-array activePhases from hand-written JSON", () => {
|
||||
const parsed = { ...EMPTY_FORM, activePhases: undefined } as unknown as NewRuleForm;
|
||||
expect(withDerivedScopeMode(parsed).activePhases).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -293,7 +293,16 @@ export function GatherRuleCard({
|
||||
)}
|
||||
{rule.trigger === "phase_change" && rule.triggerPhase && (
|
||||
<span className="text-gray-500">
|
||||
on phase: <code className="px-1 bg-gray-200 rounded text-xs">{rule.triggerPhase}</code>
|
||||
entering <code className="px-1 bg-gray-200 rounded text-xs">{formatGatherPhase(rule.triggerPhase)}</code>
|
||||
</span>
|
||||
)}
|
||||
{rule.trigger === "phase_exit" && (
|
||||
<span className="text-gray-500">
|
||||
{rule.triggerPhase ? (
|
||||
<>leaving <code className="px-1 bg-gray-200 rounded text-xs">{formatGatherPhase(rule.triggerPhase)}</code></>
|
||||
) : (
|
||||
"on every phase exit"
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{rule.trigger === "on_event" && rule.triggerEventType && (
|
||||
|
||||
@@ -11,8 +11,13 @@ import {
|
||||
TARGET_PLACEHOLDERS,
|
||||
TARGET_HINTS,
|
||||
GATHER_PHASES,
|
||||
PHASE_TRIGGERS,
|
||||
EMIT_MODES,
|
||||
formatTrigger,
|
||||
formatGatherPhase,
|
||||
firesExactlyOnce,
|
||||
supportsPhaseScope,
|
||||
supportsEmitMode,
|
||||
} from "../types";
|
||||
import { KNOWN_EVENT_TYPES, findEventType } from "../eventTypes";
|
||||
import { validateGatherRuleTarget } from "@/utils/guardValidation";
|
||||
@@ -425,7 +430,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.trigger === "phase_change" && (
|
||||
{PHASE_TRIGGERS.includes(form.trigger) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Trigger Phase</label>
|
||||
<select
|
||||
@@ -433,7 +438,7 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
onChange={(e) => setForm({ ...form, triggerPhase: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
>
|
||||
<option value="">Any phase change</option>
|
||||
<option value="">Any phase</option>
|
||||
{form.triggerPhase && !GATHER_PHASES.some((p) => p.value === form.triggerPhase) && (
|
||||
<option value={form.triggerPhase}>{form.triggerPhase} (legacy value)</option>
|
||||
)}
|
||||
@@ -441,7 +446,11 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">Run this rule when the enrollment reaches this phase. "Any phase change" fires once per phase transition.</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{form.trigger === "phase_exit"
|
||||
? "Collect once when the enrollment LEAVES this phase — a snapshot of its end state. \"Any phase\" fires on every phase transition. Also fires when the phase is left because enrollment failed."
|
||||
: "Collect once when the enrollment REACHES this phase. \"Any phase\" fires on every phase transition."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -480,8 +489,31 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row: Active During (phase scope) + Emit Mode */}
|
||||
{/* One-shot triggers pin the moment themselves — say so instead of offering controls
|
||||
that could only ever suppress the single firing. */}
|
||||
{firesExactlyOnce(form) && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-xs text-gray-500">
|
||||
{form.trigger === "startup" ? (
|
||||
<>
|
||||
This rule collects <span className="font-medium text-gray-700">exactly once</span>, when the agent
|
||||
starts monitoring. Emit mode doesn't apply to a single collection — but you can delay that
|
||||
collection with a phase scope below.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This rule collects <span className="font-medium text-gray-700">exactly once</span>, when the enrollment{" "}
|
||||
{form.trigger === "phase_exit" ? "leaves" : "reaches"}{" "}
|
||||
<span className="font-medium text-gray-700">{formatGatherPhase(form.triggerPhase)}</span>. Phase scoping
|
||||
and emit mode don't apply to a single collection.
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row: Active During (phase scope) + Emit Mode — only where they change behavior */}
|
||||
{(supportsPhaseScope(form) || supportsEmitMode(form)) && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{supportsPhaseScope(form) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Active During</label>
|
||||
<select
|
||||
@@ -502,8 +534,14 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<option value="during">Only during specific phases</option>
|
||||
<option value="from">From a phase onwards</option>
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">Restrict when this rule runs. Outside its scope the rule is idle — interval rules stop polling, triggers are ignored.</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{form.trigger === "startup"
|
||||
? "Restrict when this rule may start. With a scope it waits and collects once the scope is first reached, instead of at agent start."
|
||||
: "Restrict when this rule runs. Outside its scope the rule is idle — interval rules stop polling, triggers are ignored."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{supportsEmitMode(form) && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Emit Mode</label>
|
||||
<select
|
||||
@@ -517,9 +555,11 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">On change: polls on the trigger cadence but only emits an event when the collected result changes. The first collection always emits.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.scopeMode === "during" && (
|
||||
{supportsPhaseScope(form) && form.scopeMode === "during" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Active Phases</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
@@ -542,11 +582,15 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">The rule runs only while the enrollment is in one of the selected phases. No selection = all phases.</p>
|
||||
<p className={`text-xs mt-1 ${form.activePhases.length === 0 ? "text-amber-600" : "text-gray-400"}`}>
|
||||
{form.activePhases.length === 0
|
||||
? "Select at least one phase — otherwise switch \"Active During\" back to \"All phases\"."
|
||||
: "The rule runs only while the enrollment is in one of the selected phases."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.scopeMode === "from" && (
|
||||
{supportsPhaseScope(form) && form.scopeMode === "from" && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Active From Phase</label>
|
||||
<select
|
||||
@@ -559,7 +603,11 @@ export function GatherRuleFormFields({ form, setForm, showRuleId, unrestrictedMo
|
||||
<option key={p.value} value={p.value}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-gray-400 mt-1">Once the enrollment reaches this phase the rule activates and stays active for the rest of the session. No selection = all phases.</p>
|
||||
<p className={`text-xs mt-1 ${form.activeFromPhase ? "text-gray-400" : "text-amber-600"}`}>
|
||||
{form.activeFromPhase
|
||||
? "Once the enrollment reaches this phase the rule activates and stays active for the rest of the session."
|
||||
: "Select a phase — otherwise switch \"Active During\" back to \"All phases\"."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { FormJsonToggle, JsonModeToggleButtons } from "@/components/rules/FormJs
|
||||
import { useAuthenticatedFetch, useNotificationMessages, useGlobalAdminScope } from "@/hooks";
|
||||
import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner";
|
||||
import { TenantScopeSelector } from "@/components/TenantScopeSelector";
|
||||
import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, withDerivedScopeMode } from "./types";
|
||||
import { GatherRule, NewRuleForm, EMPTY_FORM, CATEGORY_COLORS, PHASE_TRIGGERS, buildScopeFields, validateScopeSelection, withDerivedScopeMode } from "./types";
|
||||
import { GatherRuleFormFields } from "./components/GatherRuleFormFields";
|
||||
import { GatherRuleCard } from "./components/GatherRuleCard";
|
||||
|
||||
@@ -198,6 +198,12 @@ export default function GatherRulesPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopeError = validateScopeSelection(form);
|
||||
if (scopeError) {
|
||||
showError(scopeError);
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
const payload = {
|
||||
ruleId: form.ruleId,
|
||||
@@ -209,11 +215,9 @@ export default function GatherRulesPage() {
|
||||
parameters: buildParameters(form),
|
||||
trigger: form.trigger,
|
||||
intervalSeconds: form.trigger === "interval" ? form.intervalSeconds : null,
|
||||
triggerPhase: form.trigger === "phase_change" ? form.triggerPhase : null,
|
||||
triggerPhase: PHASE_TRIGGERS.includes(form.trigger) ? form.triggerPhase : null,
|
||||
triggerEventType: form.trigger === "on_event" ? form.triggerEventType : null,
|
||||
activePhases: form.scopeMode === "during" && form.activePhases.length > 0 ? form.activePhases : null,
|
||||
activeFromPhase: form.scopeMode === "from" && form.activeFromPhase ? form.activeFromPhase : null,
|
||||
emitMode: form.emitMode || null,
|
||||
...buildScopeFields(form),
|
||||
outputEventType: form.outputEventType,
|
||||
outputSeverity: form.outputSeverity,
|
||||
};
|
||||
@@ -286,6 +290,12 @@ export default function GatherRulesPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const scopeError = validateScopeSelection(form);
|
||||
if (scopeError) {
|
||||
showError(scopeError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
title: form.title,
|
||||
@@ -296,11 +306,9 @@ export default function GatherRulesPage() {
|
||||
parameters: buildParameters(form),
|
||||
trigger: form.trigger,
|
||||
intervalSeconds: form.trigger === "interval" ? form.intervalSeconds : null,
|
||||
triggerPhase: form.trigger === "phase_change" ? form.triggerPhase : null,
|
||||
triggerPhase: PHASE_TRIGGERS.includes(form.trigger) ? form.triggerPhase : null,
|
||||
triggerEventType: form.trigger === "on_event" ? form.triggerEventType : null,
|
||||
activePhases: form.scopeMode === "during" && form.activePhases.length > 0 ? form.activePhases : null,
|
||||
activeFromPhase: form.scopeMode === "from" && form.activeFromPhase ? form.activeFromPhase : null,
|
||||
emitMode: form.emitMode || null,
|
||||
...buildScopeFields(form),
|
||||
outputEventType: form.outputEventType,
|
||||
outputSeverity: form.outputSeverity,
|
||||
version: bumpVersion(rule.version),
|
||||
|
||||
@@ -61,7 +61,10 @@ export interface NewRuleForm {
|
||||
|
||||
export const CATEGORIES = ["network", "identity", "apps", "device", "esp", "enrollment"] as const;
|
||||
export const COLLECTOR_TYPES = ["registry", "eventlog", "wmi", "file", "command_allowlisted", "logparser", "json", "xml"] as const;
|
||||
export const TRIGGERS = ["startup", "phase_change", "interval", "on_event"] as const;
|
||||
export const TRIGGERS = ["startup", "phase_change", "phase_exit", "interval", "on_event"] as const;
|
||||
|
||||
/** Triggers that collect at a phase boundary and therefore use the triggerPhase field. */
|
||||
export const PHASE_TRIGGERS: ReadonlyArray<string> = ["phase_change", "phase_exit"];
|
||||
export const SEVERITIES = ["info", "warning", "error", "critical"] as const;
|
||||
|
||||
// Canonical phase-scope tokens: the backend EnrollmentPhase enum NAMES from Start(0) through
|
||||
@@ -168,12 +171,66 @@ export const EMPTY_FORM: NewRuleForm = {
|
||||
|
||||
export function formatTrigger(trigger: string) {
|
||||
switch (trigger) {
|
||||
case "phase_change": return "Phase Change";
|
||||
case "phase_change": return "Phase Start";
|
||||
case "phase_exit": return "Phase End";
|
||||
case "on_event": return "On Event";
|
||||
default: return trigger.charAt(0).toUpperCase() + trigger.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the trigger already pins the rule to a single firing: Startup, or a phase
|
||||
* trigger naming a CONCRETE phase (Phase Start / Phase End dedup per rule+phase). The
|
||||
* any-phase variants, Interval and On Event can all fire repeatedly.
|
||||
*/
|
||||
export function firesExactlyOnce(form: Pick<NewRuleForm, "trigger" | "triggerPhase">): boolean {
|
||||
if (form.trigger === "startup") return true;
|
||||
return PHASE_TRIGGERS.includes(form.trigger) && !!form.triggerPhase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether phase scope can change anything for this trigger. It cannot when the trigger
|
||||
* names a concrete phase — that phase IS the moment, so a scope could only ever suppress
|
||||
* the single firing, never move it. Startup keeps it (scope defers the one-shot until the
|
||||
* scope activates); Interval / On Event / any-phase variants are its main use.
|
||||
*/
|
||||
export function supportsPhaseScope(form: Pick<NewRuleForm, "trigger" | "triggerPhase">): boolean {
|
||||
return !(PHASE_TRIGGERS.includes(form.trigger) && !!form.triggerPhase);
|
||||
}
|
||||
|
||||
/** Emit mode dedups repeated results — meaningless for a rule that fires exactly once. */
|
||||
export function supportsEmitMode(form: Pick<NewRuleForm, "trigger" | "triggerPhase">): boolean {
|
||||
return !firesExactlyOnce(form);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a scope mode that was selected but left unfilled. Without this the payload
|
||||
* silently sends null (= unrestricted) and the rule runs everywhere while the form still
|
||||
* reads "From a phase onwards" — the exact trap of a mode dropdown with an empty detail.
|
||||
*/
|
||||
export function validateScopeSelection(form: NewRuleForm): string | null {
|
||||
if (!supportsPhaseScope(form)) return null;
|
||||
if (form.scopeMode === "during" && form.activePhases.length === 0)
|
||||
return 'Select at least one phase under "Active Phases", or set "Active During" back to "All phases".';
|
||||
if (form.scopeMode === "from" && !form.activeFromPhase)
|
||||
return 'Select a phase under "Active From Phase", or set "Active During" back to "All phases".';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope + emit fields for the create/save payload. Controls the form hides for the current
|
||||
* trigger must not leak stale state from an earlier selection, so they are nulled here
|
||||
* rather than at each call site.
|
||||
*/
|
||||
export function buildScopeFields(form: NewRuleForm) {
|
||||
const scoped = supportsPhaseScope(form);
|
||||
return {
|
||||
activePhases: scoped && form.scopeMode === "during" && form.activePhases.length > 0 ? form.activePhases : null,
|
||||
activeFromPhase: scoped && form.scopeMode === "from" && form.activeFromPhase ? form.activeFromPhase : null,
|
||||
emitMode: supportsEmitMode(form) ? (form.emitMode || null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a form object after JSON-mode merges. Pasted JSON is usually rule-shaped
|
||||
* (activePhases/activeFromPhase/emitMode, no scopeMode key), so the UI-only scopeMode must
|
||||
|
||||
@@ -838,6 +838,7 @@ export default function LandingPage() {
|
||||
<ul className="space-y-1.5">
|
||||
<li><a href="/privacy" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Privacy Policy</a></li>
|
||||
<li><a href="/terms" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Terms of Use</a></li>
|
||||
<li><a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-xs text-gray-500 hover:text-blue-600 transition-colors">Imprint</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -847,7 +848,7 @@ export default function LandingPage() {
|
||||
<div className="mt-8 pt-5 border-t border-gray-200 flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<p className="text-[11px] text-gray-400">
|
||||
© 2026 Autopilot Monitor. Built with ❤️ by Oliver Kieselbach.{" "}
|
||||
<span className="inline-block">Powered by Azure.</span>
|
||||
<span className="inline-block">Hosted on Azure by glueckkanja AG.</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-400">
|
||||
Open source. Star us on{' '}
|
||||
|
||||
@@ -1,96 +1,221 @@
|
||||
import { PublicPageHeader } from "../../components/PublicPageHeader";
|
||||
|
||||
const LAST_UPDATED = "21 July 2026";
|
||||
const DOCS_SECURITY_FAQ = "https://docs.autopilotmonitor.com/trust/security-faq";
|
||||
const DOCS_DATA_FLOWS = "https://docs.autopilotmonitor.com/trust/data-flows";
|
||||
|
||||
function DocsLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-600 hover:text-indigo-800 underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<PublicPageHeader title="Privacy Policy" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Data Collection</h2>
|
||||
<p className="text-sm text-gray-500">Last updated: {LAST_UPDATED}</p>
|
||||
<p className="text-gray-700">
|
||||
This service collects and processes the following data to provide monitoring functionality:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>Device hardware information (manufacturer, model, serial number)</li>
|
||||
<li>Autopilot provisioning session data (status, events, timestamps)</li>
|
||||
<li>Azure AD/Entra ID tenant information</li>
|
||||
<li>User authentication information (UPN, display name, tenant ID)</li>
|
||||
<li>Operational telemetry and audit logs</li>
|
||||
<li>Anonymized usage telemetry (via Azure Application Insights, no cookies) — used to understand which features are used and improve the service. No personal data is transmitted.</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Data Processing Context</h2>
|
||||
<p className="text-gray-700">
|
||||
During an Autopilot enrollment, the user authenticates solely to verify their identity and initiate the process.
|
||||
After that, the user is not actively interacting with the device while provisioning runs. As a result, the data
|
||||
collected by the monitoring agent consists exclusively of <strong>technical enrollment events</strong> — no user
|
||||
activity, browsing data, or personal content is captured.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Tenant administrators retain full control over collected data through the following options:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Data Retention</strong> — configurable retention period per tenant (default 90 days); expired sessions are automatically purged</li>
|
||||
<li><strong>Delete Session</strong> — delete individual monitoring sessions on demand</li>
|
||||
<li><strong>Offboard Tenant</strong> — remove all data and configurations for a tenant from the service</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
These controls ensure that no personal information accumulates in the backend beyond what is necessary for
|
||||
enrollment monitoring. The service is designed for operational transparency, not user surveillance.
|
||||
Autopilot Monitor collects technical telemetry about Windows Autopilot enrollments so that IT teams can see
|
||||
what happened during a device provisioning and why it failed. This policy explains what is collected, where it
|
||||
is stored, who can reach it, and how long it is kept. The technical detail behind every statement here is
|
||||
published in the <DocsLink href={DOCS_SECURITY_FAQ}>Security & Privacy FAQ</DocsLink>.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Data Storage & Security</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Roles: Who Is Responsible for What</h2>
|
||||
<p className="text-gray-700">
|
||||
The platform is built with a layered security architecture designed to protect data at every level:
|
||||
For enrollment telemetry, <strong>your organization is the controller and Autopilot Monitor acts as your
|
||||
processor</strong>. You decide which devices are monitored, what additional data your gather rules collect,
|
||||
how long it is retained, and when it is deleted.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
For operating the service itself — administrator accounts, the audit trail of portal actions, and operational
|
||||
telemetry — <strong>glueckkanja AG</strong> is the controller.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
glueckkanja AG, a German company certified to ISO/IEC 27001, operates Autopilot Monitor for{" "}
|
||||
<strong>both plans</strong> — the same service, the same infrastructure, the same protection measures. Company
|
||||
details are in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>.
|
||||
The project is maintained by Oliver Kieselbach, who acts in that role on behalf of glueckkanja AG and is the
|
||||
contact for the open-source project and the Community edition.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
A <strong>data processing agreement (DPA / AVV) is available on request</strong>, concluded with
|
||||
glueckkanja AG. On the Enterprise plan it forms part of the written agreement.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">Authentication & Device Identity</h3>
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">What We Collect</h2>
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">From enrolling devices</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>Device agents authenticate via <strong>Intune MDM client certificates</strong>, validated against the embedded Intune CA chain</li>
|
||||
<li>Web users authenticate via <strong>Microsoft Entra ID (Azure AD)</strong> with multi-tenant JWT validation</li>
|
||||
<li><strong>Autopilot device validation</strong> via Microsoft Graph — only registered Autopilot devices are accepted</li>
|
||||
<li>Optional <strong>hardware whitelist</strong> for additional device verification</li>
|
||||
<li>Per-device <strong>rate limiting</strong> (sliding window) to prevent abuse</li>
|
||||
<li><strong>Device identity</strong> — serial number, device name, manufacturer, model, and the Entra ID tenant the device enrolls into</li>
|
||||
<li><strong>Enrollment progress</strong> — phases, ESP stages, application and script results, policy activity, reboots, timings, and failure codes</li>
|
||||
<li><strong>Device context</strong> — OS build, hardware characteristics, disk and network state, plus whatever your own gather rules request</li>
|
||||
<li><strong>Approximate location</strong> — country, region, city, and approximate coordinates, if geolocation is enabled for your tenant</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">Tenant Isolation</h3>
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">From portal users</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>Strict <strong>multi-tenant data isolation</strong> — all storage queries are partitioned by Tenant ID</li>
|
||||
<li>Real-time channels (SignalR) are scoped to <strong>tenant-specific groups</strong></li>
|
||||
<li>Independent configuration, audit logs, and device management per tenant</li>
|
||||
<li>Sign-in identity from Microsoft Entra ID — user principal name, display name, and tenant ID</li>
|
||||
<li>Audit records of administrative actions, including who performed them and when</li>
|
||||
<li>Operational request telemetry used to run and support the service</li>
|
||||
<li>A <strong>contact address</strong> for your tenant, if one is provided</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">Transport & Data Protection</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>All communication encrypted via <strong>HTTPS/TLS</strong>; real-time updates via secure WebSocket</li>
|
||||
<li>Diagnostics upload URLs are issued on-demand, and never persisted on the device</li>
|
||||
<li>Azure Storage encryption at rest for all persisted data</li>
|
||||
<li>PII logging disabled in production environments</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-medium text-gray-800 mt-4">Access Control</h3>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Role-based access</strong>: Tenant Admin (full tenant management), Operator, Users</li>
|
||||
<li>Device blocking capabilities for compromised or unauthorized devices</li>
|
||||
<li>Comprehensive <strong>audit logging</strong> of administrative actions</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Data Sharing</h2>
|
||||
<p className="text-gray-700">
|
||||
Your data is not shared with third parties. Access is restricted to:
|
||||
The contact address is used <strong>only to reach you about this service</strong> — a technical problem
|
||||
affecting your tenant, a security matter, or a change that needs an administrator's attention. It is
|
||||
never used for marketing and never shared. Your administrators set and change it under Settings → Tenant →
|
||||
Contact, and clearing it removes it. Where an organization signed up for the Private Preview and gave a
|
||||
notification address, that address is copied once as the initial contact and is yours to change from then on.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">What We Do Not Collect</h2>
|
||||
<p className="text-gray-700">
|
||||
During Autopilot provisioning the user signs in once to start the process and then does not interact with the
|
||||
device while it provisions. The agent captures <strong>no browsing history, no file or document content, no
|
||||
keystrokes, no screen content, and no application usage tracking</strong>. It removes itself when enrollment
|
||||
finishes. The service is designed for operational transparency, not user surveillance.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Gather rules cannot be used to widen this: <code className="text-sm bg-gray-100 px-1 py-0.5 rounded">C:\Users</code>{" "}
|
||||
is always blocked for privacy reasons, and downloading files, creating users, manipulating boot configuration,
|
||||
and establishing persistence are hard-blocked and cannot be enabled by configuration.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">IP Addresses and Geolocation</h2>
|
||||
<p className="text-gray-700">
|
||||
Geolocation is a tenant setting and is <strong>enabled by default</strong>. While it is enabled:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>Authenticated users within your tenant</li>
|
||||
<li>Global Administrators (for platform operations and support)</li>
|
||||
<li>The <strong>session record</strong> stores only the derived location — country, region, city, and approximate coordinates. The IP is deliberately excluded from the location event shown in the timeline.</li>
|
||||
<li>The <strong>outbound public IP is additionally stored once per session as a separate diagnostic event.</strong> It is hidden from the timeline view by default, but it is retained and queryable, and it expires with your retention period like any other event.</li>
|
||||
<li>Service request telemetry does not carry device IP addresses; the platform masks them and the application never sets them.</li>
|
||||
<li>A source IP is also stored on <strong>distress reports</strong> — the emergency channel an agent uses to report that it is failing — as part of that incident record.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
<strong>Disabling geolocation stops all of this</strong> — no IP and no location data is collected. It is a
|
||||
tenant setting and also an agent command-line switch. Only the Geographic Performance view is affected.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Where Your Data Is Stored</h2>
|
||||
<p className="text-gray-700">
|
||||
All customer data — sessions, events, configuration, audit logs, diagnostics, and backups — is stored in
|
||||
Microsoft Azure in <strong>Germany West Central</strong>. The only component outside that region is the portal
|
||||
front-end, served as static assets from West Europe; it stores no customer data. There is no cross-region
|
||||
replication and no transfer of customer data outside the EU by the platform.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Who Can Access Your Data</h2>
|
||||
<p className="text-gray-700">
|
||||
Your data is not sold, and it is not shared with third parties for their own purposes. Access is limited to:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Authenticated users in your own tenant</strong>, according to their role — Admin, Operator, Viewer, or a role-less Member limited to the Progress Portal</li>
|
||||
<li><strong>Platform operators</strong> — Global Admin for operations and support, and Global Reader for read-only support with configuration secrets redacted</li>
|
||||
<li><strong>Delegated (MSP) administrators you or your provider have been granted</strong> — read-only, limited to exactly the tenants in scope, with configuration secrets redacted, and with every grant and revocation written to <em>your</em> tenant's audit log so you can always see who was given access to your data</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
Delegated administration is an Enterprise capability and is off unless explicitly granted. Full detail on the
|
||||
isolation model is in the <DocsLink href={DOCS_SECURITY_FAQ}>Security & Privacy FAQ</DocsLink>.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">External Services</h2>
|
||||
<p className="text-gray-700">
|
||||
<strong>Microsoft Azure is the only place your data is stored.</strong> Resend delivers the onboarding
|
||||
approval email when a tenant is activated — it receives an administrator's email address and the tenant
|
||||
domain, never enrollment telemetry. Vulnerability reference data is read inbound from NVD, the CISA KEV
|
||||
catalog, and MSRC; nothing about your environment is sent to them.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Further connections exist only because you configure them: your own Azure storage account for diagnostics,
|
||||
notification channels such as Teams, Slack, or a generic webhook, and your own AI assistant if a user connects
|
||||
one through the MCP integration. The platform itself makes no calls to any AI or LLM provider.{" "}
|
||||
<DocsLink href={DOCS_DATA_FLOWS}>Data Flows & External Services</DocsLink> maps every outbound connection
|
||||
and what it carries. The data processing agreement, available on request, is the authoritative document for
|
||||
the parties engaged and the terms of their engagement.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Diagnostics Uploads</h2>
|
||||
<p className="text-gray-700">
|
||||
Diagnostics upload is <strong>off by default</strong>. When enabled, the default destination is{" "}
|
||||
<strong>your own Azure storage account</strong> — the package never reaches our infrastructure. Hosted upload
|
||||
exists as an alternative but is opt-in only and requires an explicit administrator action behind a clearly
|
||||
marked disclosure that data leaves your tenant. It is never enabled silently.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">How Long We Keep Data</h2>
|
||||
<p className="text-gray-700">
|
||||
Retention is <strong>configured by you per tenant, defaulting to 90 days</strong> — 7 to 90 days on the
|
||||
Community plan and 7 to 365 days on Enterprise. Expired sessions are purged automatically. You additionally
|
||||
control:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Delete session</strong> — remove an individual monitoring session on demand</li>
|
||||
<li><strong>Offboard tenant</strong> — remove your tenant's data and configuration from the service entirely, as a verified multi-phase cascade</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
Two things intentionally survive tenant offboarding, neither of which contains enrollment telemetry or
|
||||
personal data:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Product feedback</strong> you submitted — it is not tied to enrollment data and is what improves the product.</li>
|
||||
<li><strong>Custom rules and IME log patterns</strong> you authored are archived rather than deleted. Detection knowledge is what makes this product useful, so contributed rules and patterns are subject to entering the community pool — the next organization hitting the same enrollment failure gets a diagnosis instead of a mystery. A rule is a detection definition, not device data.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
Either can be removed on request.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">How Your Data Is Protected</h2>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li><strong>Device authentication by mutual TLS</strong> using the Intune MDM client certificate, validated against pinned Intune root CAs rather than the operating system trust store — and rejected if no trust anchor loads</li>
|
||||
<li><strong>Autopilot device validation via Microsoft Graph</strong> — only devices registered in your tenant are accepted, with an optional hardware allow-list on top</li>
|
||||
<li><strong>Entra ID authentication</strong> for portal users with a restricted signing-algorithm allow-list and identity PII logging disabled</li>
|
||||
<li><strong>Fail-closed authorization</strong> — every API route must be registered with an access policy; an unregistered route is unreachable</li>
|
||||
<li><strong>Structural tenant isolation</strong> — storage partitioning by tenant, with the tenant identity taken from the validated token and never from a client-supplied header</li>
|
||||
<li><strong>Encryption</strong> — HTTPS with a TLS 1.2 floor in transit, Azure Storage encryption at rest with platform-managed keys</li>
|
||||
<li><strong>Managed identity instead of storage keys</strong>, and secret-less OIDC deployment pipelines</li>
|
||||
<li><strong>Rate limiting</strong> per device, per portal user, and per MCP user</li>
|
||||
<li><strong>Verifiable agent binaries</strong> — Sigstore build attestation plus a four-stage integrity chain through download, backend cross-check, and runtime self-verification</li>
|
||||
<li><strong>Manifest-first deletion</strong> — what will be deleted is captured, restorably, before anything is removed</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Your Rights</h2>
|
||||
<p className="text-gray-700">
|
||||
As this is an environment operated under best-effort principles, formal data subject rights (access, deletion, portability)
|
||||
are not guaranteed. However, we will make reasonable efforts to accommodate such requests on a case-by-case basis.
|
||||
Where the GDPR applies, you have the right to access, correction, deletion, restriction of processing,
|
||||
portability, and to object to processing. We act on such requests within the statutory time limits. In
|
||||
practice most requests resolve immediately in the portal, because deletion of individual sessions, full tenant
|
||||
offboarding, and the retention period are all controls you hold yourself.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
Where Autopilot Monitor acts as your processor, requests from your own employees should be directed to your
|
||||
organization; we support you in fulfilling them.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Changes to This Policy</h2>
|
||||
<p className="text-gray-700">
|
||||
Material changes are reflected in the "last updated" date above and announced through the service
|
||||
announcements in the portal. If you hold a signed data processing agreement, notification follows the terms of
|
||||
that agreement.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold text-gray-900 mt-6">Contact</h2>
|
||||
<p className="text-gray-700">
|
||||
For privacy questions, a data processing agreement, or a data subject request, contact glueckkanja AG using
|
||||
the details in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>.
|
||||
For the open-source project and the Community edition you can also reach the maintainer via{" "}
|
||||
<a href="https://www.linkedin.com/in/oliver-kieselbach" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">LinkedIn</a>{" "}
|
||||
or open a{" "}
|
||||
<a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">GitHub issue</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -150,6 +150,10 @@ interface TenantConfigContextValue {
|
||||
setRebootOnComplete: (v: boolean) => void;
|
||||
rebootDelaySeconds: number;
|
||||
setRebootDelaySeconds: (v: number) => void;
|
||||
contactEmail: string;
|
||||
setContactEmail: (v: string) => void;
|
||||
handleSaveContact: () => void;
|
||||
handleResetContact: () => void;
|
||||
enableGeoLocation: boolean;
|
||||
setEnableGeoLocation: (v: boolean) => void;
|
||||
enableTimezoneAutoSet: boolean;
|
||||
@@ -364,6 +368,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
const [keepLogFile, setKeepLogFile] = useState(false);
|
||||
const [rebootOnComplete, setRebootOnComplete] = useState(false);
|
||||
const [rebootDelaySeconds, setRebootDelaySeconds] = useState(10);
|
||||
const [contactEmail, setContactEmail] = useState("");
|
||||
const [enableGeoLocation, setEnableGeoLocation] = useState(true);
|
||||
const [enableTimezoneAutoSet, setEnableTimezoneAutoSet] = useState(false);
|
||||
const [enableImeMatchLog, setEnableImeMatchLog] = useState(false);
|
||||
@@ -481,6 +486,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setKeepLogFile(data.keepLogFile ?? false);
|
||||
setRebootOnComplete(data.rebootOnComplete ?? false);
|
||||
setRebootDelaySeconds(data.rebootDelaySeconds ?? 10);
|
||||
setContactEmail(data.contactEmail ?? "");
|
||||
setEnableGeoLocation(data.enableGeoLocation ?? true);
|
||||
setEnableTimezoneAutoSet(data.enableTimezoneAutoSet ?? false);
|
||||
setEnableImeMatchLog(data.enableImeMatchLog ?? false);
|
||||
@@ -675,6 +681,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
keepLogFile,
|
||||
rebootOnComplete,
|
||||
rebootDelaySeconds,
|
||||
contactEmail: contactEmail.trim(),
|
||||
enableGeoLocation,
|
||||
enableTimezoneAutoSet,
|
||||
enableImeMatchLog,
|
||||
@@ -755,7 +762,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
manufacturerWhitelist, modelWhitelist, validateAutopilotDevice, validateCorporateIdentifier, validateDeviceAssociation,
|
||||
dataRetentionDays, sessionTimeoutHours, enablePerformanceCollector, performanceCollectorInterval,
|
||||
helloWaitTimeoutSeconds, selfDestructOnComplete, keepLogFile, rebootOnComplete, rebootDelaySeconds,
|
||||
enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, logLevel, showScriptOutput, showEnrollmentSummary,
|
||||
contactEmail, enableGeoLocation, enableTimezoneAutoSet, enableImeMatchLog, logLevel, showScriptOutput, showEnrollmentSummary,
|
||||
enrollmentSummaryTimeoutSeconds, enrollmentSummaryBrandingImageUrl, enrollmentSummaryLaunchRetrySeconds,
|
||||
notificationChannels,
|
||||
slaTargetSuccessRate, slaTargetMaxDurationMinutes, slaTargetAppInstallSuccessRate,
|
||||
@@ -1087,6 +1094,7 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setKeepLogFile(config.keepLogFile ?? false);
|
||||
setRebootOnComplete(config.rebootOnComplete ?? false);
|
||||
setRebootDelaySeconds(config.rebootDelaySeconds ?? 10);
|
||||
setContactEmail(config.contactEmail ?? "");
|
||||
setEnableGeoLocation(config.enableGeoLocation ?? true);
|
||||
setEnableTimezoneAutoSet(config.enableTimezoneAutoSet ?? false);
|
||||
setEnableImeMatchLog(config.enableImeMatchLog ?? false);
|
||||
@@ -1134,6 +1142,12 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
setSlaConsecutiveFailureThreshold(config.slaConsecutiveFailureThreshold ?? 5);
|
||||
}, [config]);
|
||||
|
||||
const handleSaveContact = useCallback(() => saveConfiguration("contact"), [saveConfiguration]);
|
||||
const handleResetContact = useCallback(() => {
|
||||
if (!config) return;
|
||||
setContactEmail(config.contactEmail ?? "");
|
||||
}, [config]);
|
||||
|
||||
const handleSaveDiagnostics = useCallback(() => saveConfiguration("diagnostics"), [saveConfiguration]);
|
||||
const handleResetDiagnostics = useCallback(() => {
|
||||
if (!config) return;
|
||||
@@ -1508,6 +1522,8 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
keepLogFile, setKeepLogFile,
|
||||
rebootOnComplete, setRebootOnComplete,
|
||||
rebootDelaySeconds, setRebootDelaySeconds,
|
||||
contactEmail, setContactEmail,
|
||||
handleSaveContact, handleResetContact,
|
||||
enableGeoLocation, setEnableGeoLocation,
|
||||
enableTimezoneAutoSet, setEnableTimezoneAutoSet,
|
||||
enableImeMatchLog, setEnableImeMatchLog,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { SectionAccessManagement } from "../sections/SectionAccessManagement";
|
||||
import { SectionBootstrapSessions } from "../sections/SectionBootstrapSessions";
|
||||
import { SectionSlaTargets } from "../sections/SectionSlaTargets";
|
||||
import { SectionSubmitLogs } from "../sections/SectionSubmitLogs";
|
||||
import { SectionContact } from "../sections/SectionContact";
|
||||
import { SectionOptionalGraphCapabilities } from "../sections/SectionOptionalGraphCapabilities";
|
||||
|
||||
const SECTION_COMPONENTS: Record<TenantSectionId, React.ComponentType> = {
|
||||
@@ -22,6 +23,7 @@ const SECTION_COMPONENTS: Record<TenantSectionId, React.ComponentType> = {
|
||||
"access-management": SectionAccessManagement,
|
||||
"bootstrap-sessions": SectionBootstrapSessions,
|
||||
"graph-permissions": SectionOptionalGraphCapabilities,
|
||||
"contact": SectionContact,
|
||||
"support": SectionSubmitLogs,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useTenantConfig } from "../../TenantConfigContext";
|
||||
import SaveResetBar from "../../components/SaveResetBar";
|
||||
|
||||
export function SectionContact() {
|
||||
const {
|
||||
contactEmail, setContactEmail,
|
||||
handleSaveContact, handleResetContact,
|
||||
savingSection,
|
||||
} = useTenantConfig();
|
||||
|
||||
const trimmed = contactEmail.trim();
|
||||
const looksInvalid = trimmed.length > 0 && !trimmed.includes("@");
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-blue-50">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Contact</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">Where we reach you about the service</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="contactEmail" className="block text-sm font-medium text-gray-700">
|
||||
Contact email address
|
||||
</label>
|
||||
<input
|
||||
id="contactEmail"
|
||||
type="email"
|
||||
value={contactEmail}
|
||||
onChange={(e) => setContactEmail(e.target.value)}
|
||||
placeholder="it-operations@contoso.com"
|
||||
className="mt-1 block w-full max-w-md rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
/>
|
||||
{looksInvalid && (
|
||||
<p className="mt-1 text-sm text-amber-600">That does not look like an email address.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 border border-blue-100 p-4">
|
||||
<p className="text-sm text-blue-900">
|
||||
Used <strong>only</strong> to reach you about this service — a technical problem affecting your tenant, a
|
||||
security matter, or a change that needs an administrator's attention. Never for marketing, and never
|
||||
shared with anyone else.
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-blue-800">
|
||||
A shared team mailbox works better than a personal address. Leaving this empty is fine — it only means we
|
||||
have no way to reach you before acting on a problem affecting your tenant.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SaveResetBar
|
||||
onSave={handleSaveContact}
|
||||
onReset={handleResetContact}
|
||||
saving={savingSection === "contact"}
|
||||
canSave={!looksInvalid}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,9 @@ export const TENANT_NAV_SECTIONS = [
|
||||
{ id: "sla-targets", label: "SLA Targets", description: "SLA targets and breach notification settings" },
|
||||
{ id: "bootstrap-sessions", label: "Bootstrap Sessions", description: "Create and manage bootstrap tokens" },
|
||||
{ id: "graph-permissions", label: "Optional Graph capabilities", description: "Grant additional Microsoft Graph permissions for optional features" },
|
||||
{ id: "plan", label: "Plan", description: "Your current plan and what Enterprise adds" },
|
||||
{ id: "support", label: "Submit Logs", description: "Send diagnostic files to the Autopilot Monitor team" },
|
||||
{ id: "plan", label: "Plan", description: "Your current plan and what Enterprise adds" },
|
||||
{ id: "contact", label: "Contact", description: "Where we reach you about the service" },
|
||||
] as const;
|
||||
|
||||
export type TenantSectionId = (typeof TENANT_NAV_SECTIONS)[number]["id"];
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface TenantConfiguration {
|
||||
keepLogFile?: boolean;
|
||||
rebootOnComplete?: boolean;
|
||||
rebootDelaySeconds?: number;
|
||||
contactEmail?: string;
|
||||
enableGeoLocation?: boolean;
|
||||
enableTimezoneAutoSet?: boolean;
|
||||
enableImeMatchLog?: boolean;
|
||||
|
||||
@@ -1,47 +1,167 @@
|
||||
import { PublicPageHeader } from "../../components/PublicPageHeader";
|
||||
|
||||
const LAST_UPDATED = "21 July 2026";
|
||||
const DOCS_SECURITY_FAQ = "https://docs.autopilotmonitor.com/trust/security-faq";
|
||||
const DOCS_PLANS = "https://docs.autopilotmonitor.com/plans";
|
||||
const GITHUB_ADVISORY = "https://github.com/okieselbach/Autopilot-Monitor/security/advisories/new";
|
||||
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<PublicPageHeader title="Terms of Use" />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<p className="text-sm text-gray-500">Last updated: {LAST_UPDATED}</p>
|
||||
<p className="text-gray-700">
|
||||
By using this service, you agree to the following terms:
|
||||
Autopilot Monitor is provided by <strong>glueckkanja AG</strong>, a German company certified to ISO/IEC
|
||||
27001 — see the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>.
|
||||
Both plans run on the same service and the same infrastructure; the plan determines operating limits,
|
||||
support, and contractual commitments, not how the service works.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
The project was created and is maintained by <strong>Oliver Kieselbach</strong>, who is also the contact for
|
||||
the open-source project and the Community edition. He acts in that role on behalf of glueckkanja AG; he is
|
||||
not a separate contracting party.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 mt-4">
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">Community</h3>
|
||||
<p className="text-sm text-gray-700">
|
||||
Free, invite-only, currently in Private Preview, and maintained by Oliver Kieselbach as an open
|
||||
community contribution. Provided <strong>without any commitment by glueckkanja AG</strong> as to
|
||||
availability, support, or fitness for a particular purpose. Support is community-based via GitHub.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
<h3 className="font-semibold text-gray-900 mb-1">Enterprise</h3>
|
||||
<p className="text-sm text-gray-700">
|
||||
Commercial plan under a written agreement with glueckkanja AG. Includes support and reliability
|
||||
commitments, a data processing agreement, higher operating limits, extended retention, and delegated
|
||||
(MSP) administration. Where that agreement differs from these terms, the agreement prevails.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-700">
|
||||
The sections below apply to both plans unless stated otherwise. Plan details are documented under{" "}
|
||||
<a href={DOCS_PLANS} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Plans</a>.
|
||||
</p>
|
||||
|
||||
<div className="bg-blue-50 border-l-4 border-blue-400 p-4 rounded">
|
||||
<p className="text-blue-900 text-sm">
|
||||
<strong>Who can use it.</strong> Autopilot Monitor requires a Microsoft Entra ID tenant with Intune and
|
||||
registered Autopilot devices. The <strong>Community</strong> plan is open to anyone with such a tenant —
|
||||
organizations, non-profits, and individuals running their own lab or personal tenant alike. The{" "}
|
||||
<strong>Enterprise</strong> plan is a commercial agreement and is offered to businesses and organizations
|
||||
acting in a commercial or professional capacity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Acceptable Use</h2>
|
||||
<p className="text-gray-700">By using this service, you agree that:</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>The service is used to monitor and troubleshoot Windows Autopilot deployments for devices your organization is entitled to manage.</li>
|
||||
<li>You are responsible for ensuring your use complies with your organization's policies, your works council or employee representation obligations, and applicable law — including informing your users about monitoring where required.</li>
|
||||
<li>Access requires authentication via Microsoft Entra ID, and each tenant's data is accessible only to authorized users of that tenant, or to parties you have granted delegated access.</li>
|
||||
<li>You will not attempt to access another tenant's data, circumvent security controls, or probe the service for vulnerabilities without prior written agreement.</li>
|
||||
<li>You will not use gather rules or any other configuration to collect data unrelated to enrollment diagnostics, or to monitor individual employees.</li>
|
||||
<li>You will not use the service to build a competing product, or resell access without an agreement permitting it.</li>
|
||||
<li>Automated access, including through the MCP integration, stays within the published rate limits and quotas.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
<strong>Enforcement.</strong> Where use threatens the service or other tenants, we respond proportionately.
|
||||
Depending on the situation that may be a warning, reduced rate limits, suspension, or termination — chosen
|
||||
to fit the case, not worked through as a fixed sequence. Where the risk is immediate, access can be
|
||||
restricted at once. A warning is sent where a tenant contact address is on file and time permits.
|
||||
</p>
|
||||
<p className="text-gray-700">
|
||||
<strong>Security research is welcome</strong> and is not a violation of these terms when reported privately.
|
||||
Report findings through{" "}
|
||||
<a href={GITHUB_ADVISORY} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">GitHub Security Advisories</a>{" "}
|
||||
rather than a public issue — see{" "}
|
||||
<a href={DOCS_SECURITY_FAQ} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Security & Privacy FAQ</a>{" "}
|
||||
for what happens next. Do not access other tenants' data, degrade the service, or run automated scans
|
||||
against production while testing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Intellectual Property & Licensing</h2>
|
||||
<div className="space-y-2 text-gray-700">
|
||||
<p><strong>Open source.</strong> The Autopilot Monitor source code is published on <a href="https://github.com/okieselbach/Autopilot-Monitor" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">GitHub</a> under the licence stated in that repository. That licence governs the code — it does not grant rights to the hosted service, its infrastructure, its data, or the Autopilot Monitor name and branding.</p>
|
||||
<p><strong>Your data stays yours.</strong> Enrollment telemetry, configuration, and diagnostics belong to your organization. Using the service grants only the rights needed to operate it for you, as described in the <a href="/privacy" className="text-indigo-600 hover:text-indigo-800 underline">Privacy Policy</a>.</p>
|
||||
<p><strong>Rules you contribute</strong> are covered by the licence grant described under "Your Data, Suspension and Termination" below.</p>
|
||||
<p><strong>Third-party components</strong> remain the property of their respective owners and are used under their own licences; see the attributions below.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Delegated (MSP) Administration</h2>
|
||||
<p className="text-gray-700">
|
||||
Delegated administration lets a managing organization see a defined set of customer tenants from one place. It
|
||||
is an Enterprise capability and applies only where access has been granted.
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-gray-700 ml-4">
|
||||
<li>This service is provided for monitoring and troubleshooting Windows Autopilot deployments in your organization.</li>
|
||||
<li>You are responsible for ensuring that your use of this service complies with your organization's policies and applicable laws.</li>
|
||||
<li>Access to this service requires proper authentication via Azure AD/Entra ID.</li>
|
||||
<li>Each tenant's data is isolated and accessible only to authorized users of that tenant.</li>
|
||||
<li>You must not attempt to access data belonging to other tenants or circumvent security measures.</li>
|
||||
<li>The service operator reserves the right to suspend or terminate access at any time without prior notice.</li>
|
||||
<li>Delegated access is <strong>read-only</strong> and limited to exactly the tenants in scope. Configuration secrets are redacted.</li>
|
||||
<li>A grant is either assigned by platform operators or delegated by the customer's own tenant admin; customer-initiated delegations require approval before they take effect.</li>
|
||||
<li>Every grant, revocation, and disablement is recorded in the <strong>managed customer tenant's</strong> audit log, so a customer can always determine who holds access to their data.</li>
|
||||
<li>A managing organization is responsible for having the necessary data protection agreements in place with the customers it manages.</li>
|
||||
<li>Access can be revoked at any time by the customer or by platform operators and takes effect within seconds.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Legal Disclaimer</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Availability, Support and Data</h2>
|
||||
|
||||
<div className="bg-amber-50 border-l-4 border-amber-500 p-4 rounded">
|
||||
<h3 className="font-semibold text-amber-900 mb-2">AS-IS, No Warranty</h3>
|
||||
<h3 className="font-semibold text-amber-900 mb-2">Community plan: provided "AS-IS", no warranty</h3>
|
||||
<p className="text-amber-800 text-sm">
|
||||
This software is provided "AS-IS" without any warranty of any kind, either expressed or implied,
|
||||
including but not limited to the implied warranties of merchantability and fitness for a particular purpose.
|
||||
The Community plan is provided free of charge, "AS-IS", and without warranty of any kind, express
|
||||
or implied, including the implied warranties of merchantability and fitness for a particular purpose. It is
|
||||
currently in Private Preview: updates are frequent, availability is not guaranteed, and data structures may
|
||||
change. Production use is permitted and intended — with that trade-off understood.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-gray-700">
|
||||
<p><strong>No Liability:</strong> The service operator shall not be liable for any damages, including but not limited to direct, indirect, incidental, special, consequential, or punitive damages arising out of the use or inability to use this service.</p>
|
||||
<p><strong>No Service Level Agreement (SLA):</strong> This service is provided without any guaranteed uptime or availability. Service interruptions, maintenance windows, or complete shutdown may occur without prior notice.</p>
|
||||
<p><strong>No Data Backup Guarantee:</strong> While we implement data retention policies, we do not guarantee the preservation of your data. You are responsible for maintaining your own backups and records.</p>
|
||||
<p><strong>Best Effort Support:</strong> Support is provided on a best-effort basis with no guaranteed response times or resolution commitments.</p>
|
||||
<p><strong>Use at Your Own Risk:</strong> By using this service, you acknowledge that you do so at your own risk and that you are solely responsible for any consequences arising from your use of the service.</p>
|
||||
<p><strong>Active Development Notice:</strong> This project is under active development. Service interruptions or unexpected behavior can occur at any time, especially when updates are deployed to the backend, web application, or agent components.</p>
|
||||
<p><strong>Availability.</strong> The Community plan carries no uptime or availability commitment; interruptions, maintenance, and changes can occur without prior notice. The Enterprise plan carries the availability commitments set out in its agreement.</p>
|
||||
<p><strong>Support.</strong> Community support is best-effort via <a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">GitHub issues</a>, provided by the project maintainer and the community, with no guaranteed response or resolution time; built-in rules and IME log patterns are community-maintained. Enterprise support follows the response commitments in its agreement.</p>
|
||||
<p><strong>Data durability.</strong> Autopilot Monitor is a monitoring system, not a system of record. Configuration, authorization, and rule data is backed up daily; session and event telemetry is time-bounded operational data and is <strong>not</strong> backed up. Retain anything you need for compliance or reporting purposes in your own systems.</p>
|
||||
<p><strong>Liability.</strong> To the extent permitted by law, glueckkanja AG is not liable for indirect, incidental, special, consequential, or punitive damages, or for loss of data, profit, or business, arising from use of or inability to use the service. For the Community plan, which is provided free of charge, liability is limited to intent and gross negligence. Liability for injury to life, body or health and under mandatory statutory provisions remains unaffected. For the Enterprise plan, the liability provisions of the written agreement apply.</p>
|
||||
<p><strong>Use at your own risk.</strong> The service reports on enrollments; it does not perform them. Operational decisions you take based on its output remain your responsibility.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Third-Party Data Sources & Attributions</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Your Data, Suspension and Termination</h2>
|
||||
<div className="space-y-2 text-gray-700">
|
||||
<p><strong>Ownership.</strong> Your enrollment telemetry remains yours. Autopilot Monitor processes it to provide the service, as described in the <a href="/privacy" className="text-indigo-600 hover:text-indigo-800 underline">Privacy Policy</a>.</p>
|
||||
<p><strong>Your controls.</strong> You set the retention period, delete individual sessions, and offboard your tenant entirely at any time — no support ticket required.</p>
|
||||
<p><strong>Rules you contribute.</strong> Analyze rules, gather rules, and IME log patterns you author are detection definitions, not device data. By creating them you grant a non-exclusive, royalty-free right to retain them and to include them in the shared community rule pool, so that other organizations can benefit from a detection you built. This is the reciprocal side of a product whose built-in rules are community-maintained. You keep the right to use your own rules however you like, and you can request removal from the pool at any time.</p>
|
||||
<p><strong>Suspension.</strong> Access may be suspended where use threatens the integrity, security, or availability of the service or of other tenants — for example abusive request volumes or attempts to reach other tenants' data. Where circumstances permit, notice is given first.</p>
|
||||
<p><strong>Termination.</strong> You may stop using the service and offboard at any time. For the Community plan, the operator may discontinue the service or an individual tenant's access; reasonable advance notice will be given except where security requires immediate action. Enterprise termination follows its agreement.</p>
|
||||
<p><strong>After offboarding.</strong> Your tenant's data is removed through a verified multi-phase cascade. Product feedback you submitted, and custom rules and IME log patterns you authored, are intentionally retained — neither contains enrollment telemetry or personal data. Both are removed on request.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Security and Transparency</h2>
|
||||
<p className="text-gray-700">
|
||||
The vulnerability correlation feature uses the following external data sources to identify known vulnerabilities in installed software:
|
||||
The security architecture, data residency, external services, retention and deletion behaviour, and an explicit
|
||||
statement of what the service does <em>not</em> do are published in the{" "}
|
||||
<a href={DOCS_SECURITY_FAQ} target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Security & Privacy FAQ</a>.
|
||||
A signed data processing agreement is part of the Enterprise plan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Third-Party Data Sources & Attributions</h2>
|
||||
<p className="text-gray-700">
|
||||
The vulnerability correlation feature uses the following external data sources to identify known
|
||||
vulnerabilities in installed software. These are inbound reference-data pulls — no customer data is sent to
|
||||
them.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 border border-gray-200 rounded-lg">
|
||||
@@ -71,6 +191,15 @@ export default function TermsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Changes and Governing Law</h2>
|
||||
<div className="space-y-2 text-gray-700">
|
||||
<p><strong>Changes.</strong> These terms may be updated; the "last updated" date above reflects the current version, and material changes are announced through the service announcements in the portal. Continued use after a change constitutes acceptance.</p>
|
||||
<p><strong>Governing law.</strong> These terms are governed by German law. For the Enterprise plan, the governing-law and venue provisions of the written agreement apply.</p>
|
||||
<p><strong>Severability.</strong> If a provision is found unenforceable, the remaining provisions stay in effect.</p>
|
||||
<p><strong>Contact.</strong> Company details are in the <a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">Imprint</a>. For the project and the Community edition, reach the maintainer via <a href="https://www.linkedin.com/in/oliver-kieselbach" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">LinkedIn</a> or open a <a href="https://github.com/okieselbach/Autopilot-Monitor/issues" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:text-indigo-800 underline">GitHub issue</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -536,6 +536,19 @@ export default function Navbar() {
|
||||
</svg>
|
||||
<span>Terms of Use</span>
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href="https://www.glueckkanja.com/en/imprint"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||
onClick={() => setShowHelp(false)}
|
||||
>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
<span>Imprint</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -700,6 +713,10 @@ export default function Navbar() {
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||
<span>Terms of Use</span>
|
||||
</Link>
|
||||
<a href="https://www.glueckkanja.com/en/imprint" target="_blank" rel="noopener noreferrer" className="w-full flex items-center gap-2.5 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" onClick={() => { setShowOverflow(false); setOverflowSubmenu(null); }}>
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /></svg>
|
||||
<span>Imprint</span>
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -208,8 +208,9 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
|
||||
{ id: "cfg-sla-targets", label: "SLA Targets", href: "/settings/tenant/sla-targets" },
|
||||
{ id: "cfg-bootstrap-sessions", label: "Bootstrap Sessions", href: "/settings/tenant/bootstrap-sessions" },
|
||||
{ id: "cfg-graph-permissions", label: "Optional Graph capabilities", href: "/settings/tenant/graph-permissions" },
|
||||
{ id: "cfg-plan", label: "Plan", href: "/settings/tenant/plan" },
|
||||
{ id: "cfg-support", label: "Submit Logs", href: "/settings/tenant/support" },
|
||||
{ id: "cfg-plan", label: "Plan", href: "/settings/tenant/plan" },
|
||||
{ id: "cfg-contact", label: "Contact", href: "/settings/tenant/contact" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+9
-9
@@ -1976,9 +1976,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2877,9 +2877,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5312,9 +5312,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
|
||||
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -362,9 +362,14 @@ describe("validateGatherRuleTarget", () => {
|
||||
expect(r!.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it("returns null for eventlog type (no validation needed)", () => {
|
||||
const r = validateGatherRuleTarget("eventlog", "Application", false);
|
||||
expect(r).toBeNull();
|
||||
it("routes eventlog type to validateEventLogTarget", () => {
|
||||
const allowed = validateGatherRuleTarget("eventlog", "Application", false);
|
||||
expect(allowed).not.toBeNull();
|
||||
expect(allowed!.allowed).toBe(true);
|
||||
|
||||
const blocked = validateGatherRuleTarget("eventlog", "Security", false);
|
||||
expect(blocked).not.toBeNull();
|
||||
expect(blocked!.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("returns null for empty target", () => {
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
ALLOWED_COMMANDS_LIST,
|
||||
ALLOWED_DIAGNOSTICS_PATH_PREFIXES,
|
||||
BLOCKED_FILE_PREFIXES,
|
||||
ALLOWED_EVENT_LOG_CHANNELS,
|
||||
BLOCKED_EVENT_LOG_CHANNELS,
|
||||
} from "./guardrails.generated";
|
||||
|
||||
// Re-export for consumers that imported from here
|
||||
export { ALLOWED_REGISTRY_PREFIXES, ALLOWED_FILE_PREFIXES, ALLOWED_WMI_QUERY_PREFIXES, ALLOWED_COMMANDS_LIST, ALLOWED_DIAGNOSTICS_PATH_PREFIXES };
|
||||
export { ALLOWED_REGISTRY_PREFIXES, ALLOWED_FILE_PREFIXES, ALLOWED_WMI_QUERY_PREFIXES, ALLOWED_COMMANDS_LIST, ALLOWED_DIAGNOSTICS_PATH_PREFIXES, ALLOWED_EVENT_LOG_CHANNELS };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result type
|
||||
@@ -362,13 +364,61 @@ export function validateDiagnosticsPath(
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event log channels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Channel names match with a boundary on "/" so that "Microsoft-Windows-AAD"
|
||||
* admits "Microsoft-Windows-AAD/Operational" but not a longer provider name.
|
||||
*/
|
||||
function matchesChannel(channel: string, prefix: string): boolean {
|
||||
const c = channel.toLowerCase();
|
||||
const p = prefix.toLowerCase();
|
||||
return c === p || c.startsWith(p + "/");
|
||||
}
|
||||
|
||||
export function validateEventLogTarget(
|
||||
target: string,
|
||||
unrestrictedMode: boolean
|
||||
): ValidationResult {
|
||||
const channel = target.trim();
|
||||
|
||||
const blocked = BLOCKED_EVENT_LOG_CHANNELS.find((b) => matchesChannel(channel, b));
|
||||
if (blocked) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `"${blocked}" is never readable — it carries the audit trail or script-block logging`,
|
||||
unrestricted: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (ALLOWED_EVENT_LOG_CHANNELS.some((a) => matchesChannel(channel, a))) {
|
||||
return { allowed: true, reason: "Channel is on the allowlist", unrestricted: false };
|
||||
}
|
||||
|
||||
if (unrestrictedMode) {
|
||||
return {
|
||||
allowed: true,
|
||||
reason: "Allowed only because unrestricted mode is enabled",
|
||||
unrestricted: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "Not an allowed event log channel",
|
||||
unrestricted: false,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatcher — routes to the correct validator by collector type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate a gather rule target. Returns null for empty targets or
|
||||
* collector types that don't need path validation (eventlog).
|
||||
* collector types that have no allowlist.
|
||||
*/
|
||||
export function validateGatherRuleTarget(
|
||||
collectorType: string,
|
||||
@@ -391,7 +441,7 @@ export function validateGatherRuleTarget(
|
||||
case "command":
|
||||
return validateCommandTarget(target, unrestrictedMode);
|
||||
case "eventlog":
|
||||
return null; // Event log channel names are not path-validated
|
||||
return validateEventLogTarget(target, unrestrictedMode);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,74 @@ export const COMMAND_CATEGORIES: readonly GuardrailCategory[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const EVENT_LOG_CHANNEL_CATEGORIES: readonly GuardrailCategory[] = [
|
||||
{
|
||||
category: "Core Windows logs",
|
||||
items: [
|
||||
"Application",
|
||||
"System",
|
||||
"Setup",
|
||||
"Microsoft-Windows-Kernel-Boot",
|
||||
"Microsoft-Windows-Diagnostics-Performance",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "MDM / Enrollment",
|
||||
items: [
|
||||
"Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider",
|
||||
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider",
|
||||
"Microsoft-Windows-Provisioning-Diagnostics-Provider",
|
||||
"Microsoft-Windows-AAD",
|
||||
"Microsoft-Windows-User Device Registration",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "ESP / Shell / Apps",
|
||||
items: [
|
||||
"Microsoft-Windows-Shell-Core",
|
||||
"Microsoft-Windows-AppXDeployment",
|
||||
"Microsoft-Windows-AppXDeploymentServer",
|
||||
"Microsoft-Windows-AppReadiness",
|
||||
"Microsoft-Windows-Store",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Security posture / Crypto",
|
||||
items: [
|
||||
"Microsoft-Windows-BitLocker-API",
|
||||
"Microsoft-Windows-BitLocker-DrivePreparationTool",
|
||||
"Microsoft-Windows-TPM-WMI",
|
||||
"Microsoft-Windows-CertificateServicesClient-Lifecycle-System",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Update / Servicing",
|
||||
items: [
|
||||
"Microsoft-Windows-WindowsUpdateClient",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Logon / Session",
|
||||
items: [
|
||||
"Microsoft-Windows-Winlogon",
|
||||
"Microsoft-Windows-User Profile Service",
|
||||
"Microsoft-Windows-GroupPolicy",
|
||||
"Microsoft-Windows-TaskScheduler",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "Network",
|
||||
items: [
|
||||
"Microsoft-Windows-NetworkProfile",
|
||||
"Microsoft-Windows-Dhcp-Client",
|
||||
"Microsoft-Windows-DNS-Client",
|
||||
"Microsoft-Windows-NCSI",
|
||||
"Microsoft-Windows-WLAN-AutoConfig",
|
||||
"Microsoft-Windows-Time-Service",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Flat arrays (for validation logic)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -246,3 +314,43 @@ export const ALLOWED_DIAGNOSTICS_PATH_PREFIXES: readonly string[] = [
|
||||
export const BLOCKED_FILE_PREFIXES: readonly string[] = [
|
||||
"C:\\Users",
|
||||
];
|
||||
|
||||
export const ALLOWED_EVENT_LOG_CHANNELS: readonly string[] = [
|
||||
"Application",
|
||||
"System",
|
||||
"Setup",
|
||||
"Microsoft-Windows-Kernel-Boot",
|
||||
"Microsoft-Windows-Diagnostics-Performance",
|
||||
"Microsoft-Windows-DeviceManagement-Enterprise-Diagnostics-Provider",
|
||||
"Microsoft-Windows-ModernDeployment-Diagnostics-Provider",
|
||||
"Microsoft-Windows-Provisioning-Diagnostics-Provider",
|
||||
"Microsoft-Windows-AAD",
|
||||
"Microsoft-Windows-User Device Registration",
|
||||
"Microsoft-Windows-Shell-Core",
|
||||
"Microsoft-Windows-AppXDeployment",
|
||||
"Microsoft-Windows-AppXDeploymentServer",
|
||||
"Microsoft-Windows-AppReadiness",
|
||||
"Microsoft-Windows-Store",
|
||||
"Microsoft-Windows-BitLocker-API",
|
||||
"Microsoft-Windows-BitLocker-DrivePreparationTool",
|
||||
"Microsoft-Windows-TPM-WMI",
|
||||
"Microsoft-Windows-CertificateServicesClient-Lifecycle-System",
|
||||
"Microsoft-Windows-WindowsUpdateClient",
|
||||
"Microsoft-Windows-Winlogon",
|
||||
"Microsoft-Windows-User Profile Service",
|
||||
"Microsoft-Windows-GroupPolicy",
|
||||
"Microsoft-Windows-TaskScheduler",
|
||||
"Microsoft-Windows-NetworkProfile",
|
||||
"Microsoft-Windows-Dhcp-Client",
|
||||
"Microsoft-Windows-DNS-Client",
|
||||
"Microsoft-Windows-NCSI",
|
||||
"Microsoft-Windows-WLAN-AutoConfig",
|
||||
"Microsoft-Windows-Time-Service",
|
||||
];
|
||||
|
||||
export const BLOCKED_EVENT_LOG_CHANNELS: readonly string[] = [
|
||||
"Security",
|
||||
"Microsoft-Windows-PowerShell",
|
||||
"Windows PowerShell",
|
||||
"Microsoft-Windows-Sysmon",
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user