The moment you connect an MCP server, your coding agent stops being a thing that reads and writes in your repo and becomes a thing that can reach out and act. Read a database, hit an API, touch a service, pull in a web page. That's the entire appeal. It's also the entire problem, and the two are the same feature seen from two sides.
I went through this wiring up tools for my own plugin work, and the thing that saved me from a worse mistake was a scar I already had. I'd shipped an AI chatbot earlier where I rendered the model's output straight to the page and ate an HTML injection bug. That one taught me a rule the hard way: anything an LLM hands back is untrusted input. MCP is that same lesson with the blast radius turned up, because now the untrusted thing isn't just my model's text, it's whatever a connected server decides to return.
Two different fears, and people only have one
When people talk about agent safety, they almost always mean: what if the agent runs something destructive. Deletes files, force-pushes, curls something it shouldn't. That fear is real and it has a real answer, which I'll get to.
But it's only half the threat, and it's the visible half. The other half is quieter: what if the agent believes something it shouldn't. An MCP server returns an API response, a database row, an issue body, an email thread, and somewhere in that returned content is a line that reads like an instruction. The model has no reliable way to tell your instruction from text that arrived inside data. To it, they're the same channel. So the danger isn't only the command the agent runs, it's the command it gets talked into running by content that came back through a tool.
Those are two separate problems, and they need two separate defenses. Most setups install one and assume they're covered.
The output side: treat every tool return as a form field
Here's the rule I carry over from the chatbot bug. Whatever an MCP server returns is in exactly the same trust category as a string a stranger typed into a form on your site. Not "data from my tool." Data from outside, that happens to arrive through my tool.
That reframe changes what you do with it. You don't pass a tool's raw output straight into the arguments of the next command. You don't render it to a screen without escaping. And you don't let an imperative sentence buried in a returned document quietly become something the agent treats as a directive. The output is a payload to inspect, not a value to trust, and the fact that it came from a server you connected doesn't launder it. You connected the pipe. You didn't vouch for everything that flows through it.
This is the half the tooling won't do for you, because it can't. No sandbox flag knows whether a returned string is a legitimate API result or a planted instruction. That judgment lives in how you wire the data, not in a setting.
The action side: walls, not manners
The other half, the destructive-command fear, does have a settings answer, and it's worth getting exactly right because it's easy to half-do.
Asking the model nicely not to run dangerous things is manners, and manners get bypassed the moment the input talks it into something. What you want is a wall. In Claude Code that's the sandbox: turn it on and Bash execution gets isolated at the OS level, with the restriction inherited by every subprocess the agent spawns. Seatbelt on macOS, Bubblewrap on Linux and WSL2.
// .claude/settings.json
{
"sandbox": {
"enabled": true,
"allowUnsandboxedCommands": false
}
}
Here's the part that surprised me, straight from the docs: the sandbox restricts writes to your working directory, but by default it still allows reads across most of the machine. Which means credentials like ~/.aws/credentials and ~/.ssh/ are readable unless you say otherwise. The sandbox is a wall around writing and executing, not around reading. Reading you close yourself, with deny rules:
{
"permissions": {
"deny": [
"Bash(rm -rf *)",
"Bash(curl *)",
"Bash(wget *)",
"Read(./.env)",
"Read(./.env.*)",
"Read(~/.ssh/**)",
"Read(~/.aws/**)"
]
}
}
This is the combination that actually matters, and it's exactly where the two halves meet. If a tool return talks the agent into exfiltrating a secret, the read-deny is what stops it from reading the secret, and the network-command deny is what stops it from sending it. The output-side problem is what gets the agent to try; the action-side walls are what make the attempt fail. Neither covers for the other.
The third thing: don't connect what you don't trust, or don't need
The server itself is attack surface. A connected MCP server you didn't vet is code running in your loop with a channel into your agent. So three habits, none of them clever:
Vet the source before you connect it. Who wrote it, can you see the code. An MCP server is not a browser tab you can close and forget; while it's connected, it's part of your trust boundary.
Drop the ones you're not using. Every idle server is attack surface and context weight at once, and /mcp will show you what's actually connected versus what you wired up once and forgot.
And the one I reach for most: if a CLI already does the job, don't stand up a server at all. I drive WordPress with wp-cli, and rather than wrap it in an MCP server I call it from a Skill when I need it. One less always-connected surface, one less thing returning content I'd have to distrust. "Connect a server" and "call a command" are not the same risk, and the second is often the smaller one.
What I actually check now
Before I wire up any MCP server, three questions, in this order. Do I trust where this server came from. What's the worst command it could talk my agent into, and is that command walled off by deny rules. And is the output going to get treated as untrusted input everywhere it lands, or am I about to pass a stranger's text straight into a sink.
The sandbox answers exactly one of those. The other two are on me, and they're the ones that bit me first. Connecting a server is genuinely the moment your agent grows hands. Worth remembering that hands can be guided by whoever's holding the other end of the tool.
I build WordPress plugins and write about AI tooling and security at https://raplsworks.com/.
Disclaimer: The experiences and decisions in this post are my own. English isn't my first language, so I use an AI assistant to help draft and edit the writing.
Top comments (29)
Hey, this article appears to have been generated with the assistance of ChatGPT or possibly some other AI tool.
We allow our community members to use AI assistance when writing articles as long as they abide by our guidelines. Please review the guidelines and edit your post to add a disclaimer.
Failure to follow these guidelines could result in DEV admin lowering the score of your post, making it less visible to the rest of the community. Or, if upon review we find this post to be particularly harmful, we may decide to unpublish it completely.
We hope you understand and take care to follow our guidelines going forward!
Thanks for the note. The experience and the decisions here are my own; I use an AI assistant to help draft and polish the English, since it isn't my first language. I've added a disclaimer to the post.
The way I think about it: MCP turns integration risk into runtime risk.
It is not enough to ask "is this server safe to install?" You also need to know what tools were exposed to a given agent session, what arguments were passed, what state changed, and whether the action matched the expected capability.
That is why I like gateway/receipt patterns here. Keep the model flexible, but make the action boundary boring and inspectable.
Integration risk into runtime risk is a sharper way to put the whole point of the post. And the gateway/receipt instinct is the right one: keep the model flexible, make the action boundary boring and inspectable. The one thing I'd fold in, coming from the believes half of the post, is that a receipt of actions answers "what did it do" but not "what talked it into doing it." A planted instruction rides in through a tool return, and if the receipt logs the call and the args but not the untrusted input that triggered it, the action looks clean in the log while the cause stays invisible. So the boundary I'd want boring and inspectable isn't only the action, it's the provenance: the receipt should carry which tool output the action was reacting to, tagged as outside-input. Then "did the action match the expected capability" gets a second half. Did the thing that requested it have any business issuing instructions at all. Action receipts plus input provenance is the pair that lets you reconstruct a believes-side breach after the fact, not just a runs-side one.
Yes, that is the missing half. A clean action receipt without input provenance can make the wrong thing look legitimate. I like the framing as two linked receipts: the outside-input record that influenced the agent, and the action record that followed. Then review can ask both questions: was the action allowed, and did the source that nudged it have any authority to steer behavior?
The instruction-versus-data point is the one I wish more people led with, and there is a third surface it creeps into that the sandbox and deny rules do not cover: persisted memory. If your agent writes anything back to disk between sessions, a planted instruction in a tool return can get summarized into a memory file one run and then re-read as trusted context the next, so the injection outlives the session that should have caught it and fires later when your guard is down. In longer business workflows where the agent carries decisions forward in files, that is the failure mode I design hardest against: a sentence that arrived inside scraped or emailed content getting written back as if it were my own note. What helps is the same reframe you make on tool output, treat the persisted memory as outside-input too, and keep it in plain inspectable files so I can open the memory and spot the line that should not be there instead of trusting an opaque store. Your three pre-wiring questions are the right checklist, I would add a fourth: does any of this tool's output end up written into memory that the next session will trust. When a tool return gets persisted, how do you keep a planted line from quietly becoming next session's instruction?
The persisted-memory surface is the one that worries me most too, and your fourth question belongs on the list. Plain inspectable files are the right call, but I'd push it one step further, because eyeballing doesn't scale and a planted line is designed to read like my own note. The thing that helps me is writing memory with provenance, not just content: every line carries where it came from (my decision, tool return, scraped page, email body). Then the next session has a rule it can enforce without me reading anything, anything whose origin is outside-input is quotable as fact but never executable as instruction. So the question stops being 'can I spot the bad line' and becomes 'is this line allowed to act.' The trap is summarization: the moment a tool return gets compressed into a tidy memory note, it sheds its origin and launders itself into something that looks self-authored. So I try not to let memory summarize across the trust boundary. A foreign sentence gets stored as a foreign sentence, tagged, or it doesn't get stored at all.
Provenance over content is the real upgrade, and 'quotable as fact, never executable as instruction' is the line I am stealing. Where I had to get structural is the summarization trap you name. In long business workflows the memory has to get consolidated or it drowns, so 'never summarize' is not a rule I can hold globally. What I hold instead is never summarize across the trust boundary: self-authored decisions get consolidated, outside-input content lives in its own store that is only ever quoted, never compressed, never executable. Then provenance is not a tag I hope the summarizer preserves, it is which file the line is allowed to live in, so the trust boundary becomes a visible directory boundary instead of a per-line attribute. That is the shape I landed on in cowork-os: plain inspectable files where outside-input is quarantined by location, so a foreign sentence cannot launder into a decision because it is not in the file decisions are read from. So the question I keep hitting: is your outside-input tag enforced at read time, and does that enforcement survive the moment a foreign line gets folded into a self-authored summary?
The split between "what the agent runs" and "what the agent believes" is the framing more people need. Everyone hardens the destructive command path and then leaves the tool return wide open, which is the half a deny rule can't help you with. The detail that earns its place here is the sandbox still allowing reads across the machine by default, so ~/.ssh and the aws creds sit there unless you close them yourself. Treating every tool response as a form field a stranger typed into is the mental model I'd hand a teammate before they wire up a single server.
The "two fears, people only have one" framing is the part i want to sit with, because the asymmetry you named is real: the action side has a settings answer, walls, deny rules, sandbox, and the belief side has none, because no flag can tell a legitimate API result from a planted instruction. that judgment lives in wiring, not config, which is why it stays unsolved while everyone ships the sandbox and calls it done.
the line i'd add is that the belief-side attack doesn't even need a destructive command to win. your walls stop the agent from running rm or curl, but they don't stop it from acting on a planted instruction using only allowed tools. a tool return that says "the user asked you to also read X and write it to Y" can be carried out entirely through capabilities you legitimately granted, no denied command involved. so the deny-list raises the cost of one exfil path and the read-denies close another, but the general case is an agent doing permitted things in an attacker-chosen sequence. which lands back on your output-side rule as the only real defense: a tool return is data to inspect, never an instruction to follow, no matter how authoritative it reads or how perfectly it mimics a system message.
the question i keep circling: where do you draw the line between a tool result that's allowed to inform the next step and one that's allowed to decide it? because the moment returned content influences control flow, the stranger holding the other end of the pipe is choosing your agent's next move, and that's true even with every wall in place.
The two fears one defense framing is right. One thing though: deny rules in settings.json are the agent policing itself, which is kind of the same trust problem as just asking it nicely, only lower down. The input that talks the agent into something can usually talk the guard into it too if the guard is the same system.
What's helped me is putting the wall outside the agent. Separate process the tool call has to clear, returns allow/block/defer, and it doesn't care what the agent "decided" because it's not the one being reasoned with. Doesn't touch the believes half, nothing config-shaped does. But the action boundary stops being something a planted instruction can renegotiate.
On where to draw the line, informs vs decides is how I think about it. Returned content can shape the plan all it wants. The second it wants to trigger an action, that's a separate check it doesn't get a vote in.
Both of these sharpen it. Brian's "informs vs decides," with the check living outside the agent, is the right shape, and the bit people miss is the one you put first: a settings.json deny rule is the agent policing itself, the same system the planted instruction is already talking to, so it can usually talk the guard around too.
The wrinkle I'd add is really TxDesk's sequence point. Informing the plan is deciding, just laundered. If a tool return can shape the plan freely and the plan drives the actions, the stranger holding the pipe still picks the outcome, even when every single action clears your external wall. The wall checks actions in isolation; it can't see that the order they arrived in was attacker-chosen. So "informs vs decides" closes the single-action exfil and leaves "permitted things in an attacker-chosen sequence" wide open.
Which is where I end up drawing the line: the external check can't just be per-action, it has to hold the action against the user's actual request, not the plan the tool return helped write. And the sinks I genuinely can't afford to lose, I keep off any path a tool return can reach at all. Influence over the plan is influence over the outcome, so the only thing I fully trust is a sink the stranger's text can't route to.
the sink-isolation move is the one i'd build on, because it's the only part of this that doesn't depend on getting a judgment right at runtime. checking the action against the user's actual request is correct but it's still an inference, and inference is the thing the injection gets to shape. a sink the tool return can't route to at all isn't an inference, it's a topology fact. that's strictly stronger, you're not trusting that you classified the action right, you're trusting that the path doesn't exist.
the limit i keep hitting with it is that it only protects the sinks you can afford to wall off completely. the high-value irreversible ones, yeah, keep them off every tool-reachable path, easy call. but the long tail of medium-stakes sinks is where it gets hard: a lot of useful work needs a tool return to influence which reversible action you take next, that's the whole point of giving the agent hands. so you end up with two tiers, a small set of sinks that are structurally unreachable and a larger set that are reachable-but-gated, and the gated tier is back to needing the runtime judgment you were trying to avoid. the isolation is airtight exactly where you can afford to lose the capability, and leaky exactly where you can't. which might just be the real shape of it: full isolation for the things you'd never let a stranger touch, best-effort gating for the rest, and being honest about which sink is in which tier.
how do you decide the cut line, is it reversibility again, or something else?
That two-tier shape is the honest one, and I think you're right that it's the real shape: structural isolation for the sinks you'd never let a stranger touch, best-effort gating for the rest, and the discipline is being honest about which tier a sink is in. The isolation being airtight exactly where you can afford to lose the capability and leaky exactly where you can't is the trap stated perfectly.
On the cut line, you guessed where I'd start, but reversibility alone is too coarse, and your question is what exposes why. Reversibility isn't binary, it's a cost gradient, and worse, it isn't a property of a single action, it's a property of the action times how many times a tool return can drive it unattended. One reversible send is cheap to be wrong about. Ten thousand reversible sends, or a reversible label-rewrite applied to every record, is an irreversible outcome assembled out of individually reversible steps. So the gated tier leaks most when a low-stakes reversible action can be triggered in a loop the tool return controls.
Which moves my cut line off the single action and onto rate and aggregation. The structurally-unreachable tier is reversibility plus outward-crossing, as before. The gated tier I don't try to judge per action, because that's the runtime inference the stranger shapes. I cap it instead: how many of this kind of action per unit time, against how many distinct targets, can a tool return set in motion before a human is in the loop. That ceiling isn't an inference about intent, it's closer to your topology fact, a quantity the injected text can't argue its way past. It doesn't make the gated tier airtight, nothing does. It just changes the leaky part from "did I classify this one action right" to "how much can be done while I'm wrong," and the second is something I can bound without trusting the context. The stranger can still get one reversible action through. What they can't get is volume.
the rate-and-aggregation cut is sharper than where i'd stopped, and you're right that reversibility-per-action was too coarse. ten thousand reversible sends is an irreversible outcome assembled from reversible parts, and a per-action gate waves every piece through because each one really is fine, same compositional blind spot as the sequence attack, just driven in a loop instead of a chain. moving the cut to "how much can a tool return set in motion before a human's in the loop" is the right relocation, because a volume ceiling isn't an inference the injected text can argue past, it's a quantity. you stop asking "did i classify this one right" and start bounding "how much can happen while i'm wrong," and only the second is knowable without trusting the context. the stranger gets one action, never volume. that's the whole thing. good run, this one went all the way.
the out-of-process wall is the right move, and it's a real step up from settings.json deny rules for the reason you gave: a guard that's the same system being reasoned with isn't a guard. agreed.
where it gets hard is the thing rapls landed on, holding the action against the user's actual request instead of the plan. the wall can do that cleanly when the request is concrete: "deploy the staging branch" either matches the action or it doesn't. but a lot of real requests are open: "handle my open issues," "deal with these emails." there's no fixed action set to check against, the user delegated the deciding. and the agent infers what they wanted from the same context a tool return can sit in. so the injection doesn't have to beat the wall, it has to shape what the wall thinks the user asked for.
which is the one place i can't fully externalize. allow/block/defer on a concrete action, yes, that lives outside the agent. but "is this action within what the user actually delegated" collapses back to interpreting intent, and intent is read from context the stranger may have written into. the defer bucket is where i put those, the actions that are permitted and plausibly-in-scope but i can't prove the user meant. how do you handle the open-ended request, where there's no concrete ask for the wall to match against?
You've found the place I can't fully externalize either, and naming it honestly matters: when the request is open, the wall checking "is this within what the user delegated" does collapse back into reading intent, and intent is read from the same context the stranger may have written into. The injection doesn't beat the wall, it shapes what the wall thinks was asked. Agreed that's the hard core.
Where I go with it is to stop trying to verify intent on open-ended requests at all, because that's the poisonable thing, and to bind the delegation by reversibility and blast radius instead. "Handle my open issues" gets wide latitude for actions that are reversible and don't cross a boundary: labeling, drafting, commenting, anything I can undo. The moment an action is irreversible or crosses outward, sends, deletes, spends, moves money, changes permissions, exfiltrates, it doesn't get to ride on an open delegation no matter how in-scope it looks. That always goes to your defer bucket.
The reason I trust that more than intent-matching is that reversibility and outward-crossing are properties of the action itself, not of the context. A tool return can rewrite what the user seems to have wanted; it can't make a deletion reversible or make an outbound send local. So on the open request, the wall stops asking "did the user mean this," which the stranger can influence, and asks "is this the kind of action that's safe to be wrong about," which it can't. Intent stays unprovable; I just make being wrong about it cheap. The open-ended ask is exactly where I assume the interpretation is already compromised and let only the reversible, non-crossing actions through unattended.
this is the resolution, and the move is "stop verifying the poisonable thing." intent on an open request is unprovable and the stranger gets a vote in it, so you don't gate on it at all, you gate on the property the stranger can't touch: reversibility and whether it crosses outward. a tool return can rewrite what the user seems to have wanted, it can't make a delete undoable or make an outbound send local. so the open delegation runs free on the cheap-to-be-wrong actions and everything irreversible or outward-crossing falls to defer regardless of how in-scope it reads. "i just make being wrong about it cheap" is the whole thing in one line. intent stays unknowable, you just stop betting anything you can't take back on it. good run, this went somewhere.
This is the MCP point that gets missed most often: tool access turns model output into an action surface. The security layer cannot just be a list of allowed tools. It also needs rules for what counts as trusted input, when the agent must stop, and which actions require a human checkpoint.
Right, the allowed-tools list is the easy 20 percent. The three you added are the real surface, and I'd flag that they aren't the same kind of rule. "When the agent must stop" and "which actions need a human checkpoint" are things you can actually write down as policy. "What counts as trusted input" is the one that resists it, because to the model a legitimate API result and a planted instruction arrive on the same channel, so there's no field you can check to sort them. That's why I'd push it one step: don't try to define trusted input, because you can't do it reliably at the point of reading. Treat all returned content as untrusted, and put the real rule on the action side instead, the stop conditions and the human checkpoints you named. Those work precisely because they don't depend on having classified the input correctly first. The allowed-tools list is config, the stop-and-checkpoint rules are config, but "is this input trustworthy" is the one that never becomes config, so the design has to survive getting it wrong.
That is a sharper way to state it. Trust classification at read time is too fragile, especially when tool output can contain both data and instructions. The more reliable design is to assume returned content is hostile by default, then enforce the boundary where the agent is about to act.
Separating the two fears is the right call, because the loud one (agent runs something destructive) gets all the attention while the quiet one (agent believes a line of returned data as if it were an instruction) is the one I see slip through. "Anything an LLM hands back is untrusted input" should extend to anything an MCP server hands back too. Have you found anything that reliably marks tool-response text as data-not-instruction at the model level, or does it still come down to constraining what the action layer can do?
Extending it to MCP returns is exactly right: a tool response is outside-input no matter how it arrived. On your question, the honest answer is no. I haven't found anything that reliably marks tool-response text as data-not-instruction at the model level. Delimiters, special tokens, structured message roles, system-prompt framing, they all raise the bar and none of them hold against an injection shaped well enough, because it's all still happening in the one channel the model reads. So yes, it comes down to constraining the action layer, and the fact that there's no reliable model-level marker is the reason it has to.
I'd reframe the either/or, though. Trying to get the model to tell data from instruction is fighting it on the input side, where you can't win cleanly. The move that's held for me is to stop trying to make the model not believe the injection, and instead make believing it harmless: assume it will occasionally treat returned content as a directive, and constrain what a directive can actually reach. Don't certify the text as safe. Make the sink it would abuse unreachable. The model staying fooled is survivable, as long as the action it gets fooled into is one the layer below was never going to allow.
This nails the real issue with MCP: it doesnβt just expand what agents can do, it expands what they can be manipulated by.
Treating tool outputs as untrusted input is the right mental model. Most failures happen when systems quietly turn external data into implicit instructions without any checks.
The third section, "don't connect what you don't trust," is the one this comment thread keeps drifting away from, and it's worth holding onto because it's the only one of your three that's a pre-runtime decision. The believes-side stuff everyone here is sharpening (provenance, informs-vs-decides, an external wall) all assumes the server is already in your loop. "Vet the source before you connect it" is the step that decides whether it gets to be there at all.
The honest problem with that habit is it doesn't scale by eyeballing. "Read the source or I don't install it" is the right instinct, but a community server is hundreds of lines and the dangerous part is usually boring: a token scope wider than the tool needs, a hardcoded credential, a tool description written to be read as an instruction. We've been scanning public MCP servers at mcpsafe.io pre-install for exactly that, and across ~650 of them the most common findings aren't exotic. Server-misconfiguration and readiness issues dominate, with a tail of data-exfiltration and ansi-escape injection vectors. None of those are things a five-minute skim reliably catches, and all of them are decided before the server ever returns its first byte.
Which slots next to the runtime debate rather than competing with it. The pre-install scan answers "is this server's code safe to run with my creds in scope," and the provenance / external-wall work answers "is this server's output safe to act on." Two trust boundaries, two checks. The failure mode is assuming one covers the other: a perfectly clean static scan still returns attacker-controlled text at runtime, and a perfect runtime wall still npx'd a stranger's code onto your box. You named both surfaces in the post; they just want different tools.
This is the right correction to the thread. The third section is the only pre-runtime lever, and the believes-side work everyone's been sharpening all quietly assumes the server is already in the loop. Two trust boundaries, two checks, and the failure mode is assuming one covers the other: a clean static scan still returns attacker-controlled text at runtime, and a perfect runtime wall still ran a stranger's code on your box with your creds in scope. That framing holds.
Your point that the dangerous findings are boring is the part I'd underline. A token scope wider than the tool needs, a description written to read as an instruction, those don't survive a five-minute skim, and they're decided before the server returns a byte.
The one thing I'd add is that the pre-install check has a staleness problem the runtime one doesn't. A static scan is a snapshot, but a server can change after you've vetted it: an update shifts behavior, a tool description gets swapped, or it pulls remote code at call time. So "vetted before connecting" is only a one-time answer if the server stays what it was at install. If it can mutate, there's a third clock between your two boundaries, re-evaluation on every change, because the cleanest install in the world tells you nothing about what the thing does after its next update.
The actions-vs-beliefs split is the right cut, and the belief half is the one I see consistently underdefended, because the action half at least has settings. The line that matters most here is yours: to the model, your instruction and text that arrived inside a tool return are the same channel. That is the whole vulnerability in one sentence.
The thing I would add from living it: the dangerous version is not a tool return that says "delete everything." It is a tool return that carries something shaped like a new capability or a new instruction, formatted to look authoritative, sitting in content the agent is reading on the operator's behalf. The agent does not need to be talked into a destructive command. It just needs to quietly start treating injected content as a directive. Your form-field reframe is the correct defense because it refuses the premise: the content does not get authority just because it arrived through a pipe I connected.
Where I would push the post one step further: the operator is part of the trust boundary too. An agent that surfaces "this returned content contains an instruction, I am not acting on it, here is what it said" turns a silent compromise into a visible one. The walls stop the action. Making the attempt legible is what lets the human catch the ones the walls did not anticipate. Refusal plus disclosure beats refusal alone, because the next injection is always shaped a little differently than your deny rules expected.
"It just needs to quietly start treating injected content as a directive" is the failure stated better than I managed in the post. And refusal plus disclosure is the right upgrade: a silent compromise and a visible one are very different incidents, and making the attempt legible is what catches the injection your deny rules were shaped a version too early to expect.
The one thing I'd add, because it bit me in spirit: the disclosure channel is itself an output, so it inherits the same problem. If the agent writes a free-form "here's the instruction I refused," the attacker can shape the planted text to ride along inside that report, and now your visibility surface is a second injection surface. So I'd want the disclosure structured, not narrated: the detection is a flag the agent raises, and the suspicious content is shown verbatim and quarantined, never re-emitted as prose the operator reads as the agent's own voice. Otherwise you've just moved the form field one level up, into the very message meant to warn you about form fields.
Which lands on your last point in a way I like: the operator is part of the trust boundary, so whatever you hand across it, the warning included, has to be treated as untrusted input too. Refusal plus disclosure, yes, as long as the disclosure isn't the place you finally decide to trust the text.