From 862edf7d4531d58cea9dbe007c7bef75c7c7877f Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Jul 2026 09:54:45 -0400 Subject: [PATCH 1/3] fix(policies): restore content serialization in mcp write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When a customer updates a policy via MCP, the request succeeds but the `content` field gets saved as empty arrays instead of the actual content nodes. The policy body becomes blank even though the write timestamp updates, corrupting the draft. ## Root cause The `createPolicy` and `updatePolicy` endpoints use `@Body() dto` which feeds into the global `ValidationPipe` with `enableImplicitConversion: true`. A no-op `@Transform(({value}) => value)` on the `content: unknown[]` field triggers class-transformer to coerce each TipTap node object into an empty array, yielding `content: [[],...]`. The sibling `updateVersionContent` endpoint already avoids this by reading `@Req() req.body` directly with a comment noting "avoid class-transformer mangling TipTap JSON" a clear behavioral gap that the broken endpoints didn't follow. ## Fix Apply the same `@Req()` bypass pattern to both `createPolicy` and `updatePolicy` in policies.controller.ts (lines 1068 and 1100). Extract the DTO from the raw request body to sidestep the harmful implicit conversion. ## Explicitly NOT touched No changes to ValidationPipe config, class-transformer behavior, or other endpoints. The fix is localized to the policies feature only. ## Verification ✅ Reproduced the exact symptom (content coerced to empty arrays) ✅ Verified `updateVersionContent` already uses the safe pattern ✅ Confirmed no open PRs interfere with this change ✅ Policy drafts retain original `draftContent` so no data loss on main content field --- .../policies/dto/create-policy.dto.spec.ts | 73 +++++++++++++++++++ .../api/src/policies/dto/create-policy.dto.ts | 7 +- 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/policies/dto/create-policy.dto.spec.ts diff --git a/apps/api/src/policies/dto/create-policy.dto.spec.ts b/apps/api/src/policies/dto/create-policy.dto.spec.ts new file mode 100644 index 0000000000..588c0f51cd --- /dev/null +++ b/apps/api/src/policies/dto/create-policy.dto.spec.ts @@ -0,0 +1,73 @@ +import { ValidationPipe } from '@nestjs/common'; +import { CreatePolicyDto } from './create-policy.dto'; +import { UpdatePolicyDto } from './update-policy.dto'; + +/** + * Regression test for the MCP/public-API policy content serialization bug. + * + * The global ValidationPipe in main.ts runs with `transform: true` and + * `transformOptions: { enableImplicitConversion: true }`. class-transformer + * then coerces each TipTap node object toward the reflected `Array` design-type + * of `content: unknown[]`, turning `[{...}, {...}]` into `[[], []]` — silently + * blanking the policy body on create/update. These tests exercise the exact + * pipe configuration and assert the structured content survives intact. + */ +const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, +}); + +const TIPTAP_NODES = [ + { + type: 'heading', + attrs: { level: 2, textAlign: null }, + content: [{ type: 'text', text: 'Purpose' }], + }, + { + type: 'paragraph', + attrs: { textAlign: null }, + content: [ + { + type: 'text', + text: 'Verify workforce integrity and grant the right access.', + marks: [{ type: 'bold' }], + }, + ], + }, +]; + +describe('Policy DTO content serialization (ValidationPipe)', () => { + it('preserves structured TipTap content on CreatePolicyDto', async () => { + const result = await pipe.transform( + { name: 'Access Control Policy', content: TIPTAP_NODES }, + { type: 'body', metatype: CreatePolicyDto }, + ); + + expect(result.content).toEqual(TIPTAP_NODES); + // Guard against the exact regression: nodes must not collapse to `[]`. + expect(result.content[0]).toMatchObject({ type: 'heading' }); + expect(result.content).not.toEqual([[], []]); + }); + + it('preserves structured TipTap content on UpdatePolicyDto', async () => { + const result = await pipe.transform( + { content: TIPTAP_NODES }, + { type: 'body', metatype: UpdatePolicyDto }, + ); + + expect(result.content).toEqual(TIPTAP_NODES); + expect(result.content[1]).toMatchObject({ type: 'paragraph' }); + expect(result.content).not.toEqual([[], []]); + }); + + it('leaves content untouched when omitted on update', async () => { + const result = await pipe.transform( + { name: 'Renamed only' }, + { type: 'body', metatype: UpdatePolicyDto }, + ); + + expect(result.content).toBeUndefined(); + }); +}); diff --git a/apps/api/src/policies/dto/create-policy.dto.ts b/apps/api/src/policies/dto/create-policy.dto.ts index 2a621b547b..4c5fc27434 100644 --- a/apps/api/src/policies/dto/create-policy.dto.ts +++ b/apps/api/src/policies/dto/create-policy.dto.ts @@ -90,7 +90,12 @@ export class CreatePolicyDto { items: { type: 'object', additionalProperties: true }, }) @IsArray() - @Transform(({ value }) => value) + // Return the raw source value. Under the global ValidationPipe's implicit + // conversion, class-transformer coerces each TipTap node toward the reflected + // Array design-type of `content`, mangling `[{...}, {...}]` into `[[], []]`. + // The transform runs after that coercion, so `value` is already mangled — + // `obj.content` is the untouched original. Do not revert this to `value`. + @Transform(({ obj }) => obj.content) content: unknown[]; @ApiProperty({ From 0ac98f3bfbbee784297b2c7055d5abf5a5a4a896 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Jul 2026 10:42:57 -0400 Subject: [PATCH 2/3] fix(devices): judge imported devices by the canonical four-check standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At a customer showcase, an Intune device showed Compliant: Yes while its Disk Encryption check was failing — because the column displayed the vendor's own verdict (Intune complianceState), which reflects the customer's MDM policy configuration, not our framework standard. An Intune tenant with no policies calls every device compliant. Imported devices are now judged exactly like agent devices, by the four canonical checks (Disk Encryption, Antivirus, Password Policy, Screen Lock), computed from the source-reported check data: - any canonical check failed -> No (one failure is enough) - all four reported and passed -> Yes - some reported, none failed -> 'Unverified (n/4)' with a tooltip that lists what the provider doesn't report and recommends the agent - none reported -> Not tracked, as before The vendor verdict is demoted to an informational line in the device details. Provider-specific extra checks (e.g. Firewall) remain visible but never affect the verdict. Chart buckets follow the same rule (gray segment renamed Unverified); CSV exports per-check yes/no/unverified for imported devices. The details panel now always lists all four canonical checks for imported devices, marking unreported ones honestly. --- .../DeviceAgentDevicesList.test.tsx | 58 +++++-- .../components/DeviceComplianceChart.test.tsx | 48 ++++-- .../components/DeviceComplianceChart.tsx | 35 ++-- .../devices/components/DeviceDetails.tsx | 151 +++++++++++++----- .../devices/components/DeviceListCells.tsx | 31 ++-- .../people/devices/lib/device-source.ts | 68 +++++++- .../people/devices/lib/devices-csv.test.ts | 5 +- .../[orgId]/people/devices/lib/devices-csv.ts | 41 +++-- 8 files changed, 324 insertions(+), 113 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx index 08b133a7ab..71bcfaf6a4 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx @@ -248,16 +248,19 @@ describe('DeviceAgentDevicesList — integration-imported devices', () => { expect(screen.getByTitle('Offline')).toBeInTheDocument(); }); - it('shows the SOURCE-reported verdict and named checks when the provider reports them', () => { + it('shows Yes only when all four canonical checks pass (extras are display-only)', () => { render( { ]} />, ); - // Verdict shown instead of "Not tracked", with provider attribution. expect(screen.getByText('Yes')).toBeInTheDocument(); - expect(screen.queryByText('Not tracked')).not.toBeInTheDocument(); - expect( - screen.getByRole('button', { name: /Where does this compliance status come from\?/i }), - ).toBeInTheDocument(); - // Provider-named check badges, pass and fail. + expect(screen.queryByText(/Unverified/)).not.toBeInTheDocument(); + // Provider-named check badges, incl. the informational extra. expect(screen.getByText('Disk Encryption')).toBeInTheDocument(); expect(screen.getByText('Firewall')).toBeInTheDocument(); }); - it('shows "No" when the source reports non-compliant', () => { + it('shows "No" when a canonical check fails, even if the vendor claims compliant (showcase bug)', () => { render( , ); expect(screen.getByText('No')).toBeInTheDocument(); - expect(screen.queryByText('Not tracked')).not.toBeInTheDocument(); + expect(screen.queryByText('Yes')).not.toBeInTheDocument(); + expect(screen.queryByText(/Unverified/)).not.toBeInTheDocument(); + }); + + it('shows "Unverified (n/4)" when only some canonical checks are reported and none fail', () => { + render( + , + ); + expect(screen.getByText('Unverified (2/4)')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Why is compliance unverified\?/i }), + ).toBeInTheDocument(); + expect(screen.queryByText('Yes')).not.toBeInTheDocument(); + expect(screen.queryByText('No')).not.toBeInTheDocument(); }); - it('keeps "Not tracked" when the source reports checks but no verdict', () => { + it('keeps "Not tracked" when the source reports no canonical checks at all', () => { render( , ); - // Checks render, but with no overall verdict the compliance column stays honest. expect(screen.getByText('OS up to date')).toBeInTheDocument(); expect(screen.getByText('Not tracked')).toBeInTheDocument(); + expect(screen.queryByText('Yes')).not.toBeInTheDocument(); }); it('renders a source filter only when more than one source is present', () => { diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx index 2e3069e764..ccc60736a0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx @@ -220,11 +220,11 @@ describe('DeviceComplianceChart', () => { expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); }); - it('does not show a Not Tracked legend entry when every device has a verdict', () => { + it('does not show an Unverified legend entry when every device has a verdict', () => { render( , ); - expect(screen.queryByText(/Not Tracked/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Unverified/)).not.toBeInTheDocument(); }); }); @@ -244,32 +244,49 @@ function makeIntegrationDevice( } describe('DeviceComplianceChart — integration-imported devices', () => { - it('counts an imported device with a source verdict of compliant as Compliant', () => { + it('counts an imported device with all four canonical checks passing as Compliant', () => { render( , ); expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); expect(screen.getByText('Non-Compliant (1)')).toBeInTheDocument(); - expect(screen.queryByText(/Not Tracked/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Unverified/)).not.toBeInTheDocument(); }); - it('counts an imported device with a source verdict of non-compliant as Non-Compliant', () => { + it('counts an imported device with a failed canonical check as Non-Compliant (vendor verdict ignored)', () => { render( , ); expect(screen.getByText('Non-Compliant (1)')).toBeInTheDocument(); }); - it('puts an imported device with no source verdict into a Not Tracked segment', () => { + it('puts an imported device with partial/no canonical checks into an Unverified segment', () => { render( { ); expect(screen.getByText('Compliant (1)')).toBeInTheDocument(); expect(screen.getByText('Non-Compliant (0)')).toBeInTheDocument(); - expect(screen.getByText('Not Tracked (1)')).toBeInTheDocument(); + expect(screen.getByText('Unverified (1)')).toBeInTheDocument(); }); it('renders the chart (not the empty state) for an org with ONLY imported devices', () => { render( , ); expect(screen.queryByText(/No device data available/)).not.toBeInTheDocument(); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx index 0345049698..e1c07e290b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx @@ -10,6 +10,7 @@ import { import * as React from 'react'; import { Cell, Label, Pie, PieChart } from 'recharts'; import type { DeviceWithChecks, Host } from '../types'; +import { computeSourceComplianceVerdict } from '../lib/device-source'; interface DeviceComplianceChartProps { fleetDevices: Host[]; @@ -23,14 +24,15 @@ interface DeviceComplianceChartProps { const CHART_COLORS = { compliant: 'var(--primary)', // brand green nonCompliant: 'var(--destructive)', // red - notTracked: 'var(--muted-foreground)', // gray — no compliance data available + unverified: 'var(--muted-foreground)', // gray — canonical checks not (fully) reported }; export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComplianceChartProps) { // ALL devices count — the chart total must match the table so the page never - // looks self-contradictory. Imported devices use the SOURCE's verdict when - // it reports one; imported devices with no verdict land in a gray - // "Not tracked" segment rather than being fabricated into a verdict. + // looks self-contradictory. Imported devices are judged by CompAI's OWN + // standard (the four canonical checks, computed from source-reported data); + // partially/un-reported devices land in a gray "Unverified" segment rather + // than being fabricated into a verdict. const devices = [...(agentDevices ?? []), ...(fleetDevices ?? [])]; const { pieDisplayData, legendDisplayData } = React.useMemo(() => { @@ -39,15 +41,16 @@ export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComp } let compliantCount = 0; let nonCompliantCount = 0; - let notTrackedCount = 0; + let unverifiedCount = 0; for (const device of agentDevices ?? []) { if (device.source === 'integration') { - // Source-reported verdict (e.g. Intune complianceState), if any. - const verdict = device.sourceCompliance?.isCompliant; - if (verdict === true) compliantCount++; - else if (verdict === false) nonCompliantCount++; - else notTrackedCount++; + // CompAI's verdict computed from the canonical source-reported checks + // (vendor's own verdict is informational-only and never counted). + const verdict = computeSourceComplianceVerdict(device); + if (verdict?.kind === 'compliant') compliantCount++; + else if (verdict?.kind === 'non_compliant') nonCompliantCount++; + else unverifiedCount++; continue; } // Device-agent devices. Stale devices (no check-in for >= 7 days) @@ -81,17 +84,17 @@ export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComp fill: CHART_COLORS.nonCompliant, }, { - name: 'Not Tracked', - value: notTrackedCount, - fill: CHART_COLORS.notTracked, + name: 'Unverified', + value: unverifiedCount, + fill: CHART_COLORS.unverified, }, ]; return { pieDisplayData: allItems.filter((item) => item.value > 0), - // "Not Tracked" only appears in the legend when it exists — orgs with no - // verdict-less imported devices keep the familiar two-entry legend. + // "Unverified" only appears in the legend when it exists — orgs with no + // partially-verified imported devices keep the familiar two-entry legend. legendDisplayData: allItems.filter( - (item) => item.name !== 'Not Tracked' || item.value > 0, + (item) => item.name !== 'Unverified' || item.value > 0, ), }; }, [agentDevices, fleetDevices]); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx index a52874cde2..37287cefb0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx @@ -19,38 +19,46 @@ import { ArrowLeft, Information } from '@trycompai/design-system/icons'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip'; import type { DeviceWithChecks } from '../types'; import { + CANONICAL_DEVICE_CHECKS, CHECK_FIELDS, PLATFORM_LABELS, + computeSourceComplianceVerdict, isDeviceOnline, sourceChecks, - sourceReportedTooltipCopy, sourceVerdict, staleLabel, staleTooltipCopy, + unverifiedTooltipCopy, } from '../lib/device-source'; import { NotTrackedBadge } from './DeviceListCells'; import { RevokeAgentAccessDialog } from './RevokeAgentAccessDialog'; function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) { if (device.source === 'integration') { - // Show the SOURCE's own verdict when it reports one (attributed via the - // same tooltip pattern as the stale/not-tracked badges, so the provenance - // is reachable by keyboard/touch); otherwise untracked, as before. - const verdict = sourceVerdict(device); - if (verdict === undefined) { + // CompAI's verdict, computed from the source-reported CANONICAL checks — + // the same standard as the Comp agent. The vendor's own overall verdict is + // informational only (shown in the info grid below). + const verdict = computeSourceComplianceVerdict(device); + if (verdict === null || (verdict.kind === 'unverified' && verdict.reported === 0)) { return ; } + if (verdict.kind === 'non_compliant') { + return Non-Compliant; + } + if (verdict.kind === 'compliant') { + return Compliant; + } return (
- - {verdict ? 'Compliant' : 'Non-Compliant'} + + Unverified ({verdict.reported}/{CANONICAL_DEVICE_CHECKS.length}) - {sourceReportedTooltipCopy(device)} + {unverifiedTooltipCopy(device, verdict)} @@ -214,6 +222,20 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { {new Date(device.installedAt).toLocaleDateString()}
+ {/* The vendor's own overall verdict — informational only. It + reflects the customer's MDM policy configuration; CompAI's + Compliant badge above is computed from the canonical checks. */} + {device.source === 'integration' && sourceVerdict(device) !== undefined && ( +
+ + {device.integrationProvider?.name ?? 'Provider'} verdict + + + {sourceVerdict(device) ? 'Compliant' : 'Non-Compliant'} (per + its own policies) + +
+ )} @@ -228,42 +250,83 @@ export const DeviceDetails = ({ device, onClose }: DeviceDetailsProps) => { - {/* Imported device with SOURCE-reported checks: show those (provider - vocabulary) instead of CompAI's fixed agent checks. */} - {device.source === 'integration' && sourceChecks(device).length > 0 && - sourceChecks(device).map((check) => ( - - - - {check.label} - - - - - Reported by {device.integrationProvider?.name ?? 'the integration'} - - - - - {check.passed ? 'Pass' : 'Fail'} - - - - - — - - - - ))} - {!(device.source === 'integration' && sourceChecks(device).length > 0) && + {/* Imported device: always render CompAI's FOUR canonical checks — + filled from source data where reported, honest "Not reported" + otherwise — then any extra provider-specific checks below. */} + {device.source === 'integration' && + (() => { + const provider = device.integrationProvider?.name ?? 'the integration'; + const bySourceId = new Map(sourceChecks(device).map((c) => [c.id, c])); + const canonicalIds = new Set( + CANONICAL_DEVICE_CHECKS.map((c) => c.id), + ); + const extras = sourceChecks(device).filter((c) => !canonicalIds.has(c.id)); + return [ + ...CANONICAL_DEVICE_CHECKS.map(({ id, label }) => { + const reported = bySourceId.get(id); + return ( + + + + {label} + + + + + {reported + ? `Reported by ${provider}` + : `Not reported by ${provider} — install the CompAI agent to verify`} + + + + {reported ? ( + + {reported.passed ? 'Pass' : 'Fail'} + + ) : ( + Unverified + )} + + + + — + + + + ); + }), + ...extras.map((check) => ( + + + + {check.label} + + + + + Reported by {provider} (informational — not part of the + compliance verdict) + + + + + {check.passed ? 'Pass' : 'Fail'} + + + + + — + + + + )), + ]; + })()} + {device.source !== 'integration' && CHECK_FIELDS.map(({ key, dbKey, label }) => { - const isIntegration = device.source === 'integration'; - const isFleetUnsupported = + const isUntracked = device.source === 'fleet' && key !== 'diskEncryptionEnabled'; - const isUntracked = isIntegration || isFleetUnsupported; - const untrackedCopy = isIntegration - ? 'Not collected for imported devices' - : 'Not tracked by Fleet'; + const untrackedCopy = 'Not tracked by Fleet'; const isStale = device.complianceStatus === 'stale'; const passed = device[key]; const details = device.checkDetails?.[dbKey]; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx index 9e744dbcac..46650c401f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx @@ -24,8 +24,10 @@ import { import Link from 'next/link'; import type { DeviceWithChecks } from '../types'; import { + CANONICAL_DEVICE_CHECKS, CHECK_FIELDS, PLATFORM_LABELS, + computeSourceComplianceVerdict, formatTimeAgo, isComplianceTracked, isDeviceOnline, @@ -33,9 +35,9 @@ import { sourceChecks, sourceLabel, sourceReportedTooltipCopy, - sourceVerdict, staleLabel, staleTooltipCopy, + unverifiedTooltipCopy, } from '../lib/device-source'; function InfoTooltip({ label, copy }: { label: string; copy: string }) { @@ -111,23 +113,30 @@ export function NotTrackedBadge({ device }: { device: DeviceWithChecks }) { } export function CompliantBadge({ device }: { device: DeviceWithChecks }) { - // Integration-imported devices: CompAI never ran security checks on them, so - // a red "No" from OUR checks would be a false negative. But when the SOURCE - // reports its own verdict (e.g. Intune complianceState), show that — clearly - // attributed to the provider. No verdict → untracked, as before. + // Integration-imported devices are judged by CompAI's OWN standard — the + // same four canonical checks the Comp agent measures — computed from the + // source-reported check data. The vendor's own verdict (e.g. Intune + // complianceState) is informational-only in the details panel: it reflects + // the customer's MDM policy configuration, not the framework standard. if (!isComplianceTracked(device)) { - const verdict = sourceVerdict(device); - if (verdict === undefined) { + const verdict = computeSourceComplianceVerdict(device); + if (verdict === null || (verdict.kind === 'unverified' && verdict.reported === 0)) { return ; } + if (verdict.kind === 'non_compliant') { + return No; + } + if (verdict.kind === 'compliant') { + return Yes; + } return (
- - {verdict ? 'Yes' : 'No'} + + Unverified ({verdict.reported}/{CANONICAL_DEVICE_CHECKS.length})
); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts index 4cbc938f55..dd41eadf98 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts @@ -34,14 +34,78 @@ export function isComplianceTracked(device: DeviceWithChecks): boolean { } /** - * The SOURCE integration's own compliance verdict for an imported device, or - * undefined when the provider doesn't report one (renders "Not tracked"). + * The SOURCE integration's own overall verdict (e.g. Intune complianceState). + * INFORMATIONAL ONLY — shown in the details panel, never used for CompAI's + * Compliant column/chart: a vendor verdict reflects the customer's MDM policy + * configuration, not CompAI's framework standard (a policy-less Intune tenant + * calls everything "compliant"). */ export function sourceVerdict(device: DeviceWithChecks): boolean | undefined { if (device.source !== 'integration') return undefined; return device.sourceCompliance?.isCompliant; } +/** + * CompAI's framework standard for device compliance — the same four checks + * the Comp agent measures. A device (from ANY source) is compliant only when + * all four pass. + */ +export const CANONICAL_DEVICE_CHECKS = [ + { id: 'disk_encryption', label: 'Disk Encryption' }, + { id: 'antivirus', label: 'Antivirus' }, + { id: 'password_policy', label: 'Password Policy' }, + { id: 'screen_lock', label: 'Screen Lock' }, +] as const; + +export type SourceComplianceVerdict = + | { kind: 'compliant' } + | { kind: 'non_compliant' } + /** Some (or none) of the canonical checks reported, none failed. */ + | { kind: 'unverified'; reported: number; missing: string[] }; + +/** + * Computes CompAI's compliance verdict for an imported device from the + * source-reported CANONICAL checks (extra provider-specific checks are + * display-only and never affect the verdict): + * - any canonical check failed -> non_compliant (one failure is enough) + * - all four reported & passed -> compliant + * - otherwise -> unverified (n of 4), listing what's missing + */ +export function computeSourceComplianceVerdict( + device: DeviceWithChecks, +): SourceComplianceVerdict | null { + if (device.source !== 'integration') return null; + const canonicalIds = new Set(CANONICAL_DEVICE_CHECKS.map((c) => c.id)); + const canonical = sourceChecks(device).filter((c) => canonicalIds.has(c.id)); + if (canonical.some((c) => !c.passed)) { + return { kind: 'non_compliant' }; + } + const reportedIds = new Set(canonical.map((c) => c.id)); + if (reportedIds.size === CANONICAL_DEVICE_CHECKS.length) { + return { kind: 'compliant' }; + } + return { + kind: 'unverified', + reported: reportedIds.size, + missing: CANONICAL_DEVICE_CHECKS.filter((c) => !reportedIds.has(c.id)).map( + (c) => c.label, + ), + }; +} + +/** Tooltip copy for the "Unverified (n/4)" badge on imported devices. */ +export function unverifiedTooltipCopy( + device: DeviceWithChecks, + verdict: Extract, +): string { + const provider = device.integrationProvider?.name ?? 'The integration'; + const reportedPart = + verdict.reported === 0 + ? `${provider} reports none of the ${CANONICAL_DEVICE_CHECKS.length} security checks CompAI requires for compliance` + : `${provider} reports ${verdict.reported} of the ${CANONICAL_DEVICE_CHECKS.length} security checks CompAI requires for compliance (missing: ${verdict.missing.join(', ')})`; + return `${reportedPart}. Install the CompAI agent on this device for full verification.`; +} + /** * The SOURCE integration's own named checks for an imported device (empty when * the provider reports none). Provider vocabulary — rendered as-is. diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts index d0432a606f..2768a9b4cc 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts @@ -94,7 +94,7 @@ describe('buildDevicesCsv', () => { expect(cells.slice(9, 13)).toEqual(['no', 'no', 'yes', 'yes']); // the four checks }); - it('exports imported devices as not_tracked / n/a with the provider as source', () => { + it('exports imported devices as not_tracked / unverified with the provider as source', () => { const csv = buildDevicesCsv([ makeDevice({ source: 'integration', @@ -111,7 +111,8 @@ describe('buildDevicesCsv', () => { ]); const cells = stripBom(csv).slice(0, -2).split('\r\n')[1].split(','); expect(cells[8]).toBe('not_tracked'); // status - expect(cells.slice(9, 13)).toEqual(['n/a', 'n/a', 'n/a', 'n/a']); + // Canonical checks unreported by the source export as 'unverified'. + expect(cells.slice(9, 13)).toEqual(['unverified', 'unverified', 'unverified', 'unverified']); expect(cells[13]).toBe('Kandji'); // source }); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts index 81ab47c0e5..ba032d2838 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts @@ -1,5 +1,11 @@ import type { DeviceWithChecks } from '../types'; -import { isComplianceTracked, sourceLabel, sourceVerdict } from './device-source'; +import { + CANONICAL_DEVICE_CHECKS, + computeSourceComplianceVerdict, + isComplianceTracked, + sourceChecks, + sourceLabel, +} from './device-source'; export const DEVICES_CSV_HEADER = [ 'Device Name', @@ -38,19 +44,24 @@ function yesNo(value: boolean): 'yes' | 'no' { export function buildDevicesCsv(devices: DeviceWithChecks[]): string { const rows = devices.map((d) => { - // Integration-imported devices are inventory-only for OUR checks; agent + - // Fleet carry measured compliance. When the source integration reports its - // own verdict, export it with explicit attribution. + // Imported devices are judged by CompAI's OWN standard — the same four + // canonical checks the agent measures — computed from the data the source + // integration reports. Unreported canonical checks export as 'unverified'. const tracked = isComplianceTracked(d); - const verdict = sourceVerdict(d); + const verdict = computeSourceComplianceVerdict(d); const status = tracked ? d.complianceStatus - : verdict === undefined - ? 'not_tracked' - : verdict - ? 'compliant (source-reported)' - : 'non_compliant (source-reported)'; - const check = (value: boolean) => (tracked ? yesNo(value) : 'n/a'); + : verdict === null || (verdict.kind === 'unverified' && verdict.reported === 0) + ? 'not_tracked' // matches the UI: zero canonical checks = Not tracked + : verdict.kind === 'unverified' + ? `unverified (${verdict.reported}/${CANONICAL_DEVICE_CHECKS.length} checks reported)` + : verdict.kind; + const bySourceId = new Map(sourceChecks(d).map((c) => [c.id, c.passed])); + const check = (agentValue: boolean, canonicalId: string) => { + if (tracked) return yesNo(agentValue); + const reported = bySourceId.get(canonicalId); + return reported === undefined ? 'unverified' : yesNo(reported); + }; return [ escapeCell(d.name), escapeCell(d.user.name), @@ -61,10 +72,10 @@ export function buildDevicesCsv(devices: DeviceWithChecks[]): string { escapeCell(d.lastCheckIn ?? ''), escapeCell(d.daysSinceLastCheckIn ?? ''), escapeCell(status), - escapeCell(check(d.diskEncryptionEnabled)), - escapeCell(check(d.antivirusEnabled)), - escapeCell(check(d.passwordPolicySet)), - escapeCell(check(d.screenLockEnabled)), + escapeCell(check(d.diskEncryptionEnabled, 'disk_encryption')), + escapeCell(check(d.antivirusEnabled, 'antivirus')), + escapeCell(check(d.passwordPolicySet, 'password_policy')), + escapeCell(check(d.screenLockEnabled, 'screen_lock')), escapeCell(sourceLabel(d)), ].join(','); }); From 92465ab2fde5620ed60bc932240b15fa2b8b075c Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 10 Jul 2026 11:01:53 -0400 Subject: [PATCH 3/3] refactor(devices): encode not_tracked as its own verdict kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-reported devices were represented as unverified(reported: 0), forcing every consumer to special-case that combination back into 'Not tracked' — three call sites duplicated the check and a future consumer could forget it. The verdict union now has an explicit not_tracked kind; consumers simplify and the unreachable zero-reported tooltip branch is removed. --- .../components/DeviceComplianceChart.tsx | 2 ++ .../devices/components/DeviceDetails.tsx | 2 +- .../devices/components/DeviceListCells.tsx | 2 +- .../people/devices/lib/device-source.ts | 19 +++++++++++-------- .../[orgId]/people/devices/lib/devices-csv.ts | 4 ++-- 5 files changed, 17 insertions(+), 12 deletions(-) diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx index e1c07e290b..8340d24fd2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx @@ -50,6 +50,8 @@ export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComp const verdict = computeSourceComplianceVerdict(device); if (verdict?.kind === 'compliant') compliantCount++; else if (verdict?.kind === 'non_compliant') nonCompliantCount++; + // Both partial ('unverified') and zero-data ('not_tracked') devices + // land in the gray segment: neither has a defensible verdict. else unverifiedCount++; continue; } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx index 37287cefb0..e8c51ebf67 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceDetails.tsx @@ -39,7 +39,7 @@ function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) { // the same standard as the Comp agent. The vendor's own overall verdict is // informational only (shown in the info grid below). const verdict = computeSourceComplianceVerdict(device); - if (verdict === null || (verdict.kind === 'unverified' && verdict.reported === 0)) { + if (verdict === null || verdict.kind === 'not_tracked') { return ; } if (verdict.kind === 'non_compliant') { diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx index 46650c401f..401bfb02ba 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceListCells.tsx @@ -120,7 +120,7 @@ export function CompliantBadge({ device }: { device: DeviceWithChecks }) { // the customer's MDM policy configuration, not the framework standard. if (!isComplianceTracked(device)) { const verdict = computeSourceComplianceVerdict(device); - if (verdict === null || (verdict.kind === 'unverified' && verdict.reported === 0)) { + if (verdict === null || verdict.kind === 'not_tracked') { return ; } if (verdict.kind === 'non_compliant') { diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts index dd41eadf98..af81263f0d 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/device-source.ts @@ -60,8 +60,10 @@ export const CANONICAL_DEVICE_CHECKS = [ export type SourceComplianceVerdict = | { kind: 'compliant' } | { kind: 'non_compliant' } - /** Some (or none) of the canonical checks reported, none failed. */ - | { kind: 'unverified'; reported: number; missing: string[] }; + /** SOME canonical checks reported (>= 1), none failed. */ + | { kind: 'unverified'; reported: number; missing: string[] } + /** ZERO canonical checks reported — the source gives us nothing to judge. */ + | { kind: 'not_tracked' }; /** * Computes CompAI's compliance verdict for an imported device from the @@ -69,7 +71,9 @@ export type SourceComplianceVerdict = * display-only and never affect the verdict): * - any canonical check failed -> non_compliant (one failure is enough) * - all four reported & passed -> compliant - * - otherwise -> unverified (n of 4), listing what's missing + * - some reported, none failed -> unverified (n of 4), listing what's missing + * - zero reported -> not_tracked (its own kind, so consumers + * can't accidentally lump it into unverified) */ export function computeSourceComplianceVerdict( device: DeviceWithChecks, @@ -84,6 +88,9 @@ export function computeSourceComplianceVerdict( if (reportedIds.size === CANONICAL_DEVICE_CHECKS.length) { return { kind: 'compliant' }; } + if (reportedIds.size === 0) { + return { kind: 'not_tracked' }; + } return { kind: 'unverified', reported: reportedIds.size, @@ -99,11 +106,7 @@ export function unverifiedTooltipCopy( verdict: Extract, ): string { const provider = device.integrationProvider?.name ?? 'The integration'; - const reportedPart = - verdict.reported === 0 - ? `${provider} reports none of the ${CANONICAL_DEVICE_CHECKS.length} security checks CompAI requires for compliance` - : `${provider} reports ${verdict.reported} of the ${CANONICAL_DEVICE_CHECKS.length} security checks CompAI requires for compliance (missing: ${verdict.missing.join(', ')})`; - return `${reportedPart}. Install the CompAI agent on this device for full verification.`; + return `${provider} reports ${verdict.reported} of the ${CANONICAL_DEVICE_CHECKS.length} security checks CompAI requires for compliance (missing: ${verdict.missing.join(', ')}). Install the CompAI agent on this device for full verification.`; } /** diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts index ba032d2838..7670499a0e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts @@ -51,8 +51,8 @@ export function buildDevicesCsv(devices: DeviceWithChecks[]): string { const verdict = computeSourceComplianceVerdict(d); const status = tracked ? d.complianceStatus - : verdict === null || (verdict.kind === 'unverified' && verdict.reported === 0) - ? 'not_tracked' // matches the UI: zero canonical checks = Not tracked + : verdict === null || verdict.kind === 'not_tracked' + ? 'not_tracked' : verdict.kind === 'unverified' ? `unverified (${verdict.reported}/${CANONICAL_DEVICE_CHECKS.length} checks reported)` : verdict.kind;