Skip to main content
English
Saviynt

Blog Post

Defense in Depth: Pro-Code Agents on AWS Bedrock AgentCore

8 min read

Securing Pro-Code

 

In Post 1 this series, we introduced a five-layer defense-in-depth architecture for governing AI agents. In Post 2 we showed that architecture in action with a Microsoft Copilot Studio agent, demonstrating how policy, intent, and anomaly controls can stop unsafe tool use before it reaches enterprise systems.

That naturally leads to an important question:

What about agents that are built in code rather than a visual designer?

Most enterprise AI agents aren't created in low-code platforms. They're developed by engineering teams using frameworks like LangGraph, CrewAI, Strands, and Semantic Kernel, then deployed into managed runtimes such as AWS Bedrock AgentCore. These agents often have greater autonomy, broader access to enterprise systems, and more complex workflows, making consistent governance even more important.

The good news is that nothing changes.

The same defense-in-depth architecture applies to pro-code agents because enforcement happens at the gateway, not inside the agent.

In this post, we'll take the same Google Workspace and Salesforce gateways used in Post 2 and connect them to a pro-code agent running on Bedrock AgentCore. The result is exactly what you'd expect: the same policies, the same governance controls, and the same runtime protections, regardless of how the agent was built.

Governance Shouldn’t Depend on How the Agent was Built

The most important design principle behind Zuma is that security lives at the control plane, not inside every agent.

In Post 2, the Copilot Studio agent made tool calls, and every tool call the Copilot Studio agent made was routed through a Zuma Access gateway, where all five layers were evaluated. . The agent didn't know or care that Zuma was there — it just called tools through its connectors.

Whether an agent is built in Copilot Studio, Agentforce, LangGraph, CrewAI, Strands, or a custom application, its tool calls ultimately traverse the same gateway layer. That's where identity is verified, policies are enforced, intent is evaluated, and anomalous behavior is detected. The enforcement point doesn't move; only the caller changes.

Picture1

Both agents connect through the same governed gateway. Regardless of runtime, every action is evaluated against the same identity context, policy controls, intent analysis, and anomaly detection before reaching enterprise systems such as Google Workspace or Salesforce.

That's what "no enforcement lock-in" means in practice: you govern the path, not the platform. Swap the framework, swap the runtime, swap low-code for pro-code, and the guardrails hold.

 


The Environment at a Glance

To demonstrate framework-independent enforcement, we'll replace the Copilot Studio agent from Post 2 with a Python-based agent running on AWS Bedrock AgentCore while keeping everything else unchanged.

Picture3

The flow:

Picture4 not to use

The agent receives a user prompt and the user's bearer token (usually the frontend of the agentic application will authenticate the user and obtain this token). It connects to the Zuma gateway over MCP, passing that token as Authorization: Bearer <token>. The gateway authenticates the caller, resolves the agent from its registry, evaluates all five layers, and only then lets the tool call through to Google Workspace or Salesforce.

 


Step 1:Identity travels with every call

The heart of pro-code integration is how the agent connects to the gateway. The agent opens an MCP client against the gateway's endpoint and attaches just two headers:

Picture5

The client is a Strands MCPClient wrapping an MCP streamable-HTTP transport, with those two headers attached to every request:

# mcp_client/client.py

import os

from mcp.client.streamable_http import streamablehttp_client

from strands.tools.mcp.mcp_client import MCPClient

 

MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "https://<your-zuma-gateway>/mcp/<gateway-id>")

 

# The ID of this agent's AgentCore runtime — used by Zuma to look the agent up in its registry.

AGENT_SOURCE_ID = os.environ.get("AGENT_SOURCE_ID", "<runtime-id>:1")



def get_streamable_http_mcp_client(token: str) -> MCPClient:

"""Returns an MCP client with the user's token as Bearer auth."""

return MCPClient(lambda: streamablehttp_client(

MCP_SERVER_URL,

headers={

"Authorization": f"Bearer {token}", # user identity, forwarded to the target

"X-Agent-Source-Id": AGENT_SOURCE_ID, # registry lookup key

},

))

That second header (X-Agent-Source-ID) matters more than its size suggests. It ties each runtime call back to a registered agent: one with a declared purpose, an owner, a status and a boundary. . This is what makes Layers 2 (governance) and 4 (intent) work for a pro-code agent exactly as they did for the Copilot Studio agent. The agent's identity isn't an afterthought; it travels with every call.

Claimed Identity vs. Verifiable Identity

In this example, the agent identifies itself with a plain X-Agent-Source-Id header. That's simple to wire up, but it's a self-asserted identity — the caller is telling the gateway who it is, and the gateway trusts that claim to look up the registry entry. Where the network path to the gateway is already trusted, that's often acceptable.

A stronger approach is to use a Zuma-issued cryptographically signed identity token. In this model, the gateway verifies the token signature and derives agent identity directly from trusted claims.

A verifiable identity also does something a plain header never could:the delegation chain. When a human delegates to an agent, or an agent delegates to a sub-agent, each hop is captured in the token.Zuma always knows not just which agent is acting, but on whose behalf, and through what chain of delegation it arrived. That preserves accountability across multi-agent workflows where a request may pass through several actors before it reaches a tool.

How agents obtain verifiable identities and maintain delegation chains deserves its own post, and it'll get one. The takeaway: the integration pattern is the same, and moving from a self-asserted header to a Zuma-issued verifiable identity is a drop-in upgrade to the agent's identity, not a change to the architecture.

 


Step 2: An agent with no security code in it

The agent itself is small. It's a BedrockAgentCoreApp with one streaming entrypoint. On each invocation it receives the prompt and the user's token, builds the MCP client, and hands it to a Strands Agent — the MCP client is passed directly as a tool, so the model can discover and call whatever the gateway exposes:

from strands import Agent

from bedrock_agentcore.runtime import BedrockAgentCoreApp

from model.load import load_model

from mcp_client.client import get_streamable_http_mcp_client

 

app = BedrockAgentCoreApp()

 

DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant. Use tools when appropriate."

 

@app.entrypoint async def invoke(payload, context): prompt = payload.get("prompt", "Hello, what tools do you have?")

 

# The user's identity should arrive as a bearer token in the Authorization

# header. In a real deployment, a frontend authenticates the user and the agent

# is invoked with that token on the request

 

auth_header = context.request_headers.get("Authorization", "")

token = auth_header.removeprefix("Bearer ").strip()

 

if not token:

yield "Error: missing bearer token. The caller must send Authorization: Bearer <jwt>."

return

 

# Build an MCP client bound to the user's token, and hand it to the agent as a tool.

mcp_client = get_streamable_http_mcp_client(token)

agent = Agent(

model=load_model(),

system_prompt=DEFAULT_SYSTEM_PROMPT,

tools=[mcp_client],

)

 

async for event in agent.stream_async(prompt):

if "data" in event and isinstance(event["data"], str):

yield event["data"]

 

if name == "main": app.run()

Now look at what's missing. There's no authorization logic, no policy evaluation, and no allow or deny lists. The agent takes a prompt, uses available tools forwards the user's token and streams back the response. . Access management lives at the gateway. That separation is deliberate. Developers write agent logic. Security teams own the policies. Neither has to touch the other's code.

 


Step 3: Deploy to AgentCore

Deployment is a single command with the AgentCore CLI:

agentcore deploy --yes

A couple of minutes later, you get a Runtime ARN:

✓ Deployed to 'default'

Outputs:

RuntimeArn: arn:aws:bedrock-agentcore:us-east-1:ACCOUNT:runtime/agents_productivity-assistant-XXXXX

That runtime ID is what the agent sends to the gateway as X-Agent-Source-Id. Register the agent in Zuma (or let discovery pick it up) using that source ID, assign it an owner and a boundary — and it's now a governed agent, subject to the same five layers as everything else.

 


Step 4 — Invoke It

You invoke the agent with a prompt and the user's token (usually passed by application front-end after authenticating the user):

agentcore invoke --bearer-token "$USER_TOKEN" --input '{"prompt": "Summarize my unread email"}'

 

The agent connects to the Zuma gateway, discovers the Gmail/Drive/Calendar and Salesforce tools, and the model decides which to call. Each call is evaluated by Zuma before it reaches the target — exactly as in Post 2.

 


The Same Scenarios, the Same Outcomes

Because nothing about the gateway or the policies changed, the defense-in-depth scenarios from Post 2 play out identically against the AgentCore agent:

Picture6

The prompts are the same, the model reasons the same way, and Zuma decides the same way. The only difference is the box labeled "agent": a Python process on AgentCore instead of a Copilot Studio flow.

That's the payoff of putting the guardrails at the gateway: your security posture doesn't fork every time a new agent platform shows up. Write the policy once; it governs the no-code agent, the pro-code agent, and whatever comes next.

 


One Security Model for Every Agent

Most organizations won't standardize on a single agent platform. Business teams will build in Copilot Studio and Agentforce. Development teams will write pro-code agents on Bedrock AgentCore, Vertex, or their own frameworks. Vendors will ship agents you don't control at all.

If your security model is tied to any one platform, you're re-implementing guardrails for every new tool, and the gaps between them become the attack surface. Enforcing at the gateway gives you one control plane for every agent, however it was built or where it runs. The pro-code agent we deployed in this post is subject to the identical policies as the no-code agent from Post 2, because they both connect through the same governed path.

That's the promise of Zuma:

  • Discover every agent.
  • Govern every agent.
  • Authorize every action.

No matter where the agent was built, who built it, or where it runs.

To learn more about Zuma's approach to AI identity security, visit saviynt.com/zuma.

Share this story

Subscribe to Our Newsletter

Get the latest insights on identity governance, security trends, and customer success stories delivered to your inbox.