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
This commit is contained in:
lorentz 2026-07-16 19:36:53 -04:00
parent 7048bf693a
commit 14ed8ca248
5 changed files with 51 additions and 3 deletions

View file

@ -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: [

View file

@ -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<ClassifyResu
let verdict: Verdict;
const reasons: string[] = [];
if (isSimulation) {
verdict = evaluateSpamVsUnwanted(evidence);
verdict = 'USER_AWARENESS';
reasons.push(
'Sender domain matches a known phishing-simulation vendor allowlist (KnowBe4/Breach Secure Now) — THREAT tier skipped'
);

View file

@ -58,6 +58,10 @@ describe('deriveDefaultParams', () => {
});
});
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({});
});

View file

@ -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 {};
}

View file

@ -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[];