---
phase: 21-autotask-triage-note
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - lib/services/triage-note-sanitize.ts
  - lib/services/triage-note-sanitize.test.ts
  - lib/services/triage-note-format.ts
  - lib/services/triage-note-format.test.ts
autonomous: true
requirements: [NOTE-01]

must_haves:
  truths:
    - "A pure sanitizer strips query strings and fragments from every URL and redacts token/secret markers before any text can reach a note"
    - "A pure formatter turns a structured campaign-evidence object into human-readable prose containing verdict, confidence, summary, reasons, blast-radius state, and current remediation state"
    - "The formatted note never contains a raw URL query string, Bearer token, or credential query-param value even when the source evidence does"
  artifacts:
    - path: "lib/services/triage-note-sanitize.ts"
      provides: "sanitizeUrl + sanitizeNoteText pure functions, exported for tests"
      contains: "export function sanitizeUrl"
    - path: "lib/services/triage-note-format.ts"
      provides: "TriageNoteEvidence interface + formatTriageNote(evidence) => string"
      contains: "export interface TriageNoteEvidence"
    - path: "lib/services/triage-note-sanitize.test.ts"
      provides: "Vitest coverage of URL-query stripping + secret redaction"
    - path: "lib/services/triage-note-format.test.ts"
      provides: "Vitest coverage of note sections + no-raw-secret invariant"
  key_links:
    - from: "lib/services/triage-note-format.ts"
      to: "lib/services/triage-note-sanitize.ts"
      via: "import sanitizeUrl/sanitizeNoteText"
      pattern: "from './triage-note-sanitize'"
    - from: "lib/services/triage-note-format.ts"
      to: "lib/services/mimecast-blast-radius.ts"
      via: "import type BlastRadiusResult"
      pattern: "BlastRadiusResult"
---

<objective>
Build the pure, deterministic text layer for the Autotask triage note: a sanitizer that removes malicious/secret content from any string destined for a note, and a formatter that renders a structured campaign-evidence object into human-readable prose. Both are pure functions with no DB, network, or Autotask dependency — fully unit-testable in isolation.

Purpose: NOTE-01 requires the note text be sanitized (no raw secrets/tokens/full malicious URL query strings) and human-readable. Isolating this logic as pure functions lets the security-critical sanitization be tested exhaustively and lets Plan 02's service simply gather evidence and post the produced text. Defining `TriageNoteEvidence` here also gives Plan 02 a concrete contract to build against (interface-first).

Output: `lib/services/triage-note-sanitize.ts`, `lib/services/triage-note-format.ts`, and their sibling test files.
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/21-autotask-triage-note/21-CONTEXT.md
@.planning/phases/21-autotask-triage-note/21-PATTERNS.md

<interfaces>
<!-- BlastRadiusResult (import as `type` from lib/services/mimecast-blast-radius.ts) -->
<!-- Discriminated union the formatter must render, both branches: -->
<!-- { status: 'unavailable'; reason: 'not_configured' | 'lookup_failed'; error?: string } -->
<!-- | { status: 'ok'; matched: number; delivered: number; held: number; rejected: number; clicked: number; -->
<!--     perRecipient: Array<{ recipient: string; status: 'delivered'|'held'|'rejected'|'unknown' }>; source: 'fan-out' } -->

<!-- Redaction spirit-reference (do NOT import — different technique, redacts by object key): -->
<!-- lib/services/analyzer/itglue-redact.ts exports redact()/isSensitiveKey(); SENSITIVE_KEY_PATTERN = -->
<!-- /password|secret|key|token|credential|api[_-]?key/i; REDACTED_VALUE = '[REDACTED]'. -->
<!-- Copy the module-level "SECURITY-CRITICAL / why it exists" comment convention and the -->
<!-- exported-pure-function-for-tests convention; do NOT copy the object-tree walk. -->
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Sanitizer (URL query stripping + secret redaction)</name>
  <files>lib/services/triage-note-sanitize.ts, lib/services/triage-note-sanitize.test.ts</files>
  <read_first>
    - lib/services/analyzer/itglue-redact.ts (module comment convention + exported-pure-function-for-tests shape to mirror; do NOT reuse its by-key redaction technique)
    - lib/services/remediation-service.test.ts (Vitest file structure/idioms used across lib/services)
    - .planning/phases/21-autotask-triage-note/21-CONTEXT.md (Claude's Discretion block: URL truncated to scheme+host+path, query stripped; sender emails + attachment hashes are evidence, NOT secrets — must be preserved)
  </read_first>
  <behavior>
    - sanitizeUrl('https://evil.example/login?token=abc123&next=/x#frag') returns 'https://evil.example/login' (scheme+host+path only; query AND fragment removed)
    - sanitizeUrl on a malformed/non-URL string (e.g. 'not a url', '') does not throw and returns a safe placeholder or the input with any '?...'/'#...' tail removed
    - sanitizeUrl preserves the path but never the query even for benign params (query is always stripped, not selectively)
    - sanitizeNoteText redacts an Authorization/Bearer token: input containing 'Bearer eyJabc.def.ghi' yields '[REDACTED]' in place of the token
    - sanitizeNoteText redacts credential-style query-param values inside free text: 'see http://x/y?access_token=SECRET&password=p' has no substring 'access_token=SECRET' and no substring 'password=p' in the output
    - sanitizeNoteText PRESERVES a bare sender email (e.g. 'attacker@evil.example') and a 64-char hex attachment hash unchanged (these are evidence per D — not treated as secrets)
    - Both functions are pure (no I/O), deterministic, and exported for direct import in tests
  </behavior>
  <action>
    Create `lib/services/triage-note-sanitize.ts` with a module-level SECURITY-CRITICAL comment (mirroring the "why it exists" convention at the top of `lib/services/analyzer/itglue-redact.ts`) stating plainly that this module exists so no raw secret, token, or full malicious URL query string ever reaches an Autotask note posted by Pulse.
    Export `sanitizeUrl(value: string): string` — parse with the WHATWG `URL` constructor inside a try/catch; on success return `url.origin + url.pathname` (drops search + hash); on parse failure, fall back to truncating at the first `?` or `#` and return the head, never throwing. Do not selectively keep any query params — strip the query wholesale (this satisfies NOTE-01's "full malicious URL query strings").
    Export `sanitizeNoteText(text: string): string` — apply, in order: (1) replace any `Bearer <token>` / `Authorization: <value>` sequence with `[REDACTED]`; (2) replace credential query-param assignments matching keys `token|access_token|refresh_token|secret|password|api[_-]?key|key|credential` (case-insensitive) `=<value>` with `<key>=[REDACTED]`; (3) find any full URL substring and replace it with its `sanitizeUrl()` form so query strings embedded in prose are also stripped. Use `REDACTED_MARKER = '[REDACTED]'` constant. Do NOT redact bare email addresses or hex hashes — reason in a comment that those are evidence, not credentials, per CONTEXT.md Claude's Discretion.
    Write `lib/services/triage-note-sanitize.test.ts` covering every bullet in <behavior>, following the Vitest `describe/it/expect` structure used in `lib/services/remediation-service.test.ts` (no mocks needed — pure functions).
  </action>
  <verify>
    <automated>npx vitest run lib/services/triage-note-sanitize.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `npx vitest run lib/services/triage-note-sanitize.test.ts` passes with tests for: URL query+fragment stripping, malformed-URL no-throw, Bearer redaction, credential query-param redaction, and email/hash preservation
    - Source assertion: `grep -q "export function sanitizeUrl" lib/services/triage-note-sanitize.ts` and `grep -q "export function sanitizeNoteText" lib/services/triage-note-sanitize.ts`
    - Behavior assertion: a test feeds 'https://evil.example/a?token=abc#f' to sanitizeUrl and asserts the result === 'https://evil.example/a'
    - Behavior assertion: a test asserts sanitizeNoteText output for input with 'access_token=SECRET' contains no substring 'access_token=SECRET'
    - `npx tsc --noEmit --pretty` reports no new errors in these files
  </acceptance_criteria>
  <done>Sanitizer functions exist, are pure and exported, and all sanitize tests pass; type-check clean.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Note formatter + TriageNoteEvidence contract</name>
  <files>lib/services/triage-note-format.ts, lib/services/triage-note-format.test.ts</files>
  <read_first>
    - lib/services/triage-note-sanitize.ts (the functions this formatter must route all URL/free-text through — created in Task 1)
    - lib/services/mimecast-blast-radius.ts (BlastRadiusResult discriminated union both branches — import as type only)
    - lib/services/campaign-classifier.ts (Verdict type, confidence/reasons/recommended_actions shape produced by Phase 19 that this note summarizes)
    - .planning/phases/21-autotask-triage-note/21-CONTEXT.md (D-04 full-picture content requirement; note text format is planner's/Claude's discretion but must read as human-readable prose, not a JSON dump)
  </read_first>
  <behavior>
    - formatTriageNote returns a string containing the verdict label (e.g. 'THREAT') and the confidence value
    - Output contains a Summary line and a Reasons section listing each reason string
    - Output contains a Blast Radius section: when blastRadius.status==='ok' it shows delivered/held/rejected/clicked counts; when status==='unavailable' it contains an explicit 'unavailable' string naming the reason (never silently omits the section) — per D-04
    - Output contains a Remediation section: when remediationActions is empty it states no action has been taken / actions are proposed-only; when rows exist it lists each action_type + status (+ approver when approved) reflecting CURRENT state per D-04
    - Output lists any indicator URLs in sanitized form: given an evidence URL 'http://evil.example/p?token=leak', the output contains no substring 'token=leak'
    - The whole returned string is passed through sanitizeNoteText so no secret/token survives even from free-text fields
    - Exports `TriageNoteEvidence` interface consumed by Plan 02
  </behavior>
  <action>
    Create `lib/services/triage-note-format.ts`. Export an interface `TriageNoteEvidence` with these fields (camelCase, project convention): `campaignId: string`, `reportCount: number`, `companyName?: string | null`, `subject?: string | null`, `verdict: 'SPAM' | 'UNWANTED' | 'THREAT' | null`, `confidence: number | null`, `summary: string | null`, `reasons: string[]`, `recommendedActions: string[]`, `requiresApproval: boolean`, `blastRadius: BlastRadiusResult` (import the type from `./mimecast-blast-radius`), `remediationActions: Array<{ actionType: string; status: string; approvedBy: string | null; approvedAt: string | null }>`, `urls: string[]`.
    Export `formatTriageNote(evidence: TriageNoteEvidence): string` that assembles clearly-labeled prose sections in this order — a header line ('Phishing Triage Summary' + campaign/report count), Classification (verdict + confidence + summary), Reasons (bulleted list of `evidence.reasons`), Blast Radius (render both BlastRadiusResult branches: for 'ok' show matched/delivered/held/rejected/clicked; for 'unavailable' write an explicit line like 'Blast radius: unavailable (reason)'), Recommended Actions (list `recommendedActions`; note `requiresApproval` when true), and Current Remediation State (if `remediationActions` empty, state actions are proposed-only / none taken; else list actionType — status, plus approver/approvedAt when present). Route every URL through `sanitizeUrl` and pass the fully assembled string through `sanitizeNoteText` before returning, so query strings/secrets are stripped regardless of source field. Handle null verdict/confidence gracefully (render 'not yet classified') rather than throwing.
    Write `lib/services/triage-note-format.test.ts` covering every <behavior> bullet, constructing `TriageNoteEvidence` fixtures for: an 'ok' blast-radius THREAT campaign, an 'unavailable' blast-radius campaign, an empty-remediation campaign, an approved-remediation campaign, and a campaign whose evidence URL carries a token query param (assert it is absent from output). No mocks required — pure function.
  </action>
  <verify>
    <automated>npx vitest run lib/services/triage-note-format.test.ts</automated>
  </verify>
  <acceptance_criteria>
    - `npx vitest run lib/services/triage-note-format.test.ts` passes
    - Source assertion: `grep -q "export interface TriageNoteEvidence" lib/services/triage-note-format.ts` and `grep -q "export function formatTriageNote" lib/services/triage-note-format.ts`
    - Source assertion: `grep -q "triage-note-sanitize" lib/services/triage-note-format.ts` (formatter routes through the sanitizer)
    - Behavior assertion: a test with a 'unavailable' BlastRadiusResult asserts the output contains the substring 'unavailable'
    - Behavior assertion: a test with an evidence URL containing 'token=leak' asserts output has no substring 'token=leak'
    - `npx tsc --noEmit --pretty` reports no new errors
  </acceptance_criteria>
  <done>Formatter and TriageNoteEvidence contract exist, all format tests pass, output is sanitized and human-readable; type-check clean.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| campaign/message evidence → note text | Attacker-controlled email content (subject, sender display name, URLs, reasons derived from headers) flows into a string that will be written to Autotask |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-21-01 | Information Disclosure | sanitizeUrl / sanitizeNoteText | mitigate | Strip URL query+fragment wholesale (scheme+host+path only); redact Bearer/Authorization and credential query-param values before any text is returned. Proven by Task 1 tests asserting `token=`/`access_token=`/`password=` values and Bearer tokens are absent from output. |
| T-21-02 | Injection | formatTriageNote output | mitigate | Note is plain-text (Autotask TicketNotes description is not HTML-rendered by Pulse); formatter emits prose, never executable markup. Evidence values are only ever concatenated as text, not evaluated. |
| T-21-03 | Information Disclosure | formatTriageNote (missing evidence) | accept | When blast radius is 'unavailable' or classification is null, the formatter emits an explicit "unavailable"/"not yet classified" line rather than leaking internal error detail — bounded, operator-facing, no secret content. |
| T-21-SC | Tampering | npm/pip/cargo installs | accept | No new package installs in this plan; RESEARCH disabled for project and no install tasks present. |
</threat_model>

<verification>
- `npx vitest run lib/services/triage-note-sanitize.test.ts lib/services/triage-note-format.test.ts` — both suites pass
- `npx tsc --noEmit --pretty` — no new type errors
- `grep -rq "sanitizeNoteText\|sanitizeUrl" lib/services/triage-note-format.ts` — formatter uses the sanitizer
</verification>

<success_criteria>
- Sanitizer strips URL query strings/fragments and redacts secrets/tokens, with email/hash evidence preserved (NOTE-01 sanitization foundation)
- Formatter renders verdict, confidence, summary, reasons, blast-radius (both branches), and current remediation state as human-readable prose
- `TriageNoteEvidence` is exported for Plan 02 to build against
- All unit tests pass; type-check clean
</success_criteria>

<output>
Create `.planning/phases/21-autotask-triage-note/21-01-SUMMARY.md` when done
</output>
