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.

25 minMedium
Alice’s ask

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.

Platform

What the platform provisions for you

terminal
uv run bootcamp.py up 7
  • A Cedar policy engine attached to your Gateway
  • A safe_query_policy that only permits read-looking SQL on DataStreamDatabase___query_db
  • A schema_tools permit for list_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).

You

What you do as a developer

  1. Read the policies

    terraform/participant/safe_query_policy.cedar
    permit(
      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_db through only when the query starts with SELECT or WITH (UPPER, lower or Title case) followed by a space or newline, contains no SQL comment (-- or /*), and has none of INSERT 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.cedar
    permit(
      principal,
      action in [${actions}],
      resource == AgentCore::Gateway::"${gateway_arn}"
    );

    The second permit covers DataStreamDatabase___list_tables and DataStreamDatabase___describe_table, but only the ones your mcp_server.py defines (Cedar rejects permits for tools the Gateway doesn't have). At stage 7 any other tool without a permit disappears from tools/list: default deny hides it from the agent entirely. The weather tools get a third permit from the same template, one action per tool in phase2/app/weather_lambda/tools.json, so a tool you add there (Task 2 Part B) stays visible. The Gateway's own x_amz_bedrock_agentcore_search drops out of tools/list at stage 7 but still answers, which is all your specialists need.

  2. 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 hooks list (don't delete the class), then deploy and invoke.

    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")
    terminal
    uv 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 sent

    The 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_db says it is read-only, and then the Gateway never sees a write. traces --args shows whether the DELETE was actually sent. Or prove it without the model: uv run bootcamp.py test --only 7 calls the Gateway directly and requires the DELETE to be denied.

  3. Find a write that Cedar lets through

    Challenge

    Cedar's like is 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; like is not.

    Hint 2

    A statement may start with WITH. Ask the agent to run your exact SQL, then check with uv run bootcamp.py traces --args what it really sent (models like to “fix” odd casing).

    Solution
    terminal
    uv 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-chen

    dElEtE matches none of the denied patterns, so Cedar permits the call. The MCP server then answers Error: 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 7 sends exactly these disguised writes and requires that none of them runs.

  4. 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_at might trip the UPDATE pattern.
    terminal
    uv 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 7

    Where should the real guarantee live: the policy, the tool (a read-only connection), or both?

  5. Deploy and re-test

    Put ReadOnlyGuardHook() back into hooks, then:

    terminal
    uv run bootcamp.py deploy
    uv run bootcamp.py test --only 7

Check your work

terminal
uv run bootcamp.py test --only 7

Passes 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.