All lessons › Function B — Securing AI Architectures › Securing the Architecture — Identity and Ingress
B2.6

Ingress: marking untrusted content at the door

B2
▶
Lightboard recording

Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.

Video placeholderpublishes automatically from recordings/B2.6.mp4
Risk

Concatenation destroys the one fact that separates an operator instruction from an attacker's: where it came from.

Control

Provenance tagging at every ingress point, and a rule that only trusted origins may select a tool.

The lab

Tag every span at ingress, then show the same payload refused through six different entry paths.

◉Use case relevance

By the time the model sees it, the context window is one flat string. The operator's instruction, the user's question and a paragraph from a stranger's web page are indistinguishable — unless something attached an origin to each span before they were concatenated.

At CyberTravels. You add cybertravels/provenance.py and change both handlers in ingress/chat.py to mark what they receive. --diff at B2.6 is the whole control. Then re-run the B1.2 attack against your new checkpoint and read the trace: the instruction is still there, and it is now labelled as a traveller's.

◴What this lesson is — Day 0, Day 1, Day 2

Marking untrusted text where it enters, in the file the reader already wrote: a Span carrying its origin, assigned at the boundary because that is the only place that honestly knows, and rendered to the model with delimiters a span cannot forge. Including the part people skip — marking is not filtering, and this closes nothing on its own.

Day 0 — why

Concatenation destroys the one fact that separates an operator's instruction from an attacker's: where it came from.

Day 1 — how

Tag provenance at every ingress point, and permit only trusted origins to select a tool.

Day 2 — measure

Ingress points that tag, and tool selections traceable to a trusted origin.

Measure this

what to measuredenominator or target
ingress points that tagshare of ingress points
tool selections traceable to a trusted originshare of selections

▦The framework, and how it works

   before                          after
   +-------------------------+     +---------------------------+
   | system prompt           |     | [principal] system prompt |
   | user question           |     | [principal] user question |
   | retrieved document      |     | [data]      retrieved doc |
   | tool result             |     | [data]      tool result   |
   +-------------------------+     +---------------------------+
   one flat string                 origin travels with the span

   rule that becomes possible: a [data] span may not select a tool

Mitigates: T6 Intent Breaking, direct and indirect · T1 Memory Poisoning · T12 Communication Poisoning.

This is the control for the largest risk in Chapter B1.

B1.3 worked because the context window is one flat string. Everything — operator instruction, user question, retrieved document, tool result, peer message — arrives as tokens with no marker for where it came from. The distinction the operator believed in is destroyed by the concatenation.

The control is to stop flattening: attach an origin to every span before assembly, carry it everywhere the span goes, and make one rule out of it.

Only spans from a trusted origin may select a tool.

Three properties do the work:

Tag at every ingress point. Not just retrieval — tool results, MCP tool descriptions, memory reads, and inter-agent messages are all ingress. A path you did not tag is a path with no control on it.

Carry the tag into memory. This is what stops B1.4. A summary written from a trust-0 document is itself trust-0, and if the tag is dropped on write the poison becomes a fact.

Let untrusted content still be useful. The document is read, summarised, quoted and reasoned about. What it may not do is choose an action. Refusing to read untrusted content would refuse the entire use case.

Note what this does not do: it does not detect malicious text. It never looks at the content at all, which is exactly why rephrasing does not defeat it.

What this control closes. The largest control in the chapter. It never inspects content, so rewriting the payload does not help — the check is on origin, which the attacker cannot change.

2 · The control

3 · Checking the ingress paths, as a skill

Tagging origin is the control; knowing which of CyberTravels' paths actually reaches the model without one is the audit. The procedure inventories every untrusted ingestion path — hotel descriptions, booking notes, uploaded vouchers, web fetches, and the one everybody forgets, tool results — and checks each for a screening step and for whether provenance survives into context. Note its confidence ceiling: PARTIAL, and not negotiable, because no detector is a boundary. This is the file in this repository:

▶Real time execution as skill

The skill — skills/attestation/input-injection-screening-verifier/SKILL.md

name: input-injection-screening-verifier
description: >-
  Verify that untrusted ingestion paths pass through an injection detection
  or sanitisation step before reaching model context. Use to attest
  injection screening, to inventory which untrusted sources are screened, or
  to check for the private-data plus untrusted-content plus egress
  combination.
allowed-tools: Read, Grep, Glob

Input Injection Screening Verifier

Controls: Control 5 — indirect prompt-injection screening

Confidence: PARTIAL — and this ceiling is not negotiable

You can verify that a detector exists on the path and record its class. You cannot verify that it works. Published defences that reduce attack success to a few percent under static attacks have been driven back above 95% by adaptive, search-based attacks, and human red-teamers defeat them routinely.

Record the detector class. Do not assert protection. Cap at PARTIAL.

When to use this

When a deployment claims to screen untrusted input, and specifically when somebody wants that claim recorded as PASS. It cannot be: the ceiling here is PARTIAL and not negotiable, and running this is how that gets written into the artefact rather than argued about in a review.

Procedure

  1. Inventory untrusted ingestion paths. Email, documents, web fetches, tickets, tool results, MCP tool descriptions, retrieved corpora, memory reads, inter-agent messages. Anything a party outside your trust boundary can write into.
  1. For each path, determine whether a screening step exists between ingestion and model context, and record which one — a classifier, a prompt-attack filter, delimiting or spotlighting, a dual-model pattern, or nothing.
  1. Check provenance tagging. Whether the origin survives into the context window is more durable than any detector, because it does not depend on recognising the payload.
  1. Flag the dangerous combination. Private data reachable and untrusted content ingested and an egress path available is the combination that turns injection into exfiltration. Any deployment with all three is a finding on its own, whatever the detector says.

Example

Input — the fixture committed at the top of scripts/input_injection_screening_verifier.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.

Output — the opening lines of a real run:

the same payload, through every ingress component:
   knowledge  -> refused   (knowledge may not select a tool)
   memory     -> refused   (memory may not select a tool)
   mcp        -> refused   (mcp may not select a tool)
   tools      -> refused   (tools may not select a tool)
   messaging  -> refused   (messaging may not select a tool)

rewriting the payload does not help - the check never reads it:

The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.

Output contract

{
  "deployment_id": "str",
  "ingestion_paths": [
    {"source": "str", "screened": true, "detector_class": "classifier|filter|spotlighting|dual_model|none",
     "provenance_tagged": true}
  ],
  "unscreened_sources": ["str"],
  "trifecta": {"private_data": true, "untrusted_content": true, "egress": true, "present": true},
  "verdict": "PARTIAL|FAIL",
  "verdict_ceiling_reason": "detector presence is verifiable; robustness under adaptive attack is not"
}

PASS is not a permitted value.

Failure modes

  • Reporting a detector's benchmark score as this deployment's protection. Those numbers are from static attacks.
  • Missing tool results as an ingestion path. They are the most commonly forgotten one, because they arrive from a system you trust.
  • Ignoring the combination check because each element looked acceptable alone.

Three ways to do this lesson. Pick one. They run the same procedure against the same code — what changes is who drives it, and how much you see.

1In your AI agent — you are taught

The agent reads the lesson, explains the idea, fetches the code, runs the check and answers your questions as you go. This is the one to take if you are here to learn.

Install it, once. From the folder you cloned. --all does every agent it finds on your machine:

python3 scripts/install_skills.py --all --lessons B2.6

Then restart your agent and open it in that folder. On Windows, if python3 says Python was not found, use python instead.

Then pick b2-6-ingress-marking-untrusted-content-at-the:

agentinstall just for this agentstart it
Claude Codepython3 scripts/install_skills.py --tool claude --lessons B2.6type /b2-6-ingress-marking-untrusted-content-at-the
GitHub Copilot (VS Code)python3 scripts/install_skills.py --tool copilot --lessons B2.6type /b2-6-ingress-marking-untrusted-content-at-the in Chat
Cursorpython3 scripts/install_skills.py --tool cursor --lessons B2.6type / in Agent chat and search for it
Codexpython3 scripts/install_skills.py --tool agents --lessons B2.6type $b2-6-ingress-marking-untrusted-content-at-the, or run /skills

2As one Python command — you just run it

No agent, no conversation. The same lesson executes and prints the same readback. Take this one if you have no assistant, if you are reviewing rather than learning, or if you want the result without the teaching around it.

python3 scripts/lesson.py B2.6

3Read the code, and the difference — nothing runs

Neither of the above, and no model needed. The first command writes CyberTravels exactly as it stood at the end of this lesson; the second prints the diff — the lines this lesson added or changed, and nothing from any later one.

python3 scripts/checkpoint.py --at B2.6 --out work/cybertravels
python3 scripts/checkpoint.py --at B2.6 --diff

On Windows, type python wherever these say python3 — and use \ instead of / in the paths. Everything else is identical. A0.0 step 2 explains why.

Ways 1 and 2 need a model; way 3 does not. A signed-in Claude Code CLI needs no API key, and any OpenAI-compatible endpoint works too. With neither, the audit exits 2 and says so rather than inventing an answer. A0.0 sets this up.

Ways 1 and 2 both end with the same readback: What I did, What changed, The number and Read this next. A step that was skipped or refused is reported as exactly that, never as a pass.

If you did the last lesson’s challenge, expect this to stop. Most challenges ask you to edit the code, and ways 1 and 2 will not overwrite your edits: you get stopped, not a failure, with two options — --out for a fresh folder that keeps your work, or --force to discard it and start this lesson clean.

✓What you just proved

The same payload is refused through all five untrusted ingress components and through two rewordings, the user's own request still reaches the tool, and a memory record written from an untrusted document is still refused a week later because the origin was stored with it. The skill also reports its shape. Its ceiling is the lesson: a screening step is evidence of effort, not of protection, so the verdict is capped at PARTIAL however good the detector's benchmark looks — and the combination it flags, private data reachable plus untrusted content plus egress, is the one that turns a summary into an exfiltration.

✎Your turn

List every place text enters your agent's context and check which of them attaches an origin. The untagged ones are the paths where this control does not exist, whatever the design document says.

Tools used

LLM GuardagentgatewayLlama Guard 4Claude Haiku 4.5

Skill source: skills/attestation/input-injection-screening-verifier

← PreviousB2.5 · The non-human identity lifecycle