Local LLM Series, Part 4: Testing the Grounded Assistant on a Phishing Email

October 24, 2026

Series: Overview · Part 1, Setup guide · Part 2, Deployment record · Part 3, Keeping reference data current · Part 4, Testing on a phishing email (this page). Each part can be read on its own.

Built with AI, on purpose. I used Claude (Sonnet 5.5, from Anthropic) to help plan this build, troubleshoot it, write the scripts and draft this write-up. I am not presenting the scripts as my own coding. What I am showing is the project itself: choosing the goal, running it on my own hardware, testing the results, and using AI tools to move faster on work I am still learning. See "How this was made" near the end of this page.

I ran one phishing email through the local grounded assistant nine times with the question worded the same each time. Each run exposed a different failure, and the fixes moved the page from a model that answered from memory to one where the checks and the runbook checklist come from code and the model only comments.

The test

The evidence was one email: a "Medicare Kit" survey lure sent as MyChart from the address rafHAVgM@SREuDnCiy[.]us, with the same recipient in To and Cc and two links to one storage.googleapis.com page. The question never changed: "Can you verify if this evidence indicates a phishing e-mail and what next steps should be taken?" Keeping both fixed meant every difference between runs came from changes to the code, not from how the question was asked.

I judged each answer on five things: whether each claim was supported by the excerpt it cited, whether quoted identifiers (including the URL) matched the evidence, whether the retrieved excerpts were relevant, what the grounding check flagged, and whether the next steps were complete. The reference library at the time held about 10,270 records (ATT&CK v20, CISA KEV, Sigma, Atomic Red Team, LOLBAS, LOLDrivers) plus one phishing triage runbook written for this test.

Run by run

Run 1 had no mail headers in the evidence. Runs 2 to 9 used the same email with the From, To, Cc and Subject lines added. "Uncited" is the count the grounding check reported. Run 9, the first with the final code, is described below the table.

Run What went wrong Uncited Change made next
1 Only 2 of 15 excerpts were on topic (Go shellcode tests and webmail CVEs filled the rest). It invented a "subject line", cited an unrelated ATT&CK record (T1070.008) as phishing guidance, and recommended sending links to VirusTotal. 13 Dropped filler words that matched "Steps taken" in Atomic tests, matched "phishing" to "Spearphishing", capped Atomic and KEV records at 2 per question, and wrote a phishing triage runbook.
2 Altered the URL (one character, T became L). Missed the sender mismatch. The new checker raised 4 false alarms out of 5 warnings. 2 Fixed the checker, added a URL-must-match check and a rule to perform each check the evidence allows, and reworded runbook step 2 to compare the display name with the From domain.
3 Found the sender mismatch and the personalized subject, but gave only 8 steps and dropped the log search and the purge. 12 Required [E] on every sentence about the evidence and a complete list of procedure steps, and exempted lead-in lines from the check.
4 Evidence claims all accurate, but only 4 containment steps and nothing on triage or escalation. 3 Rewrote the rule to go section by section, then built the header checks and a verbatim runbook checklist in code, so completeness no longer depends on the model.
5 Answer cut off mid-sentence at the token limit. Triage steps 5 to 7 cited the wrong excerpt. URL altered again. 11 Raised the token limit, kept runbook sections whole, rejoined line-broken URLs before the URL check, added a cut-off warning, and told the model not to copy the runbook.
6 Short and well formed, but copied "[S4]" from an example in the prompt, claimed an urgency lure the email does not have, and called Google's file hosting a suspicious domain. 0 Removed the example numbers and added an urgency check.
7 Said "the original email as a file with headers" when only 4 header lines exist. Dropped the runbook citations. 7 Added a rule that [E] is only for what the evidence shows, and moved the code checks and checklist above the model's answer.
8 Evidence findings all correct and runbook cited as [S19], but it repeated the "original email" claim and cited the class to the wrong section (S23). 5 Counted "Runbook item N:" lines as one claim each, stopped warning on one evidence citation plus one reference citation, and added the original-headers check.

Run 9, the final code. Uncited sentences: 0. The automatic checks and the runbook checklist were complete, the indicator URLs matched the evidence exactly, and the "Cannot determine" line was cited to the right runbook section. The model's commentary still had four problems:

  • It said "the original email is provided as a file with headers", which is false. This is the third run with the claim, and the new check flagged it.
  • It said every link points to the same URL. There are two, differing in the # fragment.
  • It gave runbook item 6 twice, once labeled "Next step", and skipped item 7.
  • Its verdict and classification cited only [E], so the check warned that they share almost no wording with the evidence. That warning is a heuristic, not an error.

The one factual error was caught by the check. Prompt tuning stopped here.

What changed in the code

The changes fall into three layers. The first two are plain code and behave the same every time. The third changes how the model is asked.

Retrieval (ask_grounded.py)

  • Everyday words such as "evidence", "verify", "next" and "steps" no longer count as search terms. "Steps taken" was pulling in process-injection tests for a phishing question.
  • A simple word-stem step lets "phishing" match "Spearphishing" and "phish".
  • Atomic tests, KEV entries, Sigma rules, LOLBAS and LOLDrivers are capped at 2 or 3 records per question unless the question uses that source's own words (for example "CVE" or "Sigma rule").
  • Markdown sections are cut at line ends, keep their title, and stay whole up to 3,500 characters, so a citation points at a whole runbook section.

Checks done by code (ask_grounded_web.py)

  • The page reads the From, To, Cc, Reply-To, Return-Path and Subject lines and reports: display name not in the From domain, a machine-generated-looking address, the same address in To and Cc, the recipient's name in the Subject, missing SPF/DKIM/DMARC, missing Received lines, a generic greeting, shared hosting, and what a base64 link fragment decodes to.
  • The best-matching runbook is printed verbatim as a checklist. It is chosen by comparing rare words in the runbook's scope with the question and the evidence, so a vague question such as "thoughts?" still finds it. It is also always sent to the model so it can be cited.
  • URLs that a mail client broke across two lines are rejoined before indicators are extracted.
  • Page order is now: automatic checks, runbook checklist, model commentary, grounding check, indicators, sources.

Grounding check additions

  • A URL in the answer must match the evidence character for character.
  • Warnings for an answer that looks cut off, for several reference citations on one claim, for an answer naming a subject line, sender, attachment or urgency the evidence does not contain, and for claims of an "original email" or "full headers" when there are no Received lines.
  • A list item counts as one claim, so one citation at its end covers it.

Prompt

The evidence prompt now has rules for: describing only what the evidence contains, one source per claim, saying what cannot be determined, performing each runbook check the evidence allows, not copying the runbook, using [E] only for what the evidence shows, and never claiming urgency or intent the evidence does not state.

Bugs the test exposed

Several of the failures came from the code and the prompt, not the model.

  1. Runbook sections were cut mid-sentence. Any section over 1,500 characters was sliced at a character count, so the Triage section ended at "6. Find out whether anyone" and the rest sat in a chunk with no title. This also affected what the model saw in runs 1 to 4.
  2. The indicator panel cut the URL. The code rejoined a line-wrapped URL only when the next line ended in whitespace, but the evidence had ...=> at the end. The panel showed ...JnMyP until that was fixed.
  3. The URL check missed the altered URL. The model copied the URL with a line break in the middle, and the check only compared the first half. Rejoining line-broken URLs before comparing fixed it.
  4. The new checker raised false alarms. In run 2, four of five warnings were wrong: it looked for the words "subject line" when the evidence said Subject:, and it checked advice sentences that merely mention a sender.
  5. A prompt example leaked into the answer. The prompt contained an example with "[S4]", and in run 6 the model cited S4 for a claim about the runbook. S4 was an ATT&CK detection record. Examples in a prompt need no real citation numbers.
  6. The checker punished correct citations. It flagged "[E][S19]" (evidence plus one runbook section) as a grouped citation, and treated "Runbook item 1:" as a sentence without a citation.
  7. Sections split across two excerpts caused mis-citations. In run 5 the model cited Triage steps 5 to 7 to the first half of the section.

What the model still gets wrong

Every prompt change moved the model's mistakes somewhere else. Run 6 had no uncited sentences but invented an urgency lure. Run 7 dropped the runbook citations and stated that the evidence was "the original email as a file with headers", which is false because only four header lines were pasted. Run 8 repeated that claim even though a rule forbade it, and run 9 repeated it again, though the check now flags it. A 14B model quantized to Q4, working from a long list of rules, follows some of them each time but not all of them.

The design rule that follows: anything that must be complete or exact is done by code, and the model only comments on it. The header checks, the runbook checklist, the indicator extraction and the library lookups run the same way every time, in the same order, whatever the question says. The model's paragraph is labeled "Model commentary (verify before use)" and sits below them.

This also meets a usability goal: an operator who types a vague question gets the same checks and checklist as one who types the perfect question, because matching uses the evidence and not only the question text.

Open items

  • Done in run 9: the evidence was run with the final code and the warnings were accurate. A tenth run with a different email would show whether the model's weak points are the same.
  • Try a second kind of case, such as a Windows event log with a Kerberoasting pattern, to check that the runbook matching and the caps behave on non-email evidence.
  • Write more runbooks (Kerberoasting, password spray, suspicious PowerShell). The phishing runbook is why the next steps could be traced to a source.
  • Vague questions still send about 20 loosely related excerpts to the model. A cutoff for very short questions would reduce that noise but could also drop useful records.
  • Done: speed and memory at a 16,384-token context were measured on the RX 9070 XT. A 6,311-token prompt took 3.6 seconds to process and generated at 48.8 tokens per second, using 11,028 MiB of the card's 16,304 MiB. Details are in Part 3.
  • The header notes are heuristics (for example, "mixed case looks machine-generated"). A few legitimate senders may trigger them, so they say to verify and do not call a message malicious.

How this was made

I want to be direct about who did what.

  • Written with Claude (Sonnet 5.5): the code changes described here (retrieval, the automatic checks and the prompt) and the first draft of this write-up. I ran the tests and judged the answers, but I did not write that code from scratch, and I don't claim to.
  • Mine: the goal (a private, local model for SOC work), the hardware and environment, running everything on my own PC, working through the failures, testing the model against answers I already knew, and deciding what to trust.
  • Why I'm saying so: in a SOC, being honest about what a tool did and what you verified is part of the job. The same applies to AI-assisted work. The Kerberoasting test in Part 2 is the clearest example: the model sounded confident and was wrong, so I checked it.

Using AI tools to learn faster, build real projects and then verify the output is a method I plan to keep using and keep being open about.

← Back to Cybersecurity