mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
d8ef8b11b0921822802ffe860803986aefcb276a
194 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
27bf96f23f |
docs: demo session import concept (OKF)
Records the import-session skill: the 23-step cascade inverse, the single replacement table over keys and strings, what never travels (Owner*, deletion/diagnostics columns), the SoftwareInventory counter semantics, scrubbing rules, provenance lane, and the safety model (insert-only under the new id, key invariant, zero-leak assertion, automatic rollback). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d6783fe327 |
feat: free-text note search on the annotation lists
Both annotation list routes (sessions/annotations/list, global/session-
annotations) accept ?q= -- a case-insensitive substring over the note and
the verdict, deliberately not the lane, author or rule ids, which have their
own filters. Table Storage has no contains, so it is applied in the same
client-of-Azure back-fill loop as ruleId (filtered-out rows never consume
page budget) and bound into the continuation fingerprint, so a cursor issued
for one search cannot be replayed against another; nextLink echoes it.
Portal: a debounced search box on the Annotations page (the setState lives
in the timer callback, not the effect body). MCP: list_session_annotations
gains `query` -> q. Response DTO unchanged, no wire break.
Why: demo sessions imported into the operator's own tenant fall out of every
dashboard/geo/SLA time window. A note ("wifi switch") plus this search is
how they are found again -- and it is generally useful for any judged session.
Deploy order: backend first, then web and MCP (the parameter is silently
ignored by an older backend).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
2fe1095758 |
perf(raw-events): budgeted, resumable cross-session scan; tenant from index rows; MCP pageSize override + timeout retry
query_raw_events timed out twice on the same continuation and lost all work each time. Per page the cross-session path resolved every session's tenant through a SessionsIndex full-table scan (the EventTypeIndex row already carries TenantId), ran up to 1000 per-session queries serially, applied the date window last and against the Azure system Timestamp, and had no deadline; the retry was identical because the nextLink freezes pageSize and the explicit value was dropped. Backend - RawEventsScan: index walked in chunks of 100, 20 parallel session fetches, 15 s budget checked between chunks; cursor always after the last fully processed chunk; QueryRawEventsResponse.Partial marks a budget-ended page (nothing missing up to nextLink). - Date window pushed down: Events RowKey range inside the session partition (BusinessTimestamp.EventDateFrom/ToClause) + sound index write-time pre-filter (startedAfter - MaxFutureToleranceHours - 1h). The session-StartedAt RowKey was rejected as a prefilter (WhiteGlove Part 2 emits weeks later). - Raw date filters and ordering now use the business time (RawEventTime: OccurredUtc -> RowKey -> system Timestamp); unparsable startedAfter/startedBefore is a 400; bare values are UTC. - Same scan sat behind search_sessions_by_event, search_sessions_by_cve and the deviceProperties path of search_sessions: all read Sessions by point read with the tenant their index row carries (BatchGetSessionsByKeyAsync, IndexRowKeys.ResolveTenantId); the scan-based batch helper is gone. MCP - followNextLink(..., overrides): an explicit pageSize (and fields on the raw tools) applies to a continuation in every nextLink-paginated tool; pageSize schemas lose their .default(), pageSizeForCall sends the first-page default only on a first-page call. - scanWithTimeoutFallback on the auto-exhaust tools: one retry with a halved pageSize on the same cursor, marked retriedWithPageSize; timeout error text distinguishes continuation calls. - Generated wire types regenerated (partial?: boolean). Docs: docs/backend/raw-events-scan-budget.md (design, invariants, audit of the remaining unbounded cross-tenant aggregations with live durations), business-timestamps.md consequences. Deploy backend first (additive field), then MCP. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> |
||
|
|
8a3b4136a6 |
feat(web): internal annotation lane follows the Global-Admin view
The "Internal (Autopilot Monitor)" lane is labelled "never visible to the tenant" and sits on the session page shown in every live demo, but it was gated on the platform identity alone and stayed on screen when the Global-Admin view was switched off. visibleLanes() now takes the view flag as an explicit second argument and combines it with the identity, so the rule also covers the read-only Global Reader. The /annotations overview additionally filters the rendered ROWS to the visible lanes: that list is not lane-scoped by the request, so an internal row would otherwise surface there even with the lane dropped from the filter dropdown. Not a permission change -- the backend filters the lane for tenant callers regardless (GetSessionAnnotationsFunction.FilterLanesForCaller) and owns the write matrix. Also fixes the retention-override banner: the bare warning glyph inherited the base 16px size against 12px text and sat below the first line, with an mt-0.5 hand-nudge that could not hold it. Replaced by a w-4 h-4 SVG whose box matches the text-xs line box exactly, so items-start aligns it with no margin -- and it now matches the icon style of every other alert box. The IME log pattern controls needed no change: canEdit already reads isGlobalAdmin && globalAdminMode, so demo mode reaches them through the effective flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7323ca72b2 |
feat(web): demo presentation mode for live product demos (#141)
* feat(web): demo presentation mode for live product demos The Global-Admin view toggle only ever reached the nav and the scope hooks. Every GA extra inside a tenant page was gated on the identity and stayed on screen when the toggle was switched off -- including the /health-check build cards, whose commit hashes hyperlink into the private repo, and the Decision Inspector button on the session detail page. New useGlobalAdminUi() (real GA *and* global view on) replaces the bare identity check at the five visible GA surfaces, so switching the toggle off now yields a genuinely tenant-admin-looking view. On top of that a hidden demo mode, armed by a self-consuming ?demo=1 (persisted to localStorage, then stripped from the address bar), forces the global view off for every consumer without touching the stored value, hides the toggle itself and the platform role badges, and silently replaces /admin/* with /dashboard rather than rendering the Access Denied card -- whose text would itself reveal that a platform area exists. ?demo=0 clears it and restores the previous setting. An unrecognised value leaves the stored flag alone, so a typo cannot drop the operator out mid-presentation. Admin Mode stays untouched: a tenant admin has it too, so it belongs in an honest demo. user.isGlobalAdmin is never faked globally -- that would drag canEditConfig along and leave a GA without an own tenant-admin role unable to demonstrate anything. Also hides the settings gear when neither toggle applies, which previously opened an empty panel for Operators and Viewers as well. Presentation only. Every rule mirrored here stays enforced server-side (TenantConfigValidation, GlobalAdminOnly -> 403); no backend file is touched and demo mode is not a security boundary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * chore(web): refresh generated page-lastmod Regenerated by the build (scripts/generate-lastmod.js). The committed file had drifted: about/privacy/terms carried stale dates and /buy, /help and /plans were missing entirely, so the sitemap under-reported them. Pre-existing drift, unrelated to the demo-presentation-mode change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
52ae6541a7 |
feat(agent): signal-pipeline health block in agent_metrics_snapshot
The 2026-09-01 WG-seal soak on post-SignalIngress-fix builds (memory:
project_wg_seal_postfix_soak_residual) found sessions whose DecisionEngine
never sealed Part 1 while the agent kept running for hours - and telemetry
could not distinguish a frozen ingress worker from a deliberate Weak
sealing-classifier verdict, because the metrics snapshots carried no
pipeline counters at all.
Every agent_metrics_snapshot now carries the block. The fields are MONOTONIC
totals plus a monotonic queue peak - deliberately no momentary queue length:
a snapshot samples the integral, so any two snapshots prove whether the
worker ran in between (a frozen worker keeps signal_pending_count growing
while processed/applied stand still; a momentary gauge would alias between
ticks). Same field pattern the snapshot already uses for the spool
(spool_total_enqueued_count / spool_peak_pending_item_count).
- signal_last_ordinal / signal_processed_total (new ProcessedSignalCount) /
signal_pending_count / signal_reentrant_total: SignalIngress worker
progress; signal_queue_peak is a new lock-free Interlocked-max recorded in
Post (fast + slow path; worker-local follow-up items deliberately excluded)
- decision_stage + decision_last_applied_ordinal: from the immutable
DecisionState - answers the WG question directly ("minutes after the
tracker's whiteglove_complete and still not WhiteGloveSealed")
- wg_sealing_level / wg_sealing_score: only after the sealing classifier
produced at least one verdict (key ABSENCE, not Unknown/0 noise, for every
non-WG session) - separates "classifier said Weak" from "never ran"
Wiring is a read-only probe chain following the imeTrackerHealthProbe
pattern: EnrollmentOrchestrator passes () => _processor?.CurrentState into
IComponentFactory.CreateCollectorHosts (new optional decisionStateProbe;
test fakes updated), PeriodicCollectorLifecycleHost combines it with its
existing concrete-SignalIngress reference in BuildSignalPipelineProbe, and
AgentSelfMetricsCollector writes the fields fail-soft (a throwing probe
degrades to a snapshot without the block - pinned by test). The snapshot
event travels the collector/event path, which is independent of the signal
pipeline it measures - field-proven by the freeze cases, whose perf/metrics
events kept flowing while the engine stood still.
No backend/web/MCP change needed: no new event type, DataJson is free-form.
Tests: 5 new collector facts (full block, absence semantics x2, fail-soft,
no-probe compatibility) + 2 new SignalIngress facts (processed total stable
across drain; queue peak survives the queue draining - the property a
momentary gauge would not have). Agent suite 2812 green, solution builds
with 0 warnings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
d763a885e2 |
Ops alert channels, ops-events server-side filters, and generated MCP tool vocabularies (#140)
* feat(backend,web): Telegram als Channel-Provider + ein einziger Dispatch-Pfad Notifications hatten zwei Sendewege: WebhookNotificationService.SendToChannelsAsync fuer tenant-konfigurierte Channels, und TelegramNotificationService direkt aus dem Ops-Alert-Pfad. Ein neuer Provider haette beide anfassen muessen. Neu: NotificationChannelDispatcher ist DIE Channel-Sende-API und routet pro Provider — WebhookProviderType.Telegram zum Bot, alles andere als gerenderten Webhook-POST. SendToChannelsAsync wurde aus WebhookNotificationService entfernt statt dupliziert, damit es keinen zweiten Pfad gibt; alle sieben Call-Sites ziehen um (EventIngestProcessor, RegisterSession, ReportDistress, SlaBreachEvaluation, AnalyzeOnEnrollmentEnd, TestWebhookNotification). SendOpsAlertAsync/SendNotificationAsync sind virtual (Test-Seam), TelegramNotificationService bekommt SendAlertWithResultAsync fuer die Test-Endpoints und RenderAlertText als testbaren Kern. Telegram (=40) ist der Sonderfall: kein Webhook. Der Channel-Url traegt die CHAT-ID, der Bot-Token gehoert der Plattform (PreviewConfig). Ein Tenant-Admin wuerde durch UNSEREN Bot senden — deshalb ist das Gate eine Server-Regel, keine versteckte Dropdown-Option: ValidateTelegramChannelGate sitzt in TenantConfigValidation.ValidateModel und deckt damit BEIDE Schreibpfade ab (Full-Model-PUT und den MCP-Field-Patch ueber TenantConfigPatchService) — in der Update-Function allein waere der Patch-Pfad daran vorbeigelaufen. Ein unveraenderter, GA-angelegter Channel passiert das Gate, damit ein Tenant-Admin unrelated Config weiter speichern kann. Telegram-Ziele ueberspringen SsrfGuard.ValidateWebhookUrlFormat (eine Chat-ID ist keine URL und wuerde immer failen) und werden stattdessen als numerische Id oder @username geprueft. Mitgeliefert, weil der Dispatcher und die Ops-Seite sie brauchen: NotificationChannel.RedactList / RestoreRedactedList als geteilte Helfer hochgezogen (TenantConfiguration delegiert jetzt dorthin, statt eine zweite Kopie zu pflegen), AdminConfiguration.OpsNotificationChannelsJson + GetOpsNotificationChannels() mit Legacy-Slot-Synthese, OpsAlertRule.NotifyChannelIds/IncludePayload, NotificationAlert.DataJson (nur GenericJsonRenderer serialisiert es, additiv in schemaVersion 1.0). Web: der Channel-Editor liegt jetzt in components/notifications/ChannelEditor.tsx und wird von Tenant- und Admin-Seite geteilt (showEventToggles trennt die Faelle). Telegram erscheint im Provider-Dropdown nur fuer Global Admins (Prop aus useAuth().user?.isGlobalAdmin, Hausmuster aus SectionAutopilotValidation); ein bereits konfigurierter Telegram-Channel bleibt fuer alle sichtbar, damit ein Speichern ihn nicht still verwirft. Tests: TelegramChannelProviderTests (27) — Dispatcher-Routing inkl. "Telegram erreicht NIE den SSRF-Pfad", Chat-ID-Validierung, GA-Gate auf beiden Schreibpfaden. Mutationsgeprueft: Gate invertiert => 5 rot, zurueck => 27 gruen. OpsNotificationChannelsTests + OpsAlertPayloadTests decken die mitgelieferten Modell-Additionen ab. TestNotifications ist der gemeinsame inerte Stub, der die 17 bestehenden OpsAlertDispatchService-Konstruktionen ersetzt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend,web): Ops-Alerts routen auf benannte Channels statt auf drei feste Slots OpsAlertRule hatte kein Ziel. Jede matchende Regel ging an alle drei fest verdrahteten Slots in AdminConfiguration (Telegram-ChatId / Teams-URL / Slack-URL), und nur die ERSTE matchende Regel wurde ueberhaupt ausgewertet (FirstOrDefault). Ein Conversion-Signal an ein Sales-Team zu binden hiess also: das Sales-Team bekommt auch jede Security-Regel. Jetzt: AdminConfiguration.OpsNotificationChannelsJson haelt eine benannte Channel-Liste (dasselbe NotificationChannel-Modell wie tenant-seitig, nur anderer Scope), und OpsAlertRule.NotifyChannelIds bindet Regeln daran. Dispatch wertet ALLE matchenden Regeln aus (Where statt FirstOrDefault) und vereinigt ihre Ziele. Rueckwaertskompatibel ohne Migrationsjob: - Leere Channel-Liste => GetOpsNotificationChannels() synthetisiert je konfiguriertem Legacy-Slot einen Channel mit stabiler Id (legacy-telegram heisst "Autopilot Monitor IT Engineer") und uebernimmt die Legacy-Enabled-Flags — ein bewusst abgeschalteter Provider bleibt abgeschaltet. - GetAdminConfigurationFunction projiziert DIESELBE Synthese in das Read-Model, damit der Editor zeigt, was heute tatsaechlich Alerts empfaengt, und der erste Save exakt das materialisiert. Die Alternative waere eine zweite Kopie der Synthese-Regeln im Web gewesen, die vom Dispatcher wegdriftet. - Leere NotifyChannelIds = alle aktivierten Channels. Das ist, was jede heute existierende Regel traegt, also verhaelt sich am Deploy-Tag nichts anders. - Ids, die nicht mehr aufloesen, werden verworfen statt auf Broadcast zurueckzufallen: eine Regel, deren einziger Channel geloescht wurde, muss NICHTS erreichen, nicht alles. Die Legacy-Felder bleiben als Migrationsquelle stehen und werden von der Alerts-Section nicht mehr geschrieben — aufraeumen erst, wenn produktiv Channels stehen. Sicherheit: OpsNotificationChannelsJson ist secret-tragend und steht in der Deny-List von RedactedCopyForReader() (ConfigRedactionTests bewacht Drift). Der Admin-PUT hat bisher keinen Redacted-Restore — er bekommt jetzt einen fuer die Channel-Liste, damit ein zurueckgespieltes Reader-View nicht "***REDACTED***" als Chat-ID persistiert, plus die strukturelle Validierung, die auch die Tenant-Liste passiert. Neuer Endpoint POST /api/global/config/test-ops-channel (GlobalAdminOnly, im Policy-Catalog registriert). Kein neues Response-DTO: TestWebhookNotificationResponse hat identische Semantik und wird geteilt — nur der Doc-Kommentar nennt jetzt beide Routen. Web: die "Alert Providers"-Karte weicht der Channel-Liste (geteilter ChannelEditor, im Admin-Kontext mit Telegram und ohne die tenant-spezifischen Event-Toggles). Jede Regel bekommt einen Channel-Picker; leere Auswahl rendert als "all channels", nicht als Warnung — hier ist das der gueltige Default. Die Bindungslogik liegt testbar in opsChannelRouting.ts und spiegelt ResolveTargets: der erste explizite Pick startet vom LEEREN Set, sonst liesse sich eine Broadcast-Regel nie verengen. handleSaveOpsAlertConfig nimmt jetzt ein Objekt statt elf positionaler Argumente — bei einem Dutzend Feldern schreibt ein vertauschtes Paar sonst still eine Webhook-URL in den falschen Provider-Slot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend): TenantTrialStarted + Event-Payload optional pro Regel Der Trial-Start war stumm. POST config/{tenantId}/trial schrieb nur einen Audit-Entry, und der GA-Pfad (PATCH plan) feuerte nur beim Downgrade — also war genau der Conversion-Moment, den Sales/Support interessiert, das einzige Plan-Ereignis ohne Ops-Event. RecordTenantTrialStartedAsync (Tenant/Info) feuert jetzt aus BEIDEN Schreibpfaden, unterschieden per selfService-Flag, und jeweils NACH dem persistierten Save (SaveConfigurationAsync ist fail-loud), damit kein Alert eine Trial ankuendigt, die nicht gespeichert wurde. Der GA-Pfad haengt an der aufgezeichneten Aenderung, nicht am Request: ein reiner planTier-Edit bleibt still, ein idempotenter Save mit demselben Datum ebenfalls, und das Beenden einer Trial (explizit null) ist ein Downgrade und bleibt bei TenantPlanDowngraded. Payload-Zustellung: WriteAsync reicht die serialisierten Details an DispatchAsync durch — aber ein Channel bekommt sie nur, wenn eine Regel OpsAlertRule.IncludePayload setzt. Default AUS, bewusst: ein Ops-Alert ist ein "da ist was, schau nach"-Signal, die meisten Payloads sind im Chat Rauschen, und manche tragen Felder, die die Baseline nie traegt (Tenant-Domain, Contact-Adresse). Das Informationsniveau aller bestehenden Regeln bleibt damit unveraendert. ResolveTargets liefert deshalb zwei Gruppen. Ein Channel, den sowohl eine Payload-Regel als auch eine schlichte Regel adressiert, landet nur in der Payload-Gruppe: eine Nachricht pro Channel, und ein unbeteiligtes Geschwister-Match kann ein bewusstes Opt-in nicht aushebeln. Fuer den angereicherten Alert flacht BuildAlert die Top-Level-Skalare in Facts ab (max 12, Werte <= 256 Zeichen, Nulls und Verschachteltes raus), damit Card-Formate und der Telegram-Klartext dieselbe Information tragen wie der GenericJson-Consumer unter "data". EventType wird immer gesetzt — derselbe Wert steckt schon im "Event"-Fact, also keine zusaetzliche Information, nur ein stabiler Schluessel zum Verzweigen. Tests: TenantTrialStartedEventTests (8) — aufgezeichnete Event-Form inkl. Payload-Feldern, und die Emissionsbedingung des GA-Pfads gegen ApplyPlanChanges gepinnt (greift bei Grant und Verlaengerung, schweigt bei Trial-Ende, planTier-only und idempotentem Save). Die HTTP-Entry-Points bleiben ungetestet — Suite-Konvention ist, die reinen Kerne zu testen. Docs: OKF-Concept backend/ops-notification-channels.md. Registrierung in docs/index.md + docs/log.md fehlt bewusst — beide Dateien tragen noch fremdes uncommittetes WIP (ops-events Query-Surface), und ein Teil-Commit darin haette es mitgenommen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat(backend,mcp): ops-events server-side field filters + generated MCP tool vocabularies The ops-events read could only narrow by category and date window, so answering "did any AgentEmergencyBreak fire yesterday?" meant pulling a whole category and filtering it client-side — and for the MCP tool that client is a language model, so the question cost a category-sized response in model context. Ops-events filter surface (mirrors the audit-log filter pattern one-for-one): - OpsEventQueryFilters (Shared) + OpsEventFilterRequest (query -> filters + pagination extras) + TableOpsEventRepository.AppendFieldFilters (filters -> OData clauses). BOTH filter builders call the appender — the single-category / unpaged one and the per-partition fan-out builder that adds the RowKey bound — so "no category named" cannot silently return unfiltered rows. - minSeverity expands to an OR-set, not a range: Table Storage compares strings lexicographically and the severity names sort Critical < Error < Info < Warning, so `Severity ge 'Warning'` would drop exactly the alert-worthy half. The ladder now lives in OpsEventSeverity.Rank, shared with OpsAlertDispatchService, so "Warning and above" in a read means what it meant when the alert rules fired. - severity is normalised onto the canonical vocabulary and an unknown value is a 400: `eq` is case-sensitive, so "?severity=warning" would otherwise return an empty page that reads exactly like "no such events happened". eventType stays verbatim — stored rows outlive the vocabulary, so a retired type must remain searchable in the historical window. - Every filter value is folded into the continuation fingerprint and echoed on nextLink; extras are appended AFTER the pre-existing category/tenantId discriminators and skipped when empty, so tokens minted before the filters existed still fingerprint identically. The tenantId drill deliberately stays in memory (OrdinalIgnoreCase; an OData eq would lose rows on casing). - MCP get_ops_events gains eventType / severity / minSeverity plus a `days` shorthand resolved client-side to a concrete dateFrom (a backend-side "last N days" would re-resolve "now" on every page and blow the token fingerprint); an explicit bound always wins over the shorthand. Fixed alongside: the paged cross-category fan-out skipped Platform (Azure Monitor alerts) because its category list had drifted from OpsEventCategory — written from day one, invisible to every paged reader, still visible on the unpaged full-table path. The list is now OpsEventCategory.All plus a reflection coverage test. That drift had a structural cause, so the same round closes it for the MCP as a whole: wire TYPES reached the MCP from day one, but vocabularies did not — types are erased at runtime and z.enum() needs values, so every tool schema hand-typed its own copy with nothing to compare against. The copies had drifted: search_sessions offered 5 of 8 session statuses (no AwaitingUser, no Incomplete — both real stored statuses), both event readers offered 4 of 6 severities (no Debug, no Trace), get_ops_events named 6 of 7 ops categories. - The codegen now emits src/generated/wire-vocabularies.generated.ts (VALUES: session statuses, event severities, ops categories/severities/types, annotation lanes/verdicts) from the manifest sections listed in MCP_VOCABULARIES; SharedManifestParityTests exports the two new ops vocabularies. Freshness is pinned twice — byte-equality against a fresh codegen run in the MCP suite, and the shared-manifests-in-sync CI job. - Tool schemas derive their z.enum() from it and descriptions interpolate the list instead of spelling it. - vocabulary-drift.guard.test.ts ratchets what is left: every inline z.enum([..]) must sit on a baseline naming why it has no backend owner (MCP-local knobs, plus rule type / connection type / CVE risk, which have no C# constants class yet). A new hand-typed list fails, and so does a stale baseline entry. - OpsEventTypes (Shared) gives the 77 ops event types a canonical home: the OpsEventService write sites use the constants (a raw literal now fails OpsEventTypeDualRegisterTests, which moved from regex-scraping to reflection), the manifest exports them, and get_resource(name="ops_event_types") serves categories + severities + types to the model from the generated copy. Suites: Backend 5427, MCP 629 + tsc, Web tsc + vitest 965, generator idempotent. Deploy order: backend BEFORE the MCP redeploy — an MCP that sends filters to an older backend gets silently unfiltered rows back and presents them as filtered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: register the ops notification channels doc in the index and log The concept doc landed with the feature commits; its index entry and log entry were still uncommitted in the working tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0a0c323f73 |
fix(backend): CMTrace skew tripwire judges only the current ingest era
Three CmTraceTimeSkewRegression fires on 2026-09-01 (one tenant, sessions e797117b / c06d639d / d7c8032b, agent 2.0.1445, each exactly -60.0 min, residual 0.0, 85-88 % grid-conformant) all came from the same stale leg: those sessions are pre-provisioning, their Part 1 ran on 2026-08-20 under agent 2.0.1409 (before per-line anchoring) and left 23-26 IME samples at -60.00, while the Part 2 leg that completed that morning is clean at -0.16. The terminal reconcile scans the whole event partition, so the old leg supplied the majority of the IME samples and fired against a build the device had self-updated to minutes earlier. Real skew, 12 days old, already fixed - and >=821 pending pre-provisioning sessions with agent < 1412 are queued to repeat it. Event rows carry no agent version, so the split is drawn on ReceivedAt: ResolveEraStartUtc walks the batch stamps of BOTH sides back from the newest one and ends the era at the first gap > EraGapHours (2.0) - above any in-leg upload gap (self-metrics every few minutes, session_stalled at 60 min idle, maintenance sweep at 2 h), below every pre-provisioning handover. Median, grid residual, conformity AND the distinct-batch guard now all run on the same era-filtered sample set; the batch counters used to come from the full scan. SessionSkewScan carries ReceivedAt per sample and its 20k cap keeps the NEWEST samples (chunked trim) - keeping the head would have blinded the detector on exactly the sessions large enough to reach it. The ops event reports eraStartUtc plus the excluded per-side counts, so an inherited leg stays visible without being alarmed on. Tests: 3 new era facts, mutation-proven (EraGapHours = 100000 turns exactly those three red). The handover fact also asserts that the old leg judged on its own still fires, so it is the window that silences it, not a moved threshold. Backend 5334 green, Agent core 2805 green, solution builds with 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
eaef1601d0 |
feat(backend,mcp,web): typisierung follow-up — the deliberately-untyped remainder is typed end-to-end, and the raw table entities leave the wire (#137)
Closes the documented read-model follow-up of the typed API contract (
|
||
|
|
d055eed074 |
Typed API contract: every success response is a wire DTO, generated end-to-end into TypeScript (#136)
* feat(backend): typed API response infrastructure — IApiResponse marker, wire-settings single source, widened anonymous-body ratchet (batch 0 of typed-api-contract) IApiResponse (Shared, with SuccessMessageResponse/SuccessOnlyResponse and the ProjectedItems/WireContract attributes for the upcoming manifest export) plus generic OkAsync/CreatedAsync/JsonAsync<T> overloads constrained on it, so anonymous success bodies stop compiling once the legacy object overloads go; ApiJsonOptions is now the single source of the production wire settings (camelCase, WhenWritingNull, string enums) used by Program.cs AND the three endpoint-test harnesses (two serialized PascalCase-with-nulls, one JsonSerializerDefaults.Web — all could pass while the deployed wire differed); TypedResponseGuardTests replaces OkAsyncBaselineGuardTests with the same ratchet mechanics over two frozen baselines (44 anonymous helper sites unchanged, plus 134 anonymous success WriteAsJsonAsync sites across 76 files — error-shaped bodies stay anonymous by design) and a flatness/location reflection guard (IApiResponse implementers derive from object and live in Shared, protecting JSON key order); ApiResponseWireParityTests carries the ordinal old-vs-new serialization proof each converted site must add Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(backend): Sessions/Annotations/Reports/Raw/Progress success responses are typed wire DTOs (batch 1 of typed-api-contract) 34 anonymous success bodies across 24 functions replaced with flat IApiResponse DTOs in Shared (SessionApiModels/ReportApiModels + new Annotation/Raw/ProgressApiModels) — minimal in-place transformation, call structure/status codes untouched, declaration order == wire order, nullable exactly where a site can emit null (WhenWritingNull); fields= projections stay IReadOnlyList<object> marked [ProjectedItems(SessionSummary/ EnrollmentEvent)], raw table rows stay PascalCase-verbatim dictionaries (no DictionaryKeyPolicy — pinned by test), AnnotationWire and the distress-report aggregations now build named item types instead of anonymous ones, and shared shapes collapse to one DTO (SessionListResponse x4, SessionAnnotationListResponse x2, SessionReportListResponse/GetSessionEventsResponse paged+unpaged via absent-null nextLink); 54 new wire-parity facts serialize each OLD anonymous literal (independent fixture copied from the pre-migration site) against the new DTO ordinally, with a null case per vanishing key; ratchet baselines shrink 44->14 helper sites and 134->122 write sites; the three distress-report test files read the now-typed aggregation items directly instead of via reflection-ToDynamic Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(backend): every anonymous API success body is a typed wire DTO — ratchet baselines empty, legacy object overloads deleted (batches 2-5 of typed-api-contract) the remaining 141 anonymous success sites (Admin 36, Rules 24, Config 15, Graph 5, Metrics 16, Vulnerability 17, Infrastructure/Global/Notifications/Bootstrap/ Diagnostics 20 incl. the 429 quota body in McpQuotaEnforcementMiddleware, plus the 14 helper-call sites) now return flat IApiResponse DTOs from Shared (new Admin/ Rules/Config/Graph/Metrics/Vulnerability/Infrastructure/Notifications/Diagnostics ApiModels; GeographicApiModels extended) — same minimal in-place transformation as batch 1: declaration order == wire order, nullable exactly where a site can emit null, dual success/failure sites keep their status-code logic and reuse SuccessMessageResponse, MCP-critical key names (records/summaries/inventory) pinned; wire item shapes that were function-local (CustomsArchive Run/EntrySummary, CpeMappingDto, UnmatchedSoftwareAgg, AutoResolve items, LogPatternTestResult, CveExposureSummary/SeverityBreakdown/TopCve — records rewritten as flat classes, netstandard2.0 has no IsExternalInit) moved to Shared so the manifest export sees them; payload types that must stay in the Functions assembly (RuleDryRun, PreviewWhitelistEntity, GlobalAdminEntity, HealthCheck, GlobalNotificationDto, Backfill/Reclassification/MaintenanceResult, TenantAdminEntity, TenantConfigFieldSchema) ride as object slots — wire-identical via runtime-type serialization, proven by the parity facts; 126 new wire-parity facts total (Admin 26, Rules 12, Config 17, Graph 7, Metrics 16, Vulnerability 19, Infrastructure 21 + Dictionary-key-verbatim and null-value pins); both TypedResponseGuard baselines are now EMPTY regression guards and the untyped OkAsync/CreatedAsync(object) overloads are gone, so a new anonymous success body no longer compiles through the helper and fails the guard through raw WriteAsJsonAsync; deliberately untouched: error bodies (one anonymous shape by design), auth/me (AuthDecisionResult.Body slot shared with error bodies, tests reflect over it), CheckMcpAccess (conditional dictionary keys), raw-byte streamers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(contract): C#→TS wire codegen — shared-manifests v2 exports the full response-type graph as generated TypeScript (batch 6 of typed-api-contract) SharedManifestParityTests now writes schemaVersion 2: the v1 catalog sections stay byte-compatible (sectionFieldMap/signalrMessages/sharedManifestChecks untouched), the consumer-less sessionSummary section is gone, and a new types section carries every wire type — roots are all IApiResponse implementers plus [WireContract]-marked types (SlaViolatorSession, ImeVersionHistoryEntry), transitively closed by the new WireTypeManifestBuilder over refs, collections, dictionaries, enums and [ProjectedItems] item types (208 types). Fields honour [JsonPropertyName]/ [JsonIgnore], get-only members export readonly, a nullable slot exports optional (WhenWritingNull omits the KEY — explicit null survives only inside collections and dictionary values, so the nullable flag lives on nested descriptors only), field order follows STJ's derived-before-base serialization for the one legacy inherited wire type (LocationSessionRow : SessionSummary), and unmappable shapes (non-string dictionary keys, foreign BCL classes) fail the build instead of degrading to unknown. C# <summary> texts travel as JSDoc: Shared builds XML docs now (doc-quality warnings CS1591/1573/1574/1584/0419 suppressed — crefs to foreign assemblies cannot resolve from Shared; CS1570 malformed-XML stays an error, which surfaced a raw 0x01 control char and an unescaped ampersand in existing comments). generate-shared-manifest-types.js emits utils/wire-types.generated.ts (interfaces + string-union enums, 2.7k lines) next to the lean literal module, npm run generate:manifests wraps it, the vitest freshness test pins BOTH outputs, and a new shared-manifests-in-sync CI job (guardrails-in-sync pattern) fails on drift Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(web): hand-written response types re-export the generated wire interfaces (batch 7+8 of typed-api-contract) types/session.ts, types/enrollment.ts and types/adminConfig.ts are re-exports over utils/wire-types.generated.ts (Session = SessionSummary alias for the 25+ barrel importers; adminConfig keeps only the excessiveEventAutoActionMode literal-union narrowing and the client-side OpsAlertRule view) — the generated types are never weakened, consumers adapt: durationSeconds is now optional as on the wire (int? — consumers guard with ?? 0, which also retires the latent NaN-minutes display on in-progress sessions), status is the SessionStatus union (fixtures on canonical values; the generic string-sort test keeps its own fixture type), required bools break hand-built fixtures once → new test/factories.ts makeSession/makeEvent (Object.assign, not spread — Partial spread re-widens and tests must force data: undefined); the string|null lifecycle branches were already truthiness-based. Duplicate local types deleted: geographic-performance now uses the real LocationSessionRow wire type (incl. DO fields), GlobalSearch the generated QuickSearchResult, sla/page.tsx the generated SlaViolatorSession (status stays a number — C# int, rendered via statusLabels); sessionExportUtils aliases SessionExportEvent = EnrollmentEvent and derives RuleResultCsvData via Pick, CSV output unchanged; the three structural ReplayInputEvent clones (InstallEvent, ScriptInputEvent, DownloadEvent) are aliases now. docs: drift-guards.md replaces the shrink-only-OkAsync section with the typed-wire-DTO contract, index.md/log.md updated. tsc 0, vitest 957, eslint 0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tests): SlaMetricsServiceTests anchor sessions inside the current ISO week — the 'now - 1h' fixture crossed into LAST week during the first hour of every Monday (UTC), emptying CurrentWeek and failing the three snapshot asserts (CI on Mon 2026-08-31 00:20Z; pre-existing Monday-morning time bomb, unrelated to the DTO migration); the anchor is now clamped to the week's Monday 00:00, proven green inside the same failure window Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5f3ae2d06b |
fix(backend): SessionTenantLookup is claimed first-writer-wins at registration — the sessionId→tenantId row that every Global Admin/Reader cross-tenant session resolve trusts was an unconditional last-writer-wins upsert AFTER the session write keyed by the client-supplied sessionId, so any tenant's device (self-service onboarding) that knew a victim session GUID (portal deep links, Teams/Slack notifications, progress-portal URLs) could re-point the mapping at its own tenant and have every no-tenantId GA read and GA write (globaladmin annotation lane, restore fallback) silently served from a forged session; StoreSessionAsync now claims the row create-only BEFORE any Sessions/SessionsIndex write (409 → read owner: same tenant = re-registration, foreign tenant = SessionTenantConflictException → 409 session_owner_mismatch without disclosing the owner + SessionTenantConflict ops event, nothing written, not fail-soft), the legacy self-heal write is Add-only, ResolveSessionScopeAsync(requireGlobalAdmin) corroborates the resolved tenant against its Sessions row (silent, no operator confirmation), and scripts/Migration/Backfill-SessionTenantLookup.ps1 closed the pre-table window (13509 sessions, 8795 rows added, verify dry-run added=0); no tombstone on deletion by design
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
83e15e49de |
fix(core,backend): every payload-derived string stored into DecisionState is bounded (FactStringBounds, 256) and the reducer-verification replay streams signals — RealmJoinFacts.WithPackageStarted/Completed left PackageId/Version/Scope unbounded and deduped via a linear ordinal compare over up to 200 rows, so a forged 5000-signal stream with ~400 KB near-identical package ids cost minutes of CPU per GET sessions/{id}/reducer-verification while ReducerVerifier held all decoded payloads (~GBs) live; keys are now bounded BEFORE the dedupe scan (forged near-duplicates collapse to one row), Scope normalised to machine/user, and the same bound applied to failed app ids, HelloOutcome, ImeMatchedPatternId, EspAdvisoryFailureCategory and RJ product version/channel incl. WithVersionOverride — never truncating real telemetry (registry key names ≤ 255, GUIDs, enum-like strings); the agent adapter bounds packageId/version at the source; ReducerVerifier replays decode-fold-discard (head-signal check before the journal so stubbed streams stay silent, mid-stream truncation and count-imbalance semantics unchanged, pinned by tests); ISignalRepository.QueryBySessionAsync carries a cumulative PayloadJson budget (SignalQueryLimits, 32 M chars) reflected in the truncated flag of sessions/{id}/signals and reducer-verification; no ingest cap — stored signals stay verbatim
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3c8ef02191 |
feat(agent): RealmJoin self-update is observed and reported as realmjoin_autoupdate_detected — realmjoin_detected read the version once from RealmJoin.exe, but the tray AppUpdater often replaces the MSI-shipped binary on its first start minutes after detection (gktatooine 946ccbd6: 4.21.4 -> 4.21.18, whole deployment ran on the new build while the timeline and RealmJoinFacts kept 4.21.4); the updater's only registry write is HKLM\...\Uninstall\RealmJoin (DisplayVersion/InstallDate), so RealmJoinWatcher arms a RegNotifyChangeKeyValue watcher there once Detected fired (appearance-watch on the Uninstall parent until the tray creates the entry), re-reads the binary version after a 2 s debounce (DisplayVersion fallback while the file is locked, channel from baseline) and emits the Info event {previousVersion,newVersion,releaseChannel} plus the typed RealmJoinAutoUpdateDetected signal, whose reducer overrides RealmJoinFacts.ProductVersion/ReleaseChannel (WithVersionOverride) and touches neither gate, deadline nor LastActivityUtc; unreadable baseline at detection is seeded silently; portal device card prefers the updated version ("auto-updated from X"); event registered in Constants/resource-catalog/shared-manifests with replay fixture and watcher/adapter/reducer tests
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
01311898a8 |
fix(ci): no third-party action holds release/deploy credentials by mutable tag — build-agent creates releases with first-party gh release create (job holds contents/id-token/attestations:write), combine-rules pushes generated outputs with plain git (contents:write on main), deploy-web builds the static export in CI (setup-node 22, npm ci, next build — same path ci.yml tests) and uploads it with the exactly pinned SWA CLI 2.0.10 instead of Azure/static-web-apps-deploy (Oryx in-container build, deploy token as action input), attests portal-<sha>.tar.gz and drops the unusable per-PR preview environments; azure/login and Azure/functions-action are SHA-pinned with version comments for Dependabot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
44dd8161df |
fix(agent): ImeProcessWatcher verifies process identity before attaching and re-arms discovery after an exit — it attached to the first IntuneManagementExtension name match, so a standard user starting a renamed binary during AccountSetup could forge an ime_process_exited Warning and mute the real signal for the rest of the session (CWE-807), and even a legitimate IME service restart was never watched again; ImeProcessIdentity requires session 0 + image under the IME install root (QueryFullProcessImageName, cross-bitness), prefers the oldest trusted candidate, logs untrusted matches once per PID, and OnImeExited restarts the discovery timer (ReferenceEquals guard against duplicate events)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
74902bf185 |
fix(agent): IME per-line matching budget counts Regex.Match time only — the wall-clock window also held the match handlers and scheduler stalls, and session 946ccbd6 (Hyper-V guest, 5–10 s process-wide stalls, 1 MB log, no line over 5 KB) broke it twice on genuine lines and silently skipped their remaining patterns; ime_pattern_hits is Debug severity (operator/backend signal, hidden from the default timeline — the IME Tracker card and the ingest fold read it regardless); the session page's IME Tracker card moves to the right column under Hardware
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7a3516e570 |
feat(observability): IME pattern-drift loop — nothing the log tracker skips or stops matching stays silent: the agent keeps cumulative tracker health counters and a per-pattern match histogram (restart-safe in ImeTrackerState), reports ime_* keys in agent_metrics_snapshot (files tailed, backlog bytes, lines read, matches, oversized/timeouts/budget breaks/held tails/unanchored), raises a one-shot immediate-upload ime_tracker_degraded Warning on the first pass that skipped work, and emits ime_pattern_hits at termination (every enabled pattern, zeros included, IME version); the backend folds the histogram into the permanent per-IME-version ImePatternStats table (built-in IDs only, plausible version only, one partition read + one transactional batch) and evaluates drift without a hard-coded list — baseline = the version with the most reporting sessions (≥100), expected = ≥80 % hit rate there, drift = an expected pattern matched in none of ≥25 sessions on a newer version — raising ImePatternDriftSuspected once per version×pattern (ETag-stamped, dual-registered in the web ops-event list with a new guard test); GET metrics/ime-pattern-health (GlobalReadOrAdmin) + Global Admin → Metrics → IME Pattern Health matrix (drift/silent/low/few cells, open alerts, retired patterns) + session-page "IME Tracker" rows (latest snapshot by max sequence) + MCP get_ime_pattern_health (GA/Reader)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f4a5eac539 |
chore(rules): IME pattern-pack hygiene against the IME decompiles (1.97.107.0 / 1.104.102.0 / 1.105.103.0) — no wording drift in any shipped pattern; the 11 disabled legacy patterns whose literals last existed in IME 1.50 (fleet minimum 1.97) are deleted instead of left as re-enable traps (IME-DETECTED-OLD, IME-SET-CURRENT-1/2/3, IME-SKIP-APPLICABILITY-NOTMET, IME-SKIP-DEPENDENCY-DETECT, IME-SKIP-DETECTED, IME-SKIP-UNINSTALL-NOTDETECTED-2, IME-UPDATE-NAME-1/2/3); IME-DO-TIMEOUT-1/2 match the exact source literal "DO downloading is not finished within timeout, jobId = …, fileId = …" instead of two lookaheads; IME-DO-TEL documented as emitter-less from 1.104 on and kept only for the 1.101/1.103 fleet share
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
09492e908c |
fix(security): IME log-pattern pack is anchored and linear (all 90 patterns start with ^, IME-APP-VERSION spells the report-delta JSON out instead of a chained [^}]*? scan) and the ImeLogTracker read loop is bounded — BoundedLineReader (32 MB per line, evidence: real 5.6 MB "Get policies" lines; exact byte bookmarks, no decoder read-ahead loss on cancel), 2 s per-line matching budget across all patterns, held-back unterminated EOF tails (≤1 MB, 1 s settle) instead of dropping them and raw-matching fragments, a complete entry after a dropped one is no longer swallowed as its close, one Warning per pass (oversizedLines/regexTimeouts/lineBudgetBreaks) instead of silent Debug timeouts; unanchored PS-SCRIPT-CONTEXT false positives on [Win32App] SideCarScript… lines (which overwrote the pending platform script's RunContext) are gone; --run-ime-matching assembles multiline entries like the tracker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
e96f49799f |
fix(security): McpUsers whitelist rows are honoured only for the identity the UPN is bound to (AdminIdentityBindings tid+oid) — grant, UsagePlan override and Disabled kill-switch no longer follow a same-UPN token from another tenant or a recycled account; POST global/mcp-users binds first (resolver, 422 HomeTenantUnresolved, 409 conflict) and the settings UI shows the home-tenant picker on 422; unbound legacy rows are inert
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4864b2d21d |
fix(security): gather RuleStats resolve the device-claimed ruleId against the tenant's active gather catalog — title/category/severity come from the catalog rule, unknown or inactive IDs are dropped, and the cross-tenant global_* row is written only for IsBuiltIn/IsCommunity catalog rules (same design as the analyze path); a forged GatherRuleExecutor event can no longer inflate fleet-wide counters, respell a built-in rule's title or mint global rows for retired IDs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
56c37866d1 |
fix(security): ImeVersionHistory no longer takes a device-reported IME version as a platform fact — RecordImeVersionAsync rejects strings outside MSI ProductVersion bounds (no row/ops event/archive job), a session counts once (UpdateSessionImeAgentVersionAsync returns whether the version changed), a second tenant's sighting stamps CorroboratedAt, and the MemberRead view lists only fleet-confirmed versions (Archived, corroborated, or pre-2026-08-31) while Global scope still sees every row
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
84d06e1cf6 |
fix(security): LocalAdminAnalyzer never exempts a logged-in local SAM account from the shutdown scan (CWE-807) and matches built-ins by well-known RID so localized Guest/Administrator are not flagged
The shutdown run allowed every logged-in user (explorer.exe owner) dynamically, so a backdoor account created after the startup scan and signed in before enrollment completed was scored 0. The WMI inventory is now read first and a logged-in name that is a local account is rejected for the account and the profile check (reported as logged_in_local_accounts); only Entra/domain identities — the only ones seen in real sessions — remain dynamically allowed for their profile folder.
Built-ins are recognised by RID 500/501/503/504 on the machine SID in addition to their English names: since disabled accounts are inventoried (
|
||
|
|
88dfd331dd |
fix(security): LocalAdminAnalyzer inventories disabled accounts and reads Administrators-group membership from the SAM (NetLocalGroupGetMembers on S-1-5-32-544); a dormant /active:no admin backdoor now surfaces as unexpected_admin_members (+40, Error), built-in Administrator enabled-state reported for evidence
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d294415d29 |
fix(security): CmTraceLogParser parses in linear time (first open / last parseable trailer, timeout-backed trailer regex) instead of a greedy .* regex that backtracked quadratically on hostile lines without a timeout; agent multiline buffer gains a 1 MB char cap next to the 100-line cap, both logged at Warning, and the tail of a dropped entry is skipped instead of raw-matched
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a47e94f9a5 |
fix(security): diagnostics packager reads every file through a handle validated against its pinned source folder; a subdirectory swapped for a junction between enumeration and open is skipped instead of copied
The path guards, the enumeration's reparse-point skip and the per-file attribute check all judged path strings, while the FileStream feeding the ZIP opened that string later (after earlier files were compressed). Under any recursive source folder a local user can write to, a subdirectory turned into a junction in that window made SYSTEM copy the junction target into the tenant-uploaded package. PinnedSourceFolder holds each section folder open for the whole section (final path must equal the validated path, 8.3-expanded; FILE_LIST_DIRECTORY without delete share, so NTFS refuses renaming it or any ancestor) and opens each candidate with FILE_FLAG_OPEN_REPARSE_POINT, requiring the handle's final path to be exactly <canonical folder>\<relative path>; the archive is fed from that same handle. Manifest: FOLDER REJECTED (...) / SKIPPED (resolved outside validated folder) / FAILED (open). The race is pinned by a test that swaps the directory from the BeforeSourceFileOpen seam; on the previous code the same test copies the junction target. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ecfa59d049 |
fix(security): PUT preview/notification-email requires a member role once the tenant has members; roleless writes only in the signup window
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6e3487756a |
fix(security): gather-rule on_event feedback loop — timeline feed carries the event source and never dispatches on_event for gather-emitted events; per-rule on_event cap (100/session); backend rejects rules triggering on their own output type / gather_result
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
82b26b4ab9 |
fix(security): HTML-encode domain in transactional mails; freeze DomainName on config PUT, validate first-login seed
Welcome/farewell mails rendered DomainName unencoded while the full-model config PUT persisted it verbatim, letting a tenant admin ship attacker HTML in a signed product mail to an arbitrary recipient. Encode at Render() and the built-in builders, preserve DomainName from the stored row on PUT for every caller, and only seed a strict DNS host name at first login. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1733f4fd48 |
fix(agent): SignalIngress worker re-entrancy — never block the single consumer on its own post
EffectRunner posts ClassifierVerdictIssued from inside DecisionStepProcessor.ApplyStep, i.e. on the SignalIngress worker thread. With the bounded channel (256) full, Post's back-pressure slow path blocked the channel's only consumer as a producer: nothing could ever drain, every collector Post blocked behind it, and the whole decision/monitoring pipeline froze silently until the max-lifetime watchdog. Post now detects a call from the worker thread itself and routes the item to a worker-local follow-up queue that WorkerLoop drains (iteratively, ahead of the channel) right after the current item. Same processing path — ordinals, SignalLog, emit, reduce, ApplyStep — just never blocking. Worker posts are accepted during the Stop drain too, since the verdict belongs to the step being finished. ReentrantPostCount exposed for observability/tests. Tests: full channel + worker post releases a blocked foreign producer and processes the verdict before the next channel item (fails against the previous code by hanging); follow-up chain drained in order without recursion; verdict posted during Stop drain is not lost. Docs: agent/decision-engine.md, agent/overview.md, log.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fc46b4c78e |
feat(security): session-owner binding stage 1 (shadow) + dormant agent session rotation
Agent endpoints authenticated per tenant but authorized session writes on a caller-supplied session id. Sessions rows now carry the creating device identity (cert thumbprint + Intune device id from the CN, or bootstrap code + serial) in primary-only Owner* columns. SessionOwnershipPolicy evaluates register / telemetry / error-report requests against it; SessionOwnerBindingObserver records the outcome on the request row, as a warning and as a throttled SessionOwnerMismatch ops event. Nothing is rejected in this stage. Agent: errorCode parsing on 401/403, one-shot session rotation on session_owner_mismatch without feeding the auth-failure tracker, spool drop, agent_started.sessionRotated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c18de5d76c |
fix(security): drop client-writable certificate header; bootstrap routes token-only, fail-closed
SecurityValidator accepted certificate bytes from X-Client-Certificate when X-ARR-ClientCert was absent. That header is client-writable, and certificate content alone carries no proof of private-key possession — the TLS handshake does. On platform-cert-enforced routes the fallback was unreachable, but /api/bootstrap/* is excluded from the platform cert requirement and an EMPTY X-Bootstrap-Token passed the function gate (Headers.Contains) while the validator treated it as no token and fell through to the certificate stage, where the fallback header was parsed (live-verified 2026-08-29). - Only X-ARR-ClientCert is read; the platform strips inbound copies even on excluded paths. - IsBootstrapRoute (path prefix mirroring the exclusion entry): missing/empty token or unwired BootstrapSessionService => 401 before the certificate stage. - GetBootstrapToken treats an empty header as absent; the three bootstrap functions use it. - Tests: BootstrapRouteFailClosedTests. Docs: client-cert-tenant-binding.md transport section. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a306cd9e40 |
feat(security): resolve admin identity bindings automatically at grant time; per-tenant bindings panel
Granting a cross-tenant role is "type the UPN" again. AdminIdentityResolver derives the home tenant + object id from sign-in history (UserActivity: both values came from validated tokens, most recent sign-in wins), falls back to the UPN domain of an onboarded tenant (tid only, oid pinned on first sign-in), and never guesses when a UPN was seen under two tenants. Only a never-seen domain yields 422 HomeTenantUnresolved, on which the portal shows a home-tenant picker. - IMetricsRepository.GetSignInIdentitiesByUpnAsync (filtered UserActivity scan, grant-time only) - Grant endpoints: homeTenantId/objectId optional overrides; bindings dropped from the list bodies - Web: identity fields and binding pills removed from the delegated / tenant-group forms; new collapsed "Identity bindings" panel in Tenant Management -> Edit Tenant Configuration (bindings homed in that tenant; inline edit / remove via global/identity-bindings) - Tests: AdminIdentityResolverTests; grant validation now accepts an absent home tenant Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8ec8524252 |
fix(security): bind platform and delegated roles to the immutable Entra tid+oid, not the UPN string
GlobalAdmins, DelegatedAdmins and TenantGroupAssignments resolved their role from the upn/preferred_username claim alone while the API accepts tokens from every Entra tenant, so a genuine token minted in a foreign tenant (re-registered domain) or by a recycled UPN in the home tenant inherited platform admin. - New AdminIdentityBindings table: RK = UPN -> home TenantId + ObjectId; in TableNames.All, CriticalBackupTables and the full-restore-forbidden auth set; offboarding wipes bindings homed in the offboarded tenant - AdminIdentity (upn + tid + oid) is what GlobalAdminService.GetGlobalRoleAsync and DelegatedAdminService.GetScopeAsync resolve on; order row -> binding -> Pro gate, fail-closed (no oid / no binding / tid or oid mismatch => no role), mismatches logged at Warning - Object id pinned on the first sign-in from the bound tenant (ETag-conditional, verified against the stored winner); a foreign-tenant token can never claim the pin - Grants (auth/global-admins, global/delegated-admins, tenant-group assignees) require homeTenantId (+ optional objectId) and answer 409 instead of re-homing a bound UPN; GET/PUT/DELETE global/identity-bindings is the audited rebind surface - RequestContext.ObjectId; throttle identity prefers oid so shared UPN strings across tenants no longer collide in one rate-limit bucket - Web: home tenant / object id fields on the grant and assign forms, per-UPN binding pill - Tests: binding verdict matrix + race semantics, middleware-level denials for foreign-tenant and recycled-UPN tokens; OKF doc docs/backend/admin-identity-binding.md Legacy role rows are inert until their binding is seeded - seed before deploying the backend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
9b4222e729 |
fix(ime-archive): verify MSI ProductVersion and walk primary/secondary/hotfix hosts before archiving
Microsoft serves IME from several versionless hosts carrying different rings at once (secondary = 1.104.102.0 while hotfix = 1.105.103.0), so "new version == what the canonical URL serves" filed a 1.104 package under 1.105.103.0. The archiver now spools each candidate, reads ProductVersion with a managed CFB/MSI reader (Linux host, no msiexec) and uploads only a match; no match ends as Failed:VersionMismatch. A later sighting with a version-authoritative CSP URL re-queues an unarchived version at most once per 24 h (MsiArchiveUpdatedAt + Queued stamp). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
cf76c4338f |
feat(vuln): manual "Refresh NVD Cache" and "Sync EPSS" with their own last-sync lines
The admin Data Sync Status only offered KEV and MSRC; the NVD stale-row refresh and the EPSS re-score ran on the nightly timer alone. Both walks move out of the timer into VulnerabilityCacheRefreshService (singleton, one in-flight slot per walk) so the buttons and the nightly run share one code path and stamp the same timestamps (NvdCacheLastRefreshUtc — only for a walk that reached every stale row; EpssLastSyncUtc). POST vulnerability/sync-nvd starts the minutes-long, rate-limited NVD walk in the background and answers 202 (409 while running); the page polls sync-status (nvdRefreshRunning, nvdLastRun) every 10 s meanwhile. POST vulnerability/sync-epss runs synchronously and returns the counts. The timer skips a slot a manual run holds instead of queueing a duplicate. Shared manifest regenerated for the two new AdminConfiguration fields. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a6184f165b |
feat(vuln): FIRST EPSS as fourth source, act/attend/track priority, CVSS vector; never cache a refused NVD answer
EPSS: EpssApiClient (api.first.org, 100 ids per batch, unauthenticated, fail-soft). Score, percentile and date live on the cached CVE — one batch per CPE fill, every cve_* row re-scored by the nightly sync, only changed rows rewritten and with their original CachedAt. CveIndex carries EpssScore (max, absent when unscored) and Priority; TopCve exposes both. CvePriority: act = CISA KEV, attend = EPSS >= 0.1 or CVSS >= 9.0, track = rest — riskLevel/overall_risk semantics unchanged. NVD throttle: Polly already retried 429s; the defect was that a refused answer came back as an empty response and was cached as "0 CVEs" for seven days (also the negative auto-resolve cache). NvdFetchResult separates answered from refused; nothing persists a refusal; a 429 opens a process-wide cooldown (Retry-After capped at 120 s, else 30 s) so a run fails fast instead of paying a retry chain per software item; hard-expired rows keep serving meanwhile. The NVD->cache projection and the row builder are now single functions shared by the live fill and the sync. CVSS v3 vector string persisted and shown. Portal: EPSS column + pill, priority pill, vector line; exposure panel pills. MCP tool descriptions and the CVE-audit prompt name the fields. Terms attribution + privacy page updated in the same change. OKF doc backend/vulnerability-correlation-sources.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1a4e13f0e9 |
feat(mcp): implement MCP spec 2026-07-28 (SDK v2 dual-era handler) and Client ID Metadata Documents
Wire protocol: replace @modelcontextprotocol/sdk 1.30 with @modelcontextprotocol/server + /node 2.0.0 (zod 4). One per-request factory (mcp-server-factory.ts, extracted from index.ts) serves both eras through mcp-http.ts: 2026-07-28 clients via createMcpHandler (legacy:'reject', JSON response mode, server/discover, Mcp-Method routing), 2025 clients through the existing stateless transport with enableJsonResponse — routed on the SDK's own isLegacyRequest predicate, because the built-in stateless fallback would answer every current client with SSE and lose the gzip win. Capabilities now declare listChanged:false explicitly; cacheHints stamp ttlMs/cacheScope:'private' on discover/list results (5 min) and resource reads (60 min) — the catalog is role-dependent, so never public. Authorization: advertise client_id_metadata_document_supported; an https client_id is a Client ID Metadata Document resolved by cimd.ts (URL-shape checks, SSRF gate on every resolved address before any request, no redirects, 5 s / 16 KB caps, exact client_id match, bounded redirect_uris, 10-60 min cache). The requested redirect_uri still has to pass the dynamic-registration allowlist; HMAC-signed DCR client_ids remain the fallback. Field limits moved to oauth-limits.ts (no import cycle). Tests: mcp-http.test.ts drives the real v2 client over loopback HTTP in both eras and pins role parity; cimd.test.ts / oauth-cimd.test.ts cover recognition, SSRF, validation, cache and the router paths. Package 1.5 -> 1.6. OKF doc docs/mcp/protocol-eras-and-cimd.md; mcp-oauth-flow.md updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bf156cf862 |
fix(backend): CMTrace skew tripwire ignores backlog-replay bursts via per-sample grid conformity
CmTraceTimeSkewRegression fired 7x in its first soak week; every checked case was a relaunched agent re-tailing the IME log and uploading one burst holding most of the session's IME samples with a continuum of ages whose median hit the 15-minute grid by chance. The tripwire now also requires >= 80 % of the IME deltas (relative to the other side's median) to individually sit within 2 min of some grid multiple; era-mixing regressions still fire. Fraction is carried in the ops event message and details. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0eaa3527f9 |
feat(validation,web): device association validator generally available
Microsoft released Windows Autopilot device association on 2026-08-25. The DevPrep association lookup leaves shadow mode and becomes an accepting method of the device-validation gate (after Autopilot and Corporate Identifier, before the Cloud PC fallback; ValidatedBy = DeviceAssociation, transient -> 503). The toggle is no longer Global-Admin-only: backend PUT revert and GaOnlyFields dropped, settings toggle shown to every tenant admin under Device Preparation with the shared consent rule. Landing hero carries a release pill linking to the docs; dashboard tip added. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
040e411958 |
feat(classification,calibration): rule 5a completed-assumed, max-lifetime grace skip, drift-radar floors
First real calibration read (2026-08-27, four days instrumented) drove three data-backed changes: - Rule 5a: past grace (or with the agent provably gone), Device Setup done + real-user desktop + zero app_install_failed reconciles to Succeeded "completed (assumed)" (r5_assumed). r5-Incomplete devices re-enrolled at 2.6% - the succeeded background, not the ~29% failure level - and the sampled desktop half had clean app records. No desktop or any failed app stays Incomplete; rule 4 remains the immediate desktop+Hello reconcile. - Rule 5 skips the AwaitingUser grace once either max-lifetime watchdog shape (enrollment_failed/agent_timeout, agent_shutting_down/max_lifetime) is in the stream: all five observed maxlife:r5_awaiting parks expired unhealed while live-agent parks upgraded 7/26. The maxlife ingest appends its trigger event to the read stream so the fact is never missed; late telemetry still heals a terminal verdict. WhiteGlove rule 1b keeps its park (its one episode healed). - Drift radar: MinWindowHits raised to 10 (a 3-hit episode survived as noise), per-path share regression now needs >=5 baseline hits (new or renamed vocabulary cannot "regress"), and rule-shaped legacy:* paths count into the silence/classifier-verdict group sums on both sides of the 2026-08-23 instrumentation cutover - dissolving the lift-124 silence artifact. Episodes below the fire floors clear themselves. Backend only; the new path appears in the calibration matrix dynamically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
42c45cf567 |
feat(admin,backend): find a tenant by its notification or contact email
A delivery log names only the recipient, never the tenant the welcome mail was for. The tenant console's search now matches the notification and contact address as well as domain and tenant id; the quoted exact mode is unchanged, and no other address is searchable (an admin UPN never receives platform mail and would only widen the PII surface of the list). The addresses live in PreviewWhitelist, not on the tenant config, so a new GA/Reader-only GET /api/preview/notification-emails returns them as one map -- a single cross-partition scan instead of a point read per tenant. Both sides join on the lowercased tenant id (address rows carry the JWT tid, a config PartitionKey's casing is not guaranteed) and matching is case-insensitive, because stored addresses keep the casing the user typed. The map is best-effort in the portal: a failed read costs only the search-by-address, never the tenant list. Addresses stay out of the list rows -- they are a search key, and remain visible in the tenant editor. Match rules moved out of the component into tenantSearch.ts with unit pins, and a catalog test pins that the plural route does not resolve to the per-tenant entry one character away (which would demand a tenantId the route does not carry, and hand a delegated caller a rescue path into the whole map). Deploy: backend before web. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
51f2dabc8c |
feat(web): explain the blank WiFi signal in the network timeline
The timeline showed WiFi segments with an SSID and no signal, and an "Avg WiFi signal" stat that simply vanished — on a location-gated device that reads as a product defect rather than the Windows 24H2 gate that caused it. No agent change was needed: buildSegments already merges every wifi_signal_info into the WiFi segment covering its timestamp, so wifiDataLimitedReason rides in with the sample that was already being read. - NetSegment.dataLimitedReason, filled from the sample. - Segments that never got a sample of their own (a mid-session reconnect emits no wifi_signal_info) inherit it: the gate is a device-level setting and cannot flip during an enrollment. A segment WITH a real reading is never overwritten — pinned by a test. - Tooltip: "Signal: unavailable — Location services off on this device" where the percentage would be. - Stats row: "Avg WiFi signal: n/a (Location services off)" instead of dropping the row. network_state_change is deliberately left alone. That event has never carried signalPercent or radioType — NetworkChangeDetector collects them into its snapshot for change detection, but only before_/after_wifiSsid reaches the payload — so there is no absent field there for a reason to explain, and the SSID it does carry is unaffected by the gate. Adding it would ship a field nothing reads. Verified: web tsc + eslint (0 warnings) + vitest 893/893. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0f9a77c848 |
feat(agent,web,mcp): surface the 24H2 location gate instead of only logging it
A missing WiFi signal reading is indistinguishable from a bug unless the payload
says why, and the device-level location setting is not something an admin would
think to check. Since
|
||
|
|
058248d95c |
fix(agent): read WiFi SSID via ungated WinRT API — 24H2 location gate
wifi_signal_info was missing on most Windows 11 24H2+ WiFi enrollments (build 22631 3/3, 26100 37/178, 26200 43/95 over n=276 sessions since 08-01). Not a parser or language problem: every event that exists carries a full SSID, so the failure is binary at the reader level. Since 24H2 Windows gates WlanQueryInterface with wlan_intf_opcode_current_connection — the only opcode we use — behind precise-location consent and returns ERROR_ACCESS_DENIED. The consent prompt only fires for a process in the user's context outside System32, so the agent (SYSTEM, session 0, before the OOBE privacy page) is denied silently, and it resolves consent from the HKLM ConsentStore because the S-1-5-18 hive has none — while the interactive user on the same machine reads the SSID fine via the per-user NonPackaged value. That is why it tracks the image's location setting, not hardware, driver or language. netsh wlan hangs off the same gate, so the 2026-07 netsh fallback and its multi-language labels never covered the case they were built for; more labels cannot fix it. - New WinRtWifiSsidReader: Microsoft's documented replacement WlanConnectionProfileDetails.GetConnectedSsid (ungated, SSID only), bound via the CLR WinRT projection — no winmd, no NuGet, no csproj change — the same mechanism OobeStateReader already uses from the SYSTEM context. - New WifiInfoProvider owns the tier order (native -> WinRT -> netsh) that both collectors had duplicated. - The step diagnostics added in 2026-07 were logged at Debug while the default level is Info, so they never reached a field log. The all-tiers-failed case is now a Warning that decodes rc=5 into the location-gate explanation. Signal, PHY and channel have no ungated source and stay null on tier 2; deriving a percentage from GetSignalBars would fabricate precision. Verified: build 0 warnings, agent suite 2542/2542, interop struct sizes measured live (532/604), and the full WinRT chain resolves and returns the SSID under net48. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d6895f2c7e |
feat(security): enforce cert tenant binding — reject cross-tenant certificates
The Intune roots the chain is pinned to are shared by every Intune tenant, so a valid certificate only ever proved "issued by Intune to someone". Tenant scoping rested on the device serial, which is printed on the chassis. A Mismatch now returns 403. Switched on against measured data, not a hunch: 35,684 requests across 32 tenants since the shadow telemetry landed, every one a Match, with the Warning channel independently verified to be carrying other traffic so the zero could not be a telemetry artefact. Only Mismatch blocks, and the rule lives in one function so "what rejects a request" has a single answer. ExtensionMissing, Unparseable and RequestTenantNotAGuid mean "cannot tell" and are tolerated: the sample only covers devices active in the window, so an older certificate or a sovereign-cloud CA could still lack the extension, and rejecting on absence would lock those devices out for something outside their control. ExtensionMissing is the candidate for later tightening and has its own test pinning the tolerance, so removing it must be a decision. Evaluation fails open on its own exception — a defect on our side is not evidence of a cross-tenant certificate. The 403 body does not echo which tenant the certificate belongs to; that pair goes to the rejection log. Device binding (stage 3) is untouched: still shadow, still Global-Admin-only. Backend 4638/4638, 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c91d8d4501 |
fix(agent): complete enrollments with zero user-targeted apps instead of failing them
IME never writes its "Completed user session" line when the user has no assigned Win32 apps - its zero-app branch returns before that statement (ApplicationPoller.cs:713-721, identical in 1.80/1.83/1.97/1.104). So ImeUserSessionCompletedUtc stayed null, every arm of ShouldTransitionToAwaitingHello was unsatisfiable, and the 30-min advisory_completion backstop resolved healthy enrollments as esp_exit_without_completion_evidence. One tenant: 10 of 30 sessions in a week, all false positives - DAD-validated desktop, Hello provisioned, RealmJoin still installing. Every Succeeded session there carries ime_user_session_completed; not one of the failed ones does. IME-USER-SESSION-ZERO-APPS reads the line IME does write there and routes it through the same OnUserSessionCompleted callback. That is IME's own verdict rather than a weakened gate: the zero-app branch locks the ESP in with an empty application list, and the sibling emit site returns ProvisioningComplete right after logging it. Guarded to the AccountSetup phase so it cannot burn the adapter fire-once flag on a pre-sign-in timestamp, and replayed on the phase transition when the observation predates the phase marker. AreUserEspAppsSettled accepts an empty app list only with that explicit observation, never on emptiness alone. Second, independent defect: advisory_completion (30 min) overran the RealmJoin completion gate, which deliberately holds the session and carries its own bounded resolution (60 min from detection, activity-extended, 4 h cap). One session installed 25 RealmJoin packages inside the window, the last 2 min before the failure, while completion_waiting still read "waiting on: realmjoin_resolution". The handler now re-arms while that gate is closed and its own deadline is still armed; convergence comes from the gate itself, which always ends with an Outcome set. ime_user_session_completed gains an evidence field (zero_user_apps_enumerated / user_session_completed) - no new event type, so the Constants -> resource-catalog -> shared-manifests triple stays untouched. The pattern ships via the config API and reaches existing agents, where an unknown action is a single Debug line; the behaviour needs the agent release. Build 0 warnings. DecisionCore 558/558, V2.Core 2530/2530, Backend 4634/4634, MCP event-types-drift 6/6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bf6850dedc |
perf(security): log DevPrep association shadow once per lookup, not per request
Same gate the cert-device-binding shadow got in
|
||
|
|
35a3ed0cfb |
perf(security): log device binding once per Graph lookup, not per request
Measured on the first live enrollment: 136 agent requests produced exactly ONE Graph call, because the 30-minute positive cache absorbed the rest. So the check is not a throttling risk (the per-request Autopilot serial validator made 25 calls in the same window) and the positive TTL needs no change — raising it would only delay noticing a wiped or removed device. What the 136 requests did cost was 136 log lines all saying the same thing. Gate the shadow log on ServedFromCache so it emits one line per real lookup. Cached repeats carry no new information: the reported age just counts up mechanically from the same enrolledDateTime. Outcomes that are never cached (Transient, NoDeviceIdInCert) keep logging every time, which is what an anomaly warrants. AsCacheHit() copies rather than marking the shared cached instance, so the first real lookup is never mistaken for a cache hit and dropped. Backend 4631/4631, 0 warnings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ea6a375c4b |
feat(security): cert-to-device binding, stage 3 GA-gated shadow
Tenant binding (stage 1) proves the client certificate was issued to this
tenant. It does not prove the certificate belongs to a device the tenant
still has: a cert lifted from a decommissioned machine keeps working.
IntuneDeviceBindingValidator resolves the Intune managedDevice id from the
certificate's Subject CN against the tenant's own managedDevices inventory.
Point-GET on managedDevices/{id} rather than an OData filter, so there is no
filter literal to inject into and a 404 is unambiguous. Cache/retry/transient
contract mirrors CloudPcDeviceValidator.
Shadow only, and visible only to Global Admins (same gate as the DevPrep
association preview) because the open question is a race, not an attack: a
device object can appear after the agent's first call. Every result carries
enrolledDateTime and the log line records enrolledAgeSeconds, so a device
created seconds before the request is distinguishable from a foreign cert.
Grep marker: CERT-DEVICE-BINDING-SHADOW.
The lookup is fire-and-forget like the DevPrep shadow so a cold Graph
round-trip never sits in the agent request path. That rules out stage 1's
request-row dimension (the row is written before the task finishes), hence
the Warning log; widening beyond preview should move to inline + dimension.
Needs DeviceManagementManagedDevices.Read.All, granted per tenant via the
customer script as feature IntuneDeviceBinding. The script's catalog,
ValidateSet and help block stay pinned to GraphFeatureCatalog by
GraphFeatureCatalogSyncTests (extended with the help-block case).
Build 0 warnings, backend 4629/4629, web 874/874, tsc clean, eslint 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|