security: spotlight untrusted content + ship an honest threat model

🟢 open · #158 · Open-Legal-Products/mike ← Shawnaldinho/mike · opened 2mo ago by Shawnaldinho · +612-17 across 9 files · ↗ on GitHub

From the PR description

Background

Replaces the closed #154. That PR was cosmetic - homoglyph substitution + a static <available_documents> tag - and I had not actually tested it. The reviewer was right to call it out. This PR is the honest version.

Scope statement up front: this raises the bar on casual prompt injection. It does not prevent a determined attacker. Real prevention needs output classification + capability containment + behavioural validation against the model, and those are several-week follow-ups documented as gaps in docs/SECURITY-MODEL.md. The LLM is not a security boundary; the docs and README now say so explicitly.

What changes

1. Per-request spotlighting fence - backend/src/lib/promptFence.ts

Each request generates a fresh 64-bit hex nonce. Untrusted spans are wrapped as

``` "UNTRUSTED::"...payload..."END:" ```

The system prompt carries a fenceInstructions(nonce) block exactly once per turn telling the model that fenced content is data, not instructions, and that the nonce rotates per request and cannot be forged. Security comes from the unguessable nonce, not from sanitising the payload - that was the PR #154 mistake. Light hygiene strips C0 control bytes and caps label-shaped fields at 512 chars; no XML angle-bracket substitution.

2. Apply it to the actual attack surface - backend/src/lib/chatTools.ts

The real prompt-injection vector isn't filenames in the system prompt (the only thing #154 touched) - it's document body text returned by tool calls. This PR fences:

  • buildMessages - filenames, folder paths, workflow titles in the system prompt and on user messages.
  • enrichWithPriorEvents - filenames and workflow titles in the prior-turn summary attached to the last assistant message.
  • runToolCalls for read_document, find_in_document, fetch_documents, list_documents, list_workflows, read_workflow - document body text, search excerpts, the JSON payloads of list tools, and workflow prompt_md.

3. Thread the nonce through the routes - backend/src/routes/{chat,projectChat}.ts

Same nonce is generated at the top of the POST handler and passed into buildMessages, enrichWithPriorEvents, and runLLMStream so the system-prompt convention matches the tool-result markers.

4. Adversarial corpus + structural test runner - backend/tests/promptFence/

20 attacks across the real surface: naive override in filenames, newline-break, XML-fence-break, oversize label, homoglyph, role-play in doc body, fence-close forgery with a guessed nonce, base64 payload, tool-call injection, exfiltration prompts, multi-turn drift, workflow title/prompt overrides, search-hit injection, list_documents filename injection, folder path injection, instruction-shaped-but-benign, plus a clean control case.

runStructural.ts walks every entry through the real fenceLabel/fenceBody/buildMessages code paths and asserts:

  • per-request nonce shape (16 lowercase hex);
  • two consecutive nonces differ (replay protection);
  • the legitimate close marker appears exactly once even when the payload tries to forge it with a different static nonce;
  • control bytes are stripped;
  • oversize labels truncate to 512 chars + ellipsis;
  • the system prompt carries the matching fenceInstructions block;
  • the evil filename's embedded "SYSTEM:" string lands inside an unclosed fence, not bare in the system prompt.

``` $ npm run test:prompt-fence --prefix backend OK 91 structural assertions passed across 20 corpus entries. ```

I actually ran this. Output included verbatim above.

5. Threat model - docs/SECURITY-MODEL.md

  • Threat actors and surfaces table.
  • What the codebase does today (this PR's mechanism).
  • What the codebase does NOT do - explicitly: no behavioural validation against live models, no output classification, no capability containment between read and write tools in a single turn, no context-window-crowding defence, multi-turn carry-over of a compromised prior turn is unmitigated.
  • What operators should do (don't upload from untrusted sources without reviewing tool calls; report via GitHub security advisories).

6. README - security section

One paragraph + a link to the threat model + the GitHub private-advisories link.

What this PR explicitly does NOT claim

  • It does not prevent prompt injection. Plausibly-shaped requests inside a fence (no fence-break attempt) will still reach the model and may still be obeyed.
  • It does not test behavioural compliance against the model. The structural tests prove the wrapping is correct; whether a given model honours the spotlighting convention requires running the corpus against a live API and judging responses. Documented as a follow-up.
  • It does not add capability containment. A turn can still chain read_documentedit_document without user confirmation.

Testing

``` npm run build --prefix backend # passes npm run test:prompt-fence --prefix backend # 91/91 assertions pass ```

Closes the prompt-injection concern from https://insights.flank.ai/where-mikeoss-falls-short.html (gap 12) in honest scope: structural defence-in-depth + threat-model writeup, not a claim of prevention.

Our analysis

Nonce-fenced spotlighting to blunt prompt injection in tool results — read the full analysis →

Think the analysis missed something the PR description covers?

Capture this PR into my fork

Download a Markdown prompt that tells Claude how to port every commit in this PR into your working tree. Run it via claude -p < capture-pull-158.md from inside the repo you want the changes in.

⬇ Download capture-pull-158.md