A Deterministic Backstop for Your Agent: AgentCore Policy and Cedar
TL;DR: I bound a Cedar policy engine to the Gateway from the last article and shipped two real guards: one rejects a get_opportunity call with no opportunity_id, the other rejects a HubSpot write unless confirmed_by_presenter is present and true. Both are enforced outside the model’s own reasoning so that no prompt injection can talk its way past them. The honest limit matters as much: Cedar only ever sees one call’s own parameters, never conversation history, so the write guard verifies that the approval flag is set, not that a human actually gave the approval it claims to represent.
Part 5 of the series “Building a Partner Sales Agent on Amazon Bedrock AgentCore”, built around one real project: a dialog-based agent that connects HubSpot CRM and AWS Partner Central for an AWS Partner’s sales team. My last article opened the Gateway that every external call passes through; this article covers the Cedar policy engine bound to that Gateway, what it deterministically blocks and, just as precisely, what it cannot see.
Table of Content
- Article 1: Two Systems, One Sales Motion: An AWS Partner Agent on Amazon AgentCore Harness
- Article 2: Why I Didn't Write My Own Agent Loop: The Case for AgentCore Harness
- Article 3: Configuring AgentCore Runtime: Session Lifecycle, Container Backing, Memory
- Article 4: One Gateway, Six Tools: AgentCore Gateway as Your Agent's Only Way Out
- Article 5: A Deterministic Backstop for Your Agent: AgentCore Policy and Cedar
- Article 6: Grounding an Agent Without a Vector Database: AgentCore Managed Knowledge Base
- Article 7: From Company Name to CRM Record: One AgentCore Conversation, End to End (coming soon)
A policy engine is precisely the type of feature that often leads to overstatements about system security. When terms like “Cedar” and “deterministic enforcement” appear together, it is easy for readers to infer that the agent is comprehensively protected against all forms of misuse, including prompt injection and model errors. However, this is not the case. To clarify the technical boundaries: AgentCore Policy in this project enforces two specific guardrails that are immune to prompt injection, but its scope is tightly limited. The engine evaluates individual tool calls in isolation and has no access to the broader conversation context or history.
What AgentCore Policy is
AgentCore Policy is a Cedar-language authorization engine that attaches to an AgentCore Gateway, not to the harness. Every tool call the harness sends through the Gateway is evaluated against the engine’s policies before it reaches a target. The evaluation happens entirely outside the agent’s own code, after the model has already decided to make the call. That placement is the property that matters: a prompt-injected or plain misbehaving model cannot reason its way around a check it never participates in.
This project ships two real policies. One forbids get_opportunity calls that arrive without an opportunity_id, a plain input guard on the Partner Central read path. The other forbids upsert_contact calls, the single write path in the whole system, unless confirmed_by_presenter is present and true; the deployed field name keeps the build-time word for the sales rep driving the demo.
Thirty seconds on how Cedar evaluates a request in general, before this project’s specifics, because one detail of it produced the build’s biggest surprise. A Cedar request is allowed only when at least one permit policy matches it and no forbid policy does. An explicit forbid always beats any permit. Also a request that no policy speaks about at all gets the engine’s default answer, which is deny [1]. Hold on to that last clause: the next section shows what it did to a live terraform apply.
In the AgentCore console, Policy has its own page under Build, next to Harness, Gateway, and Memory. Creating an engine takes a name and a description; this project’s is partner_growth_agent_policy. The policies live inside the engine, each is a name, a description, and a Cedar statement entered directly. Nothing on the engine itself points at a Gateway. The association is configured on the Gateway, as a policy engine reference plus an enforcement mode [2] and that mode field holds more importance than its size suggests; the “Watching it work” section is built around it.

Why add a policy engine to a demo at all, rather than describe one? Three reasons decided it here. It was already fully modeled in the pinned Terraform provider (hashicorp/aws 6.54.0). It adds a control that lives in reviewable configuration rather than in prompt text, which extends article 1’s blast-radius argument one layer deeper. Lastly it holds on the day the model’s judgment fails or becomes manipulated, which, as the numbers later in this article show, is not a hypothetical day.
Default-deny and the baseline permit it forces
The plan was modest: associate the engine with the Gateway and ship one forbid rule, the get_opportunity input guard. The first apply did not get past creating the policy. It failed with this, verbatim:
Overly Restrictive: Policy Engine will deny every request for the specified principal/action/resource combination if the policy is added.
That is a create-time analysis, not a runtime failure and it fired for both principal types named in the error (IamEntity and OAuthUser). The service worked out what the engine’s policy set would decide with only a forbid in it, concluded the answer was “deny everything”, and refused to create the policy at all. The logic follows directly from Cedar’s default answer: with no permit anywhere. The forbid was beside the point, because every call on every target was already headed for the default deny. At the time this Gateway had four targets (list-opps, get-opp, reason, managed-kb), and associating a forbid-only engine in enforcing mode would have silenced all four, not just the malformed calls the forbid was aimed at.
The fix is a baseline permit covering every target on the Gateway. Here is the shipped statement, condensed to the Cedar block; it lists six actions today because the two HubSpot targets from article 4 joined the list when they were wired in:
permit ( principal, action in [ AgentCore::Action::"list-opps", AgentCore::Action::"get-opp", AgentCore::Action::"reason", AgentCore::Action::"managed-kb", AgentCore::Action::"hs-search", AgentCore::Action::"hs-write" ], resource == AgentCore::Gateway::"<gateway_arn>");The action names are Gateway target names, not tool names: a target-level action works as an action group covering every tool registered under that target, and since Cedar has no wildcard action, one group per target is the documented way to keep the list short [3]. And principal is left bare on purpose. This Gateway authenticates callers with AWS_IAM, and the only role granted bedrock-agentcore:InvokeGateway on it is the harness’s execution role, so IAM has already decided who can show up here. A Cedar principal condition would restate that decision without restricting anything.
For the automation path, ordering is the finding. Nothing in the forbid resource references the permit resource, so Terraform’s graph has no implicit edge between them, and a parallel apply can race the forbid’s create-time validation ahead of the permit and land in the exact failure quoted above. My shipped demo code pins the order with an explicit depends_on = [aws_bedrockagentcore_policy.pc_permit_existing_targets] on each forbid. A console reader gets the same rule in simpler form: create the baseline permit before any forbid, because the server runs the same analysis no matter how the policy arrives.
Associating the engine with the Gateway gave my a surprise. UpdateGateway, the operation the association rides on, failed five separate times with five distinct missing grants on the Gateway’s own execution role, discovered one live AccessDenied at a time: bedrock-agentcore:GetPolicyEngine on the policy-engine ARN, then AuthorizeAction and PartiallyAuthorizeActions, each needed twice, once against the policy-engine ARN and once against the Gateway’s own ARN. None of this surfaces in terraform validate. Two additional CREATE_FAILED attempts on PartiallyAuthorizeActions turned out to be IAM propagation delay rather than missing grants; the identical, already-attached policy applied cleanly on a later attempt.
Default-deny also leaves a standing rule. Any new target added to this Gateway must be added to the baseline permit in the same change, or it is denied the moment it exists. When the HubSpot targets arrived, the permit was extended first and the new forbid added after it, repeating the same permit-plus-forbid pairing and the same depends_on.
The Cedar decision flow
Reading top to bottom: a tool call arrives at the Gateway carrying the harness’s principal, and in enforcing mode nothing routes onward until the engine has answered. If no permit matches, evaluation is effectively over: deny, before any forbid is consulted. Inside the space the permit opens, each forbid is tested against the call’s own context.input. A firing forbid produces an explicit deny that names the specific policy. A call that survives both is routed to its target Lambda or the knowledge-base connector.
One precision note so the diagram doesn’t overstate itself: Cedar formally evaluates the whole policy set and combines the results, allowing a request only when some permit matches and no forbid does [1]. For a policy set of one permit and two forbids, the sequential rendering above is decision-equivalent. It makes the load-bearing fact visible: a forbid can only narrow what a permit already allowed. The baseline permit’s presence and scope set the ceiling before any forbid gets a say. That is why the permit is not boilerplate. It is the half of the policy set that decides whether the Gateway works at all.
What Cedar can and cannot see
A policy in this engine receives exactly two things about a request: the calling principal, and context.input, the current call’s own parameters [4]. The principal carries no information here, and I mean that literally. This Gateway authenticates with AWS_IAM, every call is signed by the same harness execution role, and so every request arrives as the same principal regardless of who is chatting or what the conversation is doing. That leaves context.input as the only discriminating signal a policy can act on: one call, its own arguments, nothing else.
Not conversation history. Not which turn or flow a call belongs to. Not whether a human approved anything. I ran into the shape of this limit directly: a first-draft policy referenced a context.request_type attribute, meaning “is this call happening during a BANT (budget, authority, need, timeline) qualification flow”, and it turned out simply not writable against the real Cedar schema. No such attribute exists and no policy syntax supports one.
That kills the most tempting use of the engine. Live sampling during the build measured the model calling reason_about during BANT qualification despite a system prompt telling it never to: 2 of 6 sampled runs (roughly 33 percent) under the initial version single-paragraph instruction, and 1 of 9 (roughly 11 percent) after the prompt was restructured into short, isolated, repeated rules. The restructuring did all of that work. Cedar contributed nothing to the reduction, and it cannot close the remaining 11 percent, because the violating call is a well-formed, legitimate-looking reason_about invocation, indistinguishable at the Gateway from a sanctioned use of the same tool one turn later. A blanket forbid on reason_about would take the legitimate open-ended-reasoning capability down with it. “This call is part of a BANT flow” represents conversational state, as conversational state never reaches the engine. The 11 percent stays a documented, accepted prompt-level residual, and I would rather write that sentence than let a policy engine imply coverage it does not have.
The write guard deserves the same scrutiny. Here are both shipped forbid rules together:
forbid ( principal, action == AgentCore::Action::"get-opp___get_opportunity", resource == AgentCore::Gateway::"<gateway_arn>") unless { context.input has opportunity_id };forbid ( principal, action == AgentCore::Action::"hs-write___upsert_contact", resource == AgentCore::Gateway::"<gateway_arn>") unless { context.input has confirmed_by_presenter && context.input.confirmed_by_presenter == true};The action names use the target___tool convention from last article’s story. The two rules both originated as forbid ... unless { context.input has <field> } statements. It became clear during code review that the construct only checks for the presence of a key, not its value [1]. As a result, the initial write guard would permit calls where confirmed_by_presenter was set to false. Although the model's prompt did not direct it to submit false, and such scenarios were considered unreachable in standard operation, the rationale for updating the policy was to ensure its independence from any assumptions about model behavior. Given that the purpose of this backstop is to enforce guarantees independent of the model’s instructions or actions, it was necessary to amend the policy so that the field not only must be present, but specifically must be true.
Now the honest limit of the fixed version: the guard verifies the flag, not the approval. Cedar sees that confirmed_by_presenter=true is on the call. It cannot see whether a sales rep actually typed an approval two turns earlier, because that is conversation, and conversation sits on the wrong side of the boundary. A model that attached the flag without any real approval behind it would pass this guard. The approval flow itself is prompt-level; Cedar’s contribution is narrower and sharper. Writes without the flag cannot happen, deterministically, no matter what the model narrates. Writes with a falsely attached flag are out of its reach.
Watching it work
A claim about enforcement should be tested at the enforcement boundary, so I verified both modes with direct MCP calls against the Gateway’s own endpoint, no model anywhere in the loop. That was a deliberate choice, not a shortcut. Persuading a model into producing a malformed tool call proves that the model can be compromised on that particular day; it proves nothing about the policy. A signed call I construct myself isolates the one variable the test is about.
The Gateway-level association supports two modes, LOG_ONLY and ENFORCE [2]: the first evaluates and records, the second evaluates and blocks. Under LOG_ONLY, a hand-built get_opportunity call with no opportunity_id still reached the Lambda while the evaluation recorded a DenyDecisions count of 1. That is the contract: the denial exists only as a metric while the call still goes through, which makes LOG_ONLY the safe first gear for a policy set you have not yet watched against live traffic. After that smoke pass, the mode was flipped. In the console this is the enforcement mode on the Gateway’s policy engine association; in the automated build it is one attribute:
policy_engine_configuration { arn = aws_bedrockagentcore_policy_engine.pc.policy_engine_arn mode = "ENFORCE"}Under ENFORCE, the identical malformed call was rejected before the Lambda ever ran, zero Lambda invocations logged, with an explicit MCP error naming the policy that fired, while a well-formed call in the same setup still reached Partner Central’s real API.
The write guard skipped its own LOG_ONLY period, and the reason is an automation-path finding rather than a preference. A per-policy enforcement mode exists in the imperative API, but the Terraform provider does not expose it (only validation_mode appears in the resource schema), so the Gateway-level mode is this build’s only staged-rollout knob. By the time the write guard landed, that mode was already ENFORCE and protecting the live get_opportunity guard. The guard was verified under ENFORCE instead: a direct tools/call to hs-write___upsert_contact omitting confirmed_by_presenter, bypassing the model entirely, came back denied with pc_hubspot_upsert_contact_requires_confirmation named in the error.
Know where to watch: the signal is metrics, not logs. Policy decisions surface in the AWS/Bedrock-AgentCore CloudWatch metrics namespace as DenyDecisions and AllowDecisions, with Policy, Mode, and TargetResource dimensions [6]. No policy-engine-named log group exists to search; I looked and no such log group is ever populated. Span-level detail on individual denials would require Gateway tracing.
Transferable patterns
Five habits from this subsystem generalize to any AgentCore build, whether console-managed or automated.
- Default-deny forces permit-before-forbid, explicitly ordered. Associating a default-deny engine with only your intended forbid rules blocks every call on every target, and the service refuses the policy set at create time. Write a baseline permit for every existing target first. In Terraform, order it with an explicit
depends_on, because no attribute reference exists between a forbid and the permit for the graph to infer. - State what a control cannot see, in the same document that introduces it. Cedar has no cross-turn conversational state. Naming that limit plainly costs one paragraph and saves a reader from assuming a policy engine closes every gap a system prompt leaves open.
- Verify a policy by bypassing the model. A direct call against the Gateway’s own endpoint proves an enforcement decision. A model-driven test proves only the model’s behavior on that day.
- Look for metrics, not a policy-named log group. The live signal is
DenyDecisions/AllowDecisionsin theAWS/Bedrock-AgentCorenamespace.
For readers seeking to replicate this subsystem, the following guidance builds on the design details and implementation strategies discussed throughout the preceding sections.
Four concrete notes for standing this subsystem up yourself, all from this build’s own history.
First, create the baseline permit before any forbid, whichever path you take. The create-time analysis rejects a forbid-only policy set server-side, so the ordering rule applies in the console just as it does in Terraform; automation only adds the extra step of pinning the order with depends_on, since the two policy resources share no attribute reference.
Second, grant the Gateway’s execution role the complete set of policy-engine permissions at the outset. The required IAM actions and their corresponding resources are summarized in the following table:
| IAM Action | Resource |
|---|---|
| bedrock-agentcore:GetPolicyEngine | Policy Engine ARN |
| bedrock-agentcore:AuthorizeAction | Policy Engine ARN |
| bedrock-agentcore:AuthorizeAction | Gateway ARN |
| bedrock-agentcore:PartiallyAuthorizeActions | Policy Engine ARN |
| bedrock-agentcore:PartiallyAuthorizeActions | Gateway ARN |
During implementation, missing these permissions led to five distinct live failures that were only resolved by granting all five actions in advance . When specifying the Gateway-scoped ARN, construct it from the account ID and the static Gateway name with a wildcard suffix; avoid references to the resource attribute to prevent dependency cycles, and do not rely on a literal ARN captured from a previous apply since this may break upon resource re-creation. If PartiallyAuthorizeActions fails immediately after the permissions are granted, allow additional time before troubleshooting further, as two of the observed failures were attributable to IAM propagation delays rather than missing grants.
Third, treat the permit’s action list as a hand-maintained contract. The strings in the Cedar statement are separated from the name attribute on each Gateway target, so renaming a target silently drops it from the permit, and under default-deny that means every call to the renamed target fails, discoverable only live, never by terraform plan.
Fourth, plan your verification around metrics and a direct call, as described above. If you need per-denial detail beyond the metric dimensions, budget for enabling Gateway tracing; the policy engine will not give it to you otherwise.
Implications for Subsequent Articles
In summary, the current analysis has established both the strengths and inherent limitations of AgentCore Policy and Cedar within the agentic-AI framework. This discussion sets a clear foundation for the focus of future articles in the series. Article 6 will explore the integration of the Managed Knowledge Base, demonstrating how retrieval is subject to the same policy-driven boundaries as CRM write operations. The last Article will offer a comprehensive, live demonstration incorporating the full policy stack discussed here, including the approval workflow and its deterministic safeguards. By articulating the specific guarantees and constraints of the policy engine, this article provides critical insights that inform the scope and direction of the remaining installments. Future contributions will build upon these findings, ensuring that ongoing discourse remains grounded in the transparent capabilities and limitations of the current approach.
The honest remark about AgentCore Policy is the sales pitch and the disclaimer in one: two guards no prompt can bypass, judging exactly one call at a time, blind to everything the conversation knows.
I hope this article was useful for you. I would love to receive feedback on what you liked and disliked, so that I can improve any future article.
Sources
[1] Cedar policy language documentation: authorization semantics (a request is allowed only if at least one permit matches and no forbid does; an explicit forbid overrides any permit; the default decision is deny) and the has operator’s key-presence-only semantics. https://docs.cedarpolicy.com/
[2] Amazon Bedrock AgentCore Developer Guide, Policy enforcement modes: the Gateway policy-engine association’s LOG_ONLY and ENFORCE enforcement modes. https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy-enforcement-modes.html
[3] Amazon Bedrock AgentCore Developer Guide, example policies: worked examples pairing input-guard forbid rules with baseline permits, and target-level actions grouping the tools registered under a Gateway target. https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/example-policies.html
[4] Amazon Bedrock AgentCore Developer Guide, Policy core concepts: what a policy evaluates (the calling principal and the tool call’s input) and the default-deny behavior. https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/policy-core-concepts.html
[5] AWS IAM User Guide, “The confused deputy problem”, including the aws:SourceAccount mitigation for cross-service trust. https://docs.aws.amazon.com/IAM/latest/UserGuide/confused-deputy.html
[6] Amazon Bedrock AgentCore Developer Guide, Policy observability data: AllowDecisions / DenyDecisions invocation metrics in the AWS/Bedrock-AgentCore CloudWatch namespace, with Policy, Mode, and TargetResource dimensions. https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-policy-metrics.html