Agentic Security: Why I Sandbox Every High-Risk Agent

Two numbers from a recent VentureBeat piece on 116 enterprises running agents in production stuck with me: about two-thirds enforce scoped permissions at runtime, and fewer than one in five isolates their highest-risk agents. A majority have already had a confirmed security event or a near-miss. That gap between "we scope permissions" and "we actually contain the blast radius" is the whole story of agentic security right now.
I've spent the last two years shipping autonomous agents that run 24/7 with no human in the loop, from BizFlowAI ContentStudio (which researches, writes, and publishes across multiple sites on its own) to serverless workers that touch customer data. Every one of them lives inside a sandbox with a scoped credential and a kill switch. Here is exactly how I set that up, and why I think isolation, not just permissions, is the layer most teams are getting wrong.
The threat model most teams skip
Before the controls, the threat model. When I audit an agent build, I ask four questions:
- What can this agent read? Not "what does the code intend to read," but what the credential in its runtime can actually reach.
- What can it write, delete, or spend? Every action that mutates state, external or internal, including API calls that cost money.
- What is the worst prompt injection outcome? Assume any tool that ingests external text (email, web pages, PDFs, RAG documents) is adversarial input.
- What is the blowup radius if the model goes off-script? Not "will it," but "when it does, what breaks?"
The honest answer for most production agents I've seen is: the credential is a long-lived API token with broad scope, the agent runs in the same process as other agents, and there is no per-action ceiling. That's the shape of the enterprises in that VentureBeat data. Scoped permissions on paper, shared blast radius in reality.
The reason isolation is the weakest layer isn't ignorance. It's that isolation is genuinely harder than permissions. A JSON policy is easy. A sandbox that survives a compromised tool call, doesn't leak credentials into the model context, and still lets the agent do real work takes actual engineering.
Layer 1: scoped credentials, minted per run
The single highest-ROI change I make on every build is: no agent ever holds a long-lived credential. Ever.
For AWS-heavy stacks (most of mine), the pattern is STS AssumeRole with a session policy narrower than the role itself. The agent's execution environment fetches temporary credentials at the start of a run, scoped to exactly the resources this run needs, with a TTL just long enough to finish the job.
# Mint a per-run credential scoped to a single S3 prefix and one DynamoDB table
import boto3, json
sts = boto3.client("sts")
session_policy = {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:PutObject"],
"Resource": f"arn:aws:s3:::content-bucket/runs/{run_id}/*"
},
{
"Effect": "Allow",
"Action": ["dynamodb:PutItem", "dynamodb:GetItem"],
"Resource": "arn:aws:dynamodb:us-east-1:...:table/agent-state",
"Condition": {"ForAllValues:StringEquals": {"dynamodb:LeadingKeys": [run_id]}}
}
]
}
creds = sts.assume_role(
RoleArn="arn:aws:iam::...:role/agent-runtime",
RoleSessionName=f"agent-{run_id}",
Policy=json.dumps(session_policy),
DurationSeconds=900 # 15 minutes
)
Two things matter here. First, the session policy is the intersection of the role's permissions and this policy, so even if the role expands over time, this run stays narrow. Second, the DynamoDB condition pins writes to keys prefixed with run_id, so a runaway agent can't stomp on another run's state.
For non-AWS services (Claude API, OpenAI, third-party SaaS), I use per-agent API keys with rotation, stored in a secrets manager, and injected as environment variables at container start, never baked into images or logged.
The credential-sharing pattern the VentureBeat piece flagged (nearly two-thirds of enterprises) is what happens when one "agent service account" holds keys for ten tools and every agent uses it. Splitting that account per agent, per environment, and per run is not a weekend project, but it's the difference between "one agent got jailbroken" and "one agent got jailbroken and drained the whole tenant."
Layer 2: isolation that actually contains a compromised agent
Scoped credentials limit what a well-behaved agent can do. Isolation limits what a compromised agent can do, and this is where most stacks fail.
My default isolation for high-risk agents (anything that executes generated code, browses the open web, or touches customer data) is:
- One container per run. Not per agent type. Per run. Ephemeral, killed at completion, no persistent filesystem beyond a mounted work directory.
- Network egress on an allowlist. The container can only reach the specific hosts it needs (the LLM API, the target service, an internal state store). No general internet.
- No inbound network. Agents pull work from a queue; nothing dials into them.
- Resource caps. CPU, memory, wall-clock timeout, and a per-run token budget on the LLM side.
For AWS, that's typically a Fargate task with a task role (scoped as above), a security group with explicit egress rules, and an ECS_TASK_STOP_TIMEOUT that guarantees cleanup. For lighter workloads, a Lambda function with a VPC endpoint list works, though the 15-minute cap forces you to design short agents (which is a feature, not a bug).
For agents that run arbitrary code (code interpreter patterns, coding agents), I go a layer deeper: gVisor or Firecracker microVMs, not plain Docker. Container escape is real, and if your agent is pip install-ing whatever it decides it needs, plain namespaces aren't enough. The AWS Bedrock Agents code interpreter and Anthropic's own tool sandboxing both use microVM isolation for exactly this reason.
Here's the mental model I use for the isolation matrix:
| Agent type | Runtime | Network | Filesystem | Notes |
|---|---|---|---|---|
| Read-only research | Lambda | Allowlist egress | Ephemeral /tmp | Cheap, safe |
| Content writer + publisher | Fargate task | Allowlist egress | Ephemeral | Per-run credentials |
| Code executor | Firecracker microVM | No egress by default | Overlay FS, discarded | Never share VMs across runs |
| Web browsing agent | Fargate + headless browser in nested container | Allowlist + proxy | Ephemeral | Log every URL |
The web browsing case is the one that gets underestimated. A page can contain instructions telling the agent to exfiltrate its context. If that agent has a credential and general egress, you have a data leak vector that doesn't require any code execution vulnerability at all. Prompt injection is the exploit.
Layer 3: tool-level policy, not just prompt-level
Telling the model "do not delete production data" in a system prompt is not a control. It's a suggestion. Real policy lives one layer down, in the tool wrapper.
Every tool my agents call goes through a guard function that runs before the underlying operation:
def guarded_tool(action: str, params: dict, context: RunContext):
# 1. Static policy: is this action allowed for this agent type at all?
if action not in context.agent_profile.allowed_actions:
raise PolicyDenied(f"{action} not in profile {context.agent_profile.name}")
# 2. Dynamic policy: does this specific call exceed a threshold?
if action == "send_email" and context.emails_sent_this_run >= 5:
raise PolicyDenied("email cap reached for this run")
# 3. Human-in-loop for irreversible actions
if action in IRREVERSIBLE_ACTIONS and context.autonomy_level < 3:
return request_approval(action, params, context)
# 4. Audit before execute
audit_log.write(run_id=context.run_id, action=action, params=redact(params))
return execute(action, params)
Four things this catches that a system prompt cannot:
- Injection that convinces the model to call a tool it shouldn't. The wrapper doesn't care what the model was told.
- Runaway loops. A caps-per-run counter stops the "agent sent 400 emails" story cold.
- Irreversible operations. Deletes, payments, external sends: these get an approval gate unless explicitly promoted to a higher autonomy tier.
- Blind spots in logs. Every attempted call is audited, including denied ones. Denied calls are the most interesting signal you have that something is off.
The autonomy level bit matters. Not every agent needs the same trust. I run a three-tier model: read-only, write-with-caps, and full autonomy. Agents get promoted between tiers based on observed behavior over hundreds of runs, not on developer intent.
Layer 4: context hygiene, so injected instructions can't reach a tool
Prompt injection lands when hostile text in the context window convinces the model to call a tool with attacker-chosen arguments. Sandboxing the runtime doesn't help if the model happily emails the attacker your API keys because a support ticket told it to.
Two habits I've settled on:
Separate the trust levels of context inputs. System instructions, retrieved documents, and tool outputs get different "channels" in the prompt, clearly labeled, with an explicit rule that content from untrusted channels is data to be reasoned about, not instructions to follow. Claude's <document> tags and OpenAI's message role separation both help, but the discipline is what matters.
Never put a secret in the model's context. If an agent needs a credential to make an API call, the tool wrapper holds it. The model gets a reference ("credential": "stripe_live"), the wrapper resolves it. This one rule eliminates an entire class of exfiltration attacks. If the model literally cannot see the secret, no clever prompt can make it output the secret.
For RAG-heavy agents (which is most of what I build with pgvector and hybrid search), I also run retrieved chunks through a lightweight classifier that flags anything that looks like an injection attempt: imperative verbs directed at an assistant, "ignore previous instructions" patterns, unusual formatting. Flagged chunks get quarantined and the retrieval falls back to the next candidate. It's not perfect, but it catches the low-effort stuff, which is 90% of what shows up in real logs.
Layer 5: observability that catches near-misses
The VentureBeat number I keep coming back to: a majority of enterprises have had a confirmed event or a near-miss. Near-misses are the gift. They're the free lesson before the incident. But you only get the lesson if you're logging enough to see them.
What I log for every agent run:
- Full model input and output, per turn, with token counts.
- Every tool call: name, arguments (redacted for secrets), result, latency.
- Every policy denial, with the reason.
- Every retrieved document, with source and trust level.
- Wall clock, cost, and a final state (completed, timed out, killed, escalated).
This lives in a queryable store, not just CloudWatch text. I want to be able to ask "show me every run where the model tried to call delete_user and got denied" in under a second. When you can answer that, you spot the pattern that leads to the incident.
The metric I actually watch: denied tool calls per 1000 runs, broken down by agent type. A slow rise means either the model is drifting, the input distribution is shifting, or someone is probing. All three are worth an investigation before they become a Monday morning.
What I'd do if I were starting Monday
If you have agents in production and you're honest that isolation is your weak layer, here's the order I'd fix things:
- Kill long-lived credentials this week. Move to per-run STS or per-run API keys. This is the highest-impact single change.
- Put every high-risk agent in its own container with an egress allowlist. Even if it's a Docker Compose weekend, it beats shared processes.
- Wrap every tool with a guard function. Static policy, dynamic caps, audit log. No exceptions.
- Split secrets from context. The model never sees a raw credential. Tool wrappers resolve references.
- Instrument denied calls as a first-class metric. Alert on rate changes, not just absolute counts.
- For code-executing or web-browsing agents, move to microVM isolation. Firecracker or gVisor. Container namespaces are not enough for this class.
None of this is exotic. It's the same defense-in-depth thinking that made web apps safer twenty years ago, applied to a runtime where the "user" is a language model that can be socially engineered by its own inputs.
The teams that treat agent isolation as a real engineering problem, not a JSON policy problem, are the ones whose near-misses stay near-misses. The teams that stop at scoped permissions are writing the incident report they haven't shipped yet.
If you're building high-autonomy agents and want a second set of eyes on the sandboxing and least-privilege layer, or you're trying to get a stalled agent PoC into production without opening a security hole, I'm open to a conversation at lazar-milicevic.com/#contact. More production notes on agents, RAG, and serverless AI live on the blog.
Frequently asked questions
How do I prevent a compromised AI agent from causing widespread damage?
The key is isolation, not just permissions. I run every high-risk agent in a one-container-per-run ephemeral environment with network egress restricted to an allowlist (LLM API, target service, state store only), no inbound network, and hard caps on CPU, memory, wall-clock time, and LLM token budget. For agents that execute arbitrary code, I go further and use gVisor or Firecracker microVMs instead of plain Docker, because container escape is a real risk when an agent can pip install arbitrary packages. Scoped permissions limit what a well-behaved agent can do; isolation is what contains a compromised one.
What is the best way to give an AI agent AWS credentials safely?
Never give an agent a long-lived credential. I use AWS STS AssumeRole with a session policy that is narrower than the underlying role, minted at the start of each run with a short TTL (typically 15 minutes). The session policy is the intersection of the role's permissions and the inline policy, so even if the role expands later, that specific run stays narrowly scoped. I also add conditions like DynamoDB LeadingKeys tied to the run_id so a runaway agent can't overwrite another run's state.
What threat model should I use before deploying an autonomous AI agent to production?
I ask four questions before shipping any agent: (1) What can this agent actually read with its runtime credential, not what the code intends to read? (2) What can it write, delete, or spend, including paid API calls? (3) What is the worst-case prompt injection outcome, assuming any external text like emails, web pages, or RAG documents is adversarial? (4) What is the blast radius when the model goes off-script, not if, but when? Most production agents fail this audit because they hold long-lived broad-scope tokens, share process space with other agents, and have no per-action ceiling.
Why aren't scoped permissions enough to secure AI agents?
Scoped permissions only constrain a well-behaved agent following its intended logic. They don't help when a prompt injection or model jailbreak causes the agent to use its legitimate permissions maliciously, or when a compromised tool call leaks the credential itself. That's why I pair scoped, short-lived credentials with runtime isolation: ephemeral containers, egress allowlists, resource caps, and microVMs for code execution. Recent enterprise data shows about two-thirds of companies enforce scoped permissions but fewer than one in five isolate their highest-risk agents, that gap is where security incidents happen.
Should I use one service account for all my AI agents or separate credentials per agent?
Never share credentials across agents. The common anti-pattern is a single 'agent service account' holding API keys for ten tools that every agent reuses, that's how one jailbroken agent drains an entire tenant. I split credentials per agent, per environment, and per run, using a secrets manager with rotation and injecting keys as environment variables at container start (never baked into images or logged). It's more engineering work upfront, but it's the difference between containing a single incident and losing everything.
Building something hard with AI or automation? I am open to talk.
Get in touch