Compare commits

...

35 Commits

Author SHA1 Message Date
Oliver Kieselbach 71832b9deb docs(about): list hardware rejection among the webhook alert types
The integrations list named start, success, failure and SLA alerts but omitted
hardware rejection, understating what tenants can actually subscribe to.

Verified before claiming it: WebhookNotifyOnHardwareRejection is wired
end-to-end - model, portal toggle in SectionHardwareWhitelist, and dispatch in
ReportDistressFunction. This list occurs nowhere else in the repo or in
autopilotmonitor-docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:49 +02:00
Oliver Kieselbach ce4d884abc test(gather-rules): drive the collectors through their guards
The guard functions had unit tests, but nothing proved that Registry, Wmi,
Json, Xml and File actually consult 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. Prefix-spoofing included, plus a positive case through
JsonCollector so it is pinned that the guard is a gate and not a wall.

Path traversal had no explicit coverage either. The sharpest case is
...\Logs\..\..\..\..\Secret.txt - the RAW string starts with an allowed
prefix, so a naive StartsWith would admit it; the test pins that
Path.GetFullPath canonicalizes first and the allowlist sees C:\Secret.txt.
Also traversal into the C:\Users privacy block and into System32\config, both
asserted under unrestrictedMode, which lifts the allowlist but never those.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:49 +02:00
Oliver Kieselbach b56c774103 fix(logging): strip Unicode line separators in LogSanitizer
The sanitizer matched \p{Cc}, which covers the C0/C1 control ranges but not
U+2028 LINE SEPARATOR or U+2029 PARAGRAPH SEPARATOR - those are categories
Zl/Zp. A log viewer, a JSON-lines consumer or any JavaScript-side reader still
breaks a line on them, so they were a residual log-forging vector.

Matching \p{Zl} and \p{Zp} rather than listing escapes: the two categories
contain exactly those two characters, and it keeps the source free of
invisible literals.

Verified the test is not vacuous - the old pattern left "a<U+2028>b" as "a b",
the new one yields "ab".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:48 +02:00
Oliver Kieselbach 68767f436f fix(config): validate ContactEmail and seed it conditionally
The tenant contact address had no server-side validation, so a direct API
caller could store any string in the field service notices are sent to.
UpdateTenantConfiguration now rejects non-addresses, recipient lists (a comma
would silently widen who receives those notices), display-name forms and
control characters (mail-header injection), and normalizes what it stores.
Empty stays valid - having no contact address is a legitimate state.

The seed path had a worse problem than missing validation. It was a
read-modify-write of the whole model through SaveTenantConfigurationAsync,
which upserts with TableUpdateMode.Replace and no ETag: a concurrent portal
save was clobbered by the seeder's stale snapshot of EVERY field, not just
ContactEmail. It also wrote straight through IConfigRepository, bypassing the
TenantConfigurationService cache, so a seeded address stayed invisible for the
full 5-minute TTL.

TrySeedTenantContactEmailAsync now writes ETag-conditionally in Merge mode -
one property, and a 412 means someone else wrote the row so the seed abandons
rather than overwriting the winner. A lost seed is recoverable on the next
maintenance run; a lost admin edit is not.

The same seed logic existed twice (preview notification-email save and the
maintenance backfill). Both now call a single owner on
TenantConfigurationService, which holds the never-overwrite invariant, the
cache eviction and the fail-soft guarantee. Fail-soft lives in the service
rather than the repository so it holds for any implementation.

Tests cover the validator, the service contract, and the repository against a
mocked TableClient: Merge mode, the ETag actually passed, only ContactEmail in
the patch, the 412 race, 404, storage faults. Plus a regression pin that
SaveTenantConfigurationAsync is never called from the seed path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:31:30 +02:00
Oliver Kieselbach c7b06be8b7 fix(ratelimit): key the user throttle on CallerId, not the UPN
Follow-up to 479247cd. Publishing only a genuine UPN closed the fleet-wide bucket
collapse but opened a narrower hole: AuthenticationMiddleware validates issuer,
audience, lifetime and signature but requires neither upn nor scp, so an app-only
(client-credentials) token authenticates and reaches every AuthenticatedUser route
(feedback, realtime/negotiate, progress/*, health/detailed, auth/mcp, ...). Such a
token carries no upn, so it resolved to an empty identity and UserRateLimitMiddleware
skipped it entirely — previously it at least landed in the shared anonymous bucket.

Rather than reinstating a placeholder as a bucket key, separate the two concepts:

- RequestContext.UserPrincipalName keeps its meaning — a real UPN or nothing. Audit,
  presence and admin flows continue to see only genuine user identities.
- RequestContext.CallerId is the throttle identity: UPN, else the token subject
  (oid -> sub -> appid/azp), else a fail-closed shared bucket. Empty means exactly one
  thing — no JWT at all (device/anonymous routes, which carry their own limits).
- UserRateLimitMiddleware gates and buckets on CallerId.

This matches how McpQuotaEnforcementMiddleware already keys its quota (oid), so the
subject-based identity is the existing house convention for enforcement.

10 new tests: the oid/sub/appid/azp fallback chain and its precedence, the fail-closed
bucket for a token with no identifying claim (asserting it is never "anonymous"), and
that empty CallerId occurs only on unauthenticated routes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 10:17:34 +02:00
Oliver Kieselbach 479247cdae fix(ratelimit): stop agent fleet sharing one anonymous user bucket
Device and anonymous routes published the "anonymous" placeholder as
RequestContext.UserPrincipalName. UserRateLimitMiddleware skips only on an EMPTY UPN, so
every agent of every tenant was bucketed under user_ratelimit_anonymous and throttled
against the 120/min per-user limit. On 2026-07-21 21:00-21:30 UTC that rejected 1821
agent requests with 429 (/api/agent/telemetry, config, register-session, error); the
7-day trend shows it recurring and growing with fleet size.

- PolicyEnforcementMiddleware: RequestContext.UserPrincipalName carries a genuine JWT
  UPN or nothing. decision.UserIdentifier keeps the placeholder for log lines.
- Device/bootstrap routes already hold their own limit in SecurityValidator (per cert
  thumbprint / per bootstrap token), so they lose nothing.
- stats/platform and diagnostics/download had NO limiter of their own and were riding on
  the accidental shared bucket. Both now limit per trusted client IP (60/min and 30/min)
  via ClientIpExtractor, matching the HealthCheck pattern.
- 13 new tests pin the empty-UPN contract across all 12 device/anonymous routes, plus a
  guard that an authenticated caller still surfaces its real UPN.

Solution suite 5552 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 09:48:33 +02:00
Oliver Kieselbach b6ba87f072 fix(deps): pin patched DataProtection + Cryptography.Xml to clear NuGet advisories
Microsoft.Identity.Web 4.3.0 pulls Microsoft.AspNetCore.DataProtection and
System.Security.Cryptography.Xml transitively at 10.0.0, which carry known
advisories (GHSA-9mv3-2cwr-p262 critical on DataProtection; GHSA-37gx-xxp4-5rgx
and siblings, high, on Cryptography.Xml). Add explicit references pinning both
to the patched 10.0.10 servicing build (matching the existing JwtBearer pin) —
the NuGet equivalent of the npm overrides used for the web/MCP alerts.

Pinned in the production Functions project so the fix flows to the deployed
artifact and to Functions.Tests via project reference. `dotnet list package
--vulnerable` now reports 0 across every project; build clean; 3110 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:44:44 +02:00
Oliver Kieselbach fa640ebe61 fix(security): resolve CodeQL log-forging findings and suppress by-design queries
Bucket B (real fixes) + Bucket A (config) of the CodeQL alert triage.

Log-forging (cs/log-forging, 8 sinks): add Helpers/LogSanitizer that strips
CR/LF and other control characters, and apply it to the logged copy of every
user-controlled value in AuthenticationMiddleware, PolicyEnforcementMiddleware
(Invoke + DecideAsync), McpQuotaEnforcementMiddleware and TableUserUsageRepository.
Only the logged value is sanitized — routing, authorization and storage keys are
untouched, so tracing fidelity for legitimate values (paths, UPNs, GUIDs) is
preserved. Structured logging already isolated the fields; this closes the CRLF
vector as defense-in-depth and clears the query.

Three genuine JS/TS fixes:
- guardValidation.ts: replace the no-op `.replace(/%/g, "%")` with a real
  escapeRegExp() — the author intended to escape; this also closes the latent
  metacharacter gap (js/identity-replacement).
- access-guard.ts: use a `%s` format argument instead of interpolating the UPN
  into the console.error format string (js/tainted-format-string).
- docs-corpus.ts: strip HTML tags with a loop-until-stable stripHtmlTags() so the
  sanitizer is robust and recognized as complete (js/incomplete-multi-char-sanitization).

CodeQL config: filter the two queries that fire structurally on this codebase,
where the flagged behavior is the feature — cs/exposure-of-sensitive-information
(audit/notification logs legitimately record the actor UPN / recipient email) and
cs/cleartext-storage-of-sensitive-information (rate limiters key on the trusted
client IP). Default suites stay active; genuine one-off false positives from other
queries are dismissed individually so those queries stay sharp.

Tests: Backend xUnit 3110 (+9 LogSanitizer), MCP vitest 19 (+1 tag-strip),
Web vitest 49, all tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:38:47 +02:00
Oliver Kieselbach dbc20a9f66 test(deletion-maintenance): de-flake watchdog tests via event gates
The three watchdog tests raced a fixed fanout-body sleep against sub-second
warn/severe thresholds (e.g. 150ms body vs 500ms severe). On a loaded CI
runner the whole RunCoreAsync body overran the severe threshold before the
finally-block cancelled the watchdog, so the severe OpsEvent fired and
"LongRunning past warning" flaked (3100/3101).

Replace the wall-clock race with event gates: the fanout body now blocks on
WaitForEventAsync(<watchdog OpsEvent>) instead of sleeping, so the target
watchdog is guaranteed to have fired before the body returns. The opposite
threshold is pushed to minutes (warn-only tests) or the body waits on the
severe event (both-fire test) so warn is necessarily first. OpsEvents
recording is now lock-guarded since watchdog tasks record concurrently.

No production change. Watchdog class ran 5x green (16/16); full suite 3110/3110.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:33:26 +02:00
Oliver Kieselbach cbda62d131 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-07-22 01:19:36 +02:00
Oliver Kieselbach 3951000b40 ci(deploy-web): skip SWA deploy when token absent instead of failing
Dependabot PRs run without repository secrets, so AZURE_SWA_DEPLOY_TOKEN is
empty and Azure/static-web-apps-deploy exits with an error, painting a red
"Build and Deploy Job" check on every web-touching Dependabot PR. Setting
skip_deploy_on_missing_secrets makes the action skip the deploy (green) when
the token is missing; real pushes to main still carry the token and deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:19:26 +02:00
dependabot[bot] c59842262e chore(deps): bump the web group across 1 directory with 10 updates (#104)
Bumps the web group with 10 updates in the /src/Web/autopilot-monitor-web directory:

| Package | From | To |
| --- | --- | --- |
| [@microsoft/applicationinsights-web](https://github.com/microsoft/ApplicationInsights-JS) | `3.3.11` | `3.4.3` |
| [@xyflow/react](https://github.com/xyflow/xyflow/tree/HEAD/packages/react) | `12.10.2` | `12.11.2` |
| [fflate](https://github.com/101arrowz/fflate) | `0.8.2` | `0.8.3` |
| [next](https://github.com/vercel/next.js) | `15.5.18` | `15.5.21` |
| [@next/bundle-analyzer](https://github.com/vercel/next.js/tree/HEAD/packages/next-bundle-analyzer) | `16.2.7` | `16.2.11` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.11` | `22.20.1` |
| [autoprefixer](https://github.com/postcss/autoprefixer) | `10.4.24` | `10.5.4` |
| [eslint](https://github.com/eslint/eslint) | `9.39.2` | `9.39.5` |
| [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) | `15.5.12` | `15.5.21` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.10` |



Updates `@microsoft/applicationinsights-web` from 3.3.11 to 3.4.3
- [Release notes](https://github.com/microsoft/ApplicationInsights-JS/releases)
- [Changelog](https://github.com/microsoft/ApplicationInsights-JS/blob/main/RELEASES.md)
- [Commits](https://github.com/microsoft/ApplicationInsights-JS/commits)

Updates `@xyflow/react` from 12.10.2 to 12.11.2
- [Release notes](https://github.com/xyflow/xyflow/releases)
- [Changelog](https://github.com/xyflow/xyflow/blob/main/packages/react/CHANGELOG.md)
- [Commits](https://github.com/xyflow/xyflow/commits/@xyflow/react@12.11.2/packages/react)

Updates `fflate` from 0.8.2 to 0.8.3
- [Release notes](https://github.com/101arrowz/fflate/releases)
- [Changelog](https://github.com/101arrowz/fflate/blob/master/CHANGELOG.md)
- [Commits](https://github.com/101arrowz/fflate/compare/v0.8.2...v0.8.3)

Updates `next` from 15.5.18 to 15.5.21
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.18...v15.5.21)

Updates `@next/bundle-analyzer` from 16.2.7 to 16.2.11
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v16.2.11/packages/next-bundle-analyzer)

Updates `@types/node` from 22.19.11 to 22.20.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `autoprefixer` from 10.4.24 to 10.5.4
- [Release notes](https://github.com/postcss/autoprefixer/releases)
- [Changelog](https://github.com/postcss/autoprefixer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/autoprefixer/compare/10.4.24...10.5.4)

Updates `eslint` from 9.39.2 to 9.39.5
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v9.39.2...v9.39.5)

Updates `eslint-config-next` from 15.5.12 to 15.5.21
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/commits/v15.5.21/packages/eslint-config-next)

Updates `vitest` from 4.1.2 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: "@microsoft/applicationinsights-web"
  dependency-version: 3.4.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: "@xyflow/react"
  dependency-version: 12.11.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: fflate
  dependency-version: 0.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: next
  dependency-version: 15.5.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@next/bundle-analyzer"
  dependency-version: 16.2.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/node"
  dependency-version: 22.20.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: autoprefixer
  dependency-version: 10.5.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: web
- dependency-name: eslint
  dependency-version: 9.39.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: eslint-config-next
  dependency-version: 15.5.21
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:15:43 +02:00
dependabot[bot] c3e9621a78 chore(deps): bump the mcp group across 1 directory with 5 updates (#103)
Bumps the mcp group with 5 updates in the /src/McpServer/autopilot-monitor-mcp directory:

| Package | From | To |
| --- | --- | --- |
| [@modelcontextprotocol/sdk](https://github.com/modelcontextprotocol/typescript-sdk) | `1.28.0` | `1.29.0` |
| [fuse.js](https://github.com/krisk/Fuse) | `7.1.0` | `7.5.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `22.19.15` | `22.20.1` |
| [tsx](https://github.com/privatenumber/tsx) | `4.22.4` | `4.23.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.2` | `4.1.10` |



Updates `@modelcontextprotocol/sdk` from 1.28.0 to 1.29.0
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/v1.28.0...v1.29.0)

Updates `fuse.js` from 7.1.0 to 7.5.0
- [Release notes](https://github.com/krisk/Fuse/releases)
- [Changelog](https://github.com/krisk/Fuse/blob/main/CHANGELOG.md)
- [Commits](https://github.com/krisk/Fuse/compare/v7.1.0...v7.5.0)

Updates `@types/node` from 22.19.15 to 22.20.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `tsx` from 4.22.4 to 4.23.1
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.4...v4.23.1)

Updates `vitest` from 4.1.2 to 4.1.10
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/sdk"
  dependency-version: 1.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: fuse.js
  dependency-version: 7.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: "@types/node"
  dependency-version: 22.20.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: tsx
  dependency-version: 4.23.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: mcp
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: mcp
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:15:10 +02:00
dependabot[bot] 3ed21c37d1 chore(deps): bump actions/upload-artifact from 6 to 7 (#102)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:08:23 +02:00
dependabot[bot] 4a1194569e chore(deps): bump actions/checkout from 5 to 7 (#91)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:07:14 +02:00
dependabot[bot] d0794fc7c5 chore(deps): bump actions/setup-node from 5 to 7 (#90)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:06:47 +02:00
dependabot[bot] f34c9d2322 chore(deps): bump github/codeql-action from 3 to 4 (#89)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:06:44 +02:00
dependabot[bot] 380a8417c8 chore(deps): bump actions/setup-dotnet from 5 to 6 (#88)
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-22 01:06:40 +02:00
Oliver Kieselbach 63826a4869 chore(dependabot): stop auto-PRing npm major bumps
Group config already batches minor/patch; majors fall outside the group and
each opens its own PR (zod 4, typescript 7, transformers 4, msal-react 5,
react-leaflet 5, react 19, @types/node 26, eslint-config-next 16 — all
breaking). Ignore version-update:semver-major for both npm ecosystems so
majors are reviewed deliberately instead of flooding the PR list. Security
updates are exempt from this filter, so vulnerability fixes still flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 01:03:22 +02:00
Oliver Kieselbach 7df208fac9 fix(deps): pin patched transitive deps via overrides to clear Dependabot alerts
Force the security-patched versions of transitive dependencies without
downgrading their parents (next 15, @modelcontextprotocol/sdk 1.28), which
is what `npm audit fix` would otherwise do:

- sharp ^0.35.0   (web via next, mcp via @huggingface/transformers) — libvips CVEs
- fast-uri ^3.1.4 (mcp via ajv) — host confusion
- hono ^4.12.27   (mcp via sdk) — jsx context/XSS/header advisories
- @hono/node-server ^2.0.5 (mcp via sdk)

npm audit: 0 vulnerabilities in both projects. Builds clean; MCP 447 tests
and Web 572 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 00:58:37 +02:00
Oliver Kieselbach 3f9576d4a9 fix(agent): enforce the gather-rule allowlist in every collector
LogParserCollector and EventLogCollector called no guard at all, while
File/Json/Xml did — and rules/gather-rules.md already promised customers
that log-parser paths were allowlisted. A tenant admin could author a
logparser rule against C:\Users via POST /api/rules/gather and have up to
1000 lines uploaded as telemetry, bypassing the hard C:\Users block the
same page guarantees; eventlog could read any channel, Security included.
The backend validates no collectorType, target, or parameters, so the
agent is the only place this can be enforced.

Both now guard. logparser cannot check rule.Target directly (GetFullPath
throws on a wildcard), so it checks the directory for wildcard targets and
re-checks each resolved path. Event log channels get an allowlist matched
on a "/" boundary. Hard-blocked channels live in a C# constant rather than
guardrails.json: a parse error drops the allowlists to empty, which is
fail-closed, but would have lifted a block sourced from the same file.

Also fixes an inert timeout in CommandCollector: ReadToEnd() blocks until
the pipe closes, so WaitForExit(30000) was only reached once the process
had already exited and a hung command pinned its worker indefinitely.

Adds the first real CI gate. No workflow ran tests on a pull request — the
deploy workflows build without testing and build-agent.yml covered only
DecisionCore.Tests on manual dispatch — while trust/security-faq.md
already told customers automated tests run in CI. ci.yml now runs all six
suites on every PR and merge, plus a job that fails on generated-guardrail
drift, alongside CodeQL and Dependabot. OfficeBinaryWatcherTests is marked
SerialThreading; its bounded re-probe races the shared ThreadPool and
failed once under full-suite load.

Verified: 3101 backend, 1911 agent V2, 474 decision core, 44 dialog, 572
web, 447 MCP. The timeout fix is verified by runtime, not assertion — a
Start-Sleep 120 command now returns after ~30s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 00:28:04 +02:00
Oliver Kieselbach ee58242d33 fix(mcp): tell callers to query the English-only corpora in English
A German question returns zero results from search_docs, not poor ones: both the
documentation and the rules are English, and all-MiniLM-L6-v2 is an English-only
model, so the query vector lands nowhere near any chunk. For a German-speaking
admin driving an MCP client in their own language, the tool is effectively blind
and gives no hint why.

The caller is itself a language model, so the cheapest correct fix is to say so:
both tool descriptions now instruct it to translate the question to English before
searching and to answer the user in their own language. No infrastructure, no
model change, no ranking impact.

A multilingual embedding model would handle it natively and was considered, but it
is substantially larger — which matters on a scale-to-zero container — and it would
change English retrieval as well, so it would have to be measured against
scripts/eval-docs-search.ts rather than swapped in on the assumption that more
languages cannot hurt.

Verified: 447 tests pass, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:42:56 +02:00
Oliver Kieselbach eca528b9b3 docs(mcp): attribute the cold start — the app is 0.5s of it, the platform is the rest
The boot marks answered the question on the first production cold start, and the
answer overturns the reasoning behind an infrastructure change made earlier today.

  +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)

Against a 31.5s cold start, the application accounts for half a second. The other
~98% is Container Apps activation: KEDA reacting, pod scheduling, mounting the
236 MB image, starting the container. The container also boots faster than a
development laptop (504ms vs 1273ms), so nothing here is CPU-starved.

Consequences recorded rather than quietly dropped:

- Raising the container to 0.5 vCPU was justified by the embedding model load on
  the cold-start path. Prebaked, that load is 173ms and runs in the background
  where only the first search could wait on it. The justification does not survive
  measurement. The setting stays — it plausibly helps concurrent requests, and at
  scale-to-zero the cost difference is negligible — but the Bicep comment now says
  what is true instead of what was assumed, so nobody re-derives the wrong reason.
- Index size is irrelevant to startup (60ms for 3.12 MB), so the corpus can grow
  freely, and the embedding rounding earns its keep on file size alone.
- The only lever that would actually remove the cold start is minReplicas: 1, at
  roughly 7x the monthly free consumption grant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:34:24 +02:00
Oliver Kieselbach 8305f5c850 feat(mcp): time the boot so the cold start can be attributed
A cold start after scale-to-zero measures 24.5s to first byte against 60ms warm,
and nothing outside the process could say why. 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, and piping stdout locally buffers until
exit. So the server times itself: `[boot +Nms]` marks derived from
process.uptime(), which unlike an in-process timer also covers Node's own startup
and module loading.

Measuring the precomputed path locally already contradicts two assumptions worth
recording:

  +860ms   node started (startup + module loading)
  +1199ms  corpora read from disk         (+339ms)
  +1242ms  index read, validated, hydrated (+43ms for 3.12 MB)
  +1273ms  listening                       (+31ms)
  +1497ms  query embedder warm             (+224ms, background)

The index is 43ms, not seconds — its size is not the cold-start cost, and growing
the corpus is cheap. The prebaked embedder is 224ms and loads in the background,
so only the first search could ever wait on it. That was the stated reason for
raising the container to 0.5 vCPU, which now looks like a weaker justification
than it did; the production marks will settle it. If they show the app listening
after a few seconds, the rest of the 24.5s is platform activation that neither CPU
nor index changes can touch.

Verified: 447 tests pass, marks appear on both boot paths locally. Incidentally
the run also confirmed the corpus hash guard and the staleness signal both work —
the local docs repo has moved to 6333d56 while the deployed image reports d8cb7f4,
so the precomputed index was correctly rejected and recomputed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 22:14:49 +02:00
Oliver Kieselbach c2c02b929c fix(web): show gather-rule scope/emit controls only where they change behavior
The form offered trigger, phase scope and emit mode unconditionally, which
produced combinations that cannot do anything plus one silently wrong state:

- A scope on a Phase Start / Phase End rule that names a concrete phase can only
  ever suppress the single firing — the phase IS the moment. Same for emit mode:
  a one-shot has no second result to dedup against.
- Selecting "From a phase onwards" (or "Only during specific phases") and not
  filling the detail dropdown serialised to null, i.e. the rule ran everywhere
  while the form still read "From a phase onwards".

supportsPhaseScope / supportsEmitMode now drive visibility; buildScopeFields
nulls hidden-control state so a value from an earlier trigger choice cannot leak
into the payload; validateScopeSelection rejects an unfilled scope mode on both
create and save. Startup is the deliberate exception — a scope there defers the
one-shot instead of suppressing it, so the scope control stays and only emit mode
is hidden. One-shot triggers now state in-form when they collect.

Sub-dropdown hints no longer claim "No selection = all phases" (they turn amber
and name the required action instead).

Tests: app/gather-rules/__tests__/gatherRuleFormLogic.test.ts (15) pins the
applicability matrix, the payload nulling incl. the trigger-switch leak, the
unfilled-scope rejection, and the JSON-mode scopeMode derivation.
vitest 572/572; tsc silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 22:06:50 +02:00
Oliver Kieselbach 1821af6f05 fix(mcp): fire the search_docs keyword fallback on a weak semantic head
The live test found a third recall gap. The fallback only topped up when semantic
search returned FEWER than topK results — but the more common failure is a full
list of unconvincing ones. Querying "WOW6432Node" returned three chunks scoring
~0.29, none containing the word, while the single chunk that does was never
considered at all.

The fix is a ranking change, so it was measured rather than reasoned about. Adds
scripts/eval-docs-search.ts, which scores strategies over 2465 queries in three
deliberately different classes: every chunk heading (easy by construction, so a
regression detector), rare identifiers (the class this fallback serves), and
hand-written questions (the reality anchor — not derived from the corpus, so it
shares little vocabulary with it). Embeddings are computed once and each strategy
is a pure function over the same candidates, so sweeping a threshold costs no
extra inference.

                        known-item  literal  handwritten MRR  fixed  broken
  append-only (before)       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 — so that is
what ships. Higher thresholds keep repairing identifier queries but start
demoting hand-written questions, the class that most resembles real use, so the
gain stops being free. When the head is weak, exact matches move to the front
while at least one semantic result is kept, so a weak-but-correct hit is never
pushed out entirely.

The measurement also resized the problem: identifier queries were succeeding only
66.2% of the time, so the reported case was one of roughly 750, not an outlier.
That is the part a single anecdote could not have shown, and the reason the
harness exists before the fix rather than after it.

Verified: 447 tests pass, including new cases for the weak path and for leaving a
confident ranking untouched. On the real corpus "WOW6432Node" now returns the
chunk that contains it at rank 1, while "how do I deploy the agent with Intune"
(0.819) and "what firewall URLs do I need to allow" (0.374) are unchanged and the
negative controls stay empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:39:26 +02:00
Oliver Kieselbach 62599d972c fix(mcp): put every doc token back into search_docs ranking
Two measured recall gaps in search_docs, both silent.

1. Truncation. MiniLM's context is 256 tokens; 87 of 324 chunks exceeded it and
13% of all tokens never reached the ranking — the tokenizer drops the tail
without complaint. Concretely, 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.

Shrinking chunks cannot fix this: the corpus ranges 2.43 to 5.81 characters per
token, so no character budget bounds tokens, and a budget safe for the worst case
(~620 chars) would shred prose and tables alike. So the chunk stays whole and the
embedding is split instead — index() cuts long text into 250-token windows with
40 overlap, a document carries embeddings[][], and search() scores it by its BEST
window. Max rather than mean: averaging would blur a long section's several
topics into one mediocre direction, the same dilution that makes large context
windows worse than chunking.

The tokenizer is a build-time dependency only (vocabulary, not ONNX weights,
~93 ms); production boots from precomputed vectors and never loads it.

2. No general fallback. Proper nouns embed poorly — "RealmJoin" occurs in 3
chunks yet scores under the threshold. Fuzzy matching was built, measured and
rejected: 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, and every provider already exposes it. So a
short result set is now topped up via lexicalMatch on distinctive query terms,
requiring two matching terms as corroboration — without that rule "how do I bake
sourdough bread" matched a page about LLM providers, because "bake" is a
substring of "baked into the image". Keyword hits are appended and flagged, never
sorted together with cosine scores.

Also sharpens the tool description with the measured reason to prefer this over
fetching the docs site (~1k tokens for topK=3 against ~9k raw / ~600k rendered,
version-matched, and matching on meaning), and replaces the growing tail of
positional docs parameters with a DocsSearchBundle.

Verified: 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 keep byte-identical scores and rank 1
(short chunks are one window, so the same vector as before — no long-chunk bias).
The fallback is not a dead path: across 904 rare corpus tokens queried alone it
contributes hits for 431, while negative controls stay empty. Precompute output
is accepted by the boot validator and a mutated corpus still rejected; index
2.63 -> 3.12 MB for 324 chunks / 424 vectors. The rules corpus gains the same
treatment unplanned: 128 rules now carry 172 vectors. 445 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 21:17:42 +02:00
Oliver Kieselbach 21b751be64 changed sidebar tenant settings entries ordering 2026-07-21 20:02:28 +02:00
Oliver Kieselbach 63ea2e3cee feat(mcp): search_docs — customer documentation as a third search corpus
The MCP server could answer telemetry questions ("why did this session fail")
but not product ones ("how do I deploy the agent", "where is my data stored").
Those answers already exist in the autopilotmonitor-docs bundle; search_docs
makes them semantically searchable.

Deliberately a separate index from search_knowledge: rules and prose answer
different questions, and sharing one topK would dilute both. Registered for
every role — published documentation carries no tenant data — and skipped
entirely when no corpus was baked in, so a doc-less build advertises no
broken tool.

Corpus handling:
- Chunked at h2/h3 and <details>/<summary>, each chunk prefixed with a
  "Page > Section > Subsection" breadcrumb so a mid-page hit is self-describing.
- Newlines are normalized before any parsing. This is a correctness
  precondition, not cosmetics: the bundle ships CRLF and JS `.` does not match
  \r, so /^(#{2,3})\s+(.*)$/ matched zero headings — silently, degrading every
  page to unlabelled paragraph splits.
- Two size exceptions: tables are never split (a split table leaves `| --- |`
  rows with no header), and unbroken changelog lists split at line level (left
  whole, their tails fall outside the model window and become unsearchable).
- Short sections are kept — a one-line answer is often the best hit, and the
  breadcrumb supplies the context a short body lacks.

Build and delivery:
- Docs are embedded at image build time into an optional third
  PrecomputedSection. Optional so a checkout without the docs repo does not
  invalidate the rules and event-type vectors; the reverse (server has docs,
  file does not) is staleness and is rejected.
- The deploy workflow checks the public docs repo into the build context and
  passes its SHA as DOCS_COMMIT, which /health reports alongside the chunk
  count — editing the docs does not update a running server, so staleness is
  made checkable rather than memorable.
- Embeddings are rounded to 6 decimals on serialize: 55% smaller index (2.63
  vs 5.88 MB) for a ~1e-5 norm error against a 1e-3 tolerance. indexPrecomputed
  now asserts unit norm, since the hydrate path never checked it.

Infra: the Bicep template was two changes behind the live deployment (EU
cutover and ACR hardening, both applied directly in Azure). Reconciled against
the running resources — managed-identity image pull replaces the admin
credentials and the acr-password secret — and infra/ is no longer gitignored so
it stops drifting. Container raised to 0.5 vCPU / 1.0 GiB for the cold-start
query-embedder load. The template does not model the mcp.autopilotmonitor.com
custom domain, so a full deployment would remove it; the header warns about it.

Verified: 428 tests pass; 45 pages produce 324 chunks; sample queries hit the
right page top-1/2; all 8 derived URLs return 200 live; the precompute output
is accepted by the boot validator and a mutated corpus is rejected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:48:28 +02:00
Oliver Kieselbach 93dcac44a4 fix(web): add Contact to the tenant settings sidebar
The section was registered for routing but not in the sidebar, so it was
reachable only by typing the URL. Sidebar entries come from
globalNavConfig.tsx, which is not derived from tenantNavSections.ts -
both have to be touched for a section to exist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:19:44 +02:00
Oliver Kieselbach 72be8ac89f feat(config): tenant contact address, seeded once from the preview email
Enforcement and service notices need somewhere to go. Until now the only
address on file was the Private Preview notification email, which is
onboarding data in the PreviewWhitelist table and not something a tenant
can maintain.

Adds TenantConfiguration.ContactEmail, owned and edited by the tenant's
own admins under Settings -> Tenant -> Contact:

- Seeded once when a preview notification address is saved, and only
  while ContactEmail is still empty. It is never re-synced, so an edit in
  the portal cannot be overwritten later by the onboarding source.
- Backfilled for tenants onboarded before the field existed, as a
  manual-trigger maintenance task alongside the other backfills. It skips
  any tenant that already has an address, so it converges to a no-op and
  can be dropped once everyone is covered.
- Surfaced read-only in Admin -> Tenants next to the preview address, so
  an operator who needs to warn a tenant does not have to read the table.

Purpose is stated where it is entered and in the Privacy Policy: service
matters only - a technical problem, a security matter, or a change
needing an administrator's attention. Never marketing, never shared.

The Terms already say a warning is sent "where a tenant contact address
is on file"; this is the field that makes that promise meaningful.

Wired through the full tenant-config round-trip (types, context state,
hydrate, save payload, reset, provider) so a save from any other section
cannot clobber it.

Verified: solution builds clean, 3101 backend tests and 557 web tests
pass, tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 18:00:18 +02:00
Oliver Kieselbach cd570c8b3e feat(web): add IP/licence terms, enforcement ladder, security reporting
- Intellectual Property & Licensing section: the repository licence
  governs the code, not the hosted service, its infrastructure, its data,
  or the name and branding. Customer data stays with the customer.
- Acceptable Use now states the enforcement ladder (warning, reduced rate
  limits, suspension, termination) instead of an unqualified right to
  suspend, and routes security research to GitHub Security Advisories
  with explicit testing boundaries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:38:52 +02:00
Oliver Kieselbach 6dfc564be6 fix(web): align privacy and terms with the DPA-owns-the-register split
Privacy: "Sub-processors and Outbound Data Flows" becomes "External
Services". It still names who receives data, which is a controller's own
duty, but drops the Art. 28 vocabulary and the promise to announce new
sub-processors - that commitment lives in the data processing agreement.
Points at the renamed docs page and notes the DPA is available on
request.

Terms: the MSP clause now says a managing organization is responsible for
having the necessary data protection agreements with its customers,
instead of classifying it as a processor or sub-processor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:25:36 +02:00
Oliver Kieselbach afecd18123 fix(web): patch js-yaml and brace-expansion DoS advisories
Dependabot alerts #63/#64/#65 (all high): brace-expansion exponential-time
expansion of consecutive non-expanding {} groups (GHSA-3jxr-9vmj-r5cp,
both the 1.x and 2.x trees) and js-yaml quadratic CPU consumption via
YAML merge-key chains (GHSA-52cp-r559-cp3m).

All three are transitive dev-only dependencies of the eslint toolchain
(eslint -> @eslint/eslintrc -> js-yaml; eslint/typescript-estree ->
minimatch -> brace-expansion) and resolve within their existing semver
ranges, so only the lockfile changes.

npx tsc --noEmit clean, vitest 557 passed, npm audit reports 0
vulnerabilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:53:28 +02:00
Oliver Kieselbach 5a294ac4f6 fix(mcp): patch transitive protobufjs/tar/body-parser advisories
Dependabot alert #62 (protobufjs 7.6.4, DoS via infinite loop in .proto
option parsing, GHSA fixed in 7.6.5) plus the tar (critical) and
body-parser (low) advisories npm audit surfaced alongside it. All three
are transitive under @huggingface/transformers and resolve within their
existing semver ranges, so only the lockfile changes.

Lockfile also picks up the pending 1.5.0 -> 1.5.1 package version sync.

npx tsc --noEmit clean, vitest 400 passed / 41 skipped, npm audit
reports 0 vulnerabilities.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:50:51 +02:00
88 changed files with 6331 additions and 1060 deletions
+31
View File
@@ -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
+47
View File
@@ -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
+4 -4
View File
@@ -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 }}
+116
View File
@@ -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
+59
View File
@@ -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 }}
+2 -2
View File
@@ -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'
+2 -2
View File
@@ -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'
+18 -1
View File
@@ -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 \
.
+4 -1
View File
@@ -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
View File
@@ -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/
+5
View File
@@ -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
View File
@@ -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.
+305
View File
@@ -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
+81
View File
@@ -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)
+10
View File
@@ -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
+268
View File
@@ -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
+73
View File
@@ -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",
+14 -1
View File
@@ -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');
}
@@ -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();
}
}
}
@@ -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" };
@@ -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))
{
@@ -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);
@@ -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 };
@@ -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);
}
}
@@ -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>
@@ -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"),
@@ -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
@@ -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>:
@@ -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))
{
@@ -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>
+25 -1
View File
@@ -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
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&apos;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&apos;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&apos;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 &amp; 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&apos;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&apos;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 &amp; 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&apos;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 &amp; Privacy FAQ</a>{" "}
for what happens next. Do not access other tenants&apos; 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 &amp; 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 &quot;Your Data, Suspension and Termination&quot; 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&apos;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&apos;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 &amp; 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" },
],
},
{
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -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",
];