chore: merge executor worktree (worktree-agent-ab40944a8ae803901)
This commit is contained in:
commit
032160cfe9
5 changed files with 302 additions and 1 deletions
|
|
@ -0,0 +1,108 @@
|
|||
---
|
||||
phase: 23-classification-disposition-per-client-automation-gate
|
||||
plan: 05
|
||||
subsystem: phishing-triage-automation
|
||||
tags: [webhook, phishing, postgres, vitest, autotask, gate-pattern]
|
||||
|
||||
# Dependency graph
|
||||
requires:
|
||||
- phase: 23-01
|
||||
provides: "USER_AWARENESS verdict on Verdict union, classifyCampaign, generateAndPostAcknowledgment (acknowledge_user note writer)"
|
||||
- phase: 23-03
|
||||
provides: "phishing_automation_gate table (migration 100) + admin CRUD routes for per-company auto_parse/auto_classify/auto_report toggles"
|
||||
provides:
|
||||
- "getCompanyAutomationGate(companyId) reusable COALESCE-false reader"
|
||||
- "Automatic parse -> classify -> acknowledge_user pipeline wired into the live Autotask webhook, gated per-company"
|
||||
affects: [23-remaining-plans, phishing-triage-runbook]
|
||||
|
||||
# Tech tracking
|
||||
tech-stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "Per-company opt-in settings reader (COALESCE(..., false), absent row = all-false) as a standalone testable module, mirroring the admin route's LEFT JOIN pattern but scoped to a single company_id"
|
||||
- "Gated webhook stage chain: each automation stage wrapped in its own try/catch so a stage failure logs-and-continues without blocking the fire-and-forget webhook response or aborting later stages"
|
||||
|
||||
key-files:
|
||||
created:
|
||||
- lib/services/phishing-automation-gate.ts
|
||||
- lib/services/phishing-automation-gate.test.ts
|
||||
modified:
|
||||
- lib/services/webhook-service.ts
|
||||
|
||||
key-decisions:
|
||||
- "auto_report's automatic behavior is narrowly scoped to generateAndPostAcknowledgment for USER_AWARENESS verdicts only — no other action/verdict is ever auto-posted or auto-approved (D-04 carve-out, T-23-08)"
|
||||
- "When auto_report is enabled but auto_classify did not run in the same pass (or failed), the current verdict is read from the most recent classifications row for the campaign rather than re-running classification"
|
||||
- "Detection + grouping remain unconditional for every company regardless of gate state (D-07) — only the three post-grouping stages are gated"
|
||||
|
||||
patterns-established:
|
||||
- "runGatedPhishingStages(input) as the single gated-stage entry point, called only when groupReportIntoCampaign resolves a campaignId"
|
||||
|
||||
requirements-completed: [AUTOGATE-03]
|
||||
|
||||
# Metrics
|
||||
duration: 15min
|
||||
completed: 2026-07-16
|
||||
---
|
||||
|
||||
# Phase 23 Plan 05: Wire Gated Automation Pipeline Into Webhook Summary
|
||||
|
||||
**Adds a tested `getCompanyAutomationGate` reader and wires the opted-in parse -> classify -> acknowledge_user chain into the live Autotask webhook, with detection/grouping staying always-on and every other remediation action staying manual-approval-gated.**
|
||||
|
||||
## Performance
|
||||
|
||||
- **Duration:** ~15 min
|
||||
- **Started:** 2026-07-16T23:31:00Z (approx)
|
||||
- **Completed:** 2026-07-16T23:46:41Z
|
||||
- **Tasks:** 2/2 completed
|
||||
- **Files modified:** 3 (2 created, 1 modified)
|
||||
|
||||
## Accomplishments
|
||||
- `getCompanyAutomationGate(companyId)` reusable reader: absent row / null / NaN companyId all resolve to all-false without throwing; present row maps snake_case -> camelCase.
|
||||
- `triggerPhishingDetection` now captures `groupReportIntoCampaign`'s result and, when a campaign exists, runs the new `runGatedPhishingStages` method — automatically parsing, classifying, and (only for USER_AWARENESS) posting the acknowledge_user note for opted-in companies, entirely within the existing fire-and-forget call site.
|
||||
- Detection and grouping remain unconditional (D-07); no other verdict/action (block_sender, purge_message, warn_user, reset_password, etc.) is ever auto-posted or auto-approved by this change.
|
||||
|
||||
## Task Commits
|
||||
|
||||
Each task was committed atomically:
|
||||
|
||||
1. **Task 1: Create getCompanyAutomationGate reader + tests** - `0d7974c` (test, RED) -> `e0f22f2` (feat, GREEN)
|
||||
2. **Task 2: Wire gated parse->classify->acknowledge chain into triggerPhishingDetection** - `e1193bf` (feat)
|
||||
|
||||
_TDD gate sequence for Task 1 confirmed in git log: test commit `0d7974c` precedes feat commit `e0f22f2`._
|
||||
|
||||
## Files Created/Modified
|
||||
- `lib/services/phishing-automation-gate.ts` - `getCompanyAutomationGate(companyId)`: COALESCE(..., false)-backed per-company gate reader, never throws.
|
||||
- `lib/services/phishing-automation-gate.test.ts` - 4 vitest cases: absent row, present row mapping, null companyId, NaN companyId.
|
||||
- `lib/services/webhook-service.ts` - `triggerPhishingDetection` now captures the grouping result and calls new private `runGatedPhishingStages`, which reads the gate and conditionally runs `parseAndStoreMessage`, `classifyCampaign`, and `generateAndPostAcknowledgment` (guarded by `verdict === 'USER_AWARENESS'`), each in its own try/catch.
|
||||
|
||||
## Decisions Made
|
||||
- Verdict-for-report lookup: when `auto_classify` didn't run in the same pass (either disabled or its try/catch caught an error), `auto_report`'s USER_AWARENESS check falls back to `SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1` rather than skipping the report stage entirely — this lets a company with only `auto_report` enabled (classification done via the existing manual route) still get the automatic acknowledge_user post once a human/prior automated pass has classified the campaign as USER_AWARENESS.
|
||||
- Kept `runGatedPhishingStages` as a new private method (not inlined into `triggerPhishingDetection`) to keep the always-on detect+group logic visually and structurally separate from the gated stages, per the plan's D-07 emphasis.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - plan executed exactly as written. Both tasks matched the plan's `<action>` and interface contracts (`ParseAndStoreResult`, `ClassifyResult.verdict`, `TriageNoteResult`, `GroupReportResult`) exactly as they already existed on disk from plans 23-01 and 23-03.
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None.
|
||||
|
||||
## Threat Flags
|
||||
|
||||
None — this plan's threat model (T-23-08 through T-23-11) was fully addressed as designed; no new unmodeled surface was introduced. `runGatedPhishingStages` adds no new inbound endpoint, only conditional internal calls to existing, already-reviewed service functions.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npx vitest run lib/services/phishing-automation-gate.test.ts` — 4/4 passed.
|
||||
- `npx tsc --noEmit --pretty` — clean, no errors.
|
||||
- Full `npx vitest run` — 434 passed, 2 failed. The 2 failures are pre-existing and unrelated (`lib/services/analyzer/itglue-search.test.ts`, files never touched by this plan) — logged to `.planning/phases/23-classification-disposition-per-client-automation-gate/deferred-items.md` per the scope-boundary rule rather than fixed here.
|
||||
- Acceptance-criteria greps for Task 2 all pass: `getCompanyAutomationGate` called with `companyId` (the ticket's `company_id`), `USER_AWARENESS` guards the `generateAndPostAcknowledgment` call, no auto-call to approve/remediate/block/purge/warn_user services was added, `detectPhishingTicket`/`groupReportIntoCampaign` remain unconditional, and each of the three gated stages has its own try/catch.
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- FOUND: lib/services/phishing-automation-gate.ts
|
||||
- FOUND: lib/services/phishing-automation-gate.test.ts
|
||||
- FOUND: lib/services/webhook-service.ts (modified)
|
||||
- FOUND commit 0d7974c
|
||||
- FOUND commit e0f22f2
|
||||
- FOUND commit e1193bf
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# Deferred Items — Phase 23
|
||||
|
||||
## Pre-existing test failures (out of scope for 23-05)
|
||||
|
||||
`lib/services/analyzer/itglue-search.test.ts` has 2 pre-existing failures
|
||||
(`tolerates per-call failures` test cases) unrelated to plan 23-05's changes.
|
||||
Plan 23-05 only touched `lib/services/phishing-automation-gate.ts`,
|
||||
`lib/services/phishing-automation-gate.test.ts`, and
|
||||
`lib/services/webhook-service.ts` — no analyzer/itglue files were modified.
|
||||
Full suite: `434 passed, 2 failed` both in this one unrelated file. Logged
|
||||
per SCOPE BOUNDARY rule — not fixed here.
|
||||
52
lib/services/phishing-automation-gate.test.ts
Normal file
52
lib/services/phishing-automation-gate.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock postgresClient BEFORE importing the module under test — mirrors
|
||||
// campaign-classifier.test.ts's vi.mock() factory-mocking discipline.
|
||||
const queryMock = vi.fn();
|
||||
vi.mock('./postgres-client', () => ({
|
||||
postgresClient: {
|
||||
query: (...args: unknown[]) => queryMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first -- imported after vi.mock hoisting
|
||||
import { getCompanyAutomationGate } from './phishing-automation-gate';
|
||||
|
||||
describe('getCompanyAutomationGate', () => {
|
||||
beforeEach(() => {
|
||||
queryMock.mockReset();
|
||||
});
|
||||
|
||||
it('resolves to all-false when no row exists for the company', async () => {
|
||||
queryMock.mockResolvedValueOnce({ rows: [] });
|
||||
|
||||
const gate = await getCompanyAutomationGate(123);
|
||||
|
||||
expect(gate).toEqual({ autoParse: false, autoClassify: false, autoReport: false });
|
||||
expect(queryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('resolves to the mapped camelCase values when a row exists', async () => {
|
||||
queryMock.mockResolvedValueOnce({
|
||||
rows: [{ auto_parse: true, auto_classify: false, auto_report: true }],
|
||||
});
|
||||
|
||||
const gate = await getCompanyAutomationGate(456);
|
||||
|
||||
expect(gate).toEqual({ autoParse: true, autoClassify: false, autoReport: true });
|
||||
});
|
||||
|
||||
it('resolves to all-false without querying when companyId is null', async () => {
|
||||
const gate = await getCompanyAutomationGate(null);
|
||||
|
||||
expect(gate).toEqual({ autoParse: false, autoClassify: false, autoReport: false });
|
||||
expect(queryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves to all-false without querying when companyId is NaN', async () => {
|
||||
const gate = await getCompanyAutomationGate(NaN);
|
||||
|
||||
expect(gate).toEqual({ autoParse: false, autoClassify: false, autoReport: false });
|
||||
expect(queryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
58
lib/services/phishing-automation-gate.ts
Normal file
58
lib/services/phishing-automation-gate.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/**
|
||||
* Phase 23 D-06/D-07: per-company phishing automation gate reader.
|
||||
*
|
||||
* Opt-in model — a company with no `phishing_automation_gate` row has all
|
||||
* three stages OFF. Mirrors the COALESCE(..., false)-over-LEFT-JOIN pattern
|
||||
* used by `app/api/admin/phishing-automation/route.ts`, but scoped to a
|
||||
* single company for the webhook's per-ticket gate check.
|
||||
*/
|
||||
|
||||
import { postgresClient } from './postgres-client';
|
||||
|
||||
export interface CompanyAutomationGate {
|
||||
autoParse: boolean;
|
||||
autoClassify: boolean;
|
||||
autoReport: boolean;
|
||||
}
|
||||
|
||||
const ALL_FALSE: CompanyAutomationGate = {
|
||||
autoParse: false,
|
||||
autoClassify: false,
|
||||
autoReport: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads the automation gate flags for a company. Absent row, null, or NaN
|
||||
* companyId all resolve to all-false — never throws.
|
||||
*/
|
||||
export async function getCompanyAutomationGate(
|
||||
companyId: number | null
|
||||
): Promise<CompanyAutomationGate> {
|
||||
if (companyId === null || Number.isNaN(companyId)) {
|
||||
return { ...ALL_FALSE };
|
||||
}
|
||||
|
||||
const result = await postgresClient.query<{
|
||||
auto_parse: boolean;
|
||||
auto_classify: boolean;
|
||||
auto_report: boolean;
|
||||
}>(
|
||||
`SELECT COALESCE(auto_parse, false) AS auto_parse,
|
||||
COALESCE(auto_classify, false) AS auto_classify,
|
||||
COALESCE(auto_report, false) AS auto_report
|
||||
FROM phishing_automation_gate
|
||||
WHERE company_id = $1`,
|
||||
[companyId]
|
||||
);
|
||||
|
||||
const row = result.rows[0];
|
||||
if (!row) {
|
||||
return { ...ALL_FALSE };
|
||||
}
|
||||
|
||||
return {
|
||||
autoParse: row.auto_parse,
|
||||
autoClassify: row.auto_classify,
|
||||
autoReport: row.auto_report,
|
||||
};
|
||||
}
|
||||
|
|
@ -16,6 +16,10 @@ import '../services/workflow-steps'; // Register all workflow step executors
|
|||
import { WorkflowEvent, TicketData } from '../types/workflow';
|
||||
import { detectPhishingTicket, DetectableTicket } from './phishing-detector';
|
||||
import { groupReportIntoCampaign } from './campaign-grouping-service';
|
||||
import { getCompanyAutomationGate } from './phishing-automation-gate';
|
||||
import { parseAndStoreMessage } from './phishing-eml-service';
|
||||
import { classifyCampaign, Verdict } from './campaign-classifier';
|
||||
import { generateAndPostAcknowledgment } from './triage-note-service';
|
||||
|
||||
export class WebhookService {
|
||||
private _autotaskClient: AutotaskClient | null = null;
|
||||
|
|
@ -490,7 +494,75 @@ export class WebhookService {
|
|||
const detection = await detectPhishingTicket(ticket);
|
||||
// D-01/D-08: automatic path short-circuits if already grouped.
|
||||
if (detection.flagged && detection.reportId) {
|
||||
await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true });
|
||||
const grouped = await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true });
|
||||
if (grouped?.campaignId) {
|
||||
await this.runGatedPhishingStages({
|
||||
campaignId: grouped.campaignId,
|
||||
companyId: r.company_id,
|
||||
reportId: detection.reportId,
|
||||
ticketId: Number(r.id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 23 D-04/D-06/D-07: runs the opted-in parse -> classify -> report
|
||||
* chain for a company after detection + grouping have already run
|
||||
* unconditionally. Each stage is independently gated by
|
||||
* `phishing_automation_gate` and isolated in its own try/catch so a
|
||||
* failure in one stage never blocks the webhook response or aborts a
|
||||
* later stage (T-23-09).
|
||||
*
|
||||
* auto_report auto-posts EXCLUSIVELY the acknowledge_user thank-you note,
|
||||
* and only when the campaign's current verdict is USER_AWARENESS (D-04).
|
||||
* Every other verdict/action remains proposed-only and manual-approval
|
||||
* gated — this method never calls approve/remediate/block/purge/warn_user.
|
||||
*/
|
||||
private async runGatedPhishingStages(input: {
|
||||
campaignId: string;
|
||||
companyId: number | null;
|
||||
reportId: string;
|
||||
ticketId: number;
|
||||
}): Promise<void> {
|
||||
const { campaignId, companyId, reportId, ticketId } = input;
|
||||
const gate = await getCompanyAutomationGate(companyId);
|
||||
|
||||
if (gate.autoParse) {
|
||||
try {
|
||||
await parseAndStoreMessage({ reportId, ticketId });
|
||||
} catch (err) {
|
||||
console.error('[WEBHOOK] auto_parse stage error', err);
|
||||
}
|
||||
}
|
||||
|
||||
let verdict: Verdict | null = null;
|
||||
|
||||
if (gate.autoClassify) {
|
||||
try {
|
||||
const result = await classifyCampaign(campaignId);
|
||||
verdict = result.verdict;
|
||||
} catch (err) {
|
||||
console.error('[WEBHOOK] auto_classify stage error', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (gate.autoReport) {
|
||||
try {
|
||||
if (verdict === null) {
|
||||
const latest = await postgresClient.query<{ verdict: string | null }>(
|
||||
`SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`,
|
||||
[campaignId]
|
||||
);
|
||||
verdict = (latest.rows[0]?.verdict as Verdict | undefined) ?? null;
|
||||
}
|
||||
|
||||
if (verdict === 'USER_AWARENESS') {
|
||||
await generateAndPostAcknowledgment(campaignId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[WEBHOOK] auto_report stage error', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue