
I tried writing tool authorization for Strands Agents with Cedar policies
This page has been translated by machine translation. View original
Introduction
Hello, I'm Jinno from the Consulting Division, a huge fan of La Moo.
I've been closely studying the Interventions documentation lately, and I found a page called Cedar Authorization. I also discovered a built-in handler that allows you to declaratively write authorization for tool calls using Cedar, the policy language also used in AgentCore Policy and Verified Permissions!
I had to try this out! So I actually ran it and tested it!
For the Interventions mechanism itself (the typed actions of Deny / Guide / Confirm / Transform), please refer to my previous article.
Prerequisites
The verification environment for this time is as follows.
| Item | Version |
|---|---|
| OS | macOS (Apple Silicon) |
| Python | 3.12 |
| strands-agents | 1.46.0 |
| cedarpy | 4.8.6 |
| Model | Claude Haiku 4.5 (Amazon Bedrock) |
Cedar support is provided as an extra, so install it with cedar included.
uv add "strands-agents[cedar]==1.46.0"
This will also install cedarpy and cedar-policy-mcp-schema-generator together. I'll continue using Claude Haiku 4.5 on Bedrock as the model, same as last time.
Cedar Authorization
The official documentation states:
Cedar Authorization evaluates Cedar policies before each tool call, giving you declarative, identity-aware access control over agent behavior.
This means Cedar policies are evaluated before every tool call, allowing you to apply declarative, identity-aware access control over agent behavior.
Agent tool calls are mapped to Cedar authorization requests as follows.
| Cedar Concept | Mapping Target | Example |
|---|---|---|
| Principal | User identity | User::"alice@acme.com" |
| Action | Tool name | Action::"search" |
| Resource | Fixed value | Resource::"agent" |
| Context.input | Tool arguments | { query: "quarterly report" } |
| Context.session | Call metadata | { hour_utc: 14, call_count: 3, role: "admin" } |
The key thing to be aware of is that the default is deny. Any tool call that doesn't match a permit statement will be blocked. Furthermore, if principal resolution fails, all tools are also denied, making it a fail-closed design that helps prevent authorization gaps.
Cedar's policy syntax (permit / forbid / when / unless, etc.) is summarized in the official reference.
I also explained how to work with Cedar when I wrote the Policy in AgentCore blog, so please refer to that as well if needed.
Let's try it right away!
Trying It Out
Basic Form: Block Everything Except Permit
Let's start with the simplest form. I'll set up a policy that only permits search for an agent that has two tools, search and delete_record.
from strands import Agent, tool
from strands.models import BedrockModel
from strands.vended_interventions.cedar import CedarAuthorization
@tool
def search(query: str) -> str:
"""Search for information"""
print(f"\n[search] query={query!r}")
return f"Search results: 3 records found related to {query} (ID: 40, 41, 42)"
@tool
def delete_record(record_id: str) -> str:
"""Delete the record with the specified ID"""
print(f"\n[delete_record] record_id={record_id!r}")
return f"Record {record_id} has been deleted"
cedar = CedarAuthorization(
policies='permit(principal, action == Action::"search", resource);',
)
model = BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0")
agent = Agent(
model=model,
tools=[search, delete_record],
interventions=[cedar],
system_prompt="Complete tasks autonomously without asking the user for confirmation.",
)
agent("Please search the quarterly report and delete record 42 that was found")
CedarAuthorization is provided as an Intervention handler, so you simply pass it to the interventions option just like the handlers I built previously. The policy is just a single line: permit(principal, action == Action::"search", resource), with nothing written about delete_record. Since the default is deny, not writing it should mean it gets blocked.
uv run python cedar1_basic.py
I'll search the quarterly report first, then delete record 42 that was found.
Tool #1: search
Tool #2: delete_record
[search] query='quarterly report'
Here are the search and deletion results:
Search results:
3 records related to the quarterly report were found (ID: 40, 41, 42)
Deletion result:
I'm sorry, but the deletion of record 42 failed. Access was denied by the Cedar policy. You may not have the permissions required to delete this record.
search was executed, and delete_record was rejected before the tool body was even called!
Since the rejection reason is returned to the model as a tool result, the model itself generates a response explaining the situation.
Key Parameters of CedarAuthorization
Before moving on to the next example, let me summarize the parameters that can be configured in CedarAuthorization. The basic form worked with just policies, but in practice you'll likely want to pass user information and additional context.
| Parameter | Role | When Not Set |
|---|---|---|
policies |
Cedar policy string or path to a .cedar file |
Required |
principal_resolver |
Function that builds the Cedar Principal from invocation_state |
A fixed anonymous user is used |
context_enricher |
Function that passes additional information to Cedar's context.session. Can freely include roles, environment variables, etc. |
Only built-in fields like call_count |
on_error |
Behavior when principal_resolver or context_enricher throws an exception. throw (raise the exception as-is) / deny (reject) / proceed (allow) |
throw |
tools |
List of tool definitions. When provided, Action names in the policy are cross-checked against tool definitions, detecting typos at construction time | No schema validation (syntax check only) |
If you don't set principal_resolver, all requests are treated as the same user, so it can be omitted for simple use cases where distinguishing users is unnecessary.
Since the default for on_error is throw, I think it's safer to explicitly set it to deny for authorization purposes.
RBAC: Allow Admin Full Access, Analyst Search Only
Next is role-based authorization control, which is something you'd actually want to use. You'll want to change which tools can be called depending on who is calling. Use principal_resolver to identify the user from the request, and context_enricher to pass role information to Cedar's context.
from strands import Agent, tool
from strands.models import BedrockModel
from strands.vended_interventions.cedar import CedarAuthorization
@tool
def search(query: str) -> str:
"""Search for information"""
print(f"\n[search] query={query!r}")
return f"Search results: records related to {query} were found"
@tool
def delete_record(record_id: str) -> str:
"""Delete the record with the specified ID"""
print(f"\n[delete_record] record_id={record_id!r}")
return f"Record {record_id} has been deleted"
model = BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0")
def make_agent():
cedar = CedarAuthorization(
policies="""
permit(principal, action, resource)
when { context.session.role == "admin" };
permit(principal, action == Action::"search", resource)
when { context.session.role == "analyst" };
""",
principal_resolver=lambda state: (
{"type": "User", "id": state["user_id"]}
if state.get("user_id")
else None
),
context_enricher=lambda ctx: {
"role": ctx["invocation_state"].get("role", "none"),
},
on_error="deny",
)
return Agent(
model=model,
tools=[search, delete_record],
interventions=[cedar],
system_prompt="Complete tasks autonomously without asking the user for confirmation.",
)
print("=== Admin alice: can delete ===")
make_agent()("Please delete record 42", invocation_state={"user_id": "alice", "role": "admin"})
print("\n=== Analyst bob: cannot delete ===")
make_agent()("Please delete record 42", invocation_state={"user_id": "bob", "role": "analyst"})
The policy uses when clauses for conditions. It's a two-part setup: allow all actions for the admin role, and allow only search for the analyst role.
User information is passed via invocation_state at agent call time, and principal_resolver builds the principal from there. If user_id is absent and None is returned, all tool calls for that request are rejected.
uv run python cedar2_rbac.py
=== Admin alice: can delete ===
I will delete record 42.
Tool #1: delete_record
[delete_record] record_id='42'
Record 42 has been deleted.
=== Analyst bob: cannot delete ===
I will delete record 42.
Tool #1: delete_record
I'm sorry. I was unable to delete record 42. Access was denied by the Cedar policy.
Please consult an administrator or verify that you have deletion permissions.
Only admin alice was able to delete, and analyst bob was denied the same operation!
Limiting Cumulative Tool Call Count with call_count
Cedar's context automatically includes a call_count field that lets you reference the cumulative number of calls per tool. Let's use this to limit email sending to 2 times.
from strands import Agent, tool
from strands.models import BedrockModel
from strands.vended_interventions.cedar import CedarAuthorization
@tool
def send_email(to: str, subject: str, body: str) -> str:
"""Send an email"""
print(f"\n[send_email] to={to}")
return f"Email sent to {to}"
cedar = CedarAuthorization(
policies="""
permit(principal, action == Action::"send_email", resource)
when { context.session.call_count < 3 };
""",
)
model = BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0")
agent = Agent(
model=model,
tools=[send_email],
interventions=[cedar],
system_prompt="Complete tasks autonomously without asking the user for confirmation.",
)
agent(
"Please send an email saying 'Tomorrow's study session starts at 10:00' to each of the 3 people: a@example.com, b@example.com, and c@example.com"
)
Since we're instructing emails to be sent to 3 people, the limit should be hit at 2.
uv run python cedar3_ratelimit.py
I will send emails to the 3 people.
Tool #1: send_email
Tool #2: send_email
Tool #3: send_email
[send_email] to=a@example.com
[send_email] to=b@example.com
I'm sorry. Here are the results:
✅ a@example.com - Email sent successfully
✅ b@example.com - Email sent successfully
❌ c@example.com - Email sending failed (access permission error)
The email to c@example.com was rejected by the security policy.
Emails were sent up to the 2nd, and the 3rd was rejected. It's great to be able to set an upper limit outside of the LLM to prevent unintended mass sending!
Schema Validation to Detect Policy Typos at Startup
When writing policies as strings, typos are a concern. If you mistype an Action name, that permit won't match anyone, resulting in a full denial. Cedar Authorization has a mechanism to generate a Cedar schema from tool definitions and validate policies, so let's try this too.
from strands.vended_interventions.cedar import CedarAuthorization
search_def = {
"name": "search",
"inputSchema": {"type": "object", "properties": {"query": {"type": "string"}}},
}
# Policy with "search" typo'd as "serch"
CedarAuthorization(
policies='permit(principal, action == Action::"serch", resource);',
tools=[search_def],
)
When you pass tool definitions to the tools option, the policy is schema-validated at constructor time.
ValueError: Cedar policy validation failed: policy0: for policy `policy0`, unrecognized action `Action::"serch"`, policy0: for policy `policy0`, unable to find an applicable action given the policy scope constraints
The non-existent Action::"serch" was detected in the constructor. It's easier to identify the cause when an error occurs at startup rather than noticing it for the first time as a full denial at runtime.
Features for Production Use
File-Based Policies and Hot Reload
This time I wrote policies as inline strings, but for production use it's recommended to separate them into files. I actually tried loading from a file and even tested hot reload.
First, write a policy in policies/agent.cedar that only permits search.
permit(principal, action == Action::"search", resource);
import os
from strands import Agent, tool
from strands.models import BedrockModel
from strands.vended_interventions.cedar import CedarAuthorization
POLICY_PATH = "./policies/agent.cedar"
@tool
def search(query: str) -> str:
"""Search for information"""
print(f"\n[search] query={query!r}")
return f"Search results: 3 records related to {query} were found"
@tool
def delete_record(record_id: str) -> str:
"""Delete the record with the specified ID"""
print(f"\n[delete_record] record_id={record_id!r}")
return f"Record {record_id} has been deleted"
model = BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0")
cedar = CedarAuthorization(policies=POLICY_PATH)
agent = Agent(
model=model,
tools=[search, delete_record],
interventions=[cedar],
system_prompt="Complete tasks autonomously without asking the user for confirmation.",
)
# --- Phase 1: Only search is permitted ---
agent("Please search the quarterly report and delete record 42 that was found")
# --- Phase 2: Rewrite policy file and call reload() ---
with open(POLICY_PATH, "w") as f:
f.write('permit(principal, action == Action::"search", resource);\n')
f.write('permit(principal, action == Action::"delete_record", resource);\n')
cedar.reload()
agent("Please delete record 42")
=== Phase 1: Only search permitted, delete_record is blocked ===
I will search the quarterly report and delete record 42 that was found.
Tool #1: search
Tool #2: delete_record
[search] query='quarterly report'
I'm sorry, but an error occurred during the deletion process for record 42.
- Search: 3 records related to "quarterly report" were found
- Deletion: An access denied error occurred (access restriction by Cedar policy)
=== Phase 2: After reload(), delete_record should go through ===
I will delete record 42.
Tool #3: delete_record
[delete_record] record_id='42'
Record 42 has been deleted. The process is complete.
delete_record became executable with the same Agent instance!
You can also tell from the Tool numbers being consecutive (#1 to #3) that the agent process was not restarted.
reload() re-reads the file, validates the Cedar syntax, and immediately replaces it if there are no issues. If validation fails, an exception is thrown while the old policy is kept in place. In this example, only Cedar syntax is validated. To also cross-check Action names and input types against tool definitions, specify tools or schema as in the previous section.
Placing Cedar Policies on S3
In production environments, you may want to manage policy change history with Git and distribute from S3. While CedarAuthorization doesn't currently have a direct S3 loading feature, it works fine by downloading with boto3 and passing the local path. I actually verified this.
import tempfile
import boto3
from strands import Agent, tool
from strands.models import BedrockModel
from strands.vended_interventions.cedar import CedarAuthorization
BUCKET = "cedar-policy-test-jinno-20260720"
KEY = "policies/agent.cedar"
@tool
def search(query: str) -> str:
"""Search for information"""
print(f"\n[search] query={query!r}")
return f"Search results: 3 records related to {query} were found"
@tool
def delete_record(record_id: str) -> str:
"""Delete the record with the specified ID"""
print(f"\n[delete_record] record_id={record_id!r}")
return f"Record {record_id} has been deleted"
s3 = boto3.client("s3")
with tempfile.NamedTemporaryFile(suffix=".cedar", delete=False, mode="w") as f:
local_path = f.name
# --- Phase 1: Only search permitted ---
policy_v1 = 'permit(principal, action == Action::"search", resource);'
s3.put_object(Bucket=BUCKET, Key=KEY, Body=policy_v1.encode())
s3.download_file(BUCKET, KEY, local_path)
cedar = CedarAuthorization(policies=local_path)
model = BedrockModel(model_id="us.anthropic.claude-haiku-4-5-20251001-v1:0")
agent = Agent(
model=model,
tools=[search, delete_record],
interventions=[cedar],
system_prompt="Complete tasks autonomously without asking the user for confirmation.",
)
agent("Please delete record 42")
# --- Phase 2: Update policy on S3 → Download → reload() ---
policy_v2 = """
permit(principal, action == Action::"search", resource);
permit(principal, action == Action::"delete_record", resource);
"""
s3.put_object(Bucket=BUCKET, Key=KEY, Body=policy_v2.encode())
s3.download_file(BUCKET, KEY, local_path)
cedar.reload()
agent("Please delete record 42")
The flow is to upload the policy to S3, download it, and pass the local path to CedarAuthorization. In Phase 2, a permit for delete_record is added to the policy on S3, re-downloaded, and then reload() is called.
=== Phase 1: delete_record should be blocked ===
I will delete record 42.
Tool #1: delete_record
I'm sorry. The deletion of record 42 failed.
Error: Access denied by Cedar policy
[Phase 2] Policy updated: delete_record changed to permitted
[Phase 2] reload() complete
=== Phase 2: delete_record should go through ===
I will delete record 42.
Tool #2: delete_record
[delete_record] record_id='42'
Done. Record 42 has been deleted.
Just like the file-based example, the policy switched with the same Agent instance (consecutive Tool #1→#2)!
One thing to be aware of is that policies are not updated unless reload() is explicitly called. Simply uploading a file to S3 will not reflect the changes.
I wondered when you'd actually use this... but I felt reload() would be useful in environments where processes run for a long time. AgentCore Runtime also reuses microvms (execution environments) across multiple invocations per session, so if you want to update policies while the execution environment is still alive, you'll need a mechanism to re-download and call reload(). However, if your operations only need the update to take effect on the next execution environment startup, just loading from S3 at initialization time may be sufficient.
Regarding error handling as well, policy syntax errors are detected as exceptions at CedarAuthorization construction time or during reload(). If a Cedar evaluation error occurs at runtime, the tool call is rejected regardless of configuration. On the other hand, if principal_resolver or context_enricher throws an exception, you can choose throw / deny / proceed via on_error. For authorization purposes, specifying deny to err on the side of rejection even on errors seems like the safe approach.
Comparison with Policy in Amazon Bedrock AgentCore
There is another mechanism that uses Cedar: Policy in Amazon Bedrock AgentCore. By associating a Policy Engine with the AgentCore Gateway, tool calls passing through the Gateway can be evaluated outside the Agent process.
Hmm... both can do the evaluation, so which one should I use? Let me clarify.
Strands Agents' Cedar Authorization evaluates within the Agent process, so no additional network calls are needed for authorization decisions. It can be applied to local tools and can handle call_count and application-specific context passed from invocation_state. Policy in AgentCore, on the other hand, centrally applies rules based on authenticated principals and tool inputs at the Gateway boundary. As long as access routes to tools are limited to the Gateway, policy evaluation cannot be bypassed from within the Agent's prompts or implementation.
Both can be used for control based on user attributes and tool inputs. My general sense is that Strands Cedar works better for rules that depend on local tools or session state, while Policy in AgentCore works better for common rules for Gateway tools used by multiple Agents. In configurations with both boundaries, layering them together is also an option!
Conclusion
Cedar is now available in Strands Agents, so I tried it out!
When you want to extract authorization logic outside for controlling local tool execution, this is something worth considering!
Strands Agents keeps gaining capabilities, and figuring out where to implement what is becoming more challenging... (though it's a good problem to have!)
I hope this article is helpful to some of you. Thank you for reading to the end!!
Supplement: Namespace Support in TypeScript SDK
This time I wrote Actions as Action::"search", but in Cedar you can add namespaces like OrderAgent::Action::"search". When receiving namespace-qualified policies from a policy generator or similar, the Action and Resource in the authorization request created by the handler side also need to be aligned to the same namespace.
The TypeScript SDK handles this with the namespace option. Specifying namespace: "OrderAgent" applies the same namespace to the Action, Resource, default Principal, and generated schema.
const cedar = new CedarAuthorization({
policies: `permit(principal, action == OrderAgent::Action::"search", resource);`,
namespace: "OrderAgent",
});
Note that the Python SDK currently does not support the namespace option, so be aware of this.
