From 683e647984ff16f53b8109f4ab6a2b3f217b3385 Mon Sep 17 00:00:00 2001 From: lorentz Date: Thu, 16 Jul 2026 19:08:45 -0400 Subject: [PATCH] docs(23): create phase plan (5 plans, 2 waves) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY --- .planning/ROADMAP.md | 10 +- .../23-01-PLAN.md | 179 ++++++++++++++++++ .../23-02-PLAN.md | 147 ++++++++++++++ .../23-03-PLAN.md | 157 +++++++++++++++ .../23-04-PLAN.md | 153 +++++++++++++++ .../23-05-PLAN.md | 164 ++++++++++++++++ 6 files changed, 807 insertions(+), 3 deletions(-) create mode 100644 .planning/phases/23-classification-disposition-per-client-automation-gate/23-01-PLAN.md create mode 100644 .planning/phases/23-classification-disposition-per-client-automation-gate/23-02-PLAN.md create mode 100644 .planning/phases/23-classification-disposition-per-client-automation-gate/23-03-PLAN.md create mode 100644 .planning/phases/23-classification-disposition-per-client-automation-gate/23-04-PLAN.md create mode 100644 .planning/phases/23-classification-disposition-per-client-automation-gate/23-05-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index c6f5f6b..a4a7c06 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -529,12 +529,16 @@ Phases execute in numeric order. v1.0 (Phases 1-9.1) shipped 2026-07-10. v2.0 (P ### Phase 23: Classification Disposition + Per-Client Automation Gate **Goal:** Add a dedicated "User Awareness" verdict for confirmed phishing-simulation-vendor (KnowBe4/Breach Secure Now) reports — today forced into the generic UNWANTED bucket despite the classifier already detecting the simulation vendor and explicitly skipping the THREAT tier — and add an admin UI gate page letting an admin choose, per Autotask company, whether the phishing pipeline's parse/classify/report-to-ticket stages run automatically (now that the previously-dead Autotask webhook is fixed) or require the existing manual Analyze/Classify/triage-note triggers. -**Requirements**: TBD +**Requirements**: CLASSDISP-01, CLASSDISP-02, CLASSDISP-03, AUTOGATE-01, AUTOGATE-02, AUTOGATE-03 **Depends on:** Phase 17, Phase 18, Phase 19, Phase 20, Phase 21, Phase 22 -**Plans:** 0 plans +**Plans:** 5 plans (2 waves) Plans: -- [ ] TBD (run /gsd-plan-phase 23 to break down) +- [ ] 23-01-PLAN.md — USER_AWARENESS verdict + acknowledge_user action + customer-visible note writer (noteType 18) (CLASSDISP-01, CLASSDISP-02) +- [ ] 23-02-PLAN.md — Review UI: USER_AWARENESS badge + acknowledge_user manual action (CLASSDISP-03) +- [ ] 23-03-PLAN.md — Migration 100 phishing_automation_gate + admin GET/PATCH/DELETE API (AUTOGATE-01) +- [ ] 23-04-PLAN.md — /admin/phishing-automation page (3-toggle company table) + admin index tile (AUTOGATE-02) +- [ ] 23-05-PLAN.md — Gate reader + gated parse->classify->acknowledge webhook chain (D-04 carve-out) (AUTOGATE-03) --- *Roadmap created: 2026-05-03* diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-01-PLAN.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-01-PLAN.md new file mode 100644 index 0000000..805b7b4 --- /dev/null +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-01-PLAN.md @@ -0,0 +1,179 @@ +--- +phase: 23-classification-disposition-per-client-automation-gate +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - lib/services/campaign-classifier.ts + - lib/services/campaign-classifier.test.ts + - lib/services/remediation-default-params.ts + - lib/services/remediation-default-params.test.ts + - lib/services/triage-note-service.ts + - lib/services/triage-note-service.test.ts +autonomous: true +requirements: [CLASSDISP-01, CLASSDISP-02] +must_haves: + truths: + - "A campaign whose classifier reasoning matches the phishing-simulation-vendor allowlist is classified USER_AWARENESS, not UNWANTED" + - "The USER_AWARENESS verdict recommends exactly the acknowledge_user action" + - "acknowledge_user is NOT treated as destructive (requires_approval stays false for it)" + - "acknowledge_user posts a customer-visible Autotask ticket note (noteType 18) with an appreciative thank-you body" + artifacts: + - path: "lib/services/campaign-classifier.ts" + provides: "USER_AWARENESS verdict in Verdict union + acknowledge_user action mapping + simulation-branch assignment" + contains: "USER_AWARENESS" + - path: "lib/services/remediation-default-params.ts" + provides: "acknowledge_user empty-params case" + contains: "acknowledge_user" + - path: "lib/services/triage-note-service.ts" + provides: "generateAndPostAcknowledgment writer using noteType 18" + contains: "noteType: 18" + key_links: + - from: "classifyCampaign isSimulation branch" + to: "USER_AWARENESS verdict" + via: "direct assignment (replaces evaluateSpamVsUnwanted call)" + pattern: "verdict = 'USER_AWARENESS'" + - from: "mapVerdictToActions('USER_AWARENESS')" + to: "['acknowledge_user']" + via: "new switch arm" + pattern: "acknowledge_user" +--- + + +Add the dedicated "User Awareness" classification disposition and its `acknowledge_user` delivery action. Today the classifier already detects phishing-simulation-vendor senders (KnowBe4 / Breach Secure Now) and skips the THREAT tier, but the result falls into the generic `UNWANTED` bucket with a `warn_user` action. This plan introduces a distinct 4th verdict `USER_AWARENESS`, maps it to a new non-destructive `acknowledge_user` action, and adds a triage-note-service writer that posts a customer-visible thank-you note. + +Purpose: Correctly distinguish "an employee did the right thing by reporting a training simulation" from "unwanted marketing spam", and reward it with a customer-facing acknowledgment instead of a warning. +Output: Extended `Verdict` union + action mapping, `acknowledge_user` default-params case, and `generateAndPostAcknowledgment()`. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md +@.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md + + + + +Current (line 150): `export type Verdict = 'SPAM' | 'UNWANTED' | 'THREAT';` + +Current action mapping (lines 164-186): +- SPAM -> ['no_action'] +- UNWANTED -> ['warn_user'] +- THREAT -> ['block_sender', 'purge_message'] (+ reset_password/isolate_endpoint/disable_forwarding_rule when evidence.clicked > 0) + +Destructive set (line 153) — DO NOT add acknowledge_user here: +`DESTRUCTIVE_ACTIONS = { block_sender, purge_message, reset_password, isolate_endpoint }` + +`classifyCampaign` simulation branch (lines 457-464) currently reads: +`if (isSimulation) { verdict = evaluateSpamVsUnwanted(evidence); reasons.push('Sender domain matches a known phishing-simulation vendor allowlist ...'); }` + +`ClassifyResult` (line 431) exposes `.verdict: Verdict`. +The `classifications.verdict` DB column is plain `TEXT` (migration 097 line 117) — no enum/CHECK, so no migration needed for the new value. + +From lib/services/triage-note-service.ts — existing TicketNotes write (lines 176-183): +`client.createEntity('TicketNotes', { ticketID, title, description: noteText, noteType: 1, publish: 1 })` +`generateAndPostTriageNote(campaignId: string): Promise` — per-ticket try/catch loop over reports, returns `{ noteText, tickets: TriageNotePostResult[] }`. + +From lib/services/remediation-default-params.ts — `deriveDefaultParams(actionType, evidence)` exhaustive switch with `no_action -> {}` precedent. + + + + + + + Task 1: Add USER_AWARENESS verdict + acknowledge_user action mapping + lib/services/campaign-classifier.ts, lib/services/campaign-classifier.test.ts, lib/services/remediation-default-params.ts, lib/services/remediation-default-params.test.ts + + - lib/services/campaign-classifier.ts (Verdict union line 150, DESTRUCTIVE_ACTIONS line 153, mapVerdictToActions lines 164-186, computeRequiresApproval line 189, classifyCampaign simulation branch lines 449-490) + - lib/services/campaign-classifier.test.ts (existing test structure + how verdict/action assertions are written) + - lib/services/remediation-default-params.ts (switch lines 23-42) + - lib/services/remediation-default-params.test.ts (existing case assertions) + - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (campaign-classifier + remediation-default-params sections, watch-out flag #4) + + + - mapVerdictToActions('USER_AWARENESS', ...) returns exactly ['acknowledge_user'] + - computeRequiresApproval(['acknowledge_user']) returns false (acknowledge_user is NOT in DESTRUCTIVE_ACTIONS) + - classifyCampaign, given a simulation-sender campaign (existing KnowBe4/BSN fixture), produces verdict 'USER_AWARENESS' (previously UNWANTED/SPAM) + - deriveDefaultParams('acknowledge_user', evidence) returns {} + + + Add the literal `'USER_AWARENESS'` to the `Verdict` union (line 150) so it becomes `'SPAM' | 'UNWANTED' | 'THREAT' | 'USER_AWARENESS'`. The verdict string is LOCKED to `USER_AWARENESS` (planner decision, per D-01 which delegated exact string to discretion) — Plans 02 and 05 reference this exact literal, do not rename it. In `mapVerdictToActions`, add a `case 'USER_AWARENESS': return ['acknowledge_user'];` arm. Do NOT add `acknowledge_user` to `DESTRUCTIVE_ACTIONS` (watch-out flag #4) — its auto-approval carve-out (D-04) is enforced in the webhook path, not via this invariant, so `requires_approval` must compute false for it. In `classifyCampaign`'s `if (isSimulation)` branch, replace `verdict = evaluateSpamVsUnwanted(evidence);` with `verdict = 'USER_AWARENESS';` (per D-01/D-02 this is the exact code path the classifier already isolates for the allowlist match — no new branching). Keep the existing `reasons.push('Sender domain matches a known phishing-simulation vendor allowlist ...')`. In `remediation-default-params.ts`, add `case 'acknowledge_user': return {};` alongside the `no_action` precedent. Write/extend tests: add classifier assertions that a simulation fixture yields verdict USER_AWARENESS and actions ['acknowledge_user'] with requires_approval false; add a remediation-default-params assertion for the acknowledge_user empty-params case. Follow RED (add failing tests first) -> GREEN. + + + cd /opt/stacks/pulse && npx vitest run lib/services/campaign-classifier.test.ts lib/services/remediation-default-params.test.ts && npx tsc --noEmit --pretty + + + - `grep -c "USER_AWARENESS" lib/services/campaign-classifier.ts` >= 2 (union + branch assignment + action case) + - `grep -q "case 'acknowledge_user'" lib/services/remediation-default-params.ts` succeeds + - `grep -v '^#' lib/services/campaign-classifier.ts | grep -c "acknowledge_user" ` >= 1 and acknowledge_user is absent from the DESTRUCTIVE_ACTIONS set block (lines 153-158) + - vitest run for both test files passes; a test asserts verdict === 'USER_AWARENESS' and recommendedActions === ['acknowledge_user'] and requiresApproval === false for a simulation fixture + - `npx tsc --noEmit` exits 0 + + USER_AWARENESS is a first-class verdict producing the non-destructive acknowledge_user action; simulation campaigns classify as USER_AWARENESS; type-check and tests pass. + + + + Task 2: Add generateAndPostAcknowledgment customer-visible note writer + lib/services/triage-note-service.ts, lib/services/triage-note-service.test.ts + + - lib/services/triage-note-service.ts (generateAndPostTriageNote lines 85-195, the createEntity('TicketNotes', ...) call lines 176-183, per-ticket try/catch loop lines 172-192, TriageNoteResult / TriageNotePostResult return shapes) + - lib/services/triage-note-service.test.ts (how the Autotask client is mocked, how post results are asserted) + - .planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md (D-02 tone, D-03 noteType 18 correction) + - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (triage-note-service section + watch-out flag #1) + + + Add a sibling exported async function `generateAndPostAcknowledgment(campaignId: string)` that mirrors `generateAndPostTriageNote`'s signature and `{ noteText, tickets: TriageNotePostResult[] }` return shape and its per-ticket `try/catch`-in-loop error isolation (one entry per linked report, `posted: true/false`, on failure record `error`). The note body is a short, genuinely appreciative thank-you (D-02) — e.g. thanking the employee for reporting the suspicious message and reinforcing that their vigilance helps keep the company secure. It is NOT the evidence-dump `formatTriageNote()` template; do not include any evidence, URLs, secrets, or classification internals in the body. The `createEntity('TicketNotes', ...)` call MUST use `noteType: 18` (Client Portal Note — the field that actually controls customer visibility, verified live against this tenant's TicketNotes/entityInformation/fields on 2026-07-16) and `publish: 1` UNCHANGED (publish is an internal-staff tier field, orthogonal to client visibility — do NOT flip it; see watch-out flag #1). Use `title: 'Thank You — Suspicious Email Reported'` (or similar customer-appropriate title). Gather the campaign's linked report ticket IDs the same way `generateAndPostTriageNote` does. Add tests mirroring triage-note-service.test.ts: assert the createEntity payload carries noteType 18 and publish 1, that per-ticket failures are isolated (one failing ticket does not abort the others), and that the returned tickets array reflects per-ticket posted status. + + + cd /opt/stacks/pulse && npx vitest run lib/services/triage-note-service.test.ts && npx tsc --noEmit --pretty + + + - `grep -q "generateAndPostAcknowledgment" lib/services/triage-note-service.ts` succeeds and the symbol is exported + - `grep -n "noteType: 18" lib/services/triage-note-service.ts` returns a line inside generateAndPostAcknowledgment + - No `publish: 0` or `publish:` value other than 1 introduced (grep confirms publish still 1) + - A test asserts the acknowledgment createEntity call uses noteType 18 and publish 1 + - A test asserts per-ticket error isolation (loop continues after one ticket throws) + - `npx tsc --noEmit` exits 0 + + generateAndPostAcknowledgment posts a customer-visible (noteType 18) appreciative note per linked ticket with the same error-isolation semantics as the existing triage-note writer; tests and type-check pass. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Pulse -> Autotask API | acknowledge_user note text crosses to a customer-visible surface (client portal) | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-23-01 | Information Disclosure | generateAndPostAcknowledgment note body | mitigate | Note body is a fixed appreciative template with zero evidence/URL/secret interpolation — nothing from the parsed email or classification reasons is placed in a customer-visible note | +| T-23-02 | Tampering | acknowledge_user approval invariant | mitigate | acknowledge_user is deliberately kept OUT of DESTRUCTIVE_ACTIONS and its auto-post carve-out is scoped to the webhook path only (Plan 05); no destructive action inherits the exemption | + + + +- `npx vitest run lib/services/campaign-classifier.test.ts lib/services/remediation-default-params.test.ts lib/services/triage-note-service.test.ts` passes +- `npx tsc --noEmit --pretty` passes +- USER_AWARENESS present in Verdict union and simulation branch; acknowledge_user maps and is non-destructive; note writer uses noteType 18 + + + +- A simulation-vendor campaign classifies as USER_AWARENESS with the single acknowledge_user action and requires_approval false +- generateAndPostAcknowledgment posts a customer-visible thank-you note (noteType 18, publish 1) per linked ticket with per-ticket error isolation + + + +Create `.planning/phases/23-classification-disposition-per-client-automation-gate/23-01-SUMMARY.md` when done + diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-PLAN.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-PLAN.md new file mode 100644 index 0000000..8096b94 --- /dev/null +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-PLAN.md @@ -0,0 +1,147 @@ +--- +phase: 23-classification-disposition-per-client-automation-gate +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - components/phishing/classification-card.tsx + - components/phishing/action-area-card.tsx +autonomous: true +requirements: [CLASSDISP-03] +must_haves: + truths: + - "A campaign classified USER_AWARENESS renders in ClassificationCard with a distinct badge color (not UNWANTED's amber)" + - "The acknowledge_user action renders with a human-readable label ('Acknowledge user') wherever action labels appear" + - "In the manual review UI, acknowledge_user appears as a normal checkbox + Approve action for every company, with no gate-check logic" + artifacts: + - path: "components/phishing/classification-card.tsx" + provides: "USER_AWARENESS badge variant + acknowledge_user label" + contains: "USER_AWARENESS" + - path: "components/phishing/action-area-card.tsx" + provides: "acknowledge_user ActionParamsForm case + label" + contains: "acknowledge_user" + key_links: + - from: "ClassificationCardData['verdict'] union" + to: "VERDICT_VARIANT_CLASS record" + via: "new keyed entry" + pattern: "USER_AWARENESS.*bg-" + - from: "action-area-card ActionParamsForm switch" + to: "acknowledge_user case" + via: "new switch arm mirroring no_action" + pattern: "case 'acknowledge_user'" +--- + + +Surface the new `USER_AWARENESS` verdict and `acknowledge_user` action in the Phase 22 review UI. `ClassificationCard` gets a distinct badge color for the new verdict; `ActionAreaCard` renders `acknowledge_user` as a normal recommended-action checkbox (like every other action) so an operator can approve it manually on the review page for companies that do not have report-to-ticket automation enabled. + +Purpose: Operators must be able to see the User Awareness disposition distinctly and manually approve the acknowledgment where automation is off. +Output: Extended verdict/label maps in two Phase 22 components. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md +@.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md + + + +Verdict literal: `USER_AWARENESS` +Action id: `acknowledge_user` -> display label: `Acknowledge user` + +From components/phishing/classification-card.tsx: +- `ClassificationCardData['verdict']` type (line 15): `'SPAM' | 'UNWANTED' | 'THREAT'` — add USER_AWARENESS +- `VERDICT_VARIANT_CLASS` record (lines 30-38): SPAM slate, UNWANTED amber, THREAT destructive +- `ACTION_LABEL` record (lines 40-44): block_sender/purge_message/warn_user/no_action/reset_password/isolate_endpoint/disable_forwarding_rule +- StatusBadge consumes a free-form Tailwind class string (variantClass) — a new record key is sufficient, no StatusBadge change + +From components/phishing/action-area-card.tsx: +- `ACTION_LABEL` record (lines 67-75) +- `ActionParamsForm` switch (lines 111-246); `no_action` case (lines 126-132) is the template for a no-params action +- Manual approval flow handleApprove -> POST /api/phishing/campaigns/[id]/approve (lines 375-399) + + + + + + + Task 1: Add USER_AWARENESS badge variant + acknowledge_user label to ClassificationCard + components/phishing/classification-card.tsx + + - components/phishing/classification-card.tsx (ClassificationCardData['verdict'] type line 15, VERDICT_VARIANT_CLASS lines 30-38, ACTION_LABEL lines 40-44, StatusBadge usage line 115) + - components/ui/status-badge.tsx (CustomVariantProps — confirm variantClass is a free-form class string) + - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (classification-card.tsx section) + + + Add `'USER_AWARENESS'` to the `ClassificationCardData['verdict']` union (line 15). Add a `VERDICT_VARIANT_CLASS` entry keyed `USER_AWARENESS` using a distinct, clearly-not-UNWANTED color — use emerald: `'bg-emerald-500/15 text-emerald-600'` (D-01 discretion; distinct from UNWANTED's amber and THREAT's destructive red, signalling a positive/benign disposition). Add `acknowledge_user: 'Acknowledge user'` to the `ACTION_LABEL` record. No StatusBadge component change is required — variantClass is a free-form Tailwind string. + + + cd /opt/stacks/pulse && npx tsc --noEmit --pretty + + + - `grep -q "USER_AWARENESS" components/phishing/classification-card.tsx` succeeds in both the verdict union and VERDICT_VARIANT_CLASS + - `grep -q "acknowledge_user: 'Acknowledge user'" components/phishing/classification-card.tsx` succeeds + - The USER_AWARENESS variant class differs from the UNWANTED (amber) and THREAT (destructive) entries + - `npx tsc --noEmit` exits 0 (the Record is exhaustive so a missing key would fail the build) + + ClassificationCard renders USER_AWARENESS with a distinct emerald badge and labels acknowledge_user; type-check passes. + + + + Task 2: Add acknowledge_user manual action case to ActionAreaCard + components/phishing/action-area-card.tsx + + - components/phishing/action-area-card.tsx (ACTION_LABEL lines 67-75, ActionParamsForm switch lines 111-246, no_action case lines 126-132, handleApprove flow lines 375-399) + - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (action-area-card.tsx section + watch-out flag #2) + + + Add `acknowledge_user: 'Acknowledge user'` to this file's `ACTION_LABEL` record. Add a `case 'acknowledge_user':` arm to `ActionParamsForm`'s switch, mirroring the `no_action` no-params case — render a short muted paragraph explaining "No parameters — posts a customer-visible thank-you note to the reporting employee." acknowledge_user must render exactly like every other recommended action here (checkbox + Approve button via the existing handleApprove -> POST approve flow). Do NOT add any automation-gate / per-company gate-check logic to this component (watch-out flag #2 / D-04): the auto-approval carve-out lives only in the webhook path (Plan 05); the manual review UI always shows acknowledge_user as a normal approvable action for every company regardless of gate state. + + + cd /opt/stacks/pulse && npx tsc --noEmit --pretty + + + - `grep -q "case 'acknowledge_user'" components/phishing/action-area-card.tsx` succeeds + - `grep -q "acknowledge_user: 'Acknowledge user'" components/phishing/action-area-card.tsx` succeeds + - No new reference to phishing_automation_gate, auto_report, or any gate table/flag appears in this component (grep -Ei "automation_gate|auto_report|auto_parse|auto_classify" returns nothing) + - `npx tsc --noEmit` exits 0 + + acknowledge_user renders as a normal manual checkbox+Approve action with a no-params form; no gate logic added to the manual UI; type-check passes. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| operator browser -> review UI | operator triggers approve of acknowledge_user; server-side permission gate (Phase 20/22) unchanged | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-23-03 | Elevation of Privilege | action-area-card acknowledge_user manual approve | accept | This plan adds no new permission path — acknowledge_user routes through the existing handleApprove -> POST approve flow whose server-side requirePermission gate (Phase 20) is unchanged; UI-only display change | + + + +- `npx tsc --noEmit --pretty` passes (exhaustive Record keys enforce completeness) +- USER_AWARENESS badge distinct from UNWANTED; acknowledge_user labeled and manually approvable; no gate logic in action-area-card + + + +- USER_AWARENESS renders with a distinct badge color in the review page +- acknowledge_user shows as a normal manual checkbox+Approve action for every company with no gate-check logic in the component + + + +Create `.planning/phases/23-classification-disposition-per-client-automation-gate/23-02-SUMMARY.md` when done + diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-03-PLAN.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-03-PLAN.md new file mode 100644 index 0000000..92158af --- /dev/null +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-03-PLAN.md @@ -0,0 +1,157 @@ +--- +phase: 23-classification-disposition-per-client-automation-gate +plan: 03 +type: execute +wave: 1 +depends_on: [] +files_modified: + - migrations/100_phishing_automation_gate.sql + - app/api/admin/phishing-automation/route.ts + - app/api/admin/phishing-automation/[companyId]/route.ts +autonomous: true +requirements: [AUTOGATE-01] +must_haves: + truths: + - "A phishing_automation_gate table exists keyed by company_id with auto_parse/auto_classify/auto_report booleans defaulting to false" + - "GET /api/admin/phishing-automation returns every company with its three gate flags, defaulting absent rows to all-false" + - "PATCH /api/admin/phishing-automation/{companyId} upserts the three flags with actor+timestamp stamping, admin-gated" + - "A company with no row reads as all three stages OFF (opt-in)" + artifacts: + - path: "migrations/100_phishing_automation_gate.sql" + provides: "opt-in per-company automation gate table" + contains: "phishing_automation_gate" + - path: "app/api/admin/phishing-automation/route.ts" + provides: "admin-gated GET list with COALESCE(..., false) defaults" + exports: ["GET"] + - path: "app/api/admin/phishing-automation/[companyId]/route.ts" + provides: "admin-gated PATCH upsert + DELETE revert-to-default" + exports: ["PATCH", "DELETE"] + key_links: + - from: "GET route" + to: "phishing_automation_gate" + via: "LEFT JOIN + COALESCE(..., false)" + pattern: "COALESCE\\(pag\\.auto_" + - from: "PATCH route" + to: "phishing_automation_gate" + via: "INSERT ... ON CONFLICT DO UPDATE" + pattern: "ON CONFLICT \\(company_id\\)" +--- + + +Create the per-company automation-gate persistence and admin API. A new `phishing_automation_gate` table (migration 100) stores three independent opt-in booleans per company (`auto_parse`, `auto_classify`, `auto_report`), defaulting to all-OFF. Two admin-gated routes mirror the existing `/api/admin/company-scope` pattern: a GET listing every company with its flags, and a `[companyId]` PATCH that upserts flags (plus a DELETE that reverts a company to the all-OFF default). + +Purpose: The data + API layer the admin UI (Plan 04) and the webhook auto-pipeline (Plan 05) both depend on. +Output: migration 100 + two API routes. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md +@.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md + + + +migrations/082_company_scope.sql — opt-out table shape (company_id PK -> companies(id) ON DELETE CASCADE, boolean NOT NULL DEFAULT, updated_by TEXT, updated_at TIMESTAMPTZ). This phase flips defaults to false (opt-IN) and uses THREE booleans. +Latest existing migration is 099_indicators_metadata.sql -> use 100. + +app/api/admin/company-scope/route.ts (75 lines): +- requireAdmin() guard (lines 33-35), search/type param parsing + conditions[] (lines 37-52) +- SELECT c.id::text, c.company_name, c.company_type, COALESCE(cs.in_scope, true) FROM companies c LEFT JOIN company_scope cs ... (lines 54-62) +- .map() camelCase transform + NextResponse.json (lines 64-75) + +app/api/admin/company-scope/[companyId]/route.ts (59 lines): +- PATCH: requireAdmin, parseInt companyId, body validation, INSERT ... ON CONFLICT (company_id) DO UPDATE with updated_by = session email (lines 14-43) +- DELETE: revert to default (lines 45-58) + +DB conventions: snake_case columns, camelCase API responses, postgresClient.query(sql, params) from lib/services/postgres-client.ts. + + + + + + + Task 1: Create migration 100 phishing_automation_gate + apply to dev DB + migrations/100_phishing_automation_gate.sql + + - migrations/082_company_scope.sql (full — the schema-shape analog to adapt) + - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (migration section) + - CLAUDE.md ("Migrations" + the migration-apply caveat: Postgres init only runs migrations on first volume boot; existing DB must be applied manually) + + + Create `migrations/100_phishing_automation_gate.sql` defining `CREATE TABLE IF NOT EXISTS phishing_automation_gate` with columns: `company_id BIGINT PRIMARY KEY REFERENCES companies(id) ON DELETE CASCADE`, `auto_parse BOOLEAN NOT NULL DEFAULT false`, `auto_classify BOOLEAN NOT NULL DEFAULT false`, `auto_report BOOLEAN NOT NULL DEFAULT false`, `updated_by TEXT`, `updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()`. All three booleans default false (D-06 opt-in — opposite polarity from company_scope). Add a `COMMENT ON TABLE` explaining: opt-in per-company phishing pipeline automation gate; absent row = all three stages manual-only; auto_report auto-posts ONLY acknowledge_user for USER_AWARENESS verdicts (D-04), not arbitrary actions. Use `IF NOT EXISTS` throughout. After writing, apply it to the running dev Postgres (this is an existing long-lived volume, so the file will not auto-run) — apply via the project's migration-apply path (check scripts/apply-migrations behavior first) or `docker exec pulse-postgres psql` per the CLAUDE.md caveat, then confirm the table exists. + + + cd /opt/stacks/pulse && grep -q "CREATE TABLE IF NOT EXISTS phishing_automation_gate" migrations/100_phishing_automation_gate.sql && grep -c "NOT NULL DEFAULT false" migrations/100_phishing_automation_gate.sql | grep -qx 3 && echo OK + + + - File migrations/100_phishing_automation_gate.sql exists with `phishing_automation_gate` table, PK company_id referencing companies(id) ON DELETE CASCADE + - Exactly three `BOOLEAN NOT NULL DEFAULT false` columns: auto_parse, auto_classify, auto_report + - updated_by TEXT and updated_at TIMESTAMPTZ columns present + - Table applied to dev DB: `docker exec pulse-postgres psql -U -d -c "\d phishing_automation_gate"` shows the table (or the project's chosen apply-verification command confirms existence) + + phishing_automation_gate table exists in the schema file and is applied to the dev database with opt-in (all-false) defaults. + + + + Task 2: Admin GET list route + [companyId] PATCH/DELETE route + app/api/admin/phishing-automation/route.ts, app/api/admin/phishing-automation/[companyId]/route.ts + + - app/api/admin/company-scope/route.ts (full — GET analog) + - app/api/admin/company-scope/[companyId]/route.ts (full — PATCH/DELETE analog) + - lib/auth-utils.ts (requireAdmin signature/return shape) + - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (both route sections + the "send all three values each PATCH" recommendation) + + + Create `app/api/admin/phishing-automation/route.ts` exporting `GET`, mirroring company-scope/route.ts: `requireAdmin()` guard, same `search`/`type` query param + `conditions[]` building, then `SELECT c.id::text, c.company_name, c.company_type, COALESCE(pag.auto_parse, false) AS auto_parse, COALESCE(pag.auto_classify, false) AS auto_classify, COALESCE(pag.auto_report, false) AS auto_report FROM companies c LEFT JOIN phishing_automation_gate pag ON pag.company_id = c.id WHERE ... ORDER BY c.company_name`. Map rows to camelCase `{ id, companyName, companyType, companyTypeLabel, autoParse, autoClassify, autoReport }` and return via NextResponse.json (match company-scope's response envelope). Create `app/api/admin/phishing-automation/[companyId]/route.ts` exporting `PATCH` and `DELETE`, mirroring company-scope/[companyId]/route.ts. PATCH: `requireAdmin()`, parseInt+NaN-guard companyId, validate the body carries the three booleans `autoParse`/`autoClassify`/`autoReport` (client always sends all three current values — the admin page knows them from local state, avoiding partial-update SQL), then `INSERT INTO phishing_automation_gate (company_id, auto_parse, auto_classify, auto_report, updated_by, updated_at) VALUES ($1,$2,$3,$4,$5,NOW()) ON CONFLICT (company_id) DO UPDATE SET auto_parse = EXCLUDED.auto_parse, auto_classify = EXCLUDED.auto_classify, auto_report = EXCLUDED.auto_report, updated_by = EXCLUDED.updated_by, updated_at = NOW()` with `updated_by` = `(session?.user as any)?.email ?? null`. DELETE: `requireAdmin()`, delete the company's row (reverts to all-OFF default), return `{ ok: true }`. + + + cd /opt/stacks/pulse && npx tsc --noEmit --pretty + + + - GET route: `grep -c "COALESCE(pag.auto_" app/api/admin/phishing-automation/route.ts` >= 3 and `grep -q "requireAdmin" app/api/admin/phishing-automation/route.ts` + - PATCH route: `grep -q "ON CONFLICT (company_id)" app/api/admin/phishing-automation/[companyId]/route.ts` and updates all three auto_* columns + - Both routes call `requireAdmin()` and return its error when present + - DELETE handler present in the [companyId] route + - `npx tsc --noEmit` exits 0 + + GET returns all companies with COALESCE(false) gate defaults; PATCH upserts all three flags with actor stamping; DELETE reverts to default; both admin-gated; type-check passes. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| operator browser -> admin API | authenticated admin toggles per-company automation flags | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-23-04 | Elevation of Privilege | /api/admin/phishing-automation GET+PATCH+DELETE | mitigate | Every handler calls requireAdmin() and returns its error before any DB access — no non-admin can read or change gate state | +| T-23-05 | Tampering | PATCH body (arbitrary company id / non-boolean flags) | mitigate | parseInt+NaN-guard on companyId; body validated to carry three booleans; parameterized query prevents injection; FK ON DELETE CASCADE keeps rows consistent with companies | +| T-23-06 | Repudiation | who changed a company's gate | mitigate | updated_by (session email) + updated_at stamped on every upsert | + + + +- Migration file present + applied; `npx tsc --noEmit --pretty` passes +- GET defaults absent rows to all-false; PATCH upserts with actor stamp; both admin-gated + + + +- phishing_automation_gate table exists with opt-in (all-false) defaults +- GET lists companies with three flags; PATCH upserts; DELETE reverts; all admin-gated + + + +Create `.planning/phases/23-classification-disposition-per-client-automation-gate/23-03-SUMMARY.md` when done + diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-04-PLAN.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-04-PLAN.md new file mode 100644 index 0000000..4d9ab69 --- /dev/null +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-04-PLAN.md @@ -0,0 +1,153 @@ +--- +phase: 23-classification-disposition-per-client-automation-gate +plan: 04 +type: execute +wave: 2 +depends_on: ["23-03"] +files_modified: + - app/admin/phishing-automation/page.tsx + - app/admin/page.tsx +autonomous: false +requirements: [AUTOGATE-02] +must_haves: + truths: + - "An admin can open /admin/phishing-automation and see a searchable/filterable company table" + - "Each company row exposes three independent Switch toggles: auto-parse, auto-classify, auto-report" + - "Toggling a switch PATCHes /api/admin/phishing-automation/{companyId} and reflects the new state" + - "The page is reachable from the /admin index card grid" + artifacts: + - path: "app/admin/phishing-automation/page.tsx" + provides: "admin gate page (table + search/filter + 3 Switches per row)" + min_lines: 120 + - path: "app/admin/page.tsx" + provides: "tile linking to /admin/phishing-automation" + contains: "phishing-automation" + key_links: + - from: "page toggle()" + to: "/api/admin/phishing-automation/{companyId}" + via: "fetch PATCH" + pattern: "fetch\\(`/api/admin/phishing-automation/" + - from: "page load" + to: "/api/admin/phishing-automation" + via: "fetch GET in useEffect" + pattern: "fetch\\('/api/admin/phishing-automation'" +--- + + +Build the `/admin/phishing-automation` page: a searchable/filterable company table with three per-row `Switch` toggles (auto-parse, auto-classify, auto-report), structurally mirroring the existing `/admin/client-scope` page. Add a tile to the `/admin` index card grid so the page is discoverable. + +Purpose: The human control surface for the per-company automation gate (D-08). +Output: New admin page + admin index tile. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md +@.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md + + + +{ companies: Array<{ id: string; companyName: string; companyType: number | null; companyTypeLabel: string | null; autoParse: boolean; autoClassify: boolean; autoReport: boolean }> } + + +Analog: app/admin/client-scope/page.tsx (243 lines, near line-for-line template): +- imports block lines 1-28 (shadcn Card/Table/Switch/Input/Select, PageHeader, lucide icons, sonner toast) +- Company interface + state lines 30-49 +- load() useEffect lines 51-76 +- toggle(company, next) lines 78-100 +- search Input + type Select chrome lines 102-178 +- / with trailing single cell lines 200-236 + +/admin index tile pattern: app/admin/page.tsx tile objects (lines 238-249) — { title, href, icon, description } inside a section's tiles[] array. + + + + + + + Task 1: Build /admin/phishing-automation page (3-toggle company table) + app/admin/phishing-automation/page.tsx + + - app/admin/client-scope/page.tsx (full file — the near-verbatim template) + - components/ui/switch.tsx (Switch props) + - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (page.tsx section — Company interface with 3 booleans, per-stage toggle(company, stage, next), three cells) + + + Create `app/admin/phishing-automation/page.tsx` as a `'use client'` page cloning client-scope/page.tsx's structure. Extend the `Company` interface with `autoParse: boolean; autoClassify: boolean; autoReport: boolean` (drop the single `inScope`). Repoint the load fetch to `/api/admin/phishing-automation`. Replace the single `toggle(company, next)` with a per-stage `toggle(company: Company, stage: 'autoParse' | 'autoClassify' | 'autoReport', next: boolean)` that PATCHes `/api/admin/phishing-automation/${company.id}` with a body containing ALL three current flag values (the two unchanged from local state plus the toggled one), updates local state on success, and uses the same sonner error/toast handling as client-scope. In the table, add THREE header cells (Auto-parse, Auto-classify, Auto-report) and render three `` cells per row, each wired to `toggle(company, '', next)` with an `aria-label` like `Auto-parse for ${company.companyName}`. Reuse the search Input + company-type Select filter chrome verbatim. Use a `PageHeader` titled "Phishing Automation" with a description explaining the opt-in gate and that auto-report only auto-posts the acknowledge_user thank-you note for User Awareness verdicts. Add a short helper caption near the toggles clarifying stage dependencies (report meaningfully requires classify; classify requires parse) — informational only, do not enforce in UI. + + + cd /opt/stacks/pulse && npx tsc --noEmit --pretty + The /admin/phishing-automation page with a company table and three per-row Switch toggles wired to the Plan 03 API. + + 1. Run `npm run dev` and sign in as an admin. + 2. Visit http://localhost:3100/admin/phishing-automation — confirm the company table loads with three toggle columns (Auto-parse, Auto-classify, Auto-report), all OFF by default. + 3. Toggle Auto-parse for one company; confirm a success toast and that the switch stays on after a page reload (persisted via PATCH). + 4. Use the search box and company-type filter; confirm the list filters. + + Type "approved" or describe issues + + + - `grep -q "fetch('/api/admin/phishing-automation'" app/admin/phishing-automation/page.tsx` (GET load) + - `grep -q "fetch(\`/api/admin/phishing-automation/" app/admin/phishing-automation/page.tsx` (PATCH toggle) + - Three distinct ` + Admins can view and toggle per-company auto-parse/classify/report on /admin/phishing-automation; toggles persist; type-check passes and human verification approved. + + + + Task 2: Add admin index tile for Phishing Automation + app/admin/page.tsx + + - app/admin/page.tsx (tile object shape lines 238-249, the "Tools & Data" section and its icon imports; the Client Scope tile at lines 241-246 is the direct pattern) + + + Add a new tile object to the same section that contains the "Client Scope" tile (Tools & Data) in `app/admin/page.tsx`: `{ title: 'Phishing Automation', href: '/admin/phishing-automation', icon: , description: 'Per-company opt-in gate for automatic phishing parse / classify / acknowledge-note stages' }`. Ensure the chosen icon is imported at the top of the file if not already. + + + cd /opt/stacks/pulse && grep -q "/admin/phishing-automation" app/admin/page.tsx && npx tsc --noEmit --pretty + + + - `grep -q "'/admin/phishing-automation'" app/admin/page.tsx` succeeds + - The tile has a title, href, icon (imported), and description + - `npx tsc --noEmit` exits 0 + + The /admin index card grid links to /admin/phishing-automation; type-check passes. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| operator browser -> admin page/API | admin views + toggles gate flags; page-level admin gate + API-level requireAdmin | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-23-07 | Elevation of Privilege | /admin/phishing-automation page | mitigate | Server-side authority is the Plan 03 requireAdmin() gate on every API call; the page performs no privileged action except through those admin-gated endpoints (middleware also blocks unauth'd /admin/* page loads) | + + + +- `npx tsc --noEmit --pretty` passes +- Page loads, three toggles per row persist via PATCH, reachable from /admin index (human-verified) + + + +- An admin can open /admin/phishing-automation from the /admin index and toggle each of the three per-company stages independently, with changes persisting + + + +Create `.planning/phases/23-classification-disposition-per-client-automation-gate/23-04-SUMMARY.md` when done + diff --git a/.planning/phases/23-classification-disposition-per-client-automation-gate/23-05-PLAN.md b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-05-PLAN.md new file mode 100644 index 0000000..4678743 --- /dev/null +++ b/.planning/phases/23-classification-disposition-per-client-automation-gate/23-05-PLAN.md @@ -0,0 +1,164 @@ +--- +phase: 23-classification-disposition-per-client-automation-gate +plan: 05 +type: execute +wave: 2 +depends_on: ["23-01", "23-03"] +files_modified: + - lib/services/phishing-automation-gate.ts + - lib/services/phishing-automation-gate.test.ts + - lib/services/webhook-service.ts +autonomous: true +requirements: [AUTOGATE-03] +must_haves: + truths: + - "getCompanyAutomationGate returns all-false for a company with no gate row and the stored values when a row exists" + - "On a phishing-flagged ticket webhook, after the always-on detect+group, the pipeline runs parse (if auto_parse), then classify (if auto_classify), then acknowledge_user note (if auto_report AND verdict is USER_AWARENESS) — for that ticket's company" + - "auto_report auto-posts ONLY the acknowledge_user thank-you note for USER_AWARENESS verdicts — no other verdict/action is auto-posted or auto-approved" + - "Detection + grouping still run unconditionally for every company regardless of gate state (D-07)" + - "A stage failure logs and does not block the webhook response or abort earlier successful stages" + artifacts: + - path: "lib/services/phishing-automation-gate.ts" + provides: "getCompanyAutomationGate(companyId) COALESCE-false reader" + contains: "getCompanyAutomationGate" + - path: "lib/services/webhook-service.ts" + provides: "gated parse->classify->report chain after grouping" + contains: "getCompanyAutomationGate" + key_links: + - from: "triggerPhishingDetection (after group)" + to: "getCompanyAutomationGate(r.company_id)" + via: "gate lookup then conditional stage calls" + pattern: "getCompanyAutomationGate" + - from: "auto_report branch" + to: "generateAndPostAcknowledgment(campaignId)" + via: "guarded by verdict === 'USER_AWARENESS'" + pattern: "USER_AWARENESS" +--- + + +Wire the previously-manual-only pipeline stages (parse, classify, report) into the automatic Autotask webhook path, gated per-company by the Plan 03 table. After the always-on detect+group (D-07, unchanged), the webhook looks up the ticket's company gate and conditionally runs `parseAndStoreMessage` → `classifyCampaign` → (only for USER_AWARENESS) `generateAndPostAcknowledgment`. This is the narrow D-04 carve-out: the acknowledge_user thank-you note is the ONLY action that auto-posts without manual approval, and only when the company opted into report-to-ticket automation. + +Purpose: Turn the now-live webhook into an actual auto-triage pipeline for opted-in clients, while preserving the proposed-only/manual-approval safety model for every real remediation action. +Output: A testable gate reader + gated stage chain in webhook-service. + + + +@$HOME/.claude/get-shit-done/workflows/execute-plan.md +@$HOME/.claude/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/23-classification-disposition-per-client-automation-gate/23-CONTEXT.md +@.planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md + + + +lib/services/phishing-eml-service.ts: `parseAndStoreMessage(input: { reportId: string; ticketId: number }): Promise` — never throws for expected no-op cases. +lib/services/campaign-classifier.ts (Plan 01): `classifyCampaign(campaignId: string): Promise`; ClassifyResult.verdict is `Verdict` including the new `'USER_AWARENESS'`. +lib/services/triage-note-service.ts (Plan 01): `generateAndPostAcknowledgment(campaignId: string): Promise<...>` — posts the customer-visible acknowledge_user note (noteType 18). +lib/services/campaign-grouping-service.ts: `groupReportIntoCampaign(reportId, opts?): Promise<{ campaignId: string; groupMethod: string; created: boolean }>`. +Plan 03 table: `phishing_automation_gate(company_id, auto_parse, auto_classify, auto_report, ...)`, absent row = all false. + + +triggerPhishingDetection (lines 458-495): reads the ticket row (r.id, r.company_id, ...), runs detectPhishingTicket, and on `detection.flagged && detection.reportId` calls `groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true })` (return currently discarded). +Fire-and-forget call site (lines 119-121): `this.triggerPhishingDetection(payload).catch(err => console.error('[WEBHOOK] Phishing detection error:', err));` + + +DB/style: postgresClient.query(sql, params); snake_case columns; console.error with context; no console.log debug left in. + + + + + + + Task 1: Create getCompanyAutomationGate reader + tests + lib/services/phishing-automation-gate.ts, lib/services/phishing-automation-gate.test.ts + + - app/api/admin/phishing-automation/route.ts (Plan 03 — the COALESCE(..., false) read pattern to mirror) + - lib/services/postgres-client.ts (postgresClient.query signature + how it's mocked in sibling *.test.ts files) + - lib/services/campaign-classifier.test.ts (example of how postgresClient is mocked in this codebase's vitest tests) + - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md ("Opt-in/opt-out settings-table read" shared pattern) + + + - getCompanyAutomationGate(companyId) with NO matching row resolves to { autoParse: false, autoClassify: false, autoReport: false } + - getCompanyAutomationGate(companyId) with a row { auto_parse: true, auto_classify: false, auto_report: true } resolves to { autoParse: true, autoClassify: false, autoReport: true } + - A null/invalid companyId resolves to all-false rather than throwing + + + Create `lib/services/phishing-automation-gate.ts` exporting `getCompanyAutomationGate(companyId: number | null): Promise<{ autoParse: boolean; autoClassify: boolean; autoReport: boolean }>`. If companyId is null/NaN, return all-false without querying. Otherwise run `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` and map to the camelCase shape; when no row, return all-false. Export a small type for the return shape. Write `phishing-automation-gate.test.ts` mirroring the codebase's existing postgresClient-mock style (see campaign-classifier.test.ts), covering the three behaviors above. Follow RED (failing tests first) -> GREEN. + + + cd /opt/stacks/pulse && npx vitest run lib/services/phishing-automation-gate.test.ts && npx tsc --noEmit --pretty + + + - `grep -q "export.*getCompanyAutomationGate" lib/services/phishing-automation-gate.ts` + - vitest run passes with tests asserting absent-row -> all false, present-row -> mapped values, null companyId -> all false without query + - `npx tsc --noEmit` exits 0 + + A tested, reusable getCompanyAutomationGate reader returns per-company gate flags defaulting to all-false; tests and type-check pass. + + + + Task 2: Wire gated parse->classify->acknowledge chain into triggerPhishingDetection + lib/services/webhook-service.ts + + - lib/services/webhook-service.ts (triggerPhishingDetection lines 458-495, fire-and-forget call site lines 114-122, import block) + - app/api/phishing/tickets/[ticket_id]/analyze/route.ts (the manual detect->parse->group chaining precedent) + - lib/services/campaign-classifier.ts (classifyCampaign return shape, USER_AWARENESS literal — from Plan 01) + - lib/services/triage-note-service.ts (generateAndPostAcknowledgment — from Plan 01) + - .planning/phases/23-classification-disposition-per-client-automation-gate/23-PATTERNS.md (webhook-service.ts section integration point + watch-out flags #2 and #3) + + + In `triggerPhishingDetection`, capture the grouping result: `const grouped = await groupReportIntoCampaign(detection.reportId, { skipIfAlreadyGrouped: true });`. Detection + grouping stay unconditional (D-07) — do NOT gate them. After grouping, if `grouped?.campaignId` exists, add a new private method `runGatedPhishingStages({ campaignId, companyId, reportId, ticketId })` and call it. In that method: `const gate = await getCompanyAutomationGate(companyId)` (import from the Plan 01... Plan 03 gate module `@/lib/services/phishing-automation-gate`). Then, each in its OWN try/catch that logs `console.error('[WEBHOOK] stage error', ...)` and continues to the next stage (a failure must not abort later stages or block the webhook): (1) if `gate.autoParse`: `await parseAndStoreMessage({ reportId, ticketId: Number(ticketId) })`; (2) if `gate.autoClassify`: `const result = await classifyCampaign(campaignId)` and retain `result.verdict`; (3) if `gate.autoReport`: determine the current verdict — use the just-computed `result.verdict` if classify ran this pass, otherwise read the most-recent classifications row for the campaign (`SELECT verdict FROM classifications WHERE campaign_id = $1 ORDER BY created_at DESC LIMIT 1`) — and ONLY when that verdict === `'USER_AWARENESS'` call `await generateAndPostAcknowledgment(campaignId)`. This is the narrow D-04 carve-out (watch-out flags #2/#3): auto_report auto-posts EXCLUSIVELY the acknowledge_user thank-you note for USER_AWARENESS; every other verdict/action remains proposed-only and manual-approval-gated — do NOT auto-approve or auto-post block/purge/warn_user/etc. Add the needed imports (getCompanyAutomationGate, parseAndStoreMessage, classifyCampaign, generateAndPostAcknowledgment). Keep the whole thing invoked through the existing fire-and-forget `.catch()` at the call site — do not `await` it in `processWebhook`'s main flow. Use `Number(r.id)` (the ticket id) for ticketId and `r.company_id` for companyId. + + + cd /opt/stacks/pulse && npx tsc --noEmit --pretty + + + - `grep -q "getCompanyAutomationGate" lib/services/webhook-service.ts` and it is called with the ticket's company_id + - `grep -q "USER_AWARENESS" lib/services/webhook-service.ts` guarding the auto_report acknowledge call + - The auto_report branch calls ONLY generateAndPostAcknowledgment (no auto-call to approve/remediate/block/purge/warn_user endpoints or services — grep shows no such call added) + - detect + groupReportIntoCampaign remain unconditional (not wrapped in any gate check) + - Each of the three gated stages is in its own try/catch that logs and continues + - `npx tsc --noEmit` exits 0 + + Opted-in companies get automatic parse->classify->acknowledge on phishing webhooks; acknowledge auto-posts only for USER_AWARENESS; detection/grouping stay always-on; other actions stay manual; type-check passes. + + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| Autotask webhook -> pipeline | external ticket event triggers automatic, possibly customer-visible, note posting | +| pipeline -> Autotask (customer portal) | auto-posted acknowledge_user note is client-visible | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation Plan | +|-----------|----------|-----------|-------------|-----------------| +| T-23-08 | Elevation of Privilege | auto_report carve-out | mitigate | auto_report auto-posts EXCLUSIVELY generateAndPostAcknowledgment and only when verdict === 'USER_AWARENESS'; a runtime verdict guard plus the absence of any auto-call to approve/remediate/block/purge keeps every destructive action on the manual-approval path (D-04) | +| T-23-09 | Denial of Service | stage chain blocking webhook | mitigate | Chain runs via the existing fire-and-forget `.catch()` call site; each stage has its own try/catch that logs and continues — no stage can block processWebhook's response or abort later stages | +| T-23-10 | Spoofing/Tampering | webhook authenticity | accept | Autotask webhook HMAC verification is unchanged (verified upstream in webhook-service before processWebhook); this plan adds no new inbound surface | +| T-23-11 | Information Disclosure | auto-posted note to wrong/absent company | mitigate | Gate is keyed on the ticket's own company_id; absent row = all-false so nothing auto-posts for un-opted-in companies (D-06) | + + + +- `npx vitest run lib/services/phishing-automation-gate.test.ts` passes +- `npx tsc --noEmit --pretty` passes +- Gate reader defaults to all-false; webhook runs gated parse/classify/acknowledge; acknowledge only for USER_AWARENESS; detection/grouping unconditional + + + +- For an opted-in company, a phishing-flagged webhook auto-runs parse (if on) -> classify (if on) -> acknowledge_user note (if on AND USER_AWARENESS), without a human clicking anything +- No other verdict/action is ever auto-posted or auto-approved; detection + grouping remain unconditional; un-opted-in companies see no behavior change + + + +Create `.planning/phases/23-classification-disposition-per-client-automation-gate/23-05-SUMMARY.md` when done +