{"course":{"slug":"context-engineering","title":"Context Engineering","url":"https://addaly.com/learn/context-engineering","level":"advanced","track":"build"},"lesson":{"slug":"taint-through-the-assembler","title":"Labelling every block with where it came from","url":"https://addaly.com/learn/context-engineering/taint-through-the-assembler","position":73,"of":87,"minutes":9,"answer":"Trust is a property of a block's origin rather than of its content, so the assembler should carry a provenance label on every block it packs and expose whether the finished window contains anything untrusted.","keyIdea":"Trust is a property of a block's origin rather than of its content, so the assembler should carry a provenance label on every block it packs and expose whether the finished window contains anything untrusted, which turns an unanswerable question into a boolean your code can act on."},"body":{"format":"markdown","text":"## The question your code cannot currently answer\n\nHere is a question that should be easy and in most systems is impossible: *did this request contain any text written by someone outside the organisation?*\n\nBy the time the window is assembled it is a string. The retrieved passages, the tool results, the system prompt and the user's message have been concatenated and the origins have evaporated. Nothing downstream can tell them apart, which means no policy downstream can depend on the difference.\n\nFixing this is not a security technique so much as a data-modelling correction, and it is the thing that makes every other defence in this module implementable.\n\n## Make the block a value, not a string\n\nThe assembler should work with typed blocks all the way to the final render.\n\n```python\n@dataclass(frozen=True)\nclass Block:\n    kind: str          # instructions | evidence | history | tool_result | state\n    text: str\n    origin: str        # \"system\" | \"user\" | \"corpus:policies\" | \"web:example.com\"\n    trust: str         # \"trusted\" | \"user\" | \"untrusted\"\n    source_id: str = \"\"\n```\n\nThree trust levels are enough and more become unusable.\n\n- **trusted**: text your organisation authored: the system prompt, tool descriptions, templates. Nobody outside can change it.\n- **user**: text from the authenticated person in the session. Not adversarial to themselves, but it can carry content they were sent, so it is not trusted either.\n- **untrusted**: everything else. Retrieved documents, web pages, mail bodies, tool results, files written by tools, sub-agent summaries derived from any of these.\n\nThe assignment is made where the block is created, by the code that fetched it, which is the only place that actually knows. Default to `untrusted` and require an explicit argument to claim anything better, so that a new source added next year is safe by omission rather than dangerous by omission.\n\n## Taint propagates\n\nThe rule that does the work: **any block derived from an untrusted block is untrusted.**\n\nA summary of a fetched page is untrusted. A sub-agent's findings from reading untrusted documents are untrusted. A file written by a tool that fetched untrusted content is untrusted, and stays untrusted when read back, which is the laundering path from the filesystem lesson, closed.\n\nThis is ordinary taint tracking, familiar from static analysis, and it is not subtle. What makes it valuable is that it survives the places where humans stop paying attention: three hops from the fetch, in a file, in a summary of a summary, the label is still there because a dataclass carried it.\n\nThe one exception worth allowing is a **narrowing** step: if untrusted text is reduced by your code to a constrained value (a date parsed into a date object, a classification restricted to an enum, a number validated against a range) the result is no longer free text and can be treated as trusted, because an attacker's influence over it is bounded to choosing among your options. Make that explicit and rare. Every use of it is a claim you are making about your own validator.\n\n## What the label lets you do\n\nOnce the assembler can report `window.has_untrusted`, policies become code instead of intentions:\n\n```python\nif ctx.has_untrusted:\n    tools = [t for t in tools if t.read_only]\n    render_remote_images = False\n    require_confirmation_for_writes = True\n```\n\nThat is the capability restriction of the next lesson, and it is three lines because the label exists. Without it there is nowhere to hang the condition.\n\nIt also gives you the logging that makes an incident investigable. Record, per request: which sources contributed, their trust levels, and whether any write action was taken in a turn containing untrusted material. That last combination is the one to alert on. It is rare in normal traffic and it is exactly the signature of a successful injection, and no generic observability stack will produce it for you.\n\n## Render the label into the window as well\n\nThe same information should appear in the prompt, since the model benefits from knowing:\n\n```\n<evidence trust=\"untrusted\" origin=\"web:example.com\">\n...\n</evidence>\n```\n\nBe clear-eyed about what this buys. It is a hint to the model, subject to everything the first lesson said about hints. It helps, it is free, and it is not the control. The control is the code that read `has_untrusted` and removed the write tools. Mixing these two up is the central confusion this module exists to remove.\n\n## The cost, which is one afternoon\n\nRetrofitting provenance into an assembler that concatenates strings is a day of work in a small system and a week in a large one. It touches every fetch site and every place a string is built.\n\nIt is worth scheduling before you need it, because after an incident you will be doing the same work under pressure, with an auditor asking which requests were affected: a question you can only answer if the labels were already there when it happened."},"check":{"question":"A sub-agent reads three fetched web pages and returns a 500-token summary. What trust level should that summary carry in the parent's window?","options":[{"text":"Trusted, because it was produced by your own sub-agent running your own prompt.","correct":false,"tempting":"The component is yours but its output is shaped by the attacker's input, which is exactly the case taint propagation exists to catch."},{"text":"Untrusted, because anything derived from untrusted input remains untrusted.","correct":true},{"text":"User, since the sub-agent was invoked on the authenticated user's behalf and acts with their authority.","correct":false,"tempting":"Authority to act is a separate axis from origin of text; who asked for the work does not change who wrote the words."},{"text":"Trusted, provided the summary was produced under a schema that constrained it to short factual fields.","correct":false,"tempting":"A schema narrows structure while leaving the string fields free text, so an attacker still chooses the words inside them."}]},"prev":{"slug":"exfiltration-channels","title":"The outward channels you did not know you had","url":"https://addaly.com/learn/context-engineering/exfiltration-channels"},"next":{"slug":"capabilities-not-instructions","title":"Limit what it can do, not what it can read","url":"https://addaly.com/learn/context-engineering/capabilities-not-instructions"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'Labelling every block with where it came from', in Context Engineering. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/context-engineering/taint-through-the-assembler (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"Labelling every block with where it came from","courseTitle":"Context Engineering","module":{"index":8,"slug":"untrusted-text-and-the-boundary","title":"Untrusted text and the trust boundary"},"answer":null,"keyIdea":"Trust is a property of a block’s origin rather than of its content, so the assembler should carry a provenance label on every block it packs and expose whether the finished window contains anything untrusted, which turns an unanswerable question into a boolean your code can act on.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"the-question-your-code-cannot-currently-answer","runs":[{"text":"The question your code cannot currently answer"}]},{"t":"p","runs":[{"text":"Here is a question that should be easy and in most systems is impossible: "},{"text":"did this request contain any text written by someone outside the organisation?","i":true}]},{"t":"p","runs":[{"text":"By the time the window is assembled it is a string. The retrieved passages, the tool results, the system prompt and the user’s message have been concatenated and the origins have evaporated. Nothing downstream can tell them apart, which means no policy downstream can depend on the difference."}]},{"t":"p","runs":[{"text":"Fixing this is not a security technique so much as a data-modelling correction, and it is the thing that makes every other defence in this module implementable."}]},{"t":"h2","id":"make-the-block-a-value-not-a-string","runs":[{"text":"Make the block a value, not a string"}]},{"t":"p","runs":[{"text":"The assembler should work with typed blocks all the way to the final render."}]},{"t":"code","lang":"python","label":"Python","text":"@dataclass(frozen=True)\nclass Block:\n    kind: str          # instructions | evidence | history | tool_result | state\n    text: str\n    origin: str        # \"system\" | \"user\" | \"corpus:policies\" | \"web:example.com\"\n    trust: str         # \"trusted\" | \"user\" | \"untrusted\"\n    source_id: str = \"\""},{"t":"p","runs":[{"text":"Three trust levels are enough and more become unusable."}]},{"t":"ul","items":[[{"text":"trusted","b":true},{"text":": text your organisation authored: the system prompt, tool descriptions, templates. Nobody outside can change it."}],[{"text":"user","b":true},{"text":": text from the authenticated person in the session. Not adversarial to themselves, but it can carry content they were sent, so it is not trusted either."}],[{"text":"untrusted","b":true},{"text":": everything else. Retrieved documents, web pages, mail bodies, tool results, files written by tools, sub-agent summaries derived from any of these."}]]},{"t":"p","runs":[{"text":"The assignment is made where the block is created, by the code that fetched it, which is the only place that actually knows. Default to "},{"text":"untrusted","code":true},{"text":" and require an explicit argument to claim anything better, so that a new source added next year is safe by omission rather than dangerous by omission."}]},{"t":"h2","id":"taint-propagates","runs":[{"text":"Taint propagates"}]},{"t":"p","runs":[{"text":"The rule that does the work: "},{"text":"any block derived from an untrusted block is untrusted.","b":true}]},{"t":"p","runs":[{"text":"A summary of a fetched page is untrusted. A sub-agent’s findings from reading untrusted documents are untrusted. A file written by a tool that fetched untrusted content is untrusted, and stays untrusted when read back, which is the laundering path from the filesystem lesson, closed."}]},{"t":"p","runs":[{"text":"This is ordinary taint tracking, familiar from static analysis, and it is not subtle. What makes it valuable is that it survives the places where humans stop paying attention: three hops from the fetch, in a file, in a summary of a summary, the label is still there because a dataclass carried it."}]},{"t":"p","runs":[{"text":"The one exception worth allowing is a "},{"text":"narrowing","b":true},{"text":" step: if untrusted text is reduced by your code to a constrained value (a date parsed into a date object, a classification restricted to an enum, a number validated against a range) the result is no longer free text and can be treated as trusted, because an attacker’s influence over it is bounded to choosing among your options. Make that explicit and rare. Every use of it is a claim you are making about your own validator."}]},{"t":"h2","id":"what-the-label-lets-you-do","runs":[{"text":"What the label lets you do"}]},{"t":"p","runs":[{"text":"Once the assembler can report "},{"text":"window.has_untrusted","code":true},{"text":", policies become code instead of intentions:"}]},{"t":"code","lang":"python","label":"Python","text":"if ctx.has_untrusted:\n    tools = [t for t in tools if t.read_only]\n    render_remote_images = False\n    require_confirmation_for_writes = True"},{"t":"p","runs":[{"text":"That is the capability restriction of the next lesson, and it is three lines because the label exists. Without it there is nowhere to hang the condition."}]},{"t":"p","runs":[{"text":"It also gives you the logging that makes an incident investigable. Record, per request: which sources contributed, their trust levels, and whether any write action was taken in a turn containing untrusted material. That last combination is the one to alert on. It is rare in normal traffic and it is exactly the signature of a successful injection, and no generic observability stack will produce it for you."}]},{"t":"h2","id":"render-the-label-into-the-window-as-well","runs":[{"text":"Render the label into the window as well"}]},{"t":"p","runs":[{"text":"The same information should appear in the prompt, since the model benefits from knowing:"}]},{"t":"code","lang":"","label":"Text","text":"<evidence trust=\"untrusted\" origin=\"web:example.com\">\n...\n</evidence>"},{"t":"p","runs":[{"text":"Be clear-eyed about what this buys. It is a hint to the model, subject to everything the first lesson said about hints. It helps, it is free, and it is not the control. The control is the code that read "},{"text":"has_untrusted","code":true},{"text":" and removed the write tools. Mixing these two up is the central confusion this module exists to remove."}]},{"t":"h2","id":"the-cost-which-is-one-afternoon","runs":[{"text":"The cost, which is one afternoon"}]},{"t":"p","runs":[{"text":"Retrofitting provenance into an assembler that concatenates strings is a day of work in a small system and a week in a large one. It touches every fetch site and every place a string is built."}]},{"t":"p","runs":[{"text":"It is worth scheduling before you need it, because after an incident you will be doing the same work under pressure, with an auditor asking which requests were affected: a question you can only answer if the labels were already there when it happened."}]}],"check":{"question":[{"text":"A sub-agent reads three fetched web pages and returns a 500-token summary. What trust level should that summary carry in the parent’s window?"}],"options":[{"text":[{"text":"Trusted, because it was produced by your own sub-agent running your own prompt."}],"correct":false,"tempting":[{"text":"The component is yours but its output is shaped by the attacker’s input, which is exactly the case taint propagation exists to catch."}]},{"text":[{"text":"Untrusted, because anything derived from untrusted input remains untrusted."}],"correct":true,"tempting":null},{"text":[{"text":"User, since the sub-agent was invoked on the authenticated user’s behalf and acts with their authority."}],"correct":false,"tempting":[{"text":"Authority to act is a separate axis from origin of text; who asked for the work does not change who wrote the words."}]},{"text":[{"text":"Trusted, provided the summary was produced under a schema that constrained it to short factual fields."}],"correct":false,"tempting":[{"text":"A schema narrows structure while leaving the string fields free text, so an attacker still chooses the words inside them."}]}],"order":[3,2,0,1],"why":null},"prev":{"slug":"exfiltration-channels","title":"The outward channels you did not know you had","minutes":9},"next":{"slug":"capabilities-not-instructions","title":"Limit what it can do, not what it can read","minutes":9}}}