You can't stop untrusted text from reaching a model. If your agent reads public GitHub issues, anyone can put instructions into the text it reads. The model might follow them, even when you've told it not to.
What you can control is what happens afterwards. Which tools the agent can call, what arguments they accept, and whether the model gets to make the decision to execute them at all.
Take an agent that reads GitHub issues and applies labels. It needs to understand the issue, decide on a label and update GitHub. It doesn't need permission to close issues, merge pull requests or read repository secrets.
Here's how I'd build this with Dapr.
Put the agent inside a workflow
I'd use a Dapr Workflow to manage the issue triage process, rather than letting the model decide which actions to take.
The workflow receives a verified GitHub webhook, passes the issue text to an agent and gets back a suggested label. It then checks that label before calling GitHub.
The important part is what the agent doesn't control. It doesn't choose the repository, issue number or which GitHub operation to run. Those come from the workflow and the original verified event.
The model is deciding how to classify the issue, not what it's allowed to do with GitHub.
Treat MCP tools as workflows too
Dapr has an MCPServer resource which lets you declare a connection to an MCP server. When Dapr loads it, it discovers the available tools and registers a durable workflow for each one.
So if our GitHub MCP server exposes add_label and close_issue, Dapr registers workflows named:
dapr.internal.mcp.github.CallTool.add_label
dapr.internal.mcp.github.CallTool.close_issue
The agent doesn't need to manage the MCP connection itself. The application can invoke those workflows, and Dapr handles the MCP call.
For example, the resource on our github-tools application could look like this:
apiVersion: dapr.io/v1alpha1
kind: MCPServer
metadata:
name: github
spec:
endpoint:
streamableHTTP:
url: https://github-mcp.internal/mcp
scopes:
- github-tools
The URL and tool names here are examples; you'd use those exposed by your own MCP server.
Now we need to decide which applications can invoke those tool workflows.
Enforce permissions outside the model
A list of permitted tools in a system prompt isn't a security control. The model can be convinced to ignore it.
With Dapr, you can use a WorkflowAccessPolicy to control which App IDs are allowed to schedule particular workflows on another application.
For our example:
apiVersion: dapr.io/v1alpha1
kind: WorkflowAccessPolicy
metadata:
name: github-tools-policy
namespace: default
scopes:
- github-tools
spec:
rules:
- callers:
- appID: triage-workflow
workflows:
- name: dapr.internal.mcp.github.CallTool.add_label
operations: [schedule]
This allows triage-workflow to invoke add_label on github-tools. It doesn't grant access to close_issue, or any of the other tools the MCP server might expose.
The workflow access policy is enforced by Dapr, using the calling application's verified identity. The model doesn't get to override it.
There are two details worth knowing here. You need a policy loaded for deny-by-default behaviour, and the MCP server workflows need to run under a separate App ID. Dapr always allows an application to schedule its own workflows, so you shouldn't rely on this policy to isolate tools running under the same App ID as the caller.
Check the arguments before calling GitHub
Being allowed to call add_label doesn't mean every argument should be accepted.
A poisoned issue could convince the model to suggest a label like security-approved, or try to make it update a different issue.
I'd have the agent return a label, then check it in the workflow before calling GitHub. Something like this, using the Dapr Python Workflow SDK:
import dapr.ext.workflow as wf
wfr = wf.WorkflowRuntime()
ALLOWED_LABELS = {
"bug", "feature", "docs",
"question", "needs-triage"
}
@wfr.workflow(name="triage_issue")
def triage_issue(ctx: wf.DaprWorkflowContext, issue: dict):
# classify_issue is a registered activity that calls the model
suggestion = yield ctx.call_activity(
"classify_issue",
input=issue["body"]
)
label = suggestion["label"]
if label not in ALLOWED_LABELS:
return {"status": "rejected"}
return (yield ctx.call_child_workflow(
workflow="dapr.internal.mcp.github.CallTool.add_label",
app_id="github-tools",
input={
"arguments": {
"owner": issue["owner"],
"repo": issue["repo"],
"issue_number": issue["number"],
"label": label
}
}
))
This is the core of the workflow, with the activity implementation and runtime startup left out. The tool arguments will depend on your MCP server.
Notice that the model only supplies the label. The repository and issue number come from the verified webhook that started the workflow.
I'd also validate arguments where the tool runs. Dapr's MCPServer resource supports beforeCallTool hooks, which are themselves workflows. You can use one to reject labels outside the allow-list before the call reaches the MCP server.
That way the check isn't only happening in the application that called the tool.
Use a separate workflow for anything destructive
Adding a label is one thing. Closing an issue, deleting something or deploying a release is another.
For those operations, I'd use a separate workflow that waits for human approval.
Dapr Workflows can wait for an external event, so the workflow can request approval, stop executing and resume when an authorised user responds. There's no need to keep a process running while someone makes the decision.
The approval needs to be verified by your application, not accepted as something the model says it received.
You can then give the review workflow its own App ID and grant it access to close_issue, without giving that permission to the triage workflow.
The same applies to any other tool where a bad decision is difficult to undo.
What this doesn't fix
None of this stops prompt injection from influencing the model. A malicious GitHub issue might still persuade it to suggest the wrong label.
But the model doesn't have permission to close the issue, the workflow won't accept a label outside the allowed set, and the repository and issue number aren't under the model's control.
That's a much smaller problem than giving an agent access to every tool on the MCP server and relying on a system prompt to keep it in line.
You still need to think about prompt injection. You just shouldn't be relying on the model to enforce the limits of what it's allowed to do.
Dapr documentation:
Top comments (2)
Disclosure: I work on Auten, a computer-use MCP server, so this is the version of the problem we live with.
Your pattern gets harder when the tool is a screen. The tools are generic (click an element, type text), so there is no
add_labelto allow-list, and the untrusted text arrives as the screen itself, not as an issue body. What has held up for us is moving the check from the tool name to the target: is this element a Buy, Delete or Send button, a permission dialog, a password field? Those pause for a person. Credentials go through a separate tool whose value is typed locally and never enters the model context, so an injected page has nothing to exfiltrate.Honest caveat in your terms: part of our confirm rule still lives in a skill instruction, which is the weak version you describe.
Question: is there an afterCallTool hook too? Scanning tool results before they reach the model would cover the read side.
Strong pattern. Moving enforcement out of the model is the right call. Two places I’d still look at, from the attacker’s side:
Tool definitions are untrusted input too. Dapr discovers tools at load time and registers a workflow for each one. If the MCP server is compromised or updated, a tool can keep its name (add_label) but change its behavior, or new tools can appear. Deny-by-default covers the new ones. A same-name behavior change slips past a name-based allow-list. Pinning a hash of each tool’s schema and description and alerting on drift closes that gap.
An allow-listed argument isn’t always a safe one. Your label check works because the value comes from a closed set. Free-text arguments like comment bodies or PR descriptions are exfil channels, for example a markdown image URL carrying data out. They need output filtering, not just an allow-list.
On the afterCallTool question above: a read-side hook seems like the missing half, since tool results are the main injection path.
I do reverse engineering and security review of agent and MCP tool surfaces. Happy to compare notes.