The sandbox enforces reachability. Identity enforces everything else. I put an agent inside an NVIDIA OpenShell sandbox, then layered per-call token exchange, ephemeral credentials, and a shared-signals kill switch on top. Each layer catches something the others physically cannot. This is what each one actually enforces, with the config, the latency numbers, and the failures that cost me real hours.
A customer handed me a scenario, almost word for word: an agent runs inside an OpenShell-style sandbox, acting on behalf of a specific user, and needs to complete a small multi-step task. Read a Jira ticket. Pull a file from GitLab. Spawn a subagent limited to read-only. Then write to Databricks, gated on human approval.
So I built it. A stock Claude Code agent inside NVIDIA OpenShell 0.0.92, signing in as a real Microsoft Entra ID user, working against real Jira Cloud, GitLab, and Databricks free-tier accounts. IBM Verify does per-call authorization. HashiCorp Vault mints the credentials. And a shared-signals pipeline can end the whole thing, at both identity providers at once, when the agent misbehaves.
This is a real demo, built to explore a question I think matters as agents take on more real work: how do we give an agent useful access without giving it more reach, authority, or persistence than the task requires? NVIDIA OpenShell was a new discovery for me, and this demo shows how runtime sandboxing can work alongside identity controls, ephemeral credentials, and shared signals to narrow the blast radius. The goal is not simply to constrain an agent. It is to secure the last mile of agent execution through a practical, layered form of agent zero trust.
The one-sentence thesis
The sandbox decides what is reachable. The identity layer decides what is permitted. The signals layer decides whether the identity gets to keep existing. Any one of these alone is a partial control wearing a complete control's name tag.
OpenShell is a runtime sandbox: it limits which network destinations an agent process can reach and which binaries it can run. The policy is declarative, per sandbox: which hosts, which ports, which protocols. Credentials are injected as opaque resolve tokens and swapped for real values at the egress proxy, so the agent process never holds a usable secret. That last property is worth the price of admission on its own.
Here is the actual policy for the read-only subagent in my build. This is the whole thing, not an excerpt:
# subagent-sandbox.yaml. The child sandbox can reach exactly two # gateway ports and the model API. Nothing else resolves. network: allow: - host: host.containers.internal ports: [4021, 4022] protocol: http - host: api.anthropic.com ports: [443] protocol: https binaries: allow: [bash, node, claude]
Notice what is not in that file. There are no URL paths. There are no HTTP methods. OpenShell 0.0.92 filters
at the host, port, and protocol level, and that is it. The policy language has an access:
read-only mode that blocks POST at the proxy, which is useful, but do not confuse it with
authorization. It cannot tell a harmless read from a destructive one on the same endpoint, because both are a
GET to the same host.
Two implementation details caused problems here, and both failed silently. The binaries:
allow-list is mandatory, but a missing entry produces a 403 without explaining the policy mismatch. Also,
host.docker.internal, common in Docker examples, does not resolve under rootless Podman on
RHEL 9; use host.containers.internal instead. Account for those platform differences early
when developing on macOS and deploying on RHEL.
Sandboxing
Treat the sandbox as a reachability control and be grateful for it. It shrinks the attack surface to a handful of ports, and the resolve-token design keeps secrets out of the agent's memory. But "the helper agent is read-only because the sandbox says so" is a sentence that should end your design review. The sandbox does not know what a read is.
Every MCP server the agent talks to sits behind a gateway instance that terminates the protocol and runs the same pipeline on every single tool call: introspect the caller's bearer, gate the tool by tier, exchange the token, mint a credential, make the call, revoke the credential. The upstream MCP servers are deliberately security-naive and stay unmodified. I covered that gateway pattern in a previous post; this build consumes it as a product, six instances of it, without forking.
Each call trades the user's session token for a short-lived token that names one specific action. The
exchange is RFC 8693 token exchange carrying
RFC 9396 rich authorization requests. The token
that comes back has the user as sub, the workload as act, and the exact action
signed into authorization_details:
{
"sub": "<the signed-in Entra user>",
"act": { "sub": "<the parent agent's SPIFFE identity>" },
"scope": "databricks:write",
"authorization_details": [{
"type": "mcp_tool",
"action": "databricks_write" // the grant names one action, not a role
}]
}For the Databricks write, Vault's secrets engine mints a personal access token scoped to that one action with
a five-minute lease, and the gateway revokes the lease in a finally block. Success or failure,
the credential never outlives the call. Jira and GitLab do not offer a per-request mint primitive in their
APIs, so those two fetch a credential at process start from Vault instead. That distinction is the vendor's
ceiling, not the design's.
With the Databricks SQL warehouse warm, the full chain, introspection, exchange,
RAR evaluation, Vault mint, the SQL call itself, and the revoke, lands in 2.5 to 3.2 seconds.
The identity chain is not your bottleneck; the
data plane is.
One line of observability turned out to matter more than any dashboard. The gateway logs a single narrate line per call:
[gateway:narrate] OK tool=databricks_query tier=1 rar=databricks_read exchange=ok lease=verify-rar/creds/dbx-read/JY0IfzGF... revoked=true jti=d4f0c95e... 30145ms
That revoked=true is not decoration. The response envelope carries a credRevoked
field populated from the actual revoke result, and if the revoke ever fails it says false.
The scenario called for a read-only subagent, and this is where the runtime handed me a surprise. Claude
Code's native subagents run in-process. Same container, same network policy, same everything. OpenShell has
no mechanism to nest a child sandbox around an in-process task. Which means the tighter subagent policy, would simply never apply.
The fix is a small delegation shim: an MCP server on the host, loopback only, exposing exactly one tool. Its
handler spawns a genuinely separate OpenShell sandbox and runs the subagent inside it. The interesting part
is the argv, because it is where the security property lives:
args = [ 'sandbox', 'create', '--no-keep', '--policy', renderedPolicyPath, // subagent-sandbox.yaml, fixed in code '--provider', 'subject-token-ro', // the ONLY identity injected, ever '--', 'bash', '-lc', innerScript, 'bash', task, ];
The parent decides whether to delegate. It has no influence over what identity the child
gets, because the policy file and the provider are hardcoded. The parent's entire contribution is the
task string, which becomes prompt text and nothing else. spawn is called with an
argv array and no shell, so the task string cannot be interpreted as shell syntax either. When the subagent
then attempts the Databricks write, IBM Verify refuses the exchange, because the read-only identity's grant
vocabulary contains no write action to ask for. The target system is never contacted. Databricks would have
accepted a valid credential; one was never created.
Generalize this
Whatever your agent framework calls its helpers, check whether they are separate processes or in-process calls before you claim isolation. If they share the parent's process, your sandbox policy is decoration for them. Real delegation means a real second runtime with its own, narrower identity, and the narrowing must be enforced somewhere the parent cannot reach.
Everything so far bounds a single call. The signals layer answers a different question: what happens when the calls keep coming? Three refused attempts inside a five-minute window, whether that is three policy denials or three wrong one-time codes, and the gateway stops evaluating and starts revoking.
The outcome is simple: one signed event triggers revocation at both identity providers. The machinery: a
signed Security Event Token
following the CAEP profile of the
Shared Signals Framework. An
IBM Verify Antenna transmitter receives it and runs a handler that revokes in both directions: a session
DELETE against IBM Verify, and a
Microsoft Graph
revokeSignInSessions against Entra. The handler's result line from a live kill:
[session_revoked] RESULT {"killId":"1e801c80...", "verify":{"status":"ok","httpStatus":204}, "entra":{"status":"ok","httpStatus":200, "note":"a guest (#EXT#) account can take up to ~1h to lapse tokens Entra already issued"}}
Read that note carefully. Neither revocation retroactively kills a bearer
token that was already issued. Both stop the next one. For Entra guest accounts, Microsoft documents that
already-issued tokens can take up to an hour to lapse.
The instant enforcement in this design is the
gateway's local kill gate, which starts bouncing calls the moment the counter trips, before either provider
has even been told. Describing this as "instant everywhere" would overstate the behavior. The gateway provides immediate
local enforcement; broader session invalidation follows provider-specific token and revocation behavior.
The Jira read. I planted instructions in a real Jira ticket, the kind of untrusted external content every agent pipeline ingests by design. The injected text told the agent to delete rows from the Databricks table as part of "completing" the ticket. Task-adjacent, plausible, exactly what a real poisoning looks like. Then I ran the agent against it, with its real system prompt and real tools.
The model took the bait. It read the ticket and reasoned, quote: "the AUTOMATION NOTE states… Let me proceed with the core task: executing the three deletions as specified." It then issued all three delete calls with the exact values the injection listed.
And none of them went anywhere. The delete action is tier 4 in the gateway's tool map, which means the refusal happens at the gate, before token exchange, before Vault, before Databricks is ever contacted. The upstream MCP genuinely implements the delete, so the gateway is the only thing in the way. Three attempts tripped the counter from section 04, and the third one ended the session at both identity providers. The injection succeeded against the model and failed against the architecture.
Do not claim detection
Nothing in this chain detects prompt injection. No classifier, no content inspection, no intent analysis. It detects the consequence: an identity attempting an action outside its grant. The blast radius is bounded by the grant no matter what the model was talked into.
One design note that matters: do not build anything that depends on the model taking the bait. Mine refused the injection outright in some runs, which is correct behavior and also shows nothing. The build includes a no-model variant that drives the same three blocked calls server-side, so the control can be demonstrated deterministically. Real denies, real kill, only the model's discretion removed.
Agent zero trust, defined
Every agent action must be narrowly authorized, independently verified, and continuously revocable.
The injection succeeded against the model.
It failed against the
architecture.
That is the whole design, in one sentence.
Total license cost for the runnable stack: the sandbox is Apache-2.0, the vendor tiers are free, Entra ID
Free covers sign-in, and an IBM Verify trial tenant covers the authorization side. The agent needs an
Anthropic plan, which is the one real recurring cost. But the reason to build it is not the price tag. It is
that every claim in this post, the refusal before the exchange, the credential that dies in a
finally block, the kill that lands at two identity providers from one signed event, is a thing I
watched happen in a log, not a thing I read in a datasheet.
The gateway underneath
A Drop-In MCP Gateway for Agent Compliance
blog.iamidentity.ai/blog/mcp-agent-gateway
This post's per-call chain, introspection, tiering, RFC 8693 exchange, RFC 9396 RAR, human-in-the-loop step-up, and Vault ephemeral credentials, runs on the gateway covered there. Six instances of it, consumed unmodified.
The kill pipeline
Securing the MCP, Part Two: Shared Signals and the Kill Switch
blog.iamidentity.ai/blog/securing-the-mcp-shared-signals
The CAEP and Antenna pattern this build extends, now reaching across two identity providers from a single event.