From 14ed8ca248a224c9074fed40021692f8cc21bc84 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 19:36:53 -0400 Subject: [PATCH] feat(23-01): add USER_AWARENESS verdict + acknowledge_user action mapping - Add USER_AWARENESS to the Verdict union in campaign-classifier.ts - mapVerdictToActions('USER_AWARENESS') returns ['acknowledge_user']; not added to DESTRUCTIVE_ACTIONS so requires_approval computes false - classifyCampaign's simulation branch now assigns verdict = 'USER_AWARENESS' directly instead of falling through to evaluateSpamVsUnwanted - deriveDefaultParams('acknowledge_user') returns {} (no operator-editable params) - Widen TriageNoteEvidence.verdict to admit 'USER_AWARENESS' (pure type widen, no formatting change) - Tests: classifier simulation fixtures now assert USER_AWARENESS/acknowledge_user/requiresApproval=false; new mapVerdictToActions/computeRequiresApproval/deriveDefaultParams cases --- lib/services/campaign-classifier.test.ts | 40 +++++++++++++++++++ lib/services/campaign-classifier.ts | 6 ++- .../remediation-default-params.test.ts | 4 ++ lib/services/remediation-default-params.ts | 2 + lib/services/triage-note-format.ts | 2 +- 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/lib/services/campaign-classifier.test.ts b/lib/services/campaign-classifier.test.ts index 596290d..2aaf44e 100644 --- a/lib/services/campaign-classifier.test.ts +++ b/lib/services/campaign-classifier.test.ts @@ -186,6 +186,10 @@ describe('mapVerdictToActions', () => { expect(actions).toContain('isolate_endpoint'); expect(actions).toContain('disable_forwarding_rule'); }); + + it('maps USER_AWARENESS to exactly acknowledge_user', () => { + expect(mapVerdictToActions('USER_AWARENESS', { clicked: 0 })).toEqual(['acknowledge_user']); + }); }); describe('computeRequiresApproval', () => { @@ -208,6 +212,10 @@ describe('computeRequiresApproval', () => { expect(computeRequiresApproval(['no_action'])).toBe(false); expect(computeRequiresApproval(['warn_user'])).toBe(false); }); + + it('is false for acknowledge_user (USER_AWARENESS is never destructive)', () => { + expect(computeRequiresApproval(['acknowledge_user'])).toBe(false); + }); }); // ============================================================================= @@ -353,6 +361,38 @@ describe('classifyCampaign', () => { } ); + it.each([ + ['knowbe4 (From match)', knowbe4SimMessage], + ['breach-secure-now (Return-Path match)', bsnSimMessage], + ])( + 'classifies a known simulation sender as USER_AWARENESS with acknowledge_user recommended and requiresApproval false (%s)', + async (_label, fixture) => { + stageQueries({ + reports: [ + { id: 'report-1', title: fixture.subject, created_at: '2026-07-15T00:00:00.000Z', requester_email: REPORTER_EMAIL }, + ], + messages: [toMessageRow('message-1', 'report-1', fixture)], + indicators: [], + }); + getBlastRadiusMock.mockResolvedValue({ + status: 'ok', + matched: 1, + delivered: 1, + held: 0, + rejected: 0, + clicked: 0, + perRecipient: [{ recipient: REPORTER_EMAIL, status: 'delivered' }], + source: 'fan-out', + }); + + const result = await classifyCampaign('campaign-1'); + + expect(result.verdict).toBe('USER_AWARENESS'); + expect(result.recommendedActions).toEqual(['acknowledge_user']); + expect(result.requiresApproval).toBe(false); + } + ); + it('classifies a real non-simulation signal as THREAT with destructive recommended actions (threat tier)', async () => { stageQueries({ reports: [ diff --git a/lib/services/campaign-classifier.ts b/lib/services/campaign-classifier.ts index 48605c4..4a120e9 100644 --- a/lib/services/campaign-classifier.ts +++ b/lib/services/campaign-classifier.ts @@ -147,7 +147,7 @@ export function computeConfidence(evidence: ConfidenceEvidenceFlags): Confidence // D-08: Recommended-actions vocabulary + requires_approval invariant // ============================================================================= -export type Verdict = 'SPAM' | 'UNWANTED' | 'THREAT'; +export type Verdict = 'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS'; /** Always force requires_approval:true when recommended (CLASSIFY-02). */ export const DESTRUCTIVE_ACTIONS = new Set([ @@ -182,6 +182,8 @@ export function mapVerdictToActions(verdict: Verdict, evidence: ActionEvidence): } return actions; } + case 'USER_AWARENESS': + return ['acknowledge_user']; } } @@ -458,7 +460,7 @@ export async function classifyCampaign(campaignId: string): Promise { }); }); + it('returns {} for acknowledge_user', () => { + expect(deriveDefaultParams('acknowledge_user', filledEvidence)).toEqual({}); + }); + it('returns {} for an unknown/future action type', () => { expect(deriveDefaultParams('unknown_future_type', filledEvidence)).toEqual({}); }); diff --git a/lib/services/remediation-default-params.ts b/lib/services/remediation-default-params.ts index 7ac799e..856ac39 100644 --- a/lib/services/remediation-default-params.ts +++ b/lib/services/remediation-default-params.ts @@ -36,6 +36,8 @@ export function deriveDefaultParams(actionType: string, evidence: DefaultParamEv return { deviceId: '' }; case 'disable_forwarding_rule': return { userPrincipalName: evidence.requesterEmail ?? '', ruleName: '' }; + case 'acknowledge_user': + return {}; default: return {}; } diff --git a/lib/services/triage-note-format.ts b/lib/services/triage-note-format.ts index 46a512c..f4a3a4e 100644 --- a/lib/services/triage-note-format.ts +++ b/lib/services/triage-note-format.ts @@ -22,7 +22,7 @@ export interface TriageNoteEvidence { reportCount: number; companyName?: string | null; subject?: string | null; - verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | null; + verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS' | null; confidence: number | null; summary: string | null; reasons: string[];