{"course":{"slug":"context-engineering","title":"Context Engineering","url":"https://addaly.com/learn/context-engineering","level":"advanced","track":"build"},"lesson":{"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","position":74,"of":87,"minutes":9,"answer":"Since you cannot prevent a model reading an instruction, the defence has to be that the instruction is not worth giving.","keyIdea":"Since you cannot prevent a model reading an instruction, the defence has to be that the instruction is not worth giving, which means the set of actions available in a turn is narrowed by code according to what is in the window, not by a request in the prompt."},"body":{"format":"markdown","text":"## Move the control out of the prompt\n\nEvery defence so far has been probabilistic: warnings, delimiters, labels. They shift odds. None of them is a guarantee, and an attacker gets to keep trying.\n\nThe controls in this lesson are different in kind. They are conditions in your code, evaluated before the model is called, and the model has no vote. An attacker who fully controls the model's output still cannot make a tool exist that you did not pass.\n\nThe premise is uncomfortable and clarifying: **assume the injection succeeds.** Design as though the attacker is writing the model's output directly. What can they accomplish? The answer is exactly the set of tools available in that turn, with exactly the permissions those tools hold. Shrink that set and you have shrunk the attack, regardless of how persuasive the payload was.\n\n## Read-only by default, writes by exception\n\nThe first and largest reduction. Split every tool into read and write, where write means anything with an effect outside the process: sending, deleting, purchasing, posting, modifying a record, calling an external endpoint.\n\nThen, using the provenance label from the previous lesson:\n\n```python\ntools = all_tools\nif ctx.has_untrusted:\n    tools = [t for t in all_tools if t.read_only]\n```\n\nA turn that has read a web page or an inbound email can look things up and can answer. It cannot act. If the user's request requires acting on something that was read, that becomes a second turn: one where the material has been reduced to a specific proposal the user confirmed, rather than a page of attacker-controlled prose.\n\nThis is a real product cost and it should be stated as one. It makes some flows two steps instead of one. It is also the single most effective control available, and it is about six lines.\n\n## Confirmation that shows the right thing\n\nWhere a write must be possible, put a human on it, but the design of the confirmation decides whether it is a control or a formality.\n\nA dialogue saying \"The assistant would like to send an email. Allow?\" trains the user to click yes and protects nothing.\n\nA useful confirmation shows **the actual arguments**, in full, and says what triggered them:\n\n```\nSend email\n  To:      accounts@unfamiliar-domain.example\n  Subject: Re: invoice\n  Body:    [full text, not truncated]\n\nThis action was proposed while reading: inbox message from\nstranger@example.net (untrusted).\n```\n\nThe recipient is the field that catches exfiltration, because a strange destination is visible to a human in a way that a clever paragraph is not. Truncating the body to keep the dialogue tidy removes exactly the evidence somebody needs.\n\nAnd confirmations must be rare. A user asked to approve forty actions approves the forty-first without reading. Batching many writes into one approval is worse than useless, because the odd one hides among the expected ones. If your design needs frequent confirmations, the answer is usually narrower tools, not more dialogues.\n\n## Constrain the arguments, not just the tool\n\nA tool being available does not have to mean it is available for anything.\n\n- `fetch_url` restricted to an allowlist of hosts.\n- `send_email` restricted to addresses already in the user's contacts, or to the sender of the message being replied to.\n- `run_sql` restricted to a read-only connection against named views, with a row cap.\n- `write_file` restricted to a working directory, with path traversal rejected rather than normalised.\n\nEach of these turns an open channel into a closed one. The pattern is that the *policy* lives in the tool implementation, where it is testable and reviewable, rather than in a sentence hoping the model will comply.\n\n## Scope the data down too\n\nThe trifecta has three legs and capability limits attack one of them. While you are there, attack another.\n\nAn assistant with a `search_all_documents` tool has the whole corpus in scope on every turn. One scoped to the document the user selected has one document. The second cannot leak the first's contents no matter what it is told, because it was never given them. Least privilege applied to retrieval scope is as effective here as it is anywhere else in security, and it is under-used because retrieval is usually built by people thinking about relevance rather than blast radius.\n\n## Bound the blast radius when everything fails\n\nAssume all of the above is bypassed. What limits the damage?\n\n- **Rate limits on write tools**, per user and per session. An attack that can send one email is bad. One that can send four hundred is a different event.\n- **Reversibility.** Soft-delete rather than delete. Queue outbound messages with a short delay and a cancel. Make destructive actions require a second factor.\n- **Alerting on the signature.** A write action in a turn that contained untrusted material is a rare and specific pattern, and you can only alert on it if you labelled the blocks. Alert on it.\n- **An audit trail** that records which sources were in the window when an action was taken, so the question \"which of our users were affected\" has an answer.\n\n## The summary\n\nNone of this stops prompt injection. It is not meant to. It makes a successful injection into an event with bounded consequences, detectable afterwards, reversible in the common cases.\n\nThat is a lesser claim than a fix, and it is the claim that is currently true. A team that has done all of this is in a defensible position. A team that has written a strongly worded system prompt is not, however carefully it was worded."},"check":{"question":"A system removes all write tools on any turn where untrusted content is present. Why does this hold even against a payload that completely controls the model's output?","options":[{"text":"The model recognises the removal and refuses to comply with instructions it cannot satisfy.","correct":false,"tempting":"Attributes the guarantee to the model's judgement, which is precisely the thing an injection has already subverted."},{"text":"The tool set is chosen by code before the call, so a requested tool simply is not there.","correct":true},{"text":"Untrusted blocks are placed after the tool definitions, so their instructions arrive too late to influence selection.","correct":false,"tempting":"Position affects salience, never availability; a model can call a tool defined anywhere in the request."},{"text":"Write tools require a signed capability token the model has no way of producing from inside the context window.","correct":false,"tempting":"A plausible-sounding mechanism, but the model never produces credentials in the first place; your code does, and it is the decision to pass the tool that matters."}]},"prev":{"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"},"next":{"slug":"spotlighting-and-its-limits","title":"Marking untrusted text, and how far that gets you","url":"https://addaly.com/learn/context-engineering/spotlighting-and-its-limits"},"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) 'Limit what it can do, not what it can read', in Context Engineering. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/context-engineering/capabilities-not-instructions (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":"Limit what it can do, not what it can read","courseTitle":"Context Engineering","module":{"index":8,"slug":"untrusted-text-and-the-boundary","title":"Untrusted text and the trust boundary"},"answer":null,"keyIdea":"Since you cannot prevent a model reading an instruction, the defence has to be that the instruction is not worth giving, which means the set of actions available in a turn is narrowed by code according to what is in the window, not by a request in the prompt.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"move-the-control-out-of-the-prompt","runs":[{"text":"Move the control out of the prompt"}]},{"t":"p","runs":[{"text":"Every defence so far has been probabilistic: warnings, delimiters, labels. They shift odds. None of them is a guarantee, and an attacker gets to keep trying."}]},{"t":"p","runs":[{"text":"The controls in this lesson are different in kind. They are conditions in your code, evaluated before the model is called, and the model has no vote. An attacker who fully controls the model’s output still cannot make a tool exist that you did not pass."}]},{"t":"p","runs":[{"text":"The premise is uncomfortable and clarifying: "},{"text":"assume the injection succeeds.","b":true},{"text":" Design as though the attacker is writing the model’s output directly. What can they accomplish? The answer is exactly the set of tools available in that turn, with exactly the permissions those tools hold. Shrink that set and you have shrunk the attack, regardless of how persuasive the payload was."}]},{"t":"h2","id":"read-only-by-default-writes-by-exception","runs":[{"text":"Read-only by default, writes by exception"}]},{"t":"p","runs":[{"text":"The first and largest reduction. Split every tool into read and write, where write means anything with an effect outside the process: sending, deleting, purchasing, posting, modifying a record, calling an external endpoint."}]},{"t":"p","runs":[{"text":"Then, using the provenance label from the previous lesson:"}]},{"t":"code","lang":"python","label":"Python","text":"tools = all_tools\nif ctx.has_untrusted:\n    tools = [t for t in all_tools if t.read_only]"},{"t":"p","runs":[{"text":"A turn that has read a web page or an inbound email can look things up and can answer. It cannot act. If the user’s request requires acting on something that was read, that becomes a second turn: one where the material has been reduced to a specific proposal the user confirmed, rather than a page of attacker-controlled prose."}]},{"t":"p","runs":[{"text":"This is a real product cost and it should be stated as one. It makes some flows two steps instead of one. It is also the single most effective control available, and it is about six lines."}]},{"t":"h2","id":"confirmation-that-shows-the-right-thing","runs":[{"text":"Confirmation that shows the right thing"}]},{"t":"p","runs":[{"text":"Where a write must be possible, put a human on it, but the design of the confirmation decides whether it is a control or a formality."}]},{"t":"p","runs":[{"text":"A dialogue saying “The assistant would like to send an email. Allow?” trains the user to click yes and protects nothing."}]},{"t":"p","runs":[{"text":"A useful confirmation shows "},{"text":"the actual arguments","b":true},{"text":", in full, and says what triggered them:"}]},{"t":"code","lang":"","label":"Text","text":"Send email\n  To:      accounts@unfamiliar-domain.example\n  Subject: Re: invoice\n  Body:    [full text, not truncated]\n\nThis action was proposed while reading: inbox message from\nstranger@example.net (untrusted)."},{"t":"p","runs":[{"text":"The recipient is the field that catches exfiltration, because a strange destination is visible to a human in a way that a clever paragraph is not. Truncating the body to keep the dialogue tidy removes exactly the evidence somebody needs."}]},{"t":"p","runs":[{"text":"And confirmations must be rare. A user asked to approve forty actions approves the forty-first without reading. Batching many writes into one approval is worse than useless, because the odd one hides among the expected ones. If your design needs frequent confirmations, the answer is usually narrower tools, not more dialogues."}]},{"t":"h2","id":"constrain-the-arguments-not-just-the-tool","runs":[{"text":"Constrain the arguments, not just the tool"}]},{"t":"p","runs":[{"text":"A tool being available does not have to mean it is available for anything."}]},{"t":"ul","items":[[{"text":"fetch_url","code":true},{"text":" restricted to an allowlist of hosts."}],[{"text":"send_email","code":true},{"text":" restricted to addresses already in the user’s contacts, or to the sender of the message being replied to."}],[{"text":"run_sql","code":true},{"text":" restricted to a read-only connection against named views, with a row cap."}],[{"text":"write_file","code":true},{"text":" restricted to a working directory, with path traversal rejected rather than normalised."}]]},{"t":"p","runs":[{"text":"Each of these turns an open channel into a closed one. The pattern is that the "},{"text":"policy","i":true},{"text":" lives in the tool implementation, where it is testable and reviewable, rather than in a sentence hoping the model will comply."}]},{"t":"h2","id":"scope-the-data-down-too","runs":[{"text":"Scope the data down too"}]},{"t":"p","runs":[{"text":"The trifecta has three legs and capability limits attack one of them. While you are there, attack another."}]},{"t":"p","runs":[{"text":"An assistant with a "},{"text":"search_all_documents","code":true},{"text":" tool has the whole corpus in scope on every turn. One scoped to the document the user selected has one document. The second cannot leak the first’s contents no matter what it is told, because it was never given them. Least privilege applied to retrieval scope is as effective here as it is anywhere else in security, and it is under-used because retrieval is usually built by people thinking about relevance rather than blast radius."}]},{"t":"h2","id":"bound-the-blast-radius-when-everything-fails","runs":[{"text":"Bound the blast radius when everything fails"}]},{"t":"p","runs":[{"text":"Assume all of the above is bypassed. What limits the damage?"}]},{"t":"ul","items":[[{"text":"Rate limits on write tools","b":true},{"text":", per user and per session. An attack that can send one email is bad. One that can send four hundred is a different event."}],[{"text":"Reversibility.","b":true},{"text":" Soft-delete rather than delete. Queue outbound messages with a short delay and a cancel. Make destructive actions require a second factor."}],[{"text":"Alerting on the signature.","b":true},{"text":" A write action in a turn that contained untrusted material is a rare and specific pattern, and you can only alert on it if you labelled the blocks. Alert on it."}],[{"text":"An audit trail","b":true},{"text":" that records which sources were in the window when an action was taken, so the question “which of our users were affected” has an answer."}]]},{"t":"h2","id":"the-summary","runs":[{"text":"The summary"}]},{"t":"p","runs":[{"text":"None of this stops prompt injection. It is not meant to. It makes a successful injection into an event with bounded consequences, detectable afterwards, reversible in the common cases."}]},{"t":"p","runs":[{"text":"That is a lesser claim than a fix, and it is the claim that is currently true. A team that has done all of this is in a defensible position. A team that has written a strongly worded system prompt is not, however carefully it was worded."}]}],"check":{"question":[{"text":"A system removes all write tools on any turn where untrusted content is present. Why does this hold even against a payload that completely controls the model’s output?"}],"options":[{"text":[{"text":"The model recognises the removal and refuses to comply with instructions it cannot satisfy."}],"correct":false,"tempting":[{"text":"Attributes the guarantee to the model’s judgement, which is precisely the thing an injection has already subverted."}]},{"text":[{"text":"The tool set is chosen by code before the call, so a requested tool simply is not there."}],"correct":true,"tempting":null},{"text":[{"text":"Untrusted blocks are placed after the tool definitions, so their instructions arrive too late to influence selection."}],"correct":false,"tempting":[{"text":"Position affects salience, never availability; a model can call a tool defined anywhere in the request."}]},{"text":[{"text":"Write tools require a signed capability token the model has no way of producing from inside the context window."}],"correct":false,"tempting":[{"text":"A plausible-sounding mechanism, but the model never produces credentials in the first place; your code does, and it is the decision to pass the tool that matters."}]}],"order":[0,1,3,2],"why":null},"prev":{"slug":"taint-through-the-assembler","title":"Labelling every block with where it came from","minutes":9},"next":{"slug":"spotlighting-and-its-limits","title":"Marking untrusted text, and how far that gets you","minutes":8}}}