Commit Graph

1896 Commits

Author SHA1 Message Date
Oliver Kieselbach 55744248d5 plan wording retention fix 2026-07-08 21:34:08 +02:00
Oliver Kieselbach 7e68f9d965 fix(metrics): surface terminal Incomplete bucket in usage stats
The 2026-07-08 timeout reclassification (Incomplete = terminal, non-failure)
migrated SessionStats, FleetHealthStats and SessionStatusBuckets, but two
reporting surfaces were overlooked: the live UsageMetricsService.SessionMetrics
(both platform + tenant paths, feeding get_usage_metrics and the usage
dashboards) and the persisted daily UsageMetricsSnapshot. Terminal Incomplete
sessions were counted in Total but in no breakdown bucket, so
Succeeded+Failed+InProgress no longer reconciled to Total and operators lost
sight of those devices.

- SessionMetrics + UsageMetricsSnapshot gain an Incomplete / SessionsIncomplete
  count, mirroring the migrated peers (Incomplete only, not AwaitingUser).
- Snapshot column stored + mapped in TableStorageService.Metrics; legacy rows
  deserialize to 0 (no seed bump needed).
- Success rate unchanged everywhere: denominator was already Succeeded+Failed,
  correctly excluding Incomplete/AwaitingUser. SLA paths verified already-correct
  (BuildSnapshot + breach evaluator receive terminal-filtered sessions).
- Web: Incomplete card added to both usage views (slate, matches the badge).
- MCP: query_raw_sessions status enum learns AwaitingUser/Incomplete so callers
  can filter by the new states.
- Test: UsageMetricsServiceWindowTests pins the Incomplete bucket + 50% rate on
  the crcins 1/1/8 shape.

Backend build clean, full Functions suite 2825 pass, web + MCP tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 21:24:52 +02:00
Oliver Kieselbach a91ad12788 Merge pull request #86 from okieselbach/claude/crcins-enrollment-failures-ac943m
Enrollment status reclassification: timeout != failure (AwaitingUser / Incomplete)
2026-07-08 19:28:10 +02:00
Oliver Kieselbach 419d93a2d8 fix(reclassification): review hardening for timeout status states
Addresses four review rounds on the enrollment-status reclassification
(timeout != failure; new AwaitingUser/Incomplete states).

Backend correctness:
- UpdateSessionStatusAsync ETag force-fallback now mirrors the normal path:
  uses IsTerminalTransitionAllowed + admin-marked guard, stamps CompletedAt
  for Incomplete, and persists FailureReason/FailureSnapshotJson for
  Incomplete/AwaitingUser (not just Failed) so a contended write cannot
  regress a terminal or drop the classification reason/snapshot.
- StoreSessionAsync CAS loop re-reads the terminal tuple in the fresh-read
  window and preserves a just-landed terminal (Succeeded/Failed/Incomplete)
  verbatim, so re-registration can no longer revert it via the Replace.
- SessionGraceHours + AbsoluteMaxSessionHours now roundtrip through the
  tenant-config Store/Map pair (overrides were silently lost).
- ResolveGraceHours clamps the assumed agent cap to >= the real runtime
  default (48h) until the agent honors the override, so a low override can
  never terminalize a still-running session early.
- Incomplete now funnels through the terminal counter-reconcile seam, and
  the maintenance sweep re-reconciles after the synthetic session_timeout
  event (also fixes the pre-existing off-by-one on the Failed path).

Web:
- SessionInfoCard delegates its status pill to the shared SessionStatusBadge
  (removes a divergent local map; picks up AwaitingUser/Incomplete).
- SessionTable search color map + status filter chips learn the new states.
- useSessionEvents uses isTerminalStatus() instead of a hard-coded
  Succeeded/Failed check (no needless refetch on Incomplete).
- DataManagementSection copy no longer says "Failed - Timed Out".

Docs/comments:
- Plan doc + TenantConfiguration comments corrected (+3h/51h buffer, and the
  timeout now reclassifies to AwaitingUser/Incomplete, not "Failed").

Tests: +ResolveGraceHours clamp case, +grace/abs-cap config roundtrip +
legacy-row defaults. Full backend suite green (2712), web tsc green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 19:26:41 +02:00
Oliver Kieselbach 33618e183d feat(wu-oobe): detect & surface Windows Update during OOBE + self-maintaining rule sunset
Windows Update activity during Autopilot OOBE/ESP is a blind spot no other tool
(Intune console included) surfaces well — a cumulative update installing mid-OOBE
can break enrollment (r/Intune KB5095189). This makes it visible with secured
evidence.

Agent (net48):
- WindowsUpdateTracker + WindowsUpdateWatcherHost: watch
  Microsoft-Windows-WindowsUpdateClient/Operational (EventIDs 19/20/43/44 =
  success/failure/install-started/download-started), startup backfill (OOBE updates
  run before the agent starts), RecordId watermark dedup (restart boundary vs
  intra-run seen-set so a high-id live event can't hide older backfill events),
  HRESULT->symbol decode. Emits windows_update_succeeded/_failed/_started with the
  real event Timestamp. Default-on config knobs; uncertain EventIDs configurable
  without redeploy.
- RegistryCollector: emitOnlyIfExists (suppress positive-assertion events when the
  key is absent) + Registry64 base view by default (bitness-independent, matches
  TenantIdResolver) with optional registryView override.

Rules:
- ANALYZE-DEV-004 (high: WU failed during enrollment, high-insight not KO) +
  ANALYZE-DEV-005 (info: WU installed). reboot_pending corroboration matches
  event_data exists==true.
- GATHER-DEVICE-005 -> windows_update_reboot_pending (emitOnlyIfExists),
  GATHER-DEVICE-004 -> windows_update_history (Get-HotFix, opt-in).
- 5 EventTypes synced across Constants + MCP resource-catalog + web eventTypes.

Self-maintaining rule lifecycle (analyze + gather parity):
- Gather rules gain the sunset/orphan-GC that only analyze had
  (ProcessSunsetGatherRuleAsync + runtime filter + seed/reseed/github paths).
- RuleProvenance field (embedded/github, null=embedded back-compat): the embedded
  catalog only sunsets its own rules; GitHub-reseeded rules ahead of the deployed
  binary (new ids AND content updates to existing ids) are exempt and reclaimed to
  embedded once the binary catches up. No orphans, no operator knowledge required.

Verified: full solution builds; backend suite 2770 pass, agent suite 1638 pass;
rules AJV-valid + combined.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:22:41 +02:00
Claude ff8a442195 docs: correct plan — Fleet Health Incomplete done in PR5 2026-07-08 15:39:27 +00:00
Claude 824ef9a19c feat(agent): Part C — best-effort emergency-break report [net48, CI-verify]
Make the agent's silent 48h absolute session-age break visible: on break, before
cleanup/exit, fire a SessionAgeEmergencyBreak AgentErrorReport over the resilient
emergency channel; the backend (Part B) materializes it as an agent_emergency_break
timeline event and the timeout classifier terminalizes the session instead of
waiting out grace.

Decoupled so the guard stays pure and its unit tests are untouched:
- CheckSessionAgeEmergencyBreak gains optional Action onBreakFired = null, invoked
  once when the break is confirmed, before marker/cleanup, fully swallowed.
- New EmergencyBreakReporter.TrySend builds a throwaway auth bundle
  (BackendClientFactory) and sends, bounded to a 5s wait. AgentBootstrap wires it;
  GetAgentVersion made internal.

NOTE: agent targets net48 and was NOT compiled in this environment (only the .NET 8
SDK is available) — to be built/verified locally. The Shared AgentErrorType value is
verified (compiled in the backend build).
2026-07-08 15:36:53 +00:00
Claude a2a5d2fc0e feat(web): PR5 — surface Incomplete/AwaitingUser in the UI
- sessionStatus.ts: isTerminalStatus includes Incomplete.
- SessionStatusBadge: Incomplete = neutral slate (clearly not a red failure),
  AwaitingUser = sky/blue 'still going'; clock affordance extended to Incomplete.
- Fleet Health: FleetHealthStats.Incomplete (model + BuildFleetHealthPayload) +
  web interface + a dedicated slate Incomplete stat card (FleetStatCard gained a
  'slate' color), grid widened to 5.
- Main dashboard KPI row unchanged (its Success Rate is already terminal-only/honest).

Verified: web tsc --noEmit clean; backend Functions.Tests 2709 pass.
2026-07-08 15:30:47 +00:00
Claude 96ad65f715 feat(backend): PR4 — surface Incomplete as a third state, honest failure rate
- SessionStatusBuckets: explicit AwaitingUser + Incomplete buckets (out of Other),
  still reconciling to Total.
- Metrics summary: expose awaitingUser + incomplete; failure rate is now
  Failed/(Succeeded+Failed) — Incomplete and non-terminal states are out of the
  denominator (crcins drops from failed/total to the real terminal rate).
- SessionStats.IncompleteLastNDays; dashboard SuccessRatePct was already terminal-only.
- Daily aggregation already used Succeeded+Failed denominator — unchanged.
- Tests: SessionStatusBucketsTests. Suite: 2705 pass. Fleet Health Incomplete surfacing
  deferred to PR5 (web) with its documented over-all-sessions semantics.
2026-07-08 15:17:35 +00:00
Claude c0585133be feat(backend): PR3 — reconcile late completions to Succeeded
A device that reaches the desktop is enrolled regardless of an earlier sweep
timeout guess, so a genuine completion must be able to heal a prior verdict.

- Replace the terminal idempotency guard with pure IsTerminalTransitionAllowed:
  Succeeded UPGRADES any non-Succeeded state (Failed/Incomplete/AwaitingUser →
  Succeeded); Failed/Incomplete and AwaitingUser never overwrite an existing
  terminal. Ingest already calls UpdateSessionStatusAsync(Succeeded) on a late
  enrollment_complete without a pre-terminal skip, so the relaxed guard wires the
  reconcile end-to-end.
- Clear stale FailureReason + FailureSnapshotJson on Succeeded (reconcile hygiene).
- Preserve admin-marked terminals: a late completion never auto-overrides a
  hand-marked Failed/Incomplete.
- Tests: SessionStatusTransitionTests (full matrix, 20). Suite: 2705 pass.
2026-07-08 15:11:52 +00:00
Claude 4749c21d4e feat(backend): Part B — materialize agent_emergency_break timeline event
ReportAgentErrorFunction now injects ISessionRepository and, on a
SessionAgeEmergencyBreak report, writes an agent_emergency_break EnrollmentEvent
into the session stream (Sequence one past the last event, idempotent, best-effort
— never turns the always-200 emergency channel into a retry loop). This is what
makes the agent's otherwise-silent 48h break visible in the timeline and lets the
timeout classifier see it. Pure BuildAgentEmergencyBreakEvent helper (Warning,
non-terminal) + tests. Functions.Tests: 2685 pass.
2026-07-08 15:03:42 +00:00
Claude ebc9a19857 docs: plan the agent emergency-break event (backend materialize + agent emit) 2026-07-08 14:56:16 +00:00
Claude a6f328b4c2 feat: agent_emergency_break signal — classifier consumes it (skip grace)
Groundwork for making the agent's silent 48h emergency break transparent
(docs/design/enrollment-status-reclassification.md):
- Constants.EventTypes.AgentEmergencyBreak + AgentErrorType.SessionAgeEmergencyBreak.
- EnrollmentTimeoutClassifier: ExtractRollup.HasAgentEmergencyBreak; classify a
  session carrying the break as Incomplete NOW (agent is gone) instead of holding it
  as AwaitingUser through the grace — a real completion still wins. Tests added (22 total).

Next: agent best-effort emit at the break + backend materializes the timeline event.
2026-07-08 14:54:55 +00:00
Claude deb28d1b49 fix(backend): shrink timeout grace buffer 12h→3h (48h cap → 51h grace)
A completion can only arrive <= the agent's absolute cap (it self-destructs and
sends nothing after), so the grace buffer needn't wait hours for stragglers — it
only has to span one 2h maintenance-sweep cycle + minor clock skew, and reconcile
heals any premature Incomplete. Buffer 12→3h, default grace 60→51h.
2026-07-08 14:49:47 +00:00
Claude 576fc2ff84 fix(backend): derive timeout grace from the agent's absolute session cap
The 5h→terminal grace was a magic 72h. Anchor it to the agent instead: the 6h
per-run brake emits enrollment_failed (→ Failed at ~6h), but the 48h absolute
emergency break (AbsoluteMaxSessionHours) is SILENT to the backend — it writes a
local marker, cleans up and exits with no terminal event. So a session only stays
non-terminal past 48h when the agent hit that silent cap and is provably gone.

Grace must therefore be >= the agent absolute cap (never mark Incomplete while the
agent could still legitimately enroll) and only slightly beyond it:
  effectiveGrace = max(AbsoluteMaxSessionHours + 12h buffer, SessionGraceHours) = 60h default.

- EnrollmentTimeoutClassifier.ResolveGraceHours (pure, tested): floors grace at
  agentCap + buffer; a SessionGraceHours override can only raise it.
- TenantConfiguration.SessionGraceHours default 0 (= auto-derive); mirror
  AbsoluteMaxSessionHours so the floor follows a tenant override of the agent cap.
- Sweep uses ResolveGraceHours instead of the hard-coded 72h.

Follow-up noted: wire AbsoluteMaxSessionHours down into AgentConfigResponse so the
agent and backend share one value. Functions.Tests: 2678 pass.
2026-07-08 14:41:54 +00:00
Claude 56d378f2b2 feat(backend): PR2 — reclassify timed-out sessions in the sweep
Wire EnrollmentTimeoutClassifier into MarkStalledSessionsAsTimedOutAsync so the
5h inactivity sweep stops blindly failing silent-but-provisioned sessions
(docs/design/enrollment-status-reclassification.md). Forward-only.

- TenantConfiguration.SessionGraceHours (default 72h) + default config.
- Sweep Stage 2: classify from the ESP subcategory rollup instead of hard Failed.
  Fast-path skips within-grace AwaitingUser sessions with NO event read (grace is
  time-based; late completions are caught by ingest). Per-outcome counters; audit
  + ops event only when the pass actually terminalized/transitioned something.
- session_timeout event + auto-analyze kept ONLY for Failed/Incomplete (parity with
  the agent max-lifetime path); AwaitingUser is non-terminal, Succeeded is a reconcile.
- GetStalledSessionsAsync includes AwaitingUser so it graduates to Incomplete once
  grace elapses; agent-silent (Stage 1) stays InProgress-only.
- UpdateSessionStatusAsync: Incomplete is terminal (idempotency); AwaitingUser can't
  regress a terminal; CompletedAt stamped for Incomplete (no skewing duration); reason
  + snapshot persisted for Incomplete/AwaitingUser.

Full Functions.Tests suite: 2670 pass.
2026-07-08 14:17:18 +00:00
Claude 3c254e6e03 feat(backend): PR1 — timeout classifier + AwaitingUser/Incomplete states
Foundation for reclassifying 5h-timeout sessions (docs/design/
enrollment-status-reclassification.md). No behaviour change to the sweep yet —
this only adds the pieces PR2 wires in.

- SessionStatus: append AwaitingUser (non-terminal) + Incomplete (terminal,
  non-failure). Members appended so persisted ordinals never shift.
- EnrollmentTimeoutClassifier: pure ExtractRollup (parses the esp_provisioning_status
  "N of M subcategories completed" message + the 30s all-succeeded fallback line —
  categorySucceeded is ignored, Windows never sets it) and ClassifyTimedOutSession
  implementing the decision table (explicit failure→Failed; AccountSetup all-succeeded
  / enrollment_complete→Succeeded; DeviceSetup done + within grace→AwaitingUser, else
  Incomplete; silent pre-DeviceSetup→Incomplete). Never returns Failed without an
  explicit failure signal.
- FailureSnapshotBuilder: embed the same rollup (schema v2) so the snapshot records the
  evidence behind the verdict.
- Tests: EnrollmentTimeoutClassifierTests (12). Full Functions.Tests suite 2670 pass.
2026-07-08 14:05:57 +00:00
Claude a561f56a2a docs: propose enrollment status reclassification (timeout ≠ failure)
Design note + implementation plan for splitting the 5h maintenance-sweep
"Failed" bucket into three states. Evidence (crcins.com, 90d): 90.7% of
"failures" are 5h inactivity timeouts with no explicit failure event; 99.1%
of those had DeviceSetup fully provisioned (device enrolled) and simply had
the agent go silent during the user/Account-Setup phase. True failure rate
~4-5%, not 57.2%.

Proposal (backend + stats only, no agent change / no heartbeat):
- add AwaitingUser (non-terminal) + Incomplete (terminal, non-failure) states
- classify timed-out sessions from the ESP subcategory rollup the agent
  already emits (gate on subcategories, not the unreliable categorySucceeded)
- passive grace window owned by the backend; reconcile late completions to
  Succeeded; exclude Incomplete from the failure-rate denominator
2026-07-08 13:51:48 +00:00
Oliver Kieselbach 5aa68553d2 test: extract pure seams for MDM cert selection + JWT validation, then cover them
Two security-critical paths had zero unit coverage because the logic was inlined
behind infrastructure (a real X509Store / a live OIDC ConfigurationManager +
HttpContext). Extract the decision logic into pure, testable seams — no
behavioural change — and pin it.

CertificateHelper (Agent):
- Extract SelectMdmCertificate(IEnumerable<X509Certificate2>): the issuer
  discrimination (CN=Microsoft Intune MDM Device CA only, never the MMP-C CA),
  ClientAuth-EKU filter and NotAfter tiebreaker. FindMdmCertificate now calls it.
- 13 tests incl. the dual-stack regression (issuer filter beats the NotAfter
  tiebreaker so a longer-lived MMP-C cert can't win over a genuine MDM cert).

AuthenticationMiddleware (Backend):
- Extract BuildTokenValidationParameters(signingKeys, clientId), IsValidTenantId(tid)
  and BuildTenantAuthority(issuer, tid). Invoke() now composes them.
- 16 tests validating locally-minted tokens against a test key: alg:none and HS256
  rejected (algorithm-confusion defence), expired / wrong-audience / foreign-issuer
  / unknown-signing-key rejected, valid RS256 + api:// audience + v1 issuer accepted,
  plus the tid GUID gate and v1/v2 authority mapping.

Backend 2746/2746 and Agent 1615/1615 green; full solution builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 15:45:05 +02:00
Oliver Kieselbach 24b3bc102d test: collapse literal-only Fact clusters into Theories
Six existing test classes had clusters of near-identical [Fact] methods that
differed only by input/expected literals. Convert each cluster to a single
[Theory], preserving every case (and its explanatory comment) as an InlineData/
MemberData row. Pure refactor — no behavioural change, no coverage lost.

- DeviceInfoProviderVmDetectionTests: 18 Facts -> 1 Theory (IsVirtualMachine)
- RateLimitResolverTests: 12 Facts -> 2 Theories (device + user resolve)
- SecurityValidatorTests: IsValidGuid cluster (10 Facts) -> 1 Theory
  (WithNewGuid stays a Fact — dynamic input; OData-injection Theory untouched)
- CpeUriNormalizerTests: 8 Facts -> 1 Theory (Normalize)
- SignalRGroupHelperTests: ExtractTenantId cluster (6 Facts) -> 1 Theory
  (MemberData; group names interpolate the const GUIDs. Access-gate Facts kept)
- VersionComparerTests: IsVersionAffected cluster (6 Facts) -> 1 Theory

Backend 2730/2730 and Agent 1607/1607 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 14:02:43 +02:00
Oliver Kieselbach 270f3febb9 test(agent): fix CmTraceLogParser BOM expectation — parser is BOM-tolerant
The malformed-input theory asserted a BOM-prefixed CMTrace line yields false,
assuming a leading U+FEFF breaks the StartsWith gate. It doesn't: culture-
sensitive string.StartsWith treats U+FEFF as ignorable so the gate passes, and
the unanchored regex matches the "<![LOG[..." body after the BOM — the line
parses (which is desirable; Windows writes a BOM on freshly-opened log lines).

Move the BOM case out of the malformed theory into a dedicated fact that pins
the BOM-tolerant behaviour. Product code unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 13:33:46 +02:00
Oliver Kieselbach 8246789d5d Merge pull request #85 from okieselbach/claude/test-coverage-review-3sjz8o
Add comprehensive security-hardening test suites
2026-07-08 13:15:01 +02:00
Claude 38e392cfa0 test: harden security + malformed-traffic coverage across MCP, Web, Backend, Agent
Adds targeted tests for the previously-thin attack surfaces identified in the
coverage review. New tests use [Theory]/[MemberData]/it.each so input matrices
run efficiently.

MCP (verified green):
- tools-raw-adversarial: query_table path-traversal containment (encodeURIComponent),
  continuation cross-tool-substitution guard, OData/KQL verbatim-forwarding trust
  boundary, oversized-query passthrough. Runs in CI (unit, unlike the token-gated
  integration suite).

Web (verified green):
- go/[code] route handler: code-format gate, backend-error passthrough, errorScript
  quote-escaping + length cap, and "never leak the offending value" on validation fail.

Backend (mirrors sibling tests; CI builds .NET 8):
- AuthenticationMiddleware: JWT-exempt-route decision via SkipsJwtValidation seam
  (exempt vs required, fail-closed, /api/ normalization, method-aware).
- AppInsightsQueryFunction: KQL proxy authorization gate (GlobalAdminOnly, off the
  read-only reader tier, POST-only fail-closed).
- IngestTelemetry auth path: X-Tenant-Id gate, tenant/PartitionKey decision,
  410 deletion-lock via real SessionDeletionGuard, TryReadSessionStatus.
- IngestTelemetry malformed bodies: JSON-bomb (MaxDepth 64), non-UTF8, BOM strip,
  multibyte round-trip, content-type-agnostic parse, broken-root JSON.

Agent (mirrors sibling tests; CI builds .NET 8):
- RemoteConfigService cache hardening: security-sensitive fields stripped on write
  AND read (OOBE-MITM persistence defense), in-memory object restored.
- CmTraceLogParser: malformed/truncated/BOM/junk lines return false without throwing,
  UtcNow timestamp fallback, oversized line.
- ImeLogPattern ReDoS: invalid backend regex swallowed, valid sibling still fires,
  catastrophic backtracking hits the real 1s MatchTimeout.
- CertificateHelper.IsCertificateValid validity-window/null (FindMdmCertificate needs
  a pure seam to be fully testable — noted in file header).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sfnasfv2RgyMAR55cvZFjB
2026-07-07 21:33:57 +00:00
Oliver Kieselbach 88a432dcd4 fix(mcp): restore home-tenant access for delegated (MSP) admins
A delegated admin who is also a member/operator of their own home tenant
lost MCP access to it: any non-empty delegated scope flipped the whole
session to delegated cross-tenant routing, and enforceDelegatedTenant
rejected every tenant outside the managed set — which never contains the
home tenant (GetScopeAsync uses the home tid only as the Enterprise
entitlement gate, never adds it to the scope).

Fix is MCP-only (no backend change; the backend already authorizes the
home tenant via MemberRead and bounds the /api/global/* drill away from
it, so the home tenant must route to the JWT-bound member path):

- access-guard.ts: thread homeTenantId (JWT tid, lowercased) into the
  caller context.
- client.ts: getHomeTenantId(); enforceDelegatedTenant accepts
  managed union {home}; enforceDelegatedTenantForPage resolves a
  non-global tenant-path nextLink (no embedded tenantId) to home so
  page-2 re-selects the member path; pickGlobalOrTenantPath gains an
  effectiveTenantId arg and routes delegated+home to the tenant path.
- 16 pattern-A tool call sites pass the effective tenantId (not
  devices/blocked, which is platform-only).
- shared.ts / index.ts: descriptions + instructions surface the home
  tenant as an addressable target.

The home tenant is accepted optimistically at the MCP boundary; the
backend MemberRead stays authoritative (a non-member is denied on the
member path, so no leak). Continuation defense-in-depth preserved.

Verify: tsc clean; vitest 352 passed (+7 home-tenant cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 16:49:50 +02:00
Oliver Kieselbach 9dfa3b9bb9 updated layout for plans and sidebar navigation added 2026-07-07 15:17:21 +02:00
Oliver Kieselbach 4d55b837c2 added Plans 2026-07-07 14:54:10 +02:00
Oliver Kieselbach e668c8cfe5 Merge pull request #84 from okieselbach/feature/entitlement-layer
feat(entitlements): Community/Enterprise editions with self-service trial
2026-07-07 13:57:00 +02:00
Oliver Kieselbach 49fe4460e4 docs(entitlements): MCP quota boundary is deliberately soft - correct claim + pin with tests
Codex follow-up: the previous middleware comment claimed a deterministic limit+1 block, but McpQuotaService caches the per-user decision for 60s - an allowed decision keeps admitting inside that window even after the async increments cross the limit. The soft boundary IS the intended design (bounded overshoot ~TTL x request rate per instance, same posture as the sliding-window rate limiter; one counter read per user per minute instead of per request).

- Middleware doc rewritten: check-then-increment fixes the race/inflation, but the boundary is explicitly documented as SOFT with bounded overshoot; warns against re-documenting as exact without reworking the decision cache.
- Two regression tests pin the contract: cached allowed decision keeps admitting within the TTL even after counters cross the limit (no storage re-read), and after cache expiry (simulated via eviction) the over-limit counters block.

Backend 2658/2658 green.
2026-07-07 13:50:31 +02:00
Oliver Kieselbach dfa0fe27c1 fix(entitlements): address Codex review findings
- High: TenantConfigurationService.SaveConfigurationAsync now THROWS when the repository reports a failed persist (it swallows storage exceptions and returns false) - plan/trial/config writes can no longer be audited and returned as 200 without being persisted. Cache is invalidated on every attempted save (finally), covering in-place mutations of the cached instance; the now-redundant invalidate-on-failure wrapper in PlanManagementFunction is removed. AuthFunction login side-effect saves (first-login domain write, auto-re-enable) are wrapped best-effort so a storage blip cannot 500 auth/me - they self-gate and retry on next login.
- Medium: MCP usage increment moved from AuthenticationMiddleware (fire-and-forget BEFORE the quota decision) into McpQuotaEnforcementMiddleware: strictly check-then-increment and only for requests actually served (GA tracked but quota-exempt; denied 403/429 requests no longer inflate counters; the exact-limit request is now deterministic - 1..limit pass, limit+1 blocks).
- Low: fixed both new nullable warnings (PlanTierDefinitionParser guarded json, McpQuotaEnforcementMiddleware principal null-flow).

Tests: +3 save-semantics regression tests (throw on false, cache invalidation on failed/successful save). Backend 2656/2656 green, build clean.
2026-07-07 13:38:00 +02:00
Oliver Kieselbach d8e8cd15fd feat(entitlements): Community/Enterprise editions with self-service trial
Fail-closed entitlement layer (FeatureEntitlementCatalog + TenantEntitlementService): edition resolves at read time (PlanTier == 'enterprise' OR active trial; legacy free/pro => Community, no migration).

- Plan/trial endpoints: PATCH config/{tid}/plan (GA: tier + grant/extend/end trial, audited, cache-safe) and POST config/{tid}/trial (tenant-admin self-service 30d, once per tenant, 409 after). Generic PUT preserves plan/trial fields for all callers. Fixes SetPlanTier writing past the config cache without audit/UpdatedBy.
- Rate limits: Enterprise floor 150/min for BOTH user (portal/JWT) and device (agent/cert) paths - floor raises the global default, an explicit per-tenant override still wins; MSP users follow their home tenant.
- Retention cap: Community 90d / Enterprise 365d, clamped in the retention fanout (warn log) + non-GA write validation 7..cap (only on changed values; 0=infinite stays GA-only).
- Delegated admin (MSP): Enterprise gate on the delegated admin's HOME tenant (JWT tid, fail-closed on unknown) - managed target tenants may be any edition. Grant-time target check removed; inert grants are suppressed at resolve time (single choke point in GetScopeAsync, applied after the UPN-keyed cache).
- MCP quota: McpQuotaService + McpQuotaEnforcementMiddleware (X-Client-Source: mcp only, GA exempt) enforce daily/monthly budgets - per-user plan override > tenant edition plan; limits from SectionUsagePlans with catalog fallbacks (community 100/3000, enterprise 1000/20000); 429 with X-MCP-Quota-* headers + Retry-After; fail-open on counter reads, fail-closed on plan resolution. /api/metrics/mcp-usage/me reports effective plan + counters.
- Trial visibility: TrialExpirySweepFunction (daily 03:30 UTC, informational) emits TenantTrialExpired (Warning, 24h look-back) + TenantTrialExpiring (Info, <=3d heads-up); dual-registered in web OPS_EVENT_TYPES.
- Web: EditionBadge + one-time trial CTA in settings header, Plan & Trial card in the GA tenant edit modal (own PATCH save), retention cap hints from entitlements, pure lib/edition.ts (vitest), feature-flags surface edition/trial/entitlements; MCP TENANT_SAFE_FIELDS + trial fields.

Verified: solution build clean; backend 2653/2653 tests; web tsc + 516/516 vitest; MCP server tsc + tests green.
2026-07-07 13:06:04 +02:00
Oliver Kieselbach 4a33b96db2 feat(ratelimit): unify device+user limits as global default + per-tenant override
Replace the denormalized per-tenant RateLimitRequestsPerMinute mirror (and its
SaveConfigurationAsync sync job) with compute-at-read resolution: effective limit =
per-tenant override ?? global default. Fixes the clobbering bug where a per-tenant
edit to the base field was overwritten on the next global config save.

- Model: drop TenantConfiguration.RateLimitRequestsPerMinute; add nullable
  CustomUserRateLimitRequestsPerMinute (device override CustomRateLimit... kept).
- RateLimitResolver centralizes precedence (device + user); both paths call it.
- SecurityValidator reads the global default (AdminConfigurationService threaded
  through ValidateSecurityAsync + all device-auth callers); 5-min cached.
- UserRateLimitMiddleware: per-tenant user override for tenant-scoped callers only
  (skipped for HasGlobalScope = GlobalAdmin|GlobalReader); GA -> GA budget, others ->
  user default. Uses GetConfigurationIfExistsAsync so an unregistered-tenant JWT can
  no longer auto-create a config row via read endpoints.
- Validation: reject rate limits < 1 at the write functions; RateLimitService clamps
  < 1 to 1 as a safety net (avoids Min()-on-empty / throttle-everything).
- Web: Global Settings exposes User + GlobalAdmin limits; tenant modal writes the
  override fields (blank = inherit); report + usage-plans updated.

Legacy RateLimitRequestsPerMinute Table column is ignored on read and dropped on the
next Replace save (self-cleaning; no migration).

Backend 2577 xUnit green, tsc clean, web 506 vitest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 11:26:29 +02:00
Oliver Kieselbach 96d2728dc9 cleanup 2026-07-06 21:50:51 +02:00
Oliver Kieselbach 8f7048c615 docs: trim architecture.md to overview + solution structure
The detailed per-component chapters (Agent internals through Build & Development)
are retired — product documentation lives at docs.autopilotmonitor.com now, and
the deep internals went stale faster than they earned their keep. What remains
is corrected against the actual tree: Shared/DecisionCore and SummaryDialog.Tests
added, src/McpServer and rules/guardrails.json were missing entirely, scripts/
gained Backup + CustomerSetup, Functions references DecisionCore too, and the
overview diagram no longer claims the web app serves docs.

docs/images stays — all five files are embedded in the repo README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:39:29 +02:00
Oliver Kieselbach 52256c9be0 Update README.md 2026-07-06 20:53:00 +02:00
Oliver Kieselbach 3e4a1d7745 docs(readme): green brand logo in front of the title
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:27:20 +02:00
Oliver Kieselbach ffb0bc1019 feat(web): local dev against deployed API without prod config changes
- msalConfig: NEXT_PUBLIC_ENTRA_API_CLIENT_ID splits the API scope from
  the SPA client id, so local dev signs in with the dev app registration
  while requesting the prod API audience. Unset in prod (fallback keeps
  the single-app behavior).
- next.config: dev-only rewrites() proxy forwards /api/* server-side to
  DEV_API_PROXY_TARGET, keeping the browser same-origin — no localhost
  CORS entry on the prod Function App.
- CSP: script-src gains unsafe-eval only when NODE_ENV=development
  (next dev HMR/react-refresh needs eval); production CSP unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:24:14 +02:00
Oliver Kieselbach 0f12ca6531 feat(web): new green brand icon + gray wordmark in portal navbar
Replace the favicon (app/icon.svg) with the new green glyph and swap the
portal navbar logo from the blue gradient box to the bare green icon with
the wordmark in sidebar gray (text-gray-600 / dark:text-gray-400). Both
portal navbar variants updated; the public landing navbar keeps the old
gradient branding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 20:24:14 +02:00
Oliver Kieselbach 9ecf5db274 feat(web): retire the roadmap page
Route, nav/footer/about links, sitemap and robots entries removed; /roadmap
temporarily redirects to / (non-permanent — a roadmap may return under the
docs site later). Verified: tsc clean, 506 tests green, production build ok.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:31:00 +02:00
Oliver Kieselbach b054537ad5 feat(web): docs cut-over — documentation moved to docs.autopilotmonitor.com
- Remove app/docs (15 sections incl. agent-internals, sidebar, gates, registry)
  and app/changelog; drop the now-unused private-preview.png
- Permanent redirects in next.config.ts: per-section /docs/* mapping to the
  matching GitBook page, /docs catch-all, /changelog -> platform changelog
- All navigation and deep links point at the GitBook site (public navbar,
  authenticated navbar help menu + overflow, global sidebar, landing footer,
  about cards, preview page, dashboard banner, WelcomeMessage, analyze-rules
  and gather-rules helper links) — external links open in a new tab
- SEO: sitemap no longer emits /docs/* or /changelog, robots allow-list
  trimmed; old URLs 308-redirect so link equity moves to the docs domain

Verified: tsc clean, 506 web tests green, production build succeeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:12:31 +02:00
Oliver Kieselbach 83e4c309bc feat(web): unify brand to "AutopilotMonitor" and pipe title separator
Match the GitBook docs site branding: the visible brand name is now
"AutopilotMonitor" (no whitespace) and browser-tab titles use
"Page | AutopilotMonitor" instead of an en-dash. Tagline titles keep
the en-dash; descriptions and SEO keywords keep the spaced spelling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 16:49:40 +02:00
Oliver Kieselbach a70785fb42 feat(rules): built-in analyze-rule hygiene pass ahead of public docs catalog
- Retire ANALYZE-ESP-003 (duplicate of ESP-001) and ANALYZE-CORR-002 (duplicate
  of APP-004) via the established sunset path; ESP-001 gains a >60min factor,
  APP-004 absorbs the co-occurrence story with working confidence factors
- CORR-003: download evidence (errorPatternId) is now a required condition —
  no more firing on proxy + any unrelated app failure
- DEV-002 v2: 'sustained' semantics — new event_count value filter
  (filterField/filterOperator/filterValue in schema, Shared model, engine, web
  types) requires >=3 snapshots above 90% memory instead of one transient spike
- DEV-003 v2: build-cutoff-only condition (<=22621) now also covers Windows 10;
  LTSC caveat + maintenance note (raise to 22631 after 2026-11-10)
- SEC-001 v2: rewritten for the post-June-2026 reality — certificates HAVE
  expired, active remediation required (OEM firmware hint added)
- SEC-005/006 v2.1: allow-list extended with OEM factory-preload families
  (Intel PPM, AMD Power, Lenovo ThinkPad power/battery, Surface/carrier),
  telemetry-curated from 1780 fired sessions; imaging leftovers (USMT.PPKG,
  InstallOrder.ini) and generic names deliberately still alert
- APP-001: suppressByEvent on app_install_completed; APP-002: exact MSI exit
  codes instead of bare 16xx; APP-006 reframed as 'App Install Retry Loop';
  ID-001: title/logic mismatch fixed + GATHER-ID-002 dependency documented
- Fix all 13 dead confidence factors (signal-name mismatches / unsupported
  syntax) by rewiring or removal; version bumps everywhere so seeding
  propagates the updates
- New RuleEngineEventCountFilterTests + SEC-005 OEM allow-list coverage

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:32:32 +02:00
Oliver Kieselbach fe9ffc3f67 fix(security): backend-review Lows — SignalR revoke enforcement, revoke audit integrity, casing
Revoke enforcement (L4): a revoked delegated (MSP) user kept receiving pushes on
already-joined SignalR groups until the connection dropped (join-time-only
authorization). Negotiate now goes through the Management SDK and binds the
client token to a SignalR user id (lowercased UPN); new DisconnectUserAsync
strips the user from all groups server-side and pushes "accessRevoked", on
which the web client restarts its connection so every re-join re-runs
authorization against the fresh scope. Wired into delegated revoke/disable and
tenant-group unassign / remove-tenant / delete-group. Residual (documented): a
tampered client ignoring the push, and connections negotiated before this
deploy (no user id), keep streams until the connection drops.

Revoke audit integrity (L5): POST /global/delegated-admins now requires a GUID
tenantId (mirrors the tenant-group path), and DELETE reports 404 for an
assignment that never existed instead of returning 200 and writing a false
customer-visible "access removed" audit row (repo delete now checks the 404
response of the idempotent DeleteEntityAsync).

Casing (L6): the SignalR group join/leave cross-tenant check now compares
tenant IDs OrdinalIgnoreCase, aligned with the middleware and the delegated
scope dictionary, so a casing difference can never flip which branch
authorizes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 16:06:39 +02:00
Oliver Kieselbach e9c5bfbafc fix(security): backend-review M1-M3 — kill-switch + MCP cache TTL + SignalR broadcast gate
M1: hoist the McpUsers Disabled-row kill-switch above the policy switch so an
explicitly disabled account is denied MCP access under EVERY policy and grant
path (AllMembers, platform roles, delegated auto-grant) — previously inert
outside the WhitelistOnly branch.

M2: drop the MCP access-guard verdict cache from 5 min to 60s. The verdict
carries the kill-switch and the delegated tenant set; 5 min stretched the
backend's 30s revocation target to minutes for MCP callers.

M3: gate same-tenant joins of the plain tenant-{tid} SignalR broadcast group
on a member role (new pure SignalRGroupHelper.IsTenantBroadcastJoinDenied) —
roleless authenticated users (Progress Portal) could passively stream
MemberRead-tier live telemetry they cannot fetch over REST. Companions:
MarkSessionSucceeded/Failed additionally push "newevents" to the per-session
group so a roleless progress watcher still sees admin-marked completion, and
the Progress Portal no longer joins the tenant group (session group covers
all its signals).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 15:44:36 +02:00
Oliver Kieselbach 34582c0f0e feat(kill-switch): deliver kill on config channel + KillSignalDelivered ops event
Kill/Block rules were only served on the telemetry response, which cannot
reach an agent whose upload loop is paused (indefinite block pins the drain
until DateTime.MaxValue), whose spool is empty, or whose binary is kill-blind.
Config is fetched at every process start (boot via Scheduled Task), making it
the reliable control channel - no heartbeat added by design (48h emergency
break + 6h max lifetime bound the remaining cases).

Backend (effective on deploy):
- KillSwitchEvaluator: shared device+version check for both channels; ingest
  wire response and check order unchanged (device first, short-circuits)
- Every served Kill emits ops event KillSignalDelivered (Security/Warning),
  throttled 24h per tenant+serial+pattern; Blocks stay event-free
- GET agent/config (incl. bootstrap wrapper) now returns DeviceBlocked/
  DeviceKillSignal/UnblockAt alongside the full config; old binaries ignore
  the new fields
- Web: KillSignalDelivered dual-registered in OPS_EVENT_TYPES

Agent V2 (effective with next agent release):
- CheckConfigKillSignal guard after the Phase-4 fetch: forced self-destruct
  (overrides SelfDestructOnComplete), enrollment-complete marker, session
  cleared, exit before mTLS/registration
- Kill honoured ONLY from a live fetch: config disk cache strips the flags on
  write and read (UnrestrictedMode pattern), guard re-checks LastFetchOutcome

Tests: 9 KillSwitchEvaluatorTests + 7 ProgramConfigKillSignalTests;
Functions 2557/2557, Agent V2 1578/1578, web tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 13:49:32 +02:00
Oliver Kieselbach d68d77975b v2 banner removal and added tipoftheday 2026-07-03 12:51:13 +02:00
Oliver Kieselbach ac72ebb420 fix(backend): delegated config/all must not silently drop tenants on read errors
Codex Medium on 43a87ff7: the delegated subset was built from
TryGetConfigurationAsync, which maps ANY storage failure to exists=false —
transient table throttling made managed tenants vanish from a 200 response,
whereas the replaced scan path rethrew and returned 500.

- New TenantConfigurationService.GetConfigurationIfExistsAsync: strict
  point-read (cached, non-creating, null on 404) that PROPAGATES storage
  errors; TryGetConfigurationAsync now delegates to it and keeps its
  fail-closed mapping for agent security gates.
- Delegated config/all reads via the strict method → a read failure fails
  the request (500) instead of shrinking the subset.
- Adjacent regressions from the same hunk: AllowedTenantIds deduped
  case-insensitively (old HashSet-filter semantics), and ids missed by the
  exact-case point-read get one scan+filter rescue pass — config PartitionKey
  casing is not guaranteed (agent ingest auto-creates rows verbatim; same
  caveat the delegated sessions fan-out handles case-insensitively).

Functions suite 2548/2548 (9 new tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:00:16 +02:00
Oliver Kieselbach bae97c2486 Merge pull request #83 from okieselbach/fix/v2-delta-review-findings
fix(agent-v2): remediate all 28 findings from the 2026-07-02 V2 delta review
2026-07-03 11:36:51 +02:00
Oliver Kieselbach 6f8ce556b7 fix(mcp-security): rate-limit /oauth/* and allowlist grant_type (review R-3)
The unauthenticated /oauth/* routes carried no throttle (accessGuard covers
/mcp only), making /oauth/token an anonymous amplifier: every POST triggers
an outbound Entra call with the app's client_secret attached, so a flood
would get the app registration itself throttled by Entra — an org-wide auth
DoS requiring zero credentials.

- Per-source-IP sliding-window throttles as per-route middleware inside
  createOAuthRouter: 20/min on /oauth/token, 60/min on register/authorize/
  callback (MCP_OAUTH_TOKEN_RATE_LIMIT_PER_MINUTE / MCP_OAUTH_RATE_LIMIT_
  PER_MINUTE). Own bucket map keyed `${kind}:${ip}` so OAuth floods never
  burn the /mcp pre-auth budget and vice versa; pruned by the shared reaper
  and capped by MAX_RATE_BUCKET_ENTRIES. 429s reuse respondRateLimited, so
  the existing `[mcp-auth] 429 kind=` log/KQL pattern covers the new kinds.
  .well-known discovery docs stay unthrottled (static JSON, no outbound work).
- grant_type allowlist on /oauth/token: missing -> invalid_request, anything
  but authorization_code/refresh_token -> unsupported_grant_type, rejected
  before the client secret leaves the building. Policy now lives in the
  proxy instead of relying on Entra-side accidents (pinned scope, uncopied
  fields) to reject client_credentials/ROPC/device-code.
- Tests: oauth-rate-limit.test.ts (tiny pinned budgets, bucket independence
  in both directions) + grant_type allowlist block in oauth-register.test.ts.
  Env pins in security-guards.test.ts hoisted above the first oauth.js
  import — oauth.ts now (transitively) loads access-guard.ts at import time.

MCP server v1.4.5 -> 1.4.6. vitest 345 passed / 41 skipped, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:27:42 +02:00
Oliver Kieselbach 9582ad8d8b fix(agent-v2): termination, recovery and lifecycle LOWs (L2-L4, L6, L7, L9, L16, L18, L19, L21)
Delta-review 2026-07-02 findings:
- L2: HelloTracker wait-timer re-arm races Stop()`s lock-free timer dispose —
  an ObjectDisposedException on a threadpool timer thread kills the process
  on net48. Swallowed with a debug log (shutdown is winning anyway).
- L3: MaybeEmitAgentLateStart gets the same _post==null guard as its siblings.
- L4: agent_late_start / lowObservationCoverage mixed clock epochs (boot =
  now-minus-uptime at termination vs wall-clock agentStartUtc). The handler
  now captures a (tick, wall) anchor pair at construction and derives boot in
  that epoch, so NTP corrections between start and termination no longer skew
  bootToStartSeconds.
- L6: terminal starved-app sweep TOCTOU — snapshot-then-emit could race a
  concurrent live app_install_starved and double-report. Replaced the
  read-model snapshot property with an atomic TryClaimStarvedUserEspAppReport
  against the live path`s dedupe set (tracker → host → surfaces → read model).
- L7: a quarantine marker whose delete persistently fails (AV/ACL) re-wiped
  the FRESH run`s state on every restart. After a successful quarantine the
  stuck marker is neutralized with a processed-sentinel; the next start only
  retries the delete.
- L9: DeviceInfoHost is created (and thus subscribes) BEFORE EspAndHelloHost,
  whose Start can immediately post EspPhaseChanged(DeviceSetup) — the
  enrollment-start re-collect trigger was missed on sessions failing before
  the end trigger.
- L16: UserEspKeepAwakeHost no longer holds _gate across the blocking
  controller Engage (Idle→Engaging→Engaged claim; a racing Release wins and
  the engage path rolls the OS hold back).
- L18: Office preinstalled short-circuit — accepted-risk comment documenting
  the silent-miss window (never a false office_install_failed).
- L19: durationSeconds carries a durationBasis discriminator (script_runtime
  vs cycle_including_reporting_latency) so platform and remediation durations
  are never compared as equals.
- L21: stale ShellCoreTrackerAdapter comment reference removed (ARCH-F7).

(L8 was fixed with M1 in the parked-tripwire commit; L11 is moot since M6
made the prefetch fingerprint static.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:19:11 +02:00
Oliver Kieselbach c23dc9a329 fix(agent-v2): console-bypass watcher hardening (L12, L13, L14, L15, L20)
Delta-review 2026-07-02 findings:
- L12: `cmd /k` leaves a fully interactive shell after running its command —
  it was classified as a scripted invocation and silently ignored (deliberate
  false-negative seam, e.g. a technician-planted `cmd /k whoami`). Now
  surfaced as low-confidence `interactive_console_with_command`; only /c
  (run-and-exit) is ignored.
- L13: the WMI trace arm + startup probe ran synchronously in the
  orchestrator`s sequential host-start loop; a WinMgmt hang (documented OOBE
  failure mode) delayed every later host. Both now run on a background
  thread, with a dispose-race guard around the watcher arm.
- L14: the desktop-arrival callback was the ONLY stop path — if the DAD never
  validated a real-user owner, the watcher flagged ordinary post-desktop user
  consoles for the agent`s whole remaining lifetime. Fallback: the host also
  stops on a DesktopArrived or HelloResolved signal on the decision rail.
- L15: wtsErrorCount counted the routine "session has no logged-on user yet"
  OOBE case as an error; ProcessOwnerLookup.ViaWts now reports real API
  failures separately and only those are counted.
- L20: WatchedProcessNames is now actually the single seam — WQL trace
  filter, startup probe and the emitted processName are all built from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 11:03:47 +02:00