Commit Graph

2229 Commits

Author SHA1 Message Date
Oliver Kieselbach 8ec5a5debd offboarding: close the un-offboard race — config freeze + fail-closed wipe guard
While a tenant offboarding is queued or draining, the TenantConfiguration
tombstone (Disabled=true, 'Offboarding in progress') is the cascade's only
auth gate. A GA lifting Disabled mid-cascade re-opened the tenant against a
wipe that was still coming: data written afterwards was destroyed, and
sessions registered after enumeration survived as orphans while their event
partitions were wiped.

Three layers close it:
- PUT config/{tenantId} returns 409 while the row is the offboarding
  tombstone — for all callers, GA included
- TenantConfigPatchService refuses patch AND revert against the tombstone
  (a revert restores a pre-offboarding snapshot with Disabled=false — the
  exact race)
- TenantOffboardingHandler re-reads the config before the destructive
  post-drain phases and fail-closes with 'unoffboard_race' when the
  tombstone is gone but the wipe has not run (last line of defense)

Shared predicate: TenantOffboardFunction.IsOffboardingTombstone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.0.1392
2026-08-07 16:25:03 +02:00
Oliver Kieselbach ff35270d5d agent: legacy bootstrap-config without TenantIdWaitSeconds must not disable the wait
A bootstrap-config.json written before the TenantIdWaitSeconds property
existed deserialized the missing field to 0, which the runtime treated as
an explicit opt-out and skipped the 600s TenantId wait entirely — exactly
the wait Hybrid-Join devices need while their Entra registration lands.

TenantIdWaitSeconds is now nullable: missing/null means 'not configured'
and falls through to the agent default (600s); an explicit 0 remains the
legacy fast-fail opt-out. The install merge carries null through instead
of freezing the default into the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:24:50 +02:00
Oliver Kieselbach 312e849573 email: welcome + farewell templates to green branding
Header gradient, CTA button, and links move from the old blue
(#2563eb/#4f46e5) to the landing-page green palette (#1e8a4c/#33b161).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 16:11:23 +02:00
Oliver Kieselbach 7433df528d web: mark-led OG/Twitter image + PNG apple-touch-icon
The OG image was an unreadable shrunken hero at card size; replace it
with a centered mark-first design (big green shape, large wordmark,
single tagline). Safari cannot use SVG for apple-touch-icon and fell
back to a letter tile — point the icons config at the file-convention
apple-icon.png, now the green mark on white (180x180).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 15:17:23 +02:00
Claude d86ae18a5e distress: carry the W365 Cloud PC marker into Devices Not Registered
First W365 field run: the rejected Cloud PC appeared in the report as an
anonymous "Microsoft Corporation / Virtual Machine" row — nothing told the
admin that the fix is the Cloud PC validator, not an Autopilot-registration
hunt. The agent's CloudPcDetector verdict (Windows365 key AND
CloudManagedDesktopExtension service) now rides the pre-auth distress channel:

- Agent: DistressReporter captures isCloudPc at construction (optional ctor
  arg, test fakes stay source-compatible); BackendClientFactory feeds it from
  CloudPcDetector; DistressReport.IsCloudPc on the wire (~18 bytes, fits the
  1536-byte payload cap without a bump — comment updated)
- Backend: DistressReportEntry.IsCloudPc, table column + read mapping
  (legacy rows -> false), structured log + AgentDistressReport AI event key;
  GetDeviceNotRegisteredFunction aggregates sticky-true per serial (same OR
  semantics as the Sessions IsCloudPc merge) and the data-quality notice
  names the new self-reported field. GA global/distress-reports serializes
  the full entry and picks it up automatically.
- Web: Cloud PC badge in the Devices Not Registered table (same sky badge as
  the session table) + conditional hint pointing at the Windows 365 Cloud PC
  Validation toggle and the W365CloudPcValidation Graph add-on.
- Tests: sticky-true + no-marker aggregation cases; MakeEntry helper gains
  the optional flag. Docs: third transport rail in
  backend/cloudpc-device-validation.md + log entry.

Unverified like every distress field — triage context only, never an auth
input. Verified: web tsc clean, eslint 0 errors (file's single warning
pre-exists). .NET build/tests run in CI (no SDK in this sandbox).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv
v2.0.1391
2026-08-07 10:52:35 +00:00
Oliver Kieselbach 1c5f69a9fe w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs (#127)
* w365: CloudPc device validator — cert-CN-bound fallback for Cloud PCs

Windows 365 Cloud PCs are never Autopilot-registered, so the serial lookup
(and the Corporate Identifier fallback) structurally reject them and Cloud PC
first-connect enrollment could not be monitored. New stage-3 fallback in the
device-validation chain: extract the Intune device id from the chain-validated
MDM client certificate's Subject CN (field-verified: CN == managedDevice id ==
cloudPC.managedDeviceId) and require a matching cloudPC object in the claimed
tenant (Graph v1.0 virtualEndpoint/cloudPCs, $filter=managedDeviceId eq).
Only service-provisioned Cloud PCs have such an object — a regular enrolled
non-Autopilot device still ends in the 403, and local W365 markers are never
consulted for auth.

- Shared: ValidatorType.CloudPc = 5, TenantConfiguration.ValidateCloudPcDevice
  (default false, tenant-admin writable), GraphAppPermissions.CloudPCReadAll,
  GraphFeatureCatalog feature W365CloudPcValidation -> CloudPC.Read.All
- Backend: CloudPcDeviceValidator (AutopilotDeviceValidator sibling contract:
  30/5 min cache, 2 attempts, transient->503; GUID gate before the OData
  filter; exact-match guard; Graph 403 = definitive "grant missing", not
  transient), SecurityValidator CN extraction + fallback stage + hard gate,
  wiring through the 5 agent endpoints, TableConfigRepository persistence
- Consent script: W365CloudPcValidation in catalog/ValidateSet/help; NEW
  GraphFeatureCatalogSyncTests pins C# catalog <-> grant script (previously
  comment-only lock-step)
- Bootstrap dev script v2.3-dev.3: guard 4 (uptime window) exempted while the
  Cloud-PC relax is active — Cloud PCs run headless for days before first
  connect; the fresh-profile window is the time anchor there (+ Pester tests)
- Web: validation toggle + context plumbing, ValidatedBy "CloudPc" label,
  privacy-page validation claim generalized (Last updated bumped)
- Docs: OKF concept doc backend/cloudpc-device-validation.md + index + log

Verified: web tsc clean, ESLint 0 errors (99 pre-existing warnings = ratchet
cap, none added). .NET build/tests and Pester run in CI — SDK download and
pwsh are unavailable in this sandbox's network policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqEFrcAHhWgCvgJgHGzMuv

* w365: agent-side IsCloudPc flag + engine expectation "no Device-ESP phase"

Companion to the CloudPc validator: the agent now classifies Windows 365
Cloud PCs locally with the bootstrap's field-verified marker AND
(HKLM\SOFTWARE\Microsoft\Windows365 key + installed
CloudManagedDesktopExtension service, probed via its Services registry key
so net48 needs no System.ServiceProcess). SKIP-safe: any error resolves
false. Never an auth input - backend admission stays exclusively the
cert-CN-bound Graph lookup (ValidatedBy=CloudPc); the two signals are
independent and may disagree.

Two transport rails:

- Session metadata: SessionRegistration.IsCloudPc -> Sessions table
  (sticky-true OR across re-registrations, IsSelfDeployingProfile pattern)
  -> SessionsIndex full mirror -> SessionSummary -> new isCloudPc search
  filter end to end (SearchSessions + QueryRawSessions + pagination
  fingerprint + all three in-memory predicates; MCP search_sessions /
  query_raw_sessions params + summary projection; web type, Cloud PC pill,
  SessionInfoCard enrollment label, CSV export appended column).
- Decision engine: isCloudPc rides the EnrollmentFactsObserved payload,
  recorded set-once for BOTH values in EnrollmentScenarioObservations.CloudPc
  and annotates the scenario profile reason
  cloud_pc_first_connect:no_device_esp_expected - the engine-recorded
  expectation that Device-ESP already ran headless at provisioning and the
  session starts at Account Setup. Deliberately reason+observation only:
  no completion arm depends on DeviceSetup, Mode stays AccountSetup/IME-
  classified (Classic), EspConfig keeps configured-FirstSync semantics.
  Positive marker surfaces as cloud_pc_marker in the audit-trail census.
  Updater idempotency: the cloudPcAnnotates gate keeps the generic v1
  reason branch from re-claiming the slot on repeat posts (would bump
  EvidenceOrdinal every post). Additive-nullable observation - no state
  schema bump (v4 convention), serializer roundtrip pinned.

Tests: CloudPcDetector resolver (marker AND, throwing probe), registration
body, facts-signal payload shape, updater annotation/idempotency/
self-deploying interplay, reducer observation both values + census fold,
serializer roundtrip, index projection superset, sticky-true both
directions. Suites: DecisionCore 500, Functions 3941, Agent V2 2154,
vitest 632 - all green; tsc web+MCP clean; ESLint 0 errors (cap 99).

Docs: cloudpc-device-validation.md follow-up section replaced with the
shipped mechanism + log entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
v2.0.1390
2026-08-07 12:31:08 +02:00
Oliver Kieselbach 17affb6dfd Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-06 18:20:07 +02:00
Oliver Kieselbach acf4d0eeaa bootstrap(dev): v2.3-dev.2 - Cloud PC detection on captured evidence
First W365 field test (2026-08-06) proved the assumed WMI signature wrong:
a real Cloud PC reports the generic Manufacturer='Microsoft Corporation' +
Model='Virtual Machine'; the 'Cloud PC Enterprise ...' string exists only in
Intune/Graph inventory. Probe on the live Cloud PC captured two local
markers instead: HKLM\SOFTWARE\Microsoft\Windows365 and the
CloudManagedDesktopExtension service (W365 management agent).

Get-CloudPcModel is replaced by Test-IsCloudPc requiring BOTH markers (each
alone has look-alikes: W365-Boot physical clients / legacy managed-desktop
agents). Raw identity and both marker states are always logged. Tests
updated to the marker model (26 tests, green under PS 5.1 and pwsh 7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 18:14:58 +02:00
Oliver Kieselbach 4c1889d011 bootstrap(dev): always log raw Manufacturer/Model for Cloud PC diagnosis
First W365 field test failed: the log said only "CloudPC: False" with no way
to tell whether Win32_ComputerSystem reported unexpected values or the query
failed. Get-CloudPcModel now logs the raw identity on every run and the
error message on failure. Detection pattern itself unchanged until the
probe from the real Cloud PC delivers evidence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 17:59:49 +02:00
Oliver Kieselbach da4de79a9d web: keep Load all clear of Prev/Next on phone-width pagination (#126) 2026-08-06 17:10:15 +02:00
Oliver Kieselbach 6f3f35e1cd web: one-click reset to "All tenants (aggregated)" in tenant scope selector
While a specific tenant is selected on an aggregated-capable page, an x
button next to the dropdown jumps straight back to the aggregate view
instead of requiring a dropdown roundtrip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:55:20 +02:00
Oliver Kieselbach ca22be5cd0 notifications: dedupe duplicate Pre-Provisioning Completed webhook + traffic-light icons
The V2 agent emits whiteglove_complete twice (raw Shell-Core event 62407 +
engine timeline event, ~1s apart). The Pending status write was not
idempotent (terminal guard allowed Pending->Pending as a "transition") and
the ingest fallback additionally forced the transitioned flag, so tenants
received the Teams card twice (HSD report). Pending->Pending is now refused
in IsTerminalTransitionAllowed, and the ingest fallback distinguishes an
already-sealed session (duplicate, no notification) from a genuine write
failure.

Notification titles now use neutral traffic-light circles (green/yellow/red)
instead of rocket/checkmark/cross/siren emojis (customer feedback).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:13:19 +02:00
Oliver Kieselbach c421b81751 web: fix all 16 exhaustive-deps lint warnings, ratchet cap 115 -> 99
Wrap ad-hoc fetch closures in useCallback and complete effect deps
(SectionPlatformUsage, usage-metrics, diagnosis, TenantConfigContext,
SignalRContext, dashboard, fleet-health, useProgressSignalR). Convert
sla's initialLoad to a ref so the fresh-once flag can't retrigger the
fetch effect. Document the three intentional live-ref bumps in unmount
cleanups (cancellation counters) with justified disables.

Also fixes a real stale-closure bug: TenantConfigContext's save callback
serialized webhookNotifyOnHardwareRejection without depending on it, so
toggling only that setting saved the old value.

The 3 react-compiler preserve-manual-memoization infos remain (compiler
bailouts, not defects) - cap ratcheted to the exact current count of 99.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 15:10:04 +02:00
Oliver Kieselbach 7636a00e24 web: fix lint regression from Load-all — lift sweep state into useDashboardSessions
The Load-all button reconstructed 'sweep running' locally via a
set-state-in-effect on loadingMore, adding a 116th react-hooks warning
over the 115 cap. The hook already knows loadMore vs loadAll apart, so
expose loadingAll from useDashboardSessions and drop the local state and
effect entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:39:51 +02:00
Oliver Kieselbach 90294455e0 added "Load all" link 2026-08-06 14:26:11 +02:00
Oliver Kieselbach 4def73f193 rules: reserve the built-in rule-ID namespace against tenant custom rules
Tenant rule IDs were free-form with only a point-in-time existence check,
so a customer could occupy a gap or retired ID like ANALYZE-SEC-002; a
later built-in with that ID would then silently shadow the tenant's rule
at merge time (global wins, tenant copy skipped) and their RuleStates
would start toggling our rule.

Enforcement: RuleIdPolicy (Shared) reserves (ANALYZE|GATHER)-<CAT>-<NUM>
case-insensitively, with the CUSTOM category as the sanctioned tenant
namespace (we never ship built-ins in a category named CUSTOM). Both
rule services reject reserved IDs on create AND on the custom-branch of
update (updates upsert, so PUT was a bypass); update endpoints now map
InvalidOperationException to 409 like create. Web forms mirror the check
inline; MCP validate_rule emits a warning finding and the authoring
guide now documents the reservation (guide templates moved to
GATHER-CUSTOM-101/ANALYZE-CUSTOM-101 — the old numeric examples would be
rejected). No existing prod custom rule violates the pattern (verified
against all 13 tenant-partition rows), so nothing needs grandfathering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 14:15:43 +02:00
Oliver Kieselbach 6bab2d3db9 updated command 2026-08-06 13:53:56 +02:00
Oliver Kieselbach 78b8dd0f1b tests: fix ContinuationToken key-rotation flake — scope the test key to the async context
The rotation test swapped the process-wide static test signing key
mid-test; xUnit runs test classes in parallel, so any pagination test
encoding/decoding during that window failed with bad_signature instead
of its expected reject reason (seen as order-dependent one-off failures
in CI). New AsyncLocal-scoped override confines the swap to the
rotation test's own execution context; the process-wide TestSetup
default is untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:51:25 +02:00
Oliver Kieselbach 14acfd5c59 rules: renumber Secure Boot rule to ANALYZE-SEC-008 — SEC-002 is a retired ID
ANALYZE-SEC-002 was the sunset AutoLogon rule; BuiltInRulesTests pins
that the ID never returns (the CI failure on main). Reusing a retired
ID would also inherit any historical tenant state keyed to it. Rule
content is unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:51:16 +02:00
Oliver Kieselbach 22d4c6c677 updated commands 2026-08-06 13:30:29 +02:00
Oliver Kieselbach 9fce499f4c rules: ANALYZE-SEC-002 — warn when Secure Boot is disabled
Fires on secureboot_status.uefiSecureBootEnabled=false; legacy BIOS
devices (registry key absent, agent sets note field) are skipped via
precondition. +20 confidence on physical devices. Validated with MCP
validate_rule (0 findings) and test_analyze_rule dry-runs: fired at 90
on a physical SecureBoot:False session, silent on a SecureBoot:True
session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 13:27:19 +02:00
Oliver Kieselbach 8c7a6410d2 web(mcp-usage): mobile-friendly layout for admin + reporting sections
Headers stack below sm, summary cards go 2-up on phones, top endpoints
show tool name with route as secondary line, and the top-users table is
replaced by stacked rows so requests/last-active are no longer cut off
on narrow screens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 12:01:10 +02:00
Oliver Kieselbach c158768343 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-05 22:55:50 +02:00
Oliver Kieselbach 75197a4184 Logparser pattern testing with the agent's exact .NET engine (test_log_pattern) (#125)
* updated commands

* feat(backend): logparser pattern test endpoint with agent-exact semantics

POST /api/rules/gather/test-pattern evaluates a logparser gather-rule regex
against pasted sample lines exactly the way the agent would on the device.
Motivated by a field incident: a customer verified a pattern with a PHP regex
tester and the .NET engine matched differently.

- CmTraceLogParser moves Agent -> AutopilotMonitor.Shared.Logging so agent
  (IME tracker, logparser collector, stall probe, IME matching mode) and
  backend run the SAME parsing implementation. Pure mechanical move, agent
  behavior unchanged (V2.Core suite 2147/2147 green).
- TestLogPatternFunction mirrors LogParserCollector verbatim: Regex(Compiled,
  1s timeout, NO IgnoreCase -> case-sensitive), first match per line, group
  "0" excluded / unsuccessful groups omitted, format cmtrace (default, match
  against parsed message) vs text (raw line), per-line outcomes incl.
  parse_failed/regex_timeout, and the agent's "set parameter format=text"
  hint verbatim. Pure compute, no tenant data. Caps: 200 lines, 8KB/line.
- Policy: TenantAdminOrGlobalReader (same tier as the analyze dry-run).
- Tests pin the parity showcases: case-sensitivity, envelope invisibility in
  cmtrace mode, optional-group omission, format hint, request validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): test_log_pattern tool + logparser lint and guide hardening

- test_log_pattern: wraps POST /api/rules/gather/test-pattern — tests a
  logparser regex against pasted sample lines with the agent-exact .NET
  semantics (case-sensitive, cmtrace/text mode). Description steers clients
  away from JS/PHP/Python regex testing.
- validate_rule: logparser parameter lint (missing/non-compiling pattern,
  unknown format value = silently cmtrace on the agent) + info pointing to
  test_log_pattern; nextStep for valid logparser drafts routes there too.
- rule_authoring_guide: logparser section now states the matching semantics
  (case-SENSITIVE, .NET, first match per line, cmtrace default) and two new
  pitfalls (engine mismatch, plain-text log under cmtrace default).
- Catalog snapshot + handler/validator tests extended (MCP suite 532 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(okf): test-pattern endpoint + shared CmTraceLogParser in rule authoring surface

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 22:48:10 +02:00
Oliver Kieselbach 179a441e64 updated commands 2026-08-05 20:21:53 +02:00
Oliver Kieselbach acc95471a6 bootstrap(dev): Pester-testable structure + guard/relax test suite + CI
Refactor Install-AutopilotMonitor-dev.ps1 (v2.3-dev) into functions
(Get-CloudPcModel, Get-RealUserProfilePaths, Get-RelaxDecision,
Get-BootstrapDecision with reason codes) with an Invoke-Bootstrap main and
a dot-source entry guard, so the Pester suite can load the logic without
executing the bootstrap. Behaviour when run directly is unchanged.

Fixes a refactoring leak found by smoke-run: Write-Log used Write-Output,
which turned function return values into arrays and swallowed console
output; now Write-Host, guarded by a dedicated logging-purity test.

Add Install-AutopilotMonitor-dev.Tests.ps1 (25 tests: Cloud PC detection,
relax scenarios incl. the W365 field case, guards 1-5, profile collection
with WMI failure path) and .github/workflows/pester.yml running the suite
under Windows PowerShell 5.1 (IME runtime) plus an ASCII guard over all
bootstrap scripts. Tests pass 25/25 under PS 5.1 and pwsh 7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:17:10 +02:00
Oliver Kieselbach 29ced81d6e docs(okf): tool-telemetry — record the real SDK 1.30 rejection shape
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:15:09 +02:00
Oliver Kieselbach 4966d23a46 fix(mcp): rejection sniffer must match the isError-result shape, not a JSON-RPC error envelope
Second live miss, root cause found via end-to-end repro against the real SDK
stack: SDK 1.30 catches every McpError around the tool handler (Zod input
validation, unknown/disabled tool) and answers with a SUCCESSFUL JSON-RPC
response whose result is a CallToolResult { isError: true, text: "MCP error
<code>: ..." } — a JSON-RPC error envelope never exists for these. The sniffer
now recognizes both shapes and extracts the code from the text prefix; handler
soft errors (toolError) never carry that prefix, so they are not double-logged.
Repro verified: the sniffer logs tool_call_rejected against a live
express + StreamableHTTPServerTransport + zod-tool stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:14:21 +02:00
Oliver Kieselbach 59ba8301a8 fix(mcp): rejection sniffer must capture res.write chunks, not only res.end
Live verification caught the miss: tool_call and search_zero_hit lines arrived,
tool_call_rejected did not. SDK 1.30 bridges the web-standard Response through
Hono's getRequestListener, which pipes ReadableStream bodies as Uint8Array
chunks via res.write followed by a BARE res.end() — the end(chunk) inspection
never saw the envelope. The sniffer (now attachToolCallRejectionSniffer in
telemetry.ts, unit-testable) wraps both write and end, reassembles up to 10 KB
and decodes string/Uint8Array chunks; tests pin the streaming path, the direct
end(string) path, the over-cap silence and the disabled no-wrap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:05:22 +02:00
Oliver Kieselbach b142fa0994 updated bootstrapper for W365 devices 2026-08-05 19:49:39 +02:00
Oliver Kieselbach afa8750cbb feat(mcp): tool-call quality telemetry — soft errors, result size, zero-hit searches, arg rejections
withToolTelemetry now receives the tool args and inspects the RESULT: handlers
never throw (toolError returns isError:true), so the previous isError flag was
effectively always false. Each MCP_TOOL_LOGGING line now carries soft-error
status, resultChars + overCap (vs. the anthropic/maxResultSizeChars hint),
a size-bounded args summary and the caller scope — the only telemetry that
covers backend-free tools (search_docs, validate_rule, get_resource).

New signals: search_zero_hit on the three search tools (unmet-demand signal)
and tool_call_rejected for JSON-RPC-level Zod/unknown-tool rejections, sniffed
at the /mcp route since the SDK answers those before any handler runs.

Bicep default toolLoggingEnabled=true (desired state; live flag via
az containerapp update). Docs: docs/mcp/tool-telemetry.md with KQL examples.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 19:16:35 +02:00
Oliver Kieselbach 3c1229a301 MCP rule authoring: deterministic context, validate_rule, and session dry-run (#124)
* feat(backend): dry-run endpoint for draft analyze rules (rule authoring)

POST /api/rules/analyze/dryrun evaluates a DRAFT analyze rule against one
session's events and returns a full diagnostic trace: per-condition
matched/evidence (incl. per-eventType occurrence counts), per-factor
confidence breakdown, verdict, and the would-be MatchedConditions map for
{{token}} interpolation preview. Persists nothing: no RuleResult row, no
session-status change, no notifications.

- RuleEngine.DryRun.cs: DryRunRuleAsync / DryRunEvaluate share the exact
  evaluators (EvaluatePrecondition/EvaluateCondition/EvaluateConfidenceFactor)
  with the production path but evaluate ALL conditions instead of breaking on
  the first failed required one; parity with EvaluateRule is pinned by tests.
- DryRunAnalyzeRuleFunction: TenantAdminOrGlobalReader (read-in-effect POST),
  GA/GlobalReader cross-tenant via session tenant resolution, 1 MB body cap,
  structural draft validation (unknown source/operator, correlation without
  joinField, invalid regex, non-evaluable confidenceFactor DSL) with
  actionable error messages.
- EndpointAccessPolicyCatalog: register the new route.
- RuleEngineDryRunTests: trace semantics, production parity matrix,
  side-effect freedom (VerifyNoOtherCalls), draft validation matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): rule-authoring resources — guide, schemas, guardrails

Deterministic authoring context for customer AIs, replacing chunked
search_docs retrieval for rule creation: get_resource (and the MCP-protocol
resources) now serve

- rule_authoring_guide: hand-written, complete authoring knowledge (collector
  types, triggers, condition sources, operators, confidence model incl. the
  exact factor-condition DSL shapes, {{token}} interpolation order, guardrail
  matching semantics, pitfalls, validate->dry-run workflow)
- rule_schemas: the JSON Schemas (2020-12) for gather + analyze rules
- rule_guardrails: the on-device collection allowlists

Schemas + guardrails are generated into rule-authoring.generated.ts by a new
combine.js emitter (same pattern as the web guardrails mirror; rules/ stays
the single source of truth and CI guardrails-in-sync guards the file).
rule-authoring-drift.test.ts additionally pins generated == repo source,
guide enums == schema enums, and the get_resource size cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): validate_rule + test_analyze_rule tools and author-rule prompt

Closes the authoring loop for customer AIs: draft -> validate -> dry-run
against a real session -> iterate -> create in the portal.

- validate_rule (MCP-local, no backend call): ajv 2020-12 validation against
  the baked schemas, guardrail pre-flight mirroring the agent matching
  semantics (hive-stripped segment-bounded registry/file prefixes,
  whitespace-bounded WMI, exact commands, /-bounded event log channels, hard
  blocks), and semantic lint for schema-valid-but-dead rules (unreachable
  threshold, non-evaluable factor DSL, unknown eventTypes, unanchored
  not_regex allowlists, unresolvable {{tokens}}, trigger cross-fields,
  outputEventType conventions/collisions).
- test_analyze_rule: calls the new POST /api/rules/analyze/dryrun and adds
  the {{token}}-interpolated explanation/remediation preview via the
  existing interpolate-rule-template.
- author-rule MCP prompt guiding scenario -> resources -> draft -> validate
  -> dry-run.
- Both tools are visible to every role (backend enforces
  TenantAdminOrGlobalReader on the dryrun route); catalog snapshot updated.
- deps: ajv + ajv-formats promoted to direct dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(okf): rule authoring surface concept doc

docs/rules/rule-authoring-surface.md: the AI-assisted authoring stack
(deterministic MCP resources via combine.js baking, MCP-local validate_rule
layers, the dry-run trace twin with its parity + side-effect invariants, and
what is deliberately absent); registered in index.md, logged 2026-08-05.

Customer docs (rules/ai-assisted-rule-authoring.md + SUMMARY + FAQ entry) are
committed in the autopilotmonitor-docs repository separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 17:24:28 +02:00
Oliver Kieselbach b4e409783e updated commands 2026-08-04 20:23:21 +02:00
Oliver Kieselbach eb94c612e7 fix(agent): gather phase/event triggers follow the engine-reduced timeline
phase_change/phase_exit/on_event gather triggers (and the phase-scope
gates) were driven by the pre-reduce SignalIngress.SignalPosted stream,
so any raw signal carrying an EspPhase payload fired them before the
decision engine had applied its gates. In session 32312a32 (RealmJoin
tenant) a Phase Start rule on FinalizingSetup fired at the raw ESP-exit
EspPhaseChanged(FinalizingSetup) signal, 7 minutes before the engine's
RealmJoin-gated phase_transition(FinalizingSetup) - and read
HKLM\SOFTWARE\RealmJoin\Custom\BIOS before the RealmJoin package that
creates it had run (exists=false).

GatherRuleExecutorHost now subscribes to the new TimelineEventStream:
EventTimelineEmitter publishes every emitted event's (eventType, phase)
post-reduce, so phase triggers fire exactly when the UI timeline shows
the phase, and on_event sees engine-emitted event types
(enrollment_complete, phase_transition, realmjoin_resolved, ...) that
never existed on the raw signal stream - restoring V1 semantics and the
documented "On Event with enrollment_complete" pattern. Raw-signal
consumers with OS-state semantics (DeviceInfoHost, UserEspKeepAwakeHost,
ProvisioningPackageHost) stay on the raw feed deliberately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.0.1389
2026-08-04 14:44:35 +02:00
Oliver Kieselbach ed5fcf3dc3 chore(deps): bump transitive npm deps to resolve Dependabot alerts
- mcp: hono 4.12.31->4.13.0 (ReDoS in CORS middleware), fast-uri
  3.1.4->3.1.5 (host confusion), ip-address 10.1.0->10.4.0 (SSRF
  bypasses), postcss 8.5.21->8.5.25 (sourceMappingURL file read)
- web: postcss 8.5.22->8.5.25, brace-expansion (DoS, npm audit)

Lockfile-only changes within existing semver ranges.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:57:04 +02:00
Oliver Kieselbach 9ba67162aa fix: session duration inflated by inherited tz-skewed IME log timestamps (#123)
Session df1fcf47 (WG Part 1, real ~1h16m) reported 8h15m: one do_telemetry
event inherited an unrelated, 8h-shifted IME log line timestamp via
LastMatchedLogTimestamp, and the backend re-anchored StartedAt on it.

Three layers:
- Agent: OnDoTelemetryReceived now carries the source-line timestamp; the
  DO-collector poll path passes null and the adapter stamps the clock
  instead of inheriting whatever line matched last.
- Backend: StartedAt alignment and duration-start selection reject
  earliest-event timestamps more than 2h before the current anchor
  (MaxStartedAtBackwardShift), on all four mutation sites incl. the
  SessionsIndex dual-write decision.
- Parser: CMTrace time fields with a UTC-bias suffix ("+480") now parse
  and honor the writer-declared offset instead of failing to match.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
v2.0.1388
2026-08-04 11:54:41 +02:00
dependabot[bot] efc96258d6 chore(deps-dev): bump the web group (#122)
Bumps the web group in /src/Web/autopilot-monitor-web with 2 updates: [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) and [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom).


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

Updates `@types/react-dom` from 19.2.3 to 19.2.4
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.4
  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-08-04 11:19:47 +02:00
Oliver Kieselbach 9d8b0fc254 feat: tenant-config field schema - MCP writes get types right on the first attempt (#121)
Follow-up to the E2E finding (95 vs 95.0): a model driving update_tenant_config
had to guess field names and JSON types from a config read. New reflection-
generated schema surface makes guessing unnecessary:

- Backend: GET config/fields-schema (GlobalAdminOnly) - built from the
  TenantConfiguration model + the patch service deny-lists at runtime, so it can
  NEVER drift from what the patch endpoint enforces. Per field: camelCase name,
  JSON type (string/boolean/integer/number), nullability (null clears), format
  hints (json / date-time), writability + deny reason, GA-only marker (phase 2),
  revert-protected marker. Literal route; FindPolicy literal-over-template
  precedence pinned by test (same mechanism as config/all).
- MCP: new strictGa READ_ONLY tool get_tenant_config_schema; update_tenant_config
  description + GA instructions headline steer the model to call it before
  composing a patch.
- Tests: drift canary (every model property appears exactly once), deny-list
  consistency, type exemplars incl. the decimal SLA column class, policy +
  route-precedence pins, MCP catalog/registration/request-shape. Backend 3854
  green, vitest 465 green, tsc clean.

Deploy order: backend first, then MCP redeploy.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 17:33:04 +02:00
Oliver Kieselbach f3028041af feat(web): tenant list shows plan badge (Community/Pro) instead of Preview
The green "Preview" badge was the activation-gate whitelist marker from the
preview era. With public availability + auto-approve live it carried no
information. Rows now show the plan tier (legacy "enterprise" resolves to
Pro, everything else Community); the amber "Waitlist" badge still appears
for unapproved tenants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 15:50:56 +02:00
Oliver Kieselbach 3628c7bc79 fix(backend): revert rehydration - whole-valued double columns must not become Int32
Prod finding during the first live E2E (gktatooine): revert_tenant_config failed
fail-closed with "could not be rehydrated". Root cause: JSON cannot distinguish
95.0 from 95, so the snapshot rehydrator turned the whole-valued SLA rate columns
(decimal? in the model, double in storage) into Int32 - and TableEntity.GetDouble
throws on the type mismatch. The rehydrator now forces GetDouble for columns the
model declares as decimal/double/float, mirroring the existing DateTime handling.

The roundtrip canary test is hardened into a reflection filler that sets EVERY
writable property non-default (with a deliberately whole-numbered decimal), so no
column type can escape the fidelity check again; plus a focused pin on the exact
prod failure. Suite 3849 green. No data was harmed: the fail-closed guard refused
the write, the snapshot itself is intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:45:11 +02:00
Oliver Kieselbach 395fdd49df Transactional tenant-config writes via MCP + ConfigurationBackups safety net (#120)
* feat(backend): pre-write config backup net - every tenant/admin config save snapshots first

New ConfigurationBackups table (PK=tenantId or GlobalConfig, RK=reverse-ticks+guid,
newest-first) holds the raw stored row as EntityJson before any overwriting save of
TenantConfiguration or AdminConfiguration. Hook lives in TableConfigRepository, the
single choke point all writers funnel through - fail-soft (a backup hiccup never
breaks a save), skips row creations and noise-only changes (LastAuthClientId flips),
prunes to the newest 2 per partition. Source/reason tagging via a new
SaveTenantConfigurationAsync overload (not optional params - Moq expression trees,
CS0854); portal-put/plan/auth/app-homing/offboard call sites tagged.

Offboarding: backup partition wiped in the bulk phase (TenantPartitionTables) and by
the marker-cleanup tombstone sweep; the GlobalConfig partition never matches a tenant
GUID and survives. Table registered in TableNames.All + CriticalBackupTables.All.

Groundwork for the transactional MCP config-write flow (patch/revert endpoints follow).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(backend): transactional tenant-config field patch + revert with verified writes

New GA-only surface (policy catalog: GlobalAdminOnly + RouteParam):
- PATCH config/{tenantId}/fields - field-level patch through TenantConfigPatchService:
  fresh ETag read -> field gate (deny-list incl. plan/trial, HomedAppClientId,
  auth provenance; unknown fields 400 with the name) -> patch onto a clone ->
  shared validation -> fail-CLOSED backup -> CAS replace -> fresh re-read ->
  verify that EXACTLY the intended fields changed -> automatic ETag-conditional
  rollback on drift (catches Store/Map serialization asymmetry).
- POST config/{tenantId}/revert - restore from a ConfigurationBackups snapshot
  (latest by default); snapshots current state first (revert is revertible),
  preserves plan/homing/provenance fields unless includeProtectedFields.
- GET config/{tenantId}/backups - metadata + masked diff only, never EntityJson.

Supporting changes: IConfigRepository gains GetTenantConfigurationWithEtagAsync +
TryReplaceTenantConfigurationAsync (412 -> false, no implicit backup hook);
validators moved from the PUT into shared TenantConfigValidation (PUT re-pointed,
forwarding shims keep test call sites); GET config/{tenantId} honors ?view=redacted
so a GA's MCP session never pulls clear-text secrets into model context.
Caller-tier deny-lists (TenantAdmin/DelegatedAdmin) ship now for the phase-2 flip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): tenant-config write tools - update/revert with snapshot-backed verified writes

First mutating surface on the MCP server, strictGa (real Global Admin) only:
- get_tenant_config: full config read, ALWAYS ?view=redacted (secrets never
  reach model context; pinned by config-write-tools tests)
- update_tenant_config: field-level patch via PATCH config/{tenantId}/fields;
  required reason; response = applied fields + masked diff + backupId
- list_tenant_config_backups: snapshot metadata + masked diff only
- revert_tenant_config: restore latest/named snapshot; protected fields stay
  current unless includeProtectedFields; revert is itself revertible

New MUTATING annotation + TenantGuidSchema (path-traversal guard) in shared.ts;
role-aware instructions headline (non-GA keeps the exact READ-ONLY wording);
catalog snapshot test extends GA_FULL + new CONFIG_WRITE_GA_STRICT difference
list (Global Reader/tenant/delegated see none of the four).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(backend): de-flake session-deletion heartbeat test - condition-gated handler instead of wall-clock window

The heartbeat test held the handler for a FIXED 350ms and expected the 50ms
heartbeat timer to tick >=3 times inside that window. On loaded CI runners the
heartbeat Task.Delay stretches disproportionately to the handler delay, the
handler completes with <3 heartbeats fired, and the awaited condition can never
become true afterwards (CI failure on PR #120). The handler now holds the
message in-flight UNTIL the test observed 3 heartbeats (with ct + own 15s
deadline as termination guards), so the assertion is reached deterministically
regardless of scheduler jitter. Verified: 12/12 local runs green, full suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 14:30:05 +02:00
Oliver Kieselbach 5edb64f3c9 fix(web): confirm-dialog errors surface in-dialog + dark-mode sheet gaps
The GA offboard and app-homing confirm dialogs reported failures via the
admin page-level error banner, which sits behind the tenant editor modal
(z-50) and the dialogs themselves (z-[60]) - the user saw nothing. Both
dialogs now take an error prop and render the failure inline; the section
routes homing/offboard failures into dedicated states cleared on open.

Dark mode: class-by-class audit of the offboard UI against the global
override sheet. The dialogs were already covered; the real gaps were
missing family members in globals.css - border-{red,amber,green,purple}-100,
border-{blue,sky,yellow,rose,emerald}-300, and text-red-600/700 (mapped to
red-400, mirroring the cyan/rose 600-to-400 convention).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:56:17 +02:00
Oliver Kieselbach 46965af290 feat(backend): finalize + arm the offboarding farewell email
Replace the [DRAFT] placeholder with the final copy (thank-you, gentle
feedback pointer to GitHub/LinkedIn, welcome-back note, last-email notice)
and remove the OffboardFarewellEmailArmed scaffolding gate. Tests move from
the disarmed-by-default contract to the armed contract (gate ordering,
never-throw, template pins). GitHub links in both email templates now point
to the renamed repo okieselbach/AutopilotMonitor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 10:20:27 +02:00
Oliver Kieselbach 1bfdb68ff6 Merge branch 'main' of https://github.com/okieselbach/Autopilot-Monitor 2026-08-02 18:02:08 +02:00
Oliver Kieselbach 5ee5114d5c updated modification datetimes page 2026-08-02 18:02:04 +02:00
Oliver Kieselbach a7bb7ebf15 feat: Pro requires a tenant contact address - gated at plan entry, never as a runtime lockout
Backend: POST config/{id}/trial now denies with 409 ContactEmailRequired
when no contact address is stored (extracted EvaluateTrialStart verdict,
order test-pinned: consumed/already-Pro win over the contact prompt). The
gate is live at the API even while the trial CTA stays behind its
"coming soon" flag; GA plan assignment stays unblocked by design. The
feature-flags payload gains contactEmailSet (bool only, never the
address - contract test updated).

Web: trial CTA shows a "set a contact address first" note instead of the
start button when the flag flips on; dashboard gets an amber non-blocking
banner for Pro tenants without a contact (admins only, fail-safe: only an
explicit contactEmailSet:false nags, so loading/error/older-backend states
stay quiet); GA tenant editor warns when assigning Pro to a tenant
without a contact address.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:58:49 +02:00
Oliver Kieselbach a7a1cc43c1 fix(backend): welcome mail survives the auto-approve race - send-on-save + once-only marker
First production auto-approve (mueller-steinag.ch) activated 64s after signup,
5s before the user saved their notification address on the activation page -
the approval path found no address and silently skipped the welcome mail.

Both racing paths now write-then-read: the notification-email save path checks
approval fresh (bypassing the 30s negative cache) and triggers the send when
activation already happened. A conditional-insert welcome-email-sent marker in
PreviewWhitelist makes the send once-only across both paths; it is consumed
strictly after the address check so it can never be burned without an address.
Revoke clears the marker, the GA resend endpoint sends unconditionally and
consumes it best-effort.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:33:59 +02:00
Oliver Kieselbach 2a5ad65dd7 fix(web): explicit JSX spaces where Turbopack eats them - "onlyto" class of typos
Turbopack drops the ambient space between an inline element and adjacent
prose in certain multi-line JSX text layouts (Babel/SWC preserve it), so
the live portal rendered "onlyto reach you", "IsEnabledflag",
"...ps1script", "5 minutesfor GitHub's", "Unavailableand the cascade".
User-reported on the Contact section; the other five sites were found by
scanning the compiled bundle for space-less text nodes after elements.

Fix: explicit {" "} at each affected boundary - it compiles to its own
text-node and cannot be trimmed. Verified in a fresh build: all six sites
now emit ..."})," ","to reach... Sites whose gap comes from ml-*/mx-*
margins or flex gap were left alone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 17:31:04 +02:00
dependabot[bot] 960b3d8ed9 chore(deps): bump github/codeql-action from 4 to 4.37.3 (#119)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4 to 4.37.3.
- [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/v4...v4.37.3)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-02 16:52:33 +02:00
Oliver Kieselbach 86bb255b11 feat(web): GA offboard button in tenant management - with type-to-confirm and ban-semantics note
The admin tenant editor gains a danger zone that queues the existing
offboarding cascade (DELETE tenants/{id}/offboard, policy already admits
GA). Guarded by a type-OFFBOARD confirmation dialog so a stray click can
never trigger it, Global Admin included.

Both the suspension block and the danger zone now spell out the
semantics: suspension is the durable lock-out lever (blocks sign-in and
auto-activation, data stays); offboarding is NOT a ban - the cascade
deletes the config row including any suspension, so a completed offboard
lets the tenant re-onboard and auto-activate on the next sign-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 16:48:53 +02:00