mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71832b9deb | |||
| ce4d884abc | |||
| b56c774103 | |||
| 68767f436f | |||
| c7b06be8b7 | |||
| 479247cdae | |||
| b6ba87f072 | |||
| fa640ebe61 | |||
| dbc20a9f66 | |||
| cbda62d131 | |||
| 3951000b40 | |||
| c59842262e | |||
| c3e9621a78 | |||
| 3ed21c37d1 | |||
| 4a1194569e | |||
| d0794fc7c5 | |||
| f34c9d2322 | |||
| 380a8417c8 | |||
| 63826a4869 | |||
| 7df208fac9 | |||
| 3f9576d4a9 | |||
| ee58242d33 | |||
| eca528b9b3 | |||
| 8305f5c850 | |||
| c2c02b929c | |||
| 1821af6f05 | |||
| 62599d972c | |||
| 21b751be64 | |||
| 63ea2e3cee | |||
| 93dcac44a4 | |||
| 72be8ac89f | |||
| cd570c8b3e | |||
| 6dfc564be6 | |||
| afecd18123 | |||
| 5a294ac4f6 |
@@ -0,0 +1,31 @@
|
||||
# CodeQL configuration.
|
||||
#
|
||||
# The default query suites stay active — this file only filters out two C# queries
|
||||
# that fire structurally on this codebase, where the flagged behavior is the feature,
|
||||
# not a defect:
|
||||
#
|
||||
# cs/exposure-of-sensitive-information
|
||||
# Audit and operational logs record WHO performed an action (actor UPN / email)
|
||||
# and notification services deliver mail to a recipient address. Writing that
|
||||
# identity to the log / notification IS the purpose of the field. Every such site
|
||||
# is a legitimate, reviewed use — the query cannot distinguish it from a leak.
|
||||
#
|
||||
# cs/cleartext-storage-of-sensitive-information
|
||||
# The rate limiters and distress guard use the trusted client IP as their bucket
|
||||
# key. The IP is an identifier for throttling, not a secret at rest; storing it in
|
||||
# the in-memory / table rate-limit key is by design.
|
||||
#
|
||||
# Genuine one-off false positives from OTHER queries (e.g. an open-redirect the taint
|
||||
# model can't see is allow-listed, a rate-limited route it doesn't recognize) are
|
||||
# dismissed individually in the Security tab so those queries stay sharp — they are
|
||||
# NOT suppressed here.
|
||||
#
|
||||
# Log-injection (cs/log-forging) is deliberately NOT filtered: user-controlled values
|
||||
# are sanitized at the sink via Helpers/LogSanitizer, so the query stays useful for
|
||||
# catching a future unsanitized sink.
|
||||
|
||||
query-filters:
|
||||
- exclude:
|
||||
id: cs/exposure-of-sensitive-information
|
||||
- exclude:
|
||||
id: cs/cleartext-storage-of-sensitive-information
|
||||
@@ -0,0 +1,47 @@
|
||||
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]
|
||||
# Major bumps are breaking and reviewed deliberately, not auto-PR'd.
|
||||
# Security updates ignore this filter, so the Security tab stays clean.
|
||||
ignore:
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /src/McpServer/autopilot-monitor-mcp
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
mcp:
|
||||
patterns: ['*']
|
||||
update-types: [minor, patch]
|
||||
# Major bumps are breaking and reviewed deliberately, not auto-PR'd.
|
||||
# Security updates ignore this filter, so the Security tab stays clean.
|
||||
ignore:
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
interval: monthly
|
||||
@@ -56,11 +56,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v7
|
||||
|
||||
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -352,13 +352,13 @@ jobs:
|
||||
subject-path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}
|
||||
path: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
|
||||
- name: Upload version metadata
|
||||
uses: actions/upload-artifact@v6
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
|
||||
@@ -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@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v6
|
||||
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@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
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@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
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@v7
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
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,59 @@
|
||||
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@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v6
|
||||
if: matrix.language == 'csharp'
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
config-file: .github/codeql/codeql-config.yml
|
||||
|
||||
- name: Build
|
||||
if: matrix.build-mode == 'manual'
|
||||
run: dotnet build AutopilotMonitor.sln --configuration Release
|
||||
|
||||
- name: Analyze
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
@@ -22,9 +22,9 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v5
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
name: Build and Deploy Backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@v3
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@v5
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
|
||||
@@ -19,7 +19,23 @@ jobs:
|
||||
name: Build and Deploy MCP Server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
# 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@v7
|
||||
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
|
||||
@@ -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 \
|
||||
.
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
lfs: false
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
@@ -39,6 +39,9 @@ jobs:
|
||||
with:
|
||||
azure_static_web_apps_api_token: ${{ secrets.AZURE_SWA_DEPLOY_TOKEN }}
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }} # Used for Github integrations (i.e. PR comments)
|
||||
# Dependabot PRs run without repo secrets, so the deploy token is absent.
|
||||
# Skip (green) instead of failing the check; real deploys on main still have the token.
|
||||
skip_deploy_on_missing_secrets: true
|
||||
action: "upload"
|
||||
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
|
||||
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
|
||||
|
||||
+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/
|
||||
|
||||
@@ -23,11 +23,16 @@ https://docs.autopilotmonitor.com (separate repository).
|
||||
# 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.
|
||||
|
||||
+17
@@ -1,7 +1,24 @@
|
||||
# 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.
|
||||
|
||||
@@ -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)
|
||||
@@ -107,6 +107,16 @@ enter-rule and an exit-rule on the same phase never cannibalise each other's slo
|
||||
`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
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
+404
@@ -0,0 +1,404 @@
|
||||
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}");
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Every remaining collector actually calls its guard
|
||||
//
|
||||
// The guard functions have their own unit tests, but those prove nothing about
|
||||
// whether a collector consults them before touching the registry, WMI or disk.
|
||||
// These drive the collectors themselves: each blocked target must come back empty,
|
||||
// emit exactly one security_warning naming the target, and produce no rule output.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
private void AssertBlocked(Dictionary<string, object> result, string target)
|
||||
{
|
||||
Assert.Empty(result);
|
||||
var warning = SingleSecurityWarning();
|
||||
Assert.Equal(true, warning.Data["blocked"]);
|
||||
Assert.Equal(target, warning.Data["target"]);
|
||||
Assert.DoesNotContain(_events, e => e.EventType == "guard_test");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(@"SAM\SAM\Domains\Account\Users")]
|
||||
[InlineData(@"SECURITY\Policy\Secrets")]
|
||||
[InlineData(@"HKLM\SOFTWARE\Microsoft\EnrollmentsSomethingElse")] // prefix spoofing
|
||||
public void RegistryCollector_OffAllowlistPath_IsBlockedBeforeTheHiveIsOpened(string target)
|
||||
=> AssertBlocked(new RegistryCollector().Execute(Rule("registry", target), Context()), target);
|
||||
|
||||
[Theory]
|
||||
[InlineData("SELECT * FROM Win32_UserAccount")]
|
||||
[InlineData("SELECT * FROM Win32_ShadowCopy")]
|
||||
[InlineData("SELECT * FROM Win32_OperatingSystemExtra")] // prefix spoofing
|
||||
public void WmiCollector_OffAllowlistQuery_IsBlockedBeforeTheQueryRuns(string query)
|
||||
=> AssertBlocked(new WmiCollector().Execute(Rule("wmi", query), Context()), query);
|
||||
|
||||
[Fact]
|
||||
public void JsonCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
|
||||
{
|
||||
const string target = @"C:\Users\Public\Documents\secrets.json";
|
||||
AssertBlocked(
|
||||
new JsonCollector().Execute(
|
||||
Rule("json", target, new Dictionary<string, string> { ["jsonpath"] = "$.token" }),
|
||||
Context()),
|
||||
target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void XmlCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
|
||||
{
|
||||
const string target = @"C:\Users\Public\Documents\secrets.xml";
|
||||
AssertBlocked(
|
||||
new XmlCollector().Execute(
|
||||
Rule("xml", target, new Dictionary<string, string> { ["xpath"] = "//token" }),
|
||||
Context()),
|
||||
target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileCollector_UserProfilePath_IsBlockedBeforeTheFileIsOpened()
|
||||
{
|
||||
const string target = @"C:\Users\Public\Documents\secrets.txt";
|
||||
AssertBlocked(new FileCollector().Execute(Rule("file", target), Context()), target);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JsonCollector_AllowedPath_ReadsTheFile()
|
||||
{
|
||||
// Counterpart to the blocked cases: proves the guard is a gate, not a wall —
|
||||
// the collector does reach the file once the path is admitted.
|
||||
var path = Path.Combine(_outsideUsers, "inventory.json");
|
||||
File.WriteAllText(path, "{ \"agent\": { \"version\": \"2.4.0\" } }");
|
||||
|
||||
var result = new JsonCollector().Execute(
|
||||
Rule("json", path, new Dictionary<string, string> { ["jsonpath"] = "$.agent.version" }),
|
||||
Context(unrestricted: true));
|
||||
|
||||
Assert.DoesNotContain(_events, e => e.EventType == Constants.EventTypes.SecurityWarning);
|
||||
Assert.Contains("2.4.0", string.Join(",", result.Values));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Path traversal
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void FilePath_TraversalOutOfAnAllowedPrefix_IsBlocked()
|
||||
{
|
||||
// The raw string starts with an allowed prefix, so a naive StartsWith check would
|
||||
// admit it. Path.GetFullPath runs FIRST, so the allowlist sees C:\Secret.txt.
|
||||
const string traversal =
|
||||
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Secret.txt";
|
||||
|
||||
Assert.Equal(@"C:\Secret.txt", Path.GetFullPath(traversal));
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilePath_TraversalIntoTheUsersHardBlock_StaysBlockedEvenUnrestricted()
|
||||
{
|
||||
// Unrestricted mode lifts the allowlist but never the C:\Users privacy block —
|
||||
// and it must not be reachable by spelling the path with '..' either.
|
||||
const string traversal =
|
||||
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Users\Public\ntuser.dat";
|
||||
|
||||
Assert.StartsWith(@"C:\Users\", Path.GetFullPath(traversal), StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal, unrestrictedMode: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FilePath_TraversalIntoTheConfigHiveDirectory_StaysBlockedEvenUnrestricted()
|
||||
{
|
||||
const string traversal = @"C:\Windows\Logs\..\System32\config\SAM";
|
||||
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal));
|
||||
Assert.False(GatherRuleGuards.IsFilePathAllowed(traversal, unrestrictedMode: true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileCollector_TraversalTarget_IsBlockedAndReported()
|
||||
{
|
||||
const string traversal =
|
||||
@"C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\..\..\..\..\Users\Public\ntuser.dat";
|
||||
|
||||
AssertBlocked(new FileCollector().Execute(Rule("file", traversal), Context()), traversal);
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+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" };
|
||||
|
||||
+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 };
|
||||
|
||||
+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.
|
||||
|
||||
@@ -55,7 +55,7 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
var previewMock = new Mock<PreviewWhitelistService>(
|
||||
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>())
|
||||
configRepo, cache, Mock.Of<ILogger<PreviewWhitelistService>>(), _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_telegramMock = new Mock<TelegramNotificationService>(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
public class LogSanitizerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Clean_WithCrlf_StripsNewlines()
|
||||
{
|
||||
// The core log-forging vector: a value that injects a fake log line.
|
||||
var forged = "/api/sessions\r\n[Auth Middleware] FAKE: admin granted";
|
||||
var cleaned = LogSanitizer.Clean(forged);
|
||||
Assert.Equal("/api/sessions[Auth Middleware] FAKE: admin granted", cleaned);
|
||||
Assert.DoesNotContain('\r', cleaned!);
|
||||
Assert.DoesNotContain('\n', cleaned!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_WithBareLineFeedOrCarriageReturn_StripsBoth()
|
||||
{
|
||||
Assert.Equal("ab", LogSanitizer.Clean("a\nb"));
|
||||
Assert.Equal("ab", LogSanitizer.Clean("a\rb"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_WithOtherControlChars_StripsThem()
|
||||
{
|
||||
// Tab (9), NUL (0) and ESC (27, terminal escape) are all removed. Built
|
||||
// from char codes so the source file carries no literal control bytes.
|
||||
var input = $"a{(char)9}b{(char)0}c{(char)27}de";
|
||||
Assert.Equal("abcde", LogSanitizer.Clean(input));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_WithUnicodeLineSeparators_StripsThem()
|
||||
{
|
||||
// U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are Unicode categories
|
||||
// Zl/Zp, not Cc — so a \p{Cc}-only pass would let them through, and a log viewer
|
||||
// or JavaScript-side reader would still break the line on them. Built from char
|
||||
// codes so the source file carries no literal separator bytes.
|
||||
var forged = $"/api/sessions{(char)0x2028}[Auth Middleware] FAKE: admin granted";
|
||||
var cleaned = LogSanitizer.Clean(forged);
|
||||
|
||||
Assert.Equal("/api/sessions[Auth Middleware] FAKE: admin granted", cleaned);
|
||||
Assert.DoesNotContain((char)0x2028, cleaned!);
|
||||
Assert.Equal("ab", LogSanitizer.Clean($"a{(char)0x2029}b"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/api/sessions/abc-123/events")]
|
||||
[InlineData("user@contoso.com")]
|
||||
[InlineData("11111111-2222-3333-4444-555555555555")]
|
||||
[InlineData("GET")]
|
||||
public void Clean_WithLegitimateValue_ReturnsUnchanged(string value)
|
||||
{
|
||||
// Real paths, UPNs, GUIDs and methods never contain control chars, so
|
||||
// tracing fidelity is fully preserved.
|
||||
Assert.Equal(value, LogSanitizer.Clean(value));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_WithNull_ReturnsNull()
|
||||
{
|
||||
Assert.Null(LogSanitizer.Clean(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_WithEmpty_ReturnsEmpty()
|
||||
{
|
||||
Assert.Equal("", LogSanitizer.Clean(""));
|
||||
}
|
||||
}
|
||||
@@ -868,4 +868,149 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
}
|
||||
|
||||
// ── Empty-UPN contract for device / anonymous routes ───────────────────────────
|
||||
//
|
||||
// UserRateLimitMiddleware buckets on RequestContext.UserPrincipalName and skips only when it is
|
||||
// EMPTY. Publishing the "anonymous" placeholder there collapsed every agent of every tenant into a
|
||||
// single shared user_ratelimit_anonymous bucket, which 429'd agent telemetry fleet-wide once the
|
||||
// per-user limit was reached. These tests pin the contract: no JWT ⇒ empty UPN.
|
||||
|
||||
[Theory]
|
||||
[InlineData("POST", "/api/agent/telemetry")]
|
||||
[InlineData("GET", "/api/agent/config")]
|
||||
[InlineData("POST", "/api/agent/register-session")]
|
||||
[InlineData("POST", "/api/agent/upload-url")]
|
||||
[InlineData("POST", "/api/agent/error")]
|
||||
[InlineData("POST", "/api/bootstrap/register-session")]
|
||||
[InlineData("GET", "/api/bootstrap/config")]
|
||||
[InlineData("POST", "/api/bootstrap/error")]
|
||||
public async Task DeviceRoute_WithoutJwt_PublishesEmptyUpn(string method, string path)
|
||||
{
|
||||
var h = BuildHarness();
|
||||
|
||||
var result = await h.Middleware.DecideAsync(method, path, null, principal: null);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
// The rate-limit bucket key: empty means "no user bucket applies to this call".
|
||||
Assert.Equal(string.Empty, result.Context!.UserPrincipalName);
|
||||
// The placeholder still renders in log lines — it just never reaches the RequestContext.
|
||||
Assert.Equal("anonymous", result.UserIdentifier);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GET", "/api/health")]
|
||||
[InlineData("GET", "/api/stats/platform")]
|
||||
[InlineData("POST", "/api/agent/distress")]
|
||||
[InlineData("GET", "/api/diagnostics/download")]
|
||||
public async Task AnonymousRoute_WithoutJwt_PublishesEmptyUpn(string method, string path)
|
||||
{
|
||||
var h = BuildHarness();
|
||||
|
||||
var result = await h.Middleware.DecideAsync(method, path, null, principal: null);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal(string.Empty, result.Context!.UserPrincipalName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedRoute_StillPublishesTheRealUpn()
|
||||
{
|
||||
// Guard the other direction: the fix must not blank the UPN for genuine JWT callers, or
|
||||
// per-user rate limiting and presence tracking would silently stop working.
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/notifications", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal(upn, result.Context!.UserPrincipalName);
|
||||
Assert.Equal(upn, result.Context!.CallerId);
|
||||
}
|
||||
|
||||
// ── CallerId: throttle identity survives tokens that carry no UPN ──────────────
|
||||
//
|
||||
// AuthenticationMiddleware validates issuer/audience/lifetime/signature but requires NO upn and no
|
||||
// scp, so an app-only (client-credentials) token authenticates and reaches every AuthenticatedUser
|
||||
// route (feedback, realtime/negotiate, progress/*, health/detailed, …). UserRateLimitMiddleware
|
||||
// skips on an EMPTY throttle identity — so if CallerId fell back to the (absent) UPN, those tokens
|
||||
// would bypass rate limiting entirely. These tests pin the fallback chain.
|
||||
|
||||
/// <summary>Principal with a tid but NO upn/preferred_username — shaped like an app-only token.</summary>
|
||||
private static ClaimsPrincipal AppOnlyPrincipal(string tenantId, params (string Type, string Value)[] claims)
|
||||
{
|
||||
var all = new List<Claim> { new("tid", tenantId) };
|
||||
all.AddRange(claims.Select(c => new Claim(c.Type, c.Value)));
|
||||
return new ClaimsPrincipal(new ClaimsIdentity(all, "TestAuth"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("oid", "aaaaaaaa-0000-0000-0000-000000000001")]
|
||||
[InlineData("http://schemas.microsoft.com/identity/claims/objectidentifier", "aaaaaaaa-0000-0000-0000-000000000002")]
|
||||
[InlineData("sub", "subject-abc")]
|
||||
[InlineData("appid", "bbbbbbbb-0000-0000-0000-000000000003")]
|
||||
[InlineData("azp", "cccccccc-0000-0000-0000-000000000004")]
|
||||
public async Task AuthenticatedUserRoute_TokenWithoutUpn_StillGetsAThrottleIdentity(string claimType, string claimValue)
|
||||
{
|
||||
var h = BuildHarness();
|
||||
|
||||
// /api/feedback/status is AuthenticatedUser — admits ANY valid principal, role unresolved.
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"GET", "/api/feedback/status", null, AppOnlyPrincipal(TenantA, (claimType, claimValue)));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
// No UPN to report — audit/presence correctly see nothing...
|
||||
Assert.Equal(string.Empty, result.Context!.UserPrincipalName);
|
||||
// ...but the throttle still has something stable to bucket on.
|
||||
Assert.Equal(claimValue, result.Context!.CallerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedUserRoute_TokenWithoutUpn_PrefersOidOverSubAndAppId()
|
||||
{
|
||||
var h = BuildHarness();
|
||||
var principal = AppOnlyPrincipal(TenantA,
|
||||
("appid", "cccccccc-0000-0000-0000-000000000009"),
|
||||
("sub", "subject-xyz"),
|
||||
("oid", "aaaaaaaa-0000-0000-0000-00000000000a"));
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/feedback/status", null, principal);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal("aaaaaaaa-0000-0000-0000-00000000000a", result.Context!.CallerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedUserRoute_TokenWithNoIdentifyingClaim_FailsClosedToASharedBucket()
|
||||
{
|
||||
// Pathological (every real Entra token has a sub): must NOT resolve to empty, because empty
|
||||
// means "skip the limiter". A shared bucket is the safe answer.
|
||||
var h = BuildHarness();
|
||||
|
||||
var result = await h.Middleware.DecideAsync(
|
||||
"GET", "/api/feedback/status", null, AppOnlyPrincipal(TenantA));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.NotEqual(string.Empty, result.Context!.CallerId);
|
||||
Assert.Equal(PolicyEnforcementMiddleware.UnidentifiedCallerBucket, result.Context!.CallerId);
|
||||
// Never reuse the log placeholder as a bucket key — that was the original fleet-wide collapse.
|
||||
Assert.NotEqual("anonymous", result.Context!.CallerId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("POST", "/api/agent/telemetry")]
|
||||
[InlineData("GET", "/api/health")]
|
||||
[InlineData("GET", "/api/stats/platform")]
|
||||
public async Task UnauthenticatedRoute_HasEmptyCallerId(string method, string path)
|
||||
{
|
||||
// Empty CallerId is the ONLY signal that skips the user limiter — it must mean
|
||||
// "no JWT at all", never "authenticated but unnamed".
|
||||
var h = BuildHarness();
|
||||
|
||||
var result = await h.Middleware.DecideAsync(method, path, null, principal: null);
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
Assert.Equal(string.Empty, result.Context!.CallerId);
|
||||
}
|
||||
}
|
||||
|
||||
+49
-10
@@ -78,15 +78,16 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
[Fact]
|
||||
public async Task RunCore_emits_LongRunning_when_body_runs_past_warning_threshold()
|
||||
{
|
||||
// Set watchdog thresholds to sub-second; make the fanout sleep long enough to trip warning
|
||||
// but not severe.
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromMilliseconds(500));
|
||||
// Deterministic (no wall-clock race): the fanout body blocks until the WARN watchdog has
|
||||
// actually recorded its OpsEvent, then returns. Severe sits minutes out, so it can never
|
||||
// fire in the moment between warn firing and the body completing (which cancels it).
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMinutes(10));
|
||||
harness.SetKillSwitch(false);
|
||||
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (SessionRetentionFanoutService.FanoutResult r, DateTime _, CancellationToken ct) =>
|
||||
{
|
||||
r.TenantsProcessed = 1;
|
||||
await Task.Delay(150, ct);
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
|
||||
});
|
||||
|
||||
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
|
||||
@@ -102,14 +103,17 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
// assigned only AFTER the fanout returned, so every LongRunning warning showed zeros.
|
||||
// The fanout now mutates the caller-supplied result incrementally; the watchdog snapshot
|
||||
// must reflect that progress mid-run.
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromSeconds(5));
|
||||
// Deterministic: counters are set at fanout entry, then the body blocks until the WARN
|
||||
// watchdog fires and snapshots them. Severe sits minutes out and never fires — no
|
||||
// body-sleep-vs-threshold race.
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromMinutes(10));
|
||||
harness.SetKillSwitch(false);
|
||||
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (SessionRetentionFanoutService.FanoutResult r, DateTime _, CancellationToken ct) =>
|
||||
{
|
||||
r.TenantsProcessed = 3;
|
||||
r.SessionsEnqueued = 7;
|
||||
await Task.Delay(200, ct); // long enough for the 50ms watchdog to fire mid-run
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunning");
|
||||
});
|
||||
|
||||
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
|
||||
@@ -122,17 +126,20 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
[Fact]
|
||||
public async Task RunCore_emits_LongRunningSevere_when_body_runs_past_severe_threshold()
|
||||
{
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(50), watchdogSevere: TimeSpan.FromMilliseconds(150));
|
||||
// Deterministic: the body blocks until the SEVERE watchdog records its OpsEvent. Warn has a
|
||||
// smaller threshold, so it is guaranteed to have fired first — neither depends on a body
|
||||
// sleep outlasting a fixed wall-clock threshold.
|
||||
var harness = new Harness(watchdogWarn: TimeSpan.FromMilliseconds(20), watchdogSevere: TimeSpan.FromMilliseconds(60));
|
||||
harness.SetKillSwitch(false);
|
||||
harness.Fanout.Setup(f => f.RunAsync(It.IsAny<SessionRetentionFanoutService.FanoutResult>(), It.IsAny<DateTime>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(async (SessionRetentionFanoutService.FanoutResult _, DateTime _, CancellationToken ct) =>
|
||||
{
|
||||
await Task.Delay(300, ct);
|
||||
await harness.WaitForEventAsync("SessionDeletionMaintenanceLongRunningSevere");
|
||||
});
|
||||
|
||||
await harness.Sut.RunCoreAsync("Test", CancellationToken.None);
|
||||
|
||||
// Both watchdogs fire: warning at 50ms, severe at 150ms.
|
||||
// Both watchdogs fire: warning first (smaller threshold), then severe (which the body waited for).
|
||||
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunning");
|
||||
Assert.Contains(harness.OpsEvents, e => e.EventName == "SessionDeletionMaintenanceLongRunningSevere");
|
||||
}
|
||||
@@ -345,6 +352,12 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
private readonly Dictionary<string, List<TableEntity>> _sessionsByState = new(StringComparer.Ordinal);
|
||||
private readonly List<DeletionManifestBlobSummary> _ttlBlobs = new();
|
||||
|
||||
// Watchdog OpsEvents are recorded from concurrent watchdog tasks; _opsSync serializes the
|
||||
// OpsEvents append + waiter signalling, and _eventWaiters lets a test's fanout body block
|
||||
// until a specific watchdog OpsEvent has actually fired (see WaitForEventAsync).
|
||||
private readonly object _opsSync = new();
|
||||
private readonly Dictionary<string, TaskCompletionSource> _eventWaiters = new(StringComparer.Ordinal);
|
||||
|
||||
public Harness(TimeSpan? watchdogWarn = null, TimeSpan? watchdogSevere = null)
|
||||
{
|
||||
StorageMock = new Mock<TableStorageService>(Mock.Of<TableServiceClient>(), NullLogger<TableStorageService>.Instance);
|
||||
@@ -376,7 +389,12 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Returns<OpsEventEntry>(e =>
|
||||
{
|
||||
OpsEvents.Add(new CapturedOpsEvent(e.EventType, e.Severity, e.Message, e.Details ?? string.Empty));
|
||||
lock (_opsSync)
|
||||
{
|
||||
OpsEvents.Add(new CapturedOpsEvent(e.EventType, e.Severity, e.Message, e.Details ?? string.Empty));
|
||||
if (_eventWaiters.TryGetValue(e.EventType, out var waiter))
|
||||
waiter.TrySetResult();
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
@@ -426,6 +444,27 @@ public class SessionDeletionMaintenanceFunctionTests
|
||||
public void SetKillSwitch(bool active) =>
|
||||
AdminConfig.Setup(a => a.IsSessionDeletionKillSwitchActiveAsync()).ReturnsAsync(active);
|
||||
|
||||
/// <summary>
|
||||
/// Deterministic watchdog gate: returns a task that completes once an OpsEvent whose type is
|
||||
/// <paramref name="eventName"/> has been recorded. A test's fanout body awaits this instead of
|
||||
/// sleeping a fixed span, so the watchdog is guaranteed to have fired before the body returns —
|
||||
/// removing the wall-clock race (body sleep vs sub-second thresholds) that flaked under CI load.
|
||||
/// </summary>
|
||||
public Task WaitForEventAsync(string eventName)
|
||||
{
|
||||
lock (_opsSync)
|
||||
{
|
||||
if (OpsEvents.Any(e => e.EventName == eventName))
|
||||
return Task.CompletedTask;
|
||||
if (!_eventWaiters.TryGetValue(eventName, out var waiter))
|
||||
{
|
||||
waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
_eventWaiters[eventName] = waiter;
|
||||
}
|
||||
return waiter.Task;
|
||||
}
|
||||
}
|
||||
|
||||
public void SeedTtlBlobs(params (string TenantId, string SessionId, string ManifestId, DateTime LastModified)[] entries)
|
||||
{
|
||||
foreach (var e in entries)
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure;
|
||||
using Azure.Data.Tables;
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Functions.Functions.Config;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
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
|
||||
{
|
||||
private const string TenantId = "77777777-7777-7777-7777-777777777777";
|
||||
|
||||
[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);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Server-side validation (UpdateTenantConfigurationFunction)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[Theory]
|
||||
[InlineData("it-operations@contoso.com")]
|
||||
[InlineData("first.last+autopilot@sub.fabrikam.co.uk")]
|
||||
[InlineData("UPPER@CONTOSO.COM")]
|
||||
public void ValidateContactEmail_accepts_real_addresses(string email)
|
||||
=> Assert.Null(UpdateTenantConfigurationFunction.ValidateContactEmail(email));
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void ValidateContactEmail_treats_absence_as_valid(string? email)
|
||||
{
|
||||
// No contact address is a legitimate state — it means we cannot reach this tenant.
|
||||
Assert.Null(UpdateTenantConfigurationFunction.ValidateContactEmail(email));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-an-address", "single")] // no @ at all
|
||||
[InlineData("@contoso.com", "single")] // empty local part
|
||||
[InlineData("ops@", "single")] // empty domain
|
||||
[InlineData("ops@@contoso.com", "single")] // two @
|
||||
[InlineData("ops@localhost", "dotted")] // unreachable bare host
|
||||
[InlineData("ops@.contoso.com", "dotted")] // leading dot
|
||||
[InlineData("ops@contoso.", "dotted")] // trailing dot
|
||||
[InlineData("ops@contoso.com, attacker@evil.test", "single address")] // recipient list
|
||||
[InlineData("ops@contoso.com; attacker@evil.test", "single address")]
|
||||
[InlineData("Ops Team <ops@contoso.com>", "single address")] // display-name form
|
||||
public void ValidateContactEmail_rejects_values_that_are_not_a_single_address(string email, string expectedHint)
|
||||
{
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail(email);
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains(expectedHint, error!, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateContactEmail_rejects_header_injection()
|
||||
{
|
||||
// Once this address is actually mailed, an embedded CR/LF would forge mail headers.
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail(
|
||||
$"ops@contoso.com{(char)13}{(char)10}Bcc: attacker@evil.test");
|
||||
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ValidateContactEmail_rejects_an_over_long_value()
|
||||
{
|
||||
var local = new string('a', UpdateTenantConfigurationFunction.MaxContactEmailLength);
|
||||
var error = UpdateTenantConfigurationFunction.ValidateContactEmail($"{local}@contoso.com");
|
||||
|
||||
Assert.NotNull(error);
|
||||
Assert.Contains("at most", error!);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Seed contract (TenantConfigurationService — the single owner)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static (TenantConfigurationService service, Mock<IConfigRepository> repo, MemoryCache cache) BuildService()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var service = new TenantConfigurationService(
|
||||
repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
return (service, repo, cache);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySeedContactEmail_when_the_write_lands_reports_success_and_drops_the_cached_row()
|
||||
{
|
||||
var (service, repo, cache) = BuildService();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com")).ReturnsAsync(true);
|
||||
cache.Set($"tenant-config:{TenantId}", new TenantConfiguration { TenantId = TenantId });
|
||||
|
||||
Assert.True(await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
|
||||
// The repository writes behind this cache; without the eviction the freshly seeded
|
||||
// address would stay invisible for the full 5-minute TTL.
|
||||
Assert.False(cache.TryGetValue($"tenant-config:{TenantId}", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySeedContactEmail_when_the_tenant_already_owns_an_address_leaves_the_cache_alone()
|
||||
{
|
||||
var (service, repo, cache) = BuildService();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(false);
|
||||
var cached = new TenantConfiguration { TenantId = TenantId, ContactEmail = "owner@contoso.com" };
|
||||
cache.Set($"tenant-config:{TenantId}", cached);
|
||||
|
||||
Assert.False(await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
|
||||
Assert.True(cache.TryGetValue($"tenant-config:{TenantId}", out TenantConfiguration? still));
|
||||
Assert.Equal("owner@contoso.com", still!.ContactEmail);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public async Task TrySeedContactEmail_with_no_address_never_reaches_storage(string? email)
|
||||
{
|
||||
var (service, repo, _) = BuildService();
|
||||
|
||||
Assert.False(await service.TrySeedContactEmailAsync(TenantId, email));
|
||||
|
||||
repo.Verify(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySeedContactEmail_with_no_tenant_never_reaches_storage()
|
||||
{
|
||||
var (service, repo, _) = BuildService();
|
||||
|
||||
Assert.False(await service.TrySeedContactEmailAsync(" ", "ops@contoso.com"));
|
||||
|
||||
repo.Verify(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySeedContactEmail_trims_before_it_reaches_storage()
|
||||
{
|
||||
var (service, repo, _) = BuildService();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com")).ReturnsAsync(true);
|
||||
|
||||
Assert.True(await service.TrySeedContactEmailAsync(TenantId, " ops@contoso.com "));
|
||||
|
||||
repo.Verify(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TrySeedContactEmail_never_writes_the_whole_configuration()
|
||||
{
|
||||
// Regression guard: the seed used to be a read-modify-write of the full model through
|
||||
// SaveTenantConfigurationAsync, which replaces the row unconditionally — a concurrent
|
||||
// portal save was clobbered by the seeder's stale snapshot of EVERY field.
|
||||
var (service, repo, _) = BuildService();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
await service.TrySeedContactEmailAsync(TenantId, "ops@contoso.com");
|
||||
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Seed trigger (PreviewWhitelistService)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static (PreviewWhitelistService preview, Mock<IConfigRepository> repo) BuildPreview()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var tenantConfig = new TenantConfigurationService(
|
||||
repo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
var preview = new PreviewWhitelistService(
|
||||
repo.Object, cache, NullLogger<PreviewWhitelistService>.Instance, tenantConfig);
|
||||
return (preview, repo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveNotificationEmail_persists_the_address_and_seeds_the_contact_field()
|
||||
{
|
||||
var (preview, repo) = BuildPreview();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com")).ReturnsAsync(true);
|
||||
|
||||
await preview.SaveNotificationEmailAsync(TenantId, "ops@contoso.com");
|
||||
|
||||
repo.Verify(r => r.SaveNotificationEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
|
||||
repo.Verify(r => r.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveNotificationEmail_clearing_the_address_does_not_seed()
|
||||
{
|
||||
var (preview, repo) = BuildPreview();
|
||||
|
||||
await preview.SaveNotificationEmailAsync(TenantId, null);
|
||||
|
||||
repo.Verify(r => r.SaveNotificationEmailAsync(TenantId, null), Times.Once);
|
||||
repo.Verify(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveNotificationEmail_still_succeeds_when_the_seed_blows_up()
|
||||
{
|
||||
// The notification email has already been persisted at that point; a failing side
|
||||
// effect must not turn a completed write into a failed request.
|
||||
var (preview, repo) = BuildPreview();
|
||||
repo.Setup(r => r.TrySeedTenantContactEmailAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ThrowsAsync(new RequestFailedException(503, "throttled"));
|
||||
|
||||
await preview.SaveNotificationEmailAsync(TenantId, "ops@contoso.com");
|
||||
|
||||
repo.Verify(r => r.SaveNotificationEmailAsync(TenantId, "ops@contoso.com"), Times.Once);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Conditional write (TableConfigRepository) — the actual invariant
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private sealed class RepoHarness
|
||||
{
|
||||
public TableConfigRepository Sut { get; }
|
||||
public Mock<TableClient> Table { get; } = new();
|
||||
public List<(TableEntity Entity, ETag IfMatch, TableUpdateMode Mode)> Updates { get; } = new();
|
||||
|
||||
public RepoHarness(TableEntity? stored, RequestFailedException? throwOnUpdate = null)
|
||||
{
|
||||
if (stored == null)
|
||||
{
|
||||
Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new RequestFailedException(404, "Not Found", "ResourceNotFound", null));
|
||||
}
|
||||
else
|
||||
{
|
||||
Table.Setup(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Response.FromValue(stored, Mock.Of<Response>()));
|
||||
}
|
||||
|
||||
Table.Setup(c => c.UpdateEntityAsync(
|
||||
It.IsAny<TableEntity>(), It.IsAny<ETag>(),
|
||||
It.IsAny<TableUpdateMode>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<TableEntity, ETag, TableUpdateMode, CancellationToken>((e, tag, mode, _) =>
|
||||
{
|
||||
if (throwOnUpdate != null)
|
||||
throw throwOnUpdate;
|
||||
Updates.Add((e, tag, mode));
|
||||
return Task.FromResult(Mock.Of<Response>());
|
||||
});
|
||||
|
||||
var serviceClient = new Mock<TableServiceClient>();
|
||||
serviceClient.Setup(c => c.GetTableClient(It.IsAny<string>())).Returns(Table.Object);
|
||||
|
||||
Sut = new TableConfigRepository(
|
||||
new TableStorageService(serviceClient.Object, NullLogger<TableStorageService>.Instance),
|
||||
NullLogger<TableConfigRepository>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
private static TableEntity StoredConfig(string? contactEmail, string etag = "W/\"stored\"")
|
||||
{
|
||||
var entity = new TableEntity(TenantId, "config") { ["DomainName"] = "contoso.com" };
|
||||
if (contactEmail != null)
|
||||
entity["ContactEmail"] = contactEmail;
|
||||
entity.ETag = new ETag(etag);
|
||||
return entity;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_seeds_only_the_contact_field_and_only_conditionally()
|
||||
{
|
||||
var harness = new RepoHarness(StoredConfig(contactEmail: null));
|
||||
|
||||
Assert.True(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, " ops@contoso.com "));
|
||||
|
||||
var (entity, ifMatch, mode) = Assert.Single(harness.Updates);
|
||||
// Merge, not Replace: every other stored property must survive untouched.
|
||||
Assert.Equal(TableUpdateMode.Merge, mode);
|
||||
Assert.Equal(new ETag("W/\"stored\""), ifMatch);
|
||||
Assert.Equal("ops@contoso.com", entity["ContactEmail"]);
|
||||
Assert.False(entity.ContainsKey("DomainName"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_never_overwrites_an_address_the_tenant_already_owns()
|
||||
{
|
||||
var harness = new RepoHarness(StoredConfig("owner@contoso.com"));
|
||||
|
||||
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
|
||||
Assert.Empty(harness.Updates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_treats_a_whitespace_only_stored_value_as_absent()
|
||||
{
|
||||
var harness = new RepoHarness(StoredConfig(" "));
|
||||
|
||||
Assert.True(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
|
||||
Assert.Single(harness.Updates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_loses_the_race_rather_than_clobbering_a_concurrent_write()
|
||||
{
|
||||
// Someone wrote the row between our read and our write. The ETag precondition turns
|
||||
// that into a 412; the seed abandons rather than overwriting the winner.
|
||||
var harness = new RepoHarness(
|
||||
StoredConfig(contactEmail: null),
|
||||
throwOnUpdate: new RequestFailedException(412, "Precondition Failed", "UpdateConditionNotSatisfied", null));
|
||||
|
||||
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_with_no_configuration_row_reports_no_seed()
|
||||
{
|
||||
var harness = new RepoHarness(stored: null);
|
||||
|
||||
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
|
||||
Assert.Empty(harness.Updates);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Repo_is_fail_soft_on_a_storage_fault()
|
||||
{
|
||||
// Callers are side effects of an already-committed write — a lost seed is recoverable
|
||||
// on the next maintenance run, a thrown exception is not.
|
||||
var harness = new RepoHarness(
|
||||
StoredConfig(contactEmail: null),
|
||||
throwOnUpdate: new RequestFailedException(503, "Server Busy"));
|
||||
|
||||
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(TenantId, "ops@contoso.com"));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, "ops@contoso.com")]
|
||||
[InlineData("", "ops@contoso.com")]
|
||||
[InlineData(TenantId, null)]
|
||||
[InlineData(TenantId, " ")]
|
||||
public async Task Repo_rejects_incomplete_input_without_touching_storage(string? tenantId, string? email)
|
||||
{
|
||||
var harness = new RepoHarness(StoredConfig(contactEmail: null));
|
||||
|
||||
Assert.False(await harness.Sut.TrySeedTenantContactEmailAsync(tenantId!, email!));
|
||||
|
||||
Assert.Empty(harness.Updates);
|
||||
harness.Table.Verify(c => c.GetEntityAsync<TableEntity>(
|
||||
It.IsAny<string>(), It.IsAny<string>(),
|
||||
It.IsAny<IEnumerable<string>>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,11 @@ public sealed class TenantOffboardFunctionTests
|
||||
return new PreviewWhitelistService(
|
||||
configRepoMock.Object,
|
||||
cache,
|
||||
NullLogger<PreviewWhitelistService>.Instance);
|
||||
NullLogger<PreviewWhitelistService>.Instance,
|
||||
new TenantConfigurationService(
|
||||
configRepoMock.Object,
|
||||
NullLogger<TenantConfigurationService>.Instance,
|
||||
cache));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Microsoft.Identity.Web" Version="4.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<!-- Security pins: Microsoft.Identity.Web 4.3.0 pulls these transitively at 10.0.0, which carries
|
||||
known advisories (GHSA-9mv3-2cwr-p262 DataProtection; GHSA-37gx-xxp4-5rgx and siblings on
|
||||
Cryptography.Xml). Force the patched 10.0.10 servicing build. Remove once Identity.Web ships
|
||||
a dependency floor past the fix. -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.10" />
|
||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.25" />
|
||||
<PackageReference Include="Resend" Version="0.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
+50
@@ -71,6 +71,54 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Conditional, single-property seed of ContactEmail (see <see cref="IConfigRepository"/>).
|
||||
/// Merge mode leaves every other stored property untouched, and the ETag precondition turns
|
||||
/// a concurrent write into a 412 rather than a silent overwrite. Both matter: the caller is
|
||||
/// a background/side-effect path that must never beat a tenant admin's own edit.
|
||||
/// </summary>
|
||||
public async Task<bool> TrySeedTenantContactEmailAsync(string tenantId, string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(email))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var existing = await _tenantConfigTableClient.GetEntityAsync<TableEntity>(tenantId, "config");
|
||||
|
||||
// The tenant already owns an address — the seed is one-way and never re-syncs.
|
||||
if (!string.IsNullOrWhiteSpace(existing.Value.GetString("ContactEmail")))
|
||||
return false;
|
||||
|
||||
var patch = new TableEntity(tenantId, "config")
|
||||
{
|
||||
["ContactEmail"] = email.Trim()
|
||||
};
|
||||
|
||||
await _tenantConfigTableClient.UpdateEntityAsync(
|
||||
patch, existing.Value.ETag, TableUpdateMode.Merge);
|
||||
return true;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// No configuration row yet — nothing to seed onto.
|
||||
return false;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
{
|
||||
// Someone wrote the row between the read and the write. They win by construction:
|
||||
// a lost seed is recoverable (the backfill retries), a lost admin edit is not.
|
||||
_logger.LogInformation(
|
||||
"Contact address seed for {TenantId} skipped — the configuration changed concurrently", tenantId);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error seeding contact address for tenant {TenantId}", tenantId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<TenantConfiguration>> GetAllTenantConfigurationsAsync()
|
||||
{
|
||||
try
|
||||
@@ -336,6 +384,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 +481,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"),
|
||||
|
||||
+2
-1
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
@@ -72,7 +73,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
}
|
||||
|
||||
_logger.LogWarning("Failed to increment user usage after {MaxRetries} retries: user={UserId}, endpoint={Endpoint}",
|
||||
maxRetries, userId, endpoint);
|
||||
maxRetries, LogSanitizer.Clean(userId), LogSanitizer.Clean(endpoint));
|
||||
}
|
||||
|
||||
public async Task<List<UserUsageRecord>> GetUsageByUserAsync(string userId, string? dateFrom = null, string? dateTo = null)
|
||||
|
||||
@@ -52,6 +52,34 @@ public static class ClaimsPrincipalExtensions
|
||||
?? principal.FindFirst("preferred_username")?.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stable per-caller identity for THROTTLING keys — never for authorization or audit.
|
||||
/// <para>
|
||||
/// Prefers the UPN so a human caller keeps the same bucket as before. Falls back to the token
|
||||
/// subject (<c>oid</c> → <c>sub</c> → <c>appid</c>/<c>azp</c>) because a valid token need not carry
|
||||
/// a user identity at all: AuthenticationMiddleware validates issuer/audience/lifetime/signature
|
||||
/// but requires no <c>upn</c>, so an app-only (client-credentials) token authenticates fine and
|
||||
/// reaches every AuthenticatedUser route. Without this fallback such a caller would present an
|
||||
/// empty identity and skip per-user rate limiting entirely.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both the raw and the WS-Fed-mapped claim names are checked: JwtSecurityTokenHandler runs with
|
||||
/// inbound claim mapping ON, so <c>oid</c>/<c>sub</c> may arrive under their schema URIs.
|
||||
/// </para>
|
||||
/// Returns null only for a principal carrying no identifying claim whatsoever; callers must
|
||||
/// decide fail-closed what that means (see <c>RequestContext.CallerId</c>).
|
||||
/// </summary>
|
||||
public static string? GetCallerId(this ClaimsPrincipal principal)
|
||||
{
|
||||
return principal.GetUserPrincipalName()
|
||||
?? principal.FindFirst("oid")?.Value
|
||||
?? principal.FindFirst("http://schemas.microsoft.com/identity/claims/objectidentifier")?.Value
|
||||
?? principal.FindFirst("sub")?.Value
|
||||
?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value
|
||||
?? principal.FindFirst("appid")?.Value
|
||||
?? principal.FindFirst("azp")?.Value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user's display name from token claims
|
||||
/// Supports both v1.0 and v2.0 token formats
|
||||
|
||||
+59
@@ -76,6 +76,19 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// The contact address is where enforcement actions and service notices are sent,
|
||||
// so it must be an address rather than whatever string a direct API caller posts.
|
||||
var contactEmailError = ValidateContactEmail(config.ContactEmail);
|
||||
if (contactEmailError != null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { success = false, message = $"Invalid contact email: {contactEmailError}" });
|
||||
return badRequest;
|
||||
}
|
||||
// Normalize so the stored value never carries surrounding whitespace, and an
|
||||
// all-whitespace submission clears the field instead of masquerading as a value.
|
||||
config.ContactEmail = string.IsNullOrWhiteSpace(config.ContactEmail) ? null : config.ContactEmail.Trim();
|
||||
|
||||
// Load the stored config up-front so we can (1) restore any redacted secret placeholders
|
||||
// before validation/save and (2) protect GA-only fields below.
|
||||
var existingConfig = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
@@ -251,6 +264,52 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
private const int MaxCustomHeaderCount = 25;
|
||||
private const int MaxNotificationChannelsJsonLength = 65536;
|
||||
|
||||
// RFC 5321 caps a forward path at 254 characters.
|
||||
internal const int MaxContactEmailLength = 254;
|
||||
|
||||
/// <summary>
|
||||
/// Validates the tenant contact address. Returns an error message, or null when valid/empty.
|
||||
/// Empty is legitimate — it means we have no way to reach the tenant.
|
||||
/// <para>
|
||||
/// Deliberately not an RFC 5322 parser: the job is to reject values that are not addresses
|
||||
/// at all. Specifically it rejects recipient lists (a comma would silently widen who receives
|
||||
/// service notices), display-name forms, and control characters (which would let a caller
|
||||
/// forge mail headers once this address is actually mailed).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal static string? ValidateContactEmail(string? email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return null;
|
||||
|
||||
var trimmed = email.Trim();
|
||||
|
||||
if (trimmed.Length > MaxContactEmailLength)
|
||||
return $"must be at most {MaxContactEmailLength} characters.";
|
||||
|
||||
foreach (var ch in trimmed)
|
||||
{
|
||||
if (char.IsControl(ch))
|
||||
return "must not contain control characters.";
|
||||
if (char.IsWhiteSpace(ch) || ch == ',' || ch == ';' || ch == '<' || ch == '>')
|
||||
return "must be a single address, without spaces, separators or angle brackets.";
|
||||
}
|
||||
|
||||
var at = trimmed.IndexOf('@');
|
||||
if (at <= 0 || at != trimmed.LastIndexOf('@') || at == trimmed.Length - 1)
|
||||
return "must contain a single \"@\" with text on both sides.";
|
||||
|
||||
// A bare host with no dot is unreachable from our sender, so it is a typo, not an address.
|
||||
var domain = trimmed.Substring(at + 1);
|
||||
if (!domain.Contains('.') || domain.StartsWith(".", StringComparison.Ordinal)
|
||||
|| domain.EndsWith(".", StringComparison.Ordinal))
|
||||
{
|
||||
return "the domain part must be a dotted host name.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the notification-channel list JSON. Returns an error message, or null when
|
||||
/// valid/empty. Strict counterpart of the fail-soft <c>NotificationChannel.ParseList</c>:
|
||||
|
||||
+30
-3
@@ -1,5 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Web;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Diagnostics;
|
||||
using AutopilotMonitor.Shared.Diagnostics;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -17,21 +19,29 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
/// from the query string, so a ticket cannot be retargeted.
|
||||
/// <para>
|
||||
/// Replay is bounded by the 10-min ticket TTL, the admin size cap, and the fact that the ticket
|
||||
/// only ever points at the minting caller's own authorized blob; HMAC forgery is infeasible, so
|
||||
/// no additional rate limiting is layered here.
|
||||
/// only ever points at the minting caller's own authorized blob; HMAC forgery is infeasible. On top
|
||||
/// of that the route carries a per-client-IP limit as defense in depth — it is unauthenticated and
|
||||
/// streams whole blobs, so a valid ticket replayed in a loop would otherwise be unbounded egress.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class DiagnosticsTicketDownloadFunction
|
||||
{
|
||||
// Bounds replay of a still-valid ticket. A real client downloads a package once; the ceiling
|
||||
// only has to stay clear of a legitimate retry-after-timeout.
|
||||
private const int MaxRequestsPerMinutePerIp = 30;
|
||||
|
||||
private readonly ILogger<DiagnosticsTicketDownloadFunction> _logger;
|
||||
private readonly DiagnosticsBlobStreamer _streamer;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
|
||||
public DiagnosticsTicketDownloadFunction(
|
||||
ILogger<DiagnosticsTicketDownloadFunction> logger,
|
||||
DiagnosticsBlobStreamer streamer)
|
||||
DiagnosticsBlobStreamer streamer,
|
||||
RateLimitService rateLimitService)
|
||||
{
|
||||
_logger = logger;
|
||||
_streamer = streamer;
|
||||
_rateLimitService = rateLimitService;
|
||||
}
|
||||
|
||||
[Function("DiagnosticsTicketDownload")]
|
||||
@@ -40,6 +50,23 @@ namespace AutopilotMonitor.Functions.Functions.Diagnostics
|
||||
{
|
||||
try
|
||||
{
|
||||
// Rightmost X-Forwarded-For hop only — leftmost entries are caller-controlled.
|
||||
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
|
||||
var rateLimitResult = _rateLimitService.CheckRateLimit(
|
||||
$"diag_ticket_download_{clientIp}", MaxRequestsPerMinutePerIp);
|
||||
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
{
|
||||
_logger.LogWarning("DiagnosticsTicketDownload rate limit exceeded for IP {ClientIp} ({Count} requests)",
|
||||
clientIp, rateLimitResult.RequestsInWindow);
|
||||
|
||||
var tooMany = req.CreateResponse(HttpStatusCode.TooManyRequests);
|
||||
if (rateLimitResult.RetryAfter.HasValue)
|
||||
tooMany.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
|
||||
await tooMany.WriteAsJsonAsync(new { success = false, message = "Rate limit exceeded." });
|
||||
return tooMany;
|
||||
}
|
||||
|
||||
var ticket = HttpUtility.ParseQueryString(req.Url.Query)["t"];
|
||||
if (string.IsNullOrEmpty(ticket))
|
||||
{
|
||||
|
||||
+32
-1
@@ -1,4 +1,6 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
@@ -9,16 +11,27 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
/// <summary>
|
||||
/// Public endpoint - no authentication required.
|
||||
/// Returns pre-computed platform stats for the landing page.
|
||||
/// Rate-limited per client IP: the route is unauthenticated and hits Table Storage on every call,
|
||||
/// so it needs a limit of its own — no user bucket applies to an anonymous caller.
|
||||
/// </summary>
|
||||
public class GetPlatformStatsFunction
|
||||
{
|
||||
// Landing-page widget: one call per page load. Generous enough that a shared corporate NAT
|
||||
// never trips it, tight enough to bound an unauthenticated storage read.
|
||||
private const int MaxRequestsPerMinutePerIp = 60;
|
||||
|
||||
private readonly ILogger<GetPlatformStatsFunction> _logger;
|
||||
private readonly IMetricsRepository _metricsRepo;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
|
||||
public GetPlatformStatsFunction(ILogger<GetPlatformStatsFunction> logger, IMetricsRepository metricsRepo)
|
||||
public GetPlatformStatsFunction(
|
||||
ILogger<GetPlatformStatsFunction> logger,
|
||||
IMetricsRepository metricsRepo,
|
||||
RateLimitService rateLimitService)
|
||||
{
|
||||
_logger = logger;
|
||||
_metricsRepo = metricsRepo;
|
||||
_rateLimitService = rateLimitService;
|
||||
}
|
||||
|
||||
[Function("GetPlatformStats")]
|
||||
@@ -27,6 +40,24 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
{
|
||||
try
|
||||
{
|
||||
// Rightmost X-Forwarded-For hop only — leftmost entries are caller-controlled and would
|
||||
// let one client rotate the rate-limit key on every request.
|
||||
var clientIp = ClientIpExtractor.GetTrustedClientIp(req);
|
||||
var rateLimitResult = _rateLimitService.CheckRateLimit(
|
||||
$"platform_stats_{clientIp}", MaxRequestsPerMinutePerIp);
|
||||
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
{
|
||||
_logger.LogWarning("Platform stats rate limit exceeded for IP {ClientIp} ({Count} requests)",
|
||||
clientIp, rateLimitResult.RequestsInWindow);
|
||||
|
||||
var tooMany = req.CreateResponse(HttpStatusCode.TooManyRequests);
|
||||
if (rateLimitResult.RetryAfter.HasValue)
|
||||
tooMany.Headers.Add("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds).ToString());
|
||||
await tooMany.WriteAsJsonAsync(new { error = "Rate limit exceeded" });
|
||||
return tooMany;
|
||||
}
|
||||
|
||||
var stats = await _metricsRepo.GetPlatformStatsAsync();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Strips control characters from user-controlled values before they reach a log
|
||||
/// sink, so a caller cannot forge additional log lines (CRLF injection) or smuggle
|
||||
/// terminal escape sequences through the message. Structured logging already stores
|
||||
/// each value as a discrete property, so this is defense-in-depth — it also clears
|
||||
/// the CodeQL cs/log-forging alerts by removing the newline characters they key on.
|
||||
/// Only ever apply to the LOGGED copy of a value; never to a value used for routing,
|
||||
/// authorization, or storage keys.
|
||||
/// </summary>
|
||||
public static class LogSanitizer
|
||||
{
|
||||
// \p{Cc} = Unicode "Other, control" (C0 + C1 ranges), which includes CR and LF.
|
||||
// \p{Zl} and \p{Zp} are the line and paragraph separators (U+2028 / U+2029). They
|
||||
// are separators, not control characters, so \p{Cc} misses them — yet a log viewer,
|
||||
// a JSON-lines consumer, or any JavaScript-side reader still breaks a line on them.
|
||||
private static readonly Regex ControlChars = new(@"[\p{Cc}\p{Zl}\p{Zp}]", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Removes CR, LF, the Unicode line/paragraph separators, and every other control
|
||||
/// character from <paramref name="value"/>. Returns null/empty inputs unchanged.
|
||||
/// </summary>
|
||||
public static string? Clean(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
return value;
|
||||
|
||||
// Explicit CR/LF removal first — this is the log-injection barrier the CodeQL
|
||||
// taint model recognizes; the regex pass then drops any remaining separators.
|
||||
var sanitized = value.Replace("\r", string.Empty).Replace("\n", string.Empty);
|
||||
return ControlChars.Replace(sanitized, string.Empty);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,23 @@ public sealed record RequestContext
|
||||
/// <summary>The user's UPN (from JWT upn/preferred_username claim).</summary>
|
||||
public string UserPrincipalName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Stable identity of the authenticated caller, for THROTTLING keys only — never authorization,
|
||||
/// never audit (use <see cref="UserPrincipalName"/> there; this may be a bare object id).
|
||||
/// <para>
|
||||
/// Empty ⇔ the request carried no JWT at all (device/bootstrap or anonymous route). Those routes
|
||||
/// bring their own limits — per cert thumbprint / per bootstrap token in SecurityValidator, per
|
||||
/// client IP on the public endpoints — so an empty value means "no user bucket applies here".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is deliberately NOT the same field as <see cref="UserPrincipalName"/>: a valid token need
|
||||
/// not carry a UPN (app-only/client-credentials tokens do not), and such a caller still reaches
|
||||
/// every AuthenticatedUser route. Keying the throttle off the UPN alone let those tokens through
|
||||
/// unlimited; keying audit off this would write object ids where a UPN is expected.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public string CallerId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>True if the user is a Global Admin of the platform (full read + write).</summary>
|
||||
public bool IsGlobalAdmin { get; init; }
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Middleware;
|
||||
|
||||
@@ -57,9 +58,12 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
// JWT validation. The exempt set is DERIVED from EndpointAccessPolicyCatalog — never a
|
||||
// hand-kept parallel list — so a new anonymous/device route can't drift out of sync here.
|
||||
var requestPath = httpContext.Request.Path.Value ?? string.Empty;
|
||||
// Sanitized copies for logging only — never used for routing/authorization decisions.
|
||||
var logPath = LogSanitizer.Clean(requestPath);
|
||||
var logMethod = LogSanitizer.Clean(httpContext.Request.Method);
|
||||
if (SkipsJwtValidation(httpContext.Request.Method, requestPath))
|
||||
{
|
||||
_logger.LogDebug("[Auth Middleware] JWT-exempt route: {Method} {Path}", httpContext.Request.Method, requestPath);
|
||||
_logger.LogDebug("[Auth Middleware] JWT-exempt route: {Method} {Path}", logMethod, logPath);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
@@ -68,7 +72,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
var authHeader = httpContext.Request.Headers.Authorization.ToString();
|
||||
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogWarning("[Auth Middleware] Blocked unauthenticated request to {Path}", requestPath);
|
||||
_logger.LogWarning("[Auth Middleware] Blocked unauthenticated request to {Path}", logPath);
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(new
|
||||
@@ -96,7 +100,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
// MSA, tid 9188040d-…) carries a GUID tid, so this reject is loss-free.
|
||||
if (!IsValidTenantId(tenantId))
|
||||
{
|
||||
_logger.LogWarning("[Auth Middleware] Rejected token with missing/non-GUID tid for {Path}", requestPath);
|
||||
_logger.LogWarning("[Auth Middleware] Rejected token with missing/non-GUID tid for {Path}", logPath);
|
||||
throw new SecurityTokenValidationException("Token tid claim is missing or not a valid GUID");
|
||||
}
|
||||
|
||||
@@ -207,7 +211,7 @@ public class AuthenticationMiddleware : IFunctionsWorkerMiddleware
|
||||
|
||||
if (!authenticated)
|
||||
{
|
||||
_logger.LogWarning("[Auth Middleware] Blocked request with invalid token to {Path}", requestPath);
|
||||
_logger.LogWarning("[Auth Middleware] Blocked request with invalid token to {Path}", logPath);
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(new
|
||||
|
||||
@@ -159,7 +159,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "[McpQuota] Failed to record usage: user={UserId}, endpoint={Endpoint}", oid, normalizedEndpoint);
|
||||
logger.LogWarning(ex, "[McpQuota] Failed to record usage: user={UserId}, endpoint={Endpoint}", LogSanitizer.Clean(oid), LogSanitizer.Clean(normalizedEndpoint));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,6 +23,15 @@ namespace AutopilotMonitor.Functions.Middleware;
|
||||
/// </summary>
|
||||
public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
/// <summary>
|
||||
/// Throttle bucket for an authenticated token that carries no identifying claim at all (no upn,
|
||||
/// oid, sub, appid or azp). Every real Entra token has at least a <c>sub</c>, so this is a
|
||||
/// fail-closed backstop rather than a live code path: such callers share one bucket instead of
|
||||
/// escaping the limiter. Deliberately NOT "anonymous" — that value is reserved for the log
|
||||
/// rendering of unauthenticated calls and must never become a rate-limit key again.
|
||||
/// </summary>
|
||||
internal const string UnidentifiedCallerBucket = "unidentified-token";
|
||||
|
||||
private readonly ILogger<PolicyEnforcementMiddleware> _logger;
|
||||
private readonly GlobalAdminService _globalAdminService;
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
@@ -57,6 +66,10 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
var httpMethod = httpContext.Request.Method;
|
||||
var queryTenantId = httpContext.Request.Query["tenantId"].FirstOrDefault();
|
||||
|
||||
// Sanitized copies for logging only — never used for routing/authorization decisions.
|
||||
var logPath = LogSanitizer.Clean(requestPath);
|
||||
var logMethod = LogSanitizer.Clean(httpMethod);
|
||||
|
||||
// Authenticated principal set by AuthenticationMiddleware (null on anonymous/device routes).
|
||||
// Read from FunctionContext.Items (reliable in the isolated worker) rather than httpContext.User.
|
||||
ClaimsPrincipal? principal = null;
|
||||
@@ -76,7 +89,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[PolicyEnforcement] Service error evaluating policy for {Method} {Path}", httpMethod, requestPath);
|
||||
_logger.LogError(ex, "[PolicyEnforcement] Service error evaluating policy for {Method} {Path}", logMethod, logPath);
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.ServiceUnavailable;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
httpContext.Response.Headers["Retry-After"] = "5";
|
||||
@@ -92,13 +105,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
context.Items[RequestContext.ItemsKey] = result.Context!;
|
||||
_logger.LogDebug("[PolicyEnforcement] ALLOW {Method} {Path} user={User} role={Role}",
|
||||
httpMethod, requestPath, result.UserIdentifier, result.UserRole);
|
||||
logMethod, logPath, LogSanitizer.Clean(result.UserIdentifier), result.UserRole);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogWarning("[PolicyEnforcement] DENIED {Method} {Path} status={Status} user={User} role={Role} reason={Reason}",
|
||||
httpMethod, requestPath, result.StatusCode, result.UserIdentifier, result.UserRole, result.LogReason);
|
||||
logMethod, logPath, result.StatusCode, LogSanitizer.Clean(result.UserIdentifier), result.UserRole, result.LogReason);
|
||||
httpContext.Response.StatusCode = result.StatusCode;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(new { error = result.ErrorCode, message = result.ErrorMessage });
|
||||
@@ -115,12 +128,16 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
internal async Task<PolicyResult> DecideAsync(
|
||||
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal)
|
||||
{
|
||||
// Sanitized copies for logging only — never used for routing/authorization decisions.
|
||||
var logPath = LogSanitizer.Clean(requestPath);
|
||||
var logMethod = LogSanitizer.Clean(httpMethod);
|
||||
|
||||
var catalogEntry = EndpointAccessPolicyCatalog.FindPolicy(httpMethod, requestPath);
|
||||
if (catalogEntry == null)
|
||||
{
|
||||
// Fail-closed: unregistered route → 403
|
||||
_logger.LogError("[PolicyEnforcement] BLOCKED unregistered route: {Method} {Path} — not in catalog (fail-closed)",
|
||||
httpMethod, requestPath);
|
||||
logMethod, logPath);
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "Forbidden", "Access denied.", "anonymous", "N/A", "Unregistered");
|
||||
}
|
||||
|
||||
@@ -188,7 +205,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
if (crossTenant && !hasGlobalScope && !delegatedGrantsRead)
|
||||
{
|
||||
_logger.LogWarning("[PolicyEnforcement] BLOCKED cross-tenant access: user={User} jwtTenant={JwtTenant} target={Target} scoping={Scoping} path={Path}",
|
||||
decision.UserIdentifier, jwtTenantId, namedTarget, catalogEntry.TenantScoping, requestPath);
|
||||
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(jwtTenantId), LogSanitizer.Clean(namedTarget), catalogEntry.TenantScoping, logPath);
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "CrossTenantAccessDenied",
|
||||
"Access denied. You can only access your own tenant's resources.",
|
||||
decision.UserIdentifier, decision.UserRole, "CrossTenant");
|
||||
@@ -206,7 +223,21 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
TenantId = jwtTenantId,
|
||||
TargetTenantId = targetTenantId,
|
||||
UserPrincipalName = decision.UserIdentifier,
|
||||
// ONLY a genuine JWT UPN lands here — never the "anonymous" placeholder that
|
||||
// decision.UserIdentifier carries on device/anonymous routes. The placeholder used to be
|
||||
// published here, which collapsed the entire agent fleet (every tenant) into one shared
|
||||
// user_ratelimit_anonymous bucket and 429'd agent telemetry fleet-wide. It stays on
|
||||
// decision.UserIdentifier for log lines, where "anonymous" is the useful rendering.
|
||||
UserPrincipalName = upn ?? string.Empty,
|
||||
// Throttle identity — NOT the same thing as the UPN. Empty only when the request carried
|
||||
// no JWT at all (device/anonymous routes, which bring their own limits). An authenticated
|
||||
// caller ALWAYS gets a non-empty value, including app-only tokens that carry no upn:
|
||||
// those authenticate fine and reach every AuthenticatedUser route, so keying the throttle
|
||||
// off the UPN alone would let them through unlimited. Fail closed if a token somehow
|
||||
// carries no identifying claim at all — a shared bucket is the safe answer there.
|
||||
CallerId = principal is null
|
||||
? string.Empty
|
||||
: (principal.GetCallerId() ?? UnidentifiedCallerBucket),
|
||||
IsGlobalAdmin = isGlobalAdmin,
|
||||
IsGlobalReader = isGlobalReader,
|
||||
// Delegated flags reflect the caller's role for THIS request (null unless a delegated grant
|
||||
|
||||
@@ -9,9 +9,14 @@ using Microsoft.Extensions.Logging;
|
||||
namespace AutopilotMonitor.Functions.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Per-user rate limiting for authenticated (JWT) requests.
|
||||
/// Runs after PolicyEnforcementMiddleware so RequestContext (UPN, IsGlobalAdmin) is already resolved.
|
||||
/// Agent/device routes have no RequestContext and are automatically skipped.
|
||||
/// Per-caller rate limiting for authenticated (JWT) requests.
|
||||
/// Runs after PolicyEnforcementMiddleware so RequestContext (CallerId, IsGlobalAdmin) is already resolved.
|
||||
/// Buckets on RequestContext.CallerId — the throttle identity, which is the UPN for a human caller and
|
||||
/// the token subject (oid/sub/appid) for a token that carries none. Device/bootstrap and anonymous
|
||||
/// routes resolve to an EMPTY CallerId and are skipped here; they carry their own limits (per cert
|
||||
/// thumbprint / per bootstrap token in SecurityValidator, per client IP on the public endpoints).
|
||||
/// Keying on CallerId rather than UserPrincipalName is what keeps BOTH holes shut: the agent fleet out
|
||||
/// of one shared bucket, and app-only tokens inside a bucket at all.
|
||||
/// </summary>
|
||||
public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
@@ -45,9 +50,12 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
}
|
||||
|
||||
var requestContext = context.GetRequestContext();
|
||||
if (string.IsNullOrEmpty(requestContext.UserPrincipalName))
|
||||
if (string.IsNullOrEmpty(requestContext.CallerId))
|
||||
{
|
||||
// Anonymous or device-auth route — no user to rate limit
|
||||
// No JWT on this request — device-auth or anonymous route. Those limit themselves
|
||||
// (per cert thumbprint / bootstrap token in SecurityValidator, per client IP on the
|
||||
// public endpoints). Gate on CallerId, NOT UserPrincipalName: a valid app-only token
|
||||
// carries no upn, and skipping on that would exempt it from throttling entirely.
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
@@ -96,13 +104,13 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
config.GlobalAdminRateLimitRequestsPerMinute,
|
||||
entitlementFloor);
|
||||
|
||||
var key = $"user_ratelimit_{requestContext.UserPrincipalName.ToLowerInvariant()}";
|
||||
var key = $"user_ratelimit_{requestContext.CallerId.ToLowerInvariant()}";
|
||||
result = _rateLimitService.CheckRateLimit(key, limit);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[UserRateLimit] Rate limit check failed for user={User}, allowing request (fail-open)",
|
||||
requestContext.UserPrincipalName);
|
||||
_logger.LogError(ex, "[UserRateLimit] Rate limit check failed for caller={Caller}, allowing request (fail-open)",
|
||||
requestContext.CallerId);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
@@ -127,8 +135,8 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
: 60;
|
||||
|
||||
_logger.LogWarning(
|
||||
"[UserRateLimit] THROTTLED user={User} requests={Count}/{Max} retryAfter={RetryAfter}s",
|
||||
requestContext.UserPrincipalName, result.RequestsInWindow, result.MaxRequests, retryAfterSeconds);
|
||||
"[UserRateLimit] THROTTLED caller={Caller} requests={Count}/{Max} retryAfter={RetryAfter}s",
|
||||
requestContext.CallerId, result.RequestsInWindow, result.MaxRequests, retryAfterSeconds);
|
||||
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
|
||||
httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString();
|
||||
|
||||
@@ -517,6 +517,58 @@ 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>
|
||||
/// <para>
|
||||
/// The enumerated snapshot is only a cheap pre-filter; the authoritative "is it still
|
||||
/// empty" check happens under an ETag inside
|
||||
/// <see cref="TenantConfigurationService.TrySeedContactEmailAsync"/>. A full-model save
|
||||
/// here would write a snapshot that is minutes old by the time the loop reaches this
|
||||
/// tenant, silently reverting whatever an admin changed in between.
|
||||
/// </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;
|
||||
|
||||
if (await _tenantConfigService.TrySeedContactEmailAsync(config.TenantId, email))
|
||||
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();
|
||||
|
||||
@@ -16,16 +16,19 @@ public class PreviewWhitelistService
|
||||
private readonly IConfigRepository _configRepo;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<PreviewWhitelistService> _logger;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
|
||||
public PreviewWhitelistService(
|
||||
IConfigRepository configRepo,
|
||||
IMemoryCache cache,
|
||||
ILogger<PreviewWhitelistService> logger)
|
||||
ILogger<PreviewWhitelistService> logger,
|
||||
TenantConfigurationService tenantConfigService)
|
||||
{
|
||||
_configRepo = configRepo;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -102,10 +105,17 @@ 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.
|
||||
/// <para>
|
||||
/// The seed is a side effect of a write that has already been persisted, so it must never
|
||||
/// fail this call — <see cref="TenantConfigurationService.TrySeedContactEmailAsync"/> is
|
||||
/// fail-soft and owns the "never overwrite what the tenant owns" invariant.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task SaveNotificationEmailAsync(string tenantId, string? email)
|
||||
{
|
||||
await _configRepo.SaveNotificationEmailAsync(tenantId, email);
|
||||
await _tenantConfigService.TrySeedContactEmailAsync(tenantId, email);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,48 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One-way seed of the tenant contact address: writes <paramref name="email"/> only while
|
||||
/// the tenant has none, and only if nothing else wrote the configuration in the meantime.
|
||||
/// Returns true when the seed landed.
|
||||
/// <para>
|
||||
/// Single owner for both seed paths (preview notification-email save and the maintenance
|
||||
/// backfill), because both need the same three things: the conditional single-property
|
||||
/// write, the "never overwrite what the tenant owns" invariant, and the cache invalidation
|
||||
/// below — the repository writes behind this cache, so without it a freshly seeded address
|
||||
/// stays invisible for the 5-minute TTL.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Fail-soft by design, unlike <see cref="SaveConfigurationAsync"/>: every caller is a side
|
||||
/// effect of an operation that has already succeeded, and a lost seed is recoverable on the
|
||||
/// next maintenance run. Enforced here rather than left to the repository, so the guarantee
|
||||
/// holds for any implementation.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public virtual async Task<bool> TrySeedContactEmailAsync(string tenantId, string? email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(email))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var seeded = await _configRepo.TrySeedTenantContactEmailAsync(tenantId, email!.Trim());
|
||||
if (seeded)
|
||||
{
|
||||
_cache.Remove($"tenant-config:{tenantId}");
|
||||
_logger.LogInformation("Seeded contact address for tenant {TenantId}", tenantId);
|
||||
}
|
||||
|
||||
return seeded;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"Could not seed contact address for tenant {TenantId} — the triggering write still stands", tenantId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates cache for a tenant (forces reload on next request)
|
||||
/// </summary>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+502
-379
File diff suppressed because it is too large
Load Diff
@@ -17,18 +17,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^5.2.1",
|
||||
"fuse.js": "^7.1.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"zod": "^3.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/compression": "^1.8.1",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"@types/node": "^22.20.1",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.2"
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.35.0",
|
||||
"fast-uri": "^3.1.4",
|
||||
"hono": "^4.12.27",
|
||||
"@hono/node-server": "^2.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,225 @@
|
||||
/**
|
||||
* 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('strips inline HTML tags from a <summary> title, leaving no angle brackets', async () => {
|
||||
const chunks = await withBundle(
|
||||
{
|
||||
'troubleshooting/faq.md': page(
|
||||
'type: FAQ\ntags: [faq]',
|
||||
['# FAQ', '', '## General', '', '<details>', '', '<summary>Does <b>keep-awake</b> work during <i>ESP</i>?</summary>', '', 'Keep-awake is an opt-in behavior during the user ESP phase for supported devices.', '', '</details>'].join('\n'),
|
||||
),
|
||||
},
|
||||
(root) => loadDocsCorpus(root),
|
||||
);
|
||||
const headings = chunks.map((c) => c.metadata.heading);
|
||||
expect(headings).toContain('General › Does keep-awake work during ESP?');
|
||||
expect(headings.every((h) => h == null || (!h.includes('<') && !h.includes('>')))).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -232,7 +232,7 @@ async function checkAccess(upn: string, token: string, clientIp: string): Promis
|
||||
}, MAX_ACCESS_CACHE_ENTRIES);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error(`[access-guard] Backend check failed for ${upn}:`, err);
|
||||
console.error('[access-guard] Backend check failed for %s:', upn, err);
|
||||
// Fail-closed: deny on backend error
|
||||
return { allowed: false, reason: 'Backend access check unavailable', isGlobalAdmin: false, isGlobalReader: false, infraError: true };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove HTML tags from a string. The pattern is applied repeatedly until the
|
||||
* string stops changing, so tag removal can never re-form a tag from the joined
|
||||
* remainder. A single `.replace(/<[^>]+>/g, '')` happens to be complete for this
|
||||
* pattern, but the looped form is the shape CodeQL accepts as a robust sanitizer
|
||||
* (js/incomplete-multi-character-sanitization) and is defensive against any
|
||||
* future change to the tag regex.
|
||||
*/
|
||||
function stripHtmlTags(input: string): string {
|
||||
let prev: string;
|
||||
let out = input;
|
||||
do {
|
||||
prev = out;
|
||||
out = out.replace(/<[^>]+>/g, '');
|
||||
} while (out !== prev);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = stripHtmlTags(summary[1]).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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -24,6 +24,21 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
/// </summary>
|
||||
Task<RawPage<TenantConfiguration>> GetTenantConfigurationsPageAsync(int pageSize, string? continuation);
|
||||
|
||||
/// <summary>
|
||||
/// Writes <paramref name="email"/> to the tenant's ContactEmail ONLY while that field is
|
||||
/// still empty, and only if nothing else wrote the row in the meantime. Returns true when
|
||||
/// the seed landed, false when the tenant already owns an address, has no config row, or
|
||||
/// lost the race.
|
||||
/// <para>
|
||||
/// Exists because the seed cannot be expressed as a read-modify-write of the whole model:
|
||||
/// <see cref="SaveTenantConfigurationAsync"/> replaces the entire row unconditionally, so a
|
||||
/// concurrent portal save would be clobbered by the seeder's stale snapshot — of every
|
||||
/// field, not just this one. The implementation must therefore write conditionally and
|
||||
/// touch no other property.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
Task<bool> TrySeedTenantContactEmailAsync(string tenantId, string email);
|
||||
|
||||
// --- Admin Configuration ---
|
||||
Task<AdminConfiguration?> GetAdminConfigurationAsync();
|
||||
Task<bool> SaveAdminConfigurationAsync(AdminConfiguration config);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function AboutPage() {
|
||||
title: "Integrations",
|
||||
items: [
|
||||
"Microsoft Intune — agent deployment target",
|
||||
"Microsoft Teams, Slack & generic JSON webhooks — start, success, failure and SLA alerts",
|
||||
"Microsoft Teams, Slack & generic JSON webhooks — start, success, failure, hardware rejection and SLA alerts",
|
||||
"Intune Management Extension (IME) log — event source for log pattern detection",
|
||||
"WMI & Registry — extended data gather rules",
|
||||
],
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
PHASE_TRIGGERS,
|
||||
EMIT_MODES,
|
||||
formatTrigger,
|
||||
formatGatherPhase,
|
||||
firesExactlyOnce,
|
||||
supportsPhaseScope,
|
||||
supportsEmitMode,
|
||||
} from "../types";
|
||||
import { KNOWN_EVENT_TYPES, findEventType } from "../eventTypes";
|
||||
import { validateGatherRuleTarget } from "@/utils/guardValidation";
|
||||
@@ -485,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
|
||||
@@ -507,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
|
||||
@@ -522,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">
|
||||
@@ -547,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
|
||||
@@ -564,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, PHASE_TRIGGERS, 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,
|
||||
@@ -211,9 +217,7 @@ export default function GatherRulesPage() {
|
||||
intervalSeconds: form.trigger === "interval" ? form.intervalSeconds : 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,
|
||||
@@ -298,9 +308,7 @@ export default function GatherRulesPage() {
|
||||
intervalSeconds: form.trigger === "interval" ? form.intervalSeconds : 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),
|
||||
|
||||
@@ -178,6 +178,59 @@ export function formatTrigger(trigger: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PublicPageHeader } from "../../components/PublicPageHeader";
|
||||
|
||||
const LAST_UPDATED = "21 July 2026";
|
||||
const DOCS_SECURITY_FAQ = "https://docs.autopilotmonitor.com/trust/security-faq";
|
||||
const DOCS_SUBPROCESSORS = "https://docs.autopilotmonitor.com/trust/subprocessors";
|
||||
const DOCS_DATA_FLOWS = "https://docs.autopilotmonitor.com/trust/data-flows";
|
||||
|
||||
function DocsLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
@@ -67,7 +67,15 @@ export default function PrivacyPage() {
|
||||
<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>
|
||||
<p className="text-gray-700">
|
||||
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">
|
||||
@@ -119,19 +127,20 @@ export default function PrivacyPage() {
|
||||
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">Sub-processors and Outbound Data Flows</h2>
|
||||
<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 sub-processor that stores your data.</strong> Resend is used solely to
|
||||
deliver 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 pulled inbound from
|
||||
NVD, the CISA KEV catalog, and MSRC; no customer data is sent to them.
|
||||
<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">
|
||||
Some data flows exist only because you configure them: your own Azure storage account for diagnostics,
|
||||
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. The complete
|
||||
list is published as <DocsLink href={DOCS_SUBPROCESSORS}>Sub-processors</DocsLink>, and new sub-processors are
|
||||
announced there before they are introduced.
|
||||
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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 (
|
||||
@@ -70,9 +71,30 @@ export default function TermsPage() {
|
||||
<li>Automated access, including through the MCP integration, stays within the published rate limits and quotas.</li>
|
||||
</ul>
|
||||
<p className="text-gray-700">
|
||||
Responsible disclosure of a security issue is welcome and will not be treated as a violation — report it
|
||||
privately rather than publicly.
|
||||
<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">
|
||||
@@ -85,7 +107,7 @@ export default function TermsPage() {
|
||||
<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 acts as a processor or sub-processor of its customers and is responsible for having the necessary agreements in place with them.</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>
|
||||
@@ -127,7 +149,7 @@ export default function TermsPage() {
|
||||
<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 security architecture, data residency, sub-processors, retention and deletion behaviour, and an explicit
|
||||
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.
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+614
-521
File diff suppressed because it is too large
Load Diff
@@ -14,38 +14,39 @@
|
||||
"dependencies": {
|
||||
"@azure/msal-browser": "^3.28.0",
|
||||
"@azure/msal-react": "^2.1.3",
|
||||
"@microsoft/applicationinsights-web": "^3.3.11",
|
||||
"@microsoft/applicationinsights-web": "^3.4.3",
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"@xyflow/react": "^12.11.2",
|
||||
"dagre": "^0.8.5",
|
||||
"fflate": "^0.8.2",
|
||||
"fflate": "^0.8.3",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^15.1.6",
|
||||
"next": "^15.5.21",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"recharts": "^2.15.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@next/bundle-analyzer": "^16.2.7",
|
||||
"@next/bundle-analyzer": "^16.2.11",
|
||||
"@types/dagre": "^0.7.54",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/node": "^22.20.1",
|
||||
"@types/react": "^18.2.48",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-next": "^15.1.6",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-config-next": "^15.5.21",
|
||||
"postcss": "^8.5.10",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.1.2"
|
||||
"vitest": "^4.1.10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-win32-x64-msvc": "npm:null@*"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "^8.5.10",
|
||||
"@nevware21/ts-utils": "^0.14.0"
|
||||
"@nevware21/ts-utils": "^0.14.0",
|
||||
"sharp": "^0.35.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -63,13 +65,19 @@ const ALLOWED_USER_PROFILE_SUBDIRS = ["AppData\\Local", "AppData\\Roaming"];
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Escape a literal string so it can be embedded in a RegExp without its
|
||||
* characters being interpreted as regex metacharacters. */
|
||||
function escapeRegExp(literal: string): string {
|
||||
return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Expand known Windows environment variables and custom tokens (case-insensitive). */
|
||||
function expandEnvVars(path: string): string {
|
||||
let result = path;
|
||||
// Expand custom agent token first
|
||||
result = result.replace(new RegExp(USER_PROFILE_TOKEN.replace(/%/g, "%"), "gi"), USER_PROFILE_PLACEHOLDER);
|
||||
result = result.replace(new RegExp(escapeRegExp(USER_PROFILE_TOKEN), "gi"), USER_PROFILE_PLACEHOLDER);
|
||||
for (const [envVar, replacement] of Object.entries(COMMON_ENV_VARS)) {
|
||||
const regex = new RegExp(envVar.replace(/%/g, "%"), "gi");
|
||||
const regex = new RegExp(escapeRegExp(envVar), "gi");
|
||||
result = result.replace(regex, replacement);
|
||||
}
|
||||
return result;
|
||||
@@ -362,13 +370,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 +447,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