Task 7 · 8 tasks
Cedar policy on the Gateway
Enforce read-only queries at the Gateway, and find out why a string policy alone is not the guarantee.
The “Runaway Query” nightmare
“What if someone talks the agent into deleting every record?” Your guard hook helps, but it lives in code anyone can edit. Alice wants a rule enforced outside the agent, at the Gateway.
What the platform provisions for you
uv run bootcamp.py up 7- A Cedar policy engine attached to your Gateway
- A
safe_query_policythat only permits read-looking SQL onDataStreamDatabase___query_db - A
schema_toolspermit forlist_tables/describe_table, if your MCP server defines them
Adding this stage usually takes ~4.5 min; the CLI prints progress (and full terraform output with --verbose).
What you do as a developer
Read the policies
terraform/participant/safe_query_policy.cedarpermit( principal, action == AgentCore::Action::"${action}", resource == AgentCore::Gateway::"${gateway_arn}" ) when { (context.input.query like "SELECT *" || context.input.query like "SELECT\n*" || context.input.query like "select *" || context.input.query like "select\n*" || context.input.query like "Select *" || context.input.query like "Select\n*" || context.input.query like "WITH *" || context.input.query like "WITH\n*" || context.input.query like "with *" || context.input.query like "with\n*" || context.input.query like "With *" || context.input.query like "With\n*") && !(context.input.query like "*--*" || context.input.query like "*/\**") && !(context.input.query like "*INSERT *" || context.input.query like "*INSERT\t*" || context.input.query like "*INSERT\n*" || context.input.query like "*INSERT\r*") && !(context.input.query like "*insert *" || context.input.query like "*insert\t*" || context.input.query like "*insert\n*" || context.input.query like "*insert\r*") && !(context.input.query like "*Insert *" || context.input.query like "*Insert\t*" || context.input.query like "*Insert\n*" || context.input.query like "*Insert\r*") && !(context.input.query like "*UPDATE *" || context.input.query like "*UPDATE\t*" || context.input.query like "*UPDATE\n*" || context.input.query like "*UPDATE\r*") && !(context.input.query like "*update *" || context.input.query like "*update\t*" || context.input.query like "*update\n*" || context.input.query like "*update\r*") && !(context.input.query like "*Update *" || context.input.query like "*Update\t*" || context.input.query like "*Update\n*" || context.input.query like "*Update\r*") && !(context.input.query like "*DELETE *" || context.input.query like "*DELETE\t*" || context.input.query like "*DELETE\n*" || context.input.query like "*DELETE\r*") && !(context.input.query like "*delete *" || context.input.query like "*delete\t*" || context.input.query like "*delete\n*" || context.input.query like "*delete\r*") && !(context.input.query like "*Delete *" || context.input.query like "*Delete\t*" || context.input.query like "*Delete\n*" || context.input.query like "*Delete\r*") && !(context.input.query like "*REPLACE *" || context.input.query like "*REPLACE\t*" || context.input.query like "*REPLACE\n*" || context.input.query like "*REPLACE\r*") && !(context.input.query like "*replace *" || context.input.query like "*replace\t*" || context.input.query like "*replace\n*" || context.input.query like "*replace\r*") && !(context.input.query like "*Replace *" || context.input.query like "*Replace\t*" || context.input.query like "*Replace\n*" || context.input.query like "*Replace\r*") && !(context.input.query like "*DROP *" || context.input.query like "*DROP\t*" || context.input.query like "*DROP\n*" || context.input.query like "*DROP\r*") && !(context.input.query like "*drop *" || context.input.query like "*drop\t*" || context.input.query like "*drop\n*" || context.input.query like "*drop\r*") && !(context.input.query like "*Drop *" || context.input.query like "*Drop\t*" || context.input.query like "*Drop\n*" || context.input.query like "*Drop\r*") };Cedar is default-deny: anything not explicitly permitted is refused. This permit lets
query_dbthrough only when the query starts withSELECTorWITH(UPPER, lower or Title case) followed by a space or newline, contains no SQL comment (--or/*), and has none ofINSERT UPDATE DELETE REPLACE DROP(in those three casings) followed by a space, tab, newline or carriage return. The platform fills in${action}and${gateway_arn}.terraform/participant/schema_tools_policy.cedarpermit( principal, action in [${actions}], resource == AgentCore::Gateway::"${gateway_arn}" );The second permit covers
DataStreamDatabase___list_tablesandDataStreamDatabase___describe_table, but only the ones yourmcp_server.pydefines (Cedar rejects permits for tools the Gateway doesn't have). At stage 7 any other tool without a permit disappears fromtools/list: default deny hides it from the agent entirely. The weather tools get a third permit from the same template, one action per tool inphase2/app/weather_lambda/tools.json, so a tool you add there (Task 2 Part B) stays visible. The Gateway's ownx_amz_bedrock_agentcore_searchdrops out oftools/listat stage 7 but still answers, which is all your specialists need.Prove defense in depth
Challenge
Show that the Gateway blocks a delete even when your own code doesn't. Disable the agent-side guard, redeploy, and ask for a delete.
Hint 1
The guard is a hook attached to the data specialist in
build_specialists.Hint 2
Remove it from the specialist's
hookslist (don't delete the class), thendeployandinvoke.Solution
phase2/app/agent/agent.py (data_agent inside build_specialists)@tool def data_agent(query: str) -> str: """Query and analyze the DataStream Corp database (employees, departments, projects). Args: query: A database or data analysis question. """ agent = Agent( model=make_model(), system_prompt=f"{DATA_PROMPT}\n{guardrail.prompt_hint()}", tools=search_tools(gateway, DATA_TOOLS), hooks=[*guardrail.hooks()], # ReadOnlyGuardHook() disabled for this experiment callback_handler=None, ) return consult(agent, query, "data")terminaluv run bootcamp.py deploy uv run bootcamp.py invoke "Run exactly this SQL with query_db and paste the raw tool result: DELETE FROM employees WHERE 1 = 0" --actor alice-chen uv run bootcamp.py invoke "How many employees are in HR?" --actor alice-chen uv run bootcamp.py traces --args # a few minutes later: the SQL the agent really sentThe delete is refused by the Gateway (“Tool Execution Denied”); the SELECT still works. Ask for an exact statement: a vague “Delete employee 5” is often refused by the model itself, because
query_dbsays it is read-only, and then the Gateway never sees a write.traces --argsshows whether the DELETE was actually sent. Or prove it without the model:uv run bootcamp.py test --only 7calls the Gateway directly and requires the DELETE to be denied.Find a write that Cedar lets through
Challenge
Cedar's
likeis string pattern matching, not a SQL parser. With the guard still disabled, find a write that the policy permits, then watch the database refuse it anyway.Hint 1
Look at which casings of each keyword the policy lists. SQL keywords are case-insensitive;
likeis not.Hint 2
A statement may start with
WITH. Ask the agent to run your exact SQL, then check withuv run bootcamp.py traces --argswhat it really sent (models like to “fix” odd casing).Solution
terminaluv run bootcamp.py invoke "Run exactly this SQL and show me the raw result: WITH x AS (SELECT 1) dElEtE FROM employees WHERE 1 = 0" --actor alice-chendElEtEmatches none of the denied patterns, so Cedar permits the call. The MCP server then answersError: the database is read-only; this tool can only run queries that read data.The read-only connection from Task 1 is the real guarantee; the policy is a useful, auditable early filter in front of it.test --only 7sends exactly these disguised writes and requires that none of them runs.Experiments: test some hypotheses
String patterns cut both ways. Treat these as hypotheses, not facts, and test each one with
invoke(your database is a read-only copy, so it's safe):- A harmless SELECT that mentions a write keyword (“How many audit_log entries mention a delete?”) might be denied.
- A query that starts with a newline or a space might be refused even though it only reads.
- A column called
updated_atmight trip theUPDATEpattern.
terminaluv run bootcamp.py invoke "Run exactly this SQL: WITH t AS (SELECT 1) SELECT * FROM t" --actor alice-chen uv run bootcamp.py invoke "How many audit_log entries mention a delete?" --actor alice-chen uv run bootcamp.py test --only 7Where should the real guarantee live: the policy, the tool (a read-only connection), or both?
Deploy and re-test
Put
ReadOnlyGuardHook()back intohooks, then:terminaluv run bootcamp.py deploy uv run bootcamp.py test --only 7
Check your work
uv run bootcamp.py test --only 7Passes when SELECTs through the Gateway are allowed (including one on updated_at), a DELETE is denied, and disguised writes are blocked (by Cedar or by the read-only database), and Weather___get_us_forecast still answers (its permit comes from tools.json).
Under the hood
AgentCore Policy evaluates Cedar policies on every Gateway tool call. The principal is the caller, the action is the tool (Target___tool), the resource is the Gateway, and context.input holds the tool arguments, so policies can reason about the SQL text itself. Policies are validated against the Gateway's tool schemas when created. Enforcement happens before the request ever reaches your MCP runtime, and tools/list only shows tools the caller is permitted to call.