AWS Bedrock Guardrails — Implementing an Allowlist

AWS Bedrock Guardrails — Implementing an Allowlist

4 Practical Approaches

When building AI agents with AWS Bedrock, guardrails are your first line of defense for keeping conversations on topic. A common requirement is to restrict an agent to a pre-defined strict allowlist, blocking everything else.

Consider you’re building a financial assistant that should strictly answer questions about investments, markets, or personal finance. Your developer intuition would most likely tell you to strictly define what constitutes a question around investments, markets, etc., and then block any question that does not pass that test.

The problem? Bedrock Guardrails only supports denied topics. There is no native allowlist concept. This post walks through three approaches to work around that constraint.

The Core Problem#

AWS Bedrock Guardrails lets you define denied topics. These are categories of conversation that should be blocked. Each topic is described in natural language, optionally with example phrases, and the service uses that description to classify incoming content. The API schema makes the limitation explicit: the type field only accepts DENY. There is no ALLOW value.

Consider our previous example of a financial assistant. To express those constraints using Guardrails, your only tool is a denylist. There, you can only define what to block, but not what to allow. That’s the gap we need to work around.

{
      "name”: "Off-topic Requests",
      "definition": """
        Any discussion unrelated to investments, 
        personal finance, banking, credit, or financial markets 
        such as cooking, sports, travel, or general knowledge.
      """,
      "type": "DENY"
}

This looks reasonable at first, but it’s explicitly unsupported. The AWS Bedrock Guardrails documentation states:

Don’t define negative topics or exceptions. For example, ‘All contents except medical information’ or ‘Contents not containing medical information’ are negative definitions of a topic and must not be used.

The definition above, “Any discussion unrelated to investments…”, is exactly this anti-pattern. AWS Bedrock Guardrails require clear, affirmative definitions of what to detect. A negation like “everything that is not finance” falls outside what the classifier is designed to handle, and AWS makes no guarantees about its behavior.

Beyond being unsupported, it also doesn’t scale. The allowed set (financial topics) is small and well-defined, while its complement is essentially infinite. AWS Bedrock Guardrails have a hard limit of 30 denied topics per guardrail, so even if you tried to enumerate off-topic subjects you’d exhaust your budget long before getting close to complete coverage.

So, if you need a true allowlist, you have to get creative.

Approach 1 — System Prompt as Allowlist#

The simplest option: define the allowed topics directly in the system prompt and instruct the agent to respond with a static fallback message for anything outside that scope.

from strands import Agent

SYSTEM_PROMPT = """
You are a financial assistant. You may ONLY answer questions about:
- Investments (stocks, bonds, ETFs, mutual funds)
- Personal finance (budgeting, saving, retirement planning)
- Banking and credit
- Financial markets and economic indicators

If the user asks about anything outside the financial domain, respond exactly with:
"I'm only able to help with financial topics. Please ask me about investments, banking, or personal finance."
Do not deviate from this message for off-topic requests.
"""

agent = Agent(system_prompt=SYSTEM_PROMPT)

Pros: Simple to implement, keeps the standard integration with AWS Bedrock Guardrails intact, no extra API calls.

Cons: Relies on the LLM to enforce the boundary. A sufficiently creative prompt might coax the model into going off-script. It’s a soft boundary, not an infrastructure-level one.

Approach 2 — Invert the Denied Topics List#

This is the clever one. Since AWS Bedrock Guardrails can only deny, the trick is to define your allowed topics as denied topics. Then you’d call the ApplyGuardrail API directly, before the agent, using the guardrail’s intervention as a signal that the topic is allowed.

This is useful because ApplyGuardrail runs independently from model inference. You can evaluate the user request before retrieval, tool execution, or the agent loop begins, instead of relying on the agent itself to decide whether the request is in scope.

The idea is to populate the denied topics list with your allowed topics. In this case, financial subjects like investments, banking, and personal finance. Then call ApplyGuardrail directly before the agent and invert the result:

Flowchart for an allowlist built by inverting Bedrock Guardrails: matching financial topics are allowed and unmatched topics are blocked.
Allowlist decision flow: ApplyGuardrail intervention means the matched financial topic is allowed; no intervention means the input is blocked.
  1. Call ApplyGuardrail with the user’s input
  2. If the guardrail intervenes → the input matched a financial topic → it’s within the allowed domain → let it through
  3. If the guardrail does not intervene → the input didn’t match any financial topic → it’s off-topic → block it
import boto3
bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")

def is_allowed_topic(user_input: str) -> bool:
    response = bedrock.apply_guardrail(
        guardrailIdentifier="your-guardrail-id",
        guardrailVersion="DRAFT",
        source="INPUT",
        content=[{"text": {"text": user_input}}],
    )
    # Guardrail intervenes = matched a financial topic = within allowed domain = allow it
    # Guardrail silent = no financial topic matched = off-topic = block it
    return response["action"] == "GUARDRAIL_INTERVENED"

Pros: Pre-model gating, operating independently of the main agent’s instructions and subsequent behaviour. This offers flexibility in working with the guardrails to suit your needs. You can apply the same guardrail for content safety policies like PII, violence and prompt injection or use two different ones: one for permitted topics and another for content policies.

Cons: There is a hard limit of 30 denied topics per guardrail, so this only works if your “allowed-list” is enumerable.

Note: AWS themselves documented this exact pattern in a Builder Center article.

Approach 3 — Strands Hook + Inverted AWS Bedrock Guardrails#

This approach leans into the Strands SDK’s hook system to intercept the request before the agent invocation begins.

Strands provides a BeforeInvocationEvent that fires once per user request, before the agent loop starts. The event exposes both a messages field (the full conversation so far) and a cancel field: set it, and the invocation is aborted with your custom message.

Note: BeforeModelCallEvent also has a cancel field but fires before every model call, including intermediate tool-use rounds, and does not expose messages. For topic classification, BeforeInvocationEvent is the right hook: it runs once, at the entry point.

from strands import Agent
from strands.hooks import BeforeInvocationEvent

async def topic_guard_hook(event: BeforeInvocationEvent) -> None:
    user_input = event.messages[-1]["content"]

    if not await is_allowed_topic(user_input):
        event.cancel = "I'm only able to help with financial topics. Please ask me about investments, banking, or personal finance."

agent = Agent(system_prompt=SYSTEM_PROMPT)
agent.add_hook(topic_guard_hook, BeforeInvocationEvent)

Making It Fast: Async Parallel Checks#

The real win here is that you can run both the allowlist classification and the content safety guardrail concurrently, so total latency is the max of the two, not their sum.

For this, you can use asyncio.to_thread to make apply_guardrail awaitable:

import asyncio
import boto3
from strands import Agent
from strands.hooks import BeforeInvocationEvent

bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")

async def apply_guardrail_async(text: str, guardrail_id: str, version: str, source: str) -> dict:
    return await asyncio.to_thread(
        bedrock.apply_guardrail,
        guardrailIdentifier=guardrail_id,
        guardrailVersion=version,
        source=source,
        content=[{"text": {"text": text}}]
    )

async def topic_guard_hook(event: BeforeInvocationEvent) -> None:
    user_input = event.messages[-1]["content"]

    # Run both checks concurrently
    allowlist_check, content_safety_check = await asyncio.gather(
        apply_guardrail_async(user_input, "allowlist-guardrail-id", "DRAFT", "INPUT"),
        apply_guardrail_async(user_input, "content-safety-guardrail-id", "DRAFT", "INPUT"),
    )

    # Allowlist: guardrail silent = no financial topic matched = off-topic (inverted logic from Approach B)
    if allowlist_check["action"] == "NONE":
        event.cancel = "I'm only able to help with financial topics. Please ask me about investments, banking, or personal finance."
        return

    # Content safety: standard logic — intervenes = blocked content
    if content_safety_check["action"] == "GUARDRAIL_INTERVENED":
        event.cancel = "I'm unable to respond to that request."

agent = Agent(system_prompt=SYSTEM_PROMPT)
agent.add_hook(topic_guard_hook, BeforeInvocationEvent)

Approach 4 — Strands Hook + Lightweight LLM#

This one is a variant of the Hooks approach. It still uses the BeforeInvocationEvent to filter the content, however, instead of inverting the Guardrails denied list as your allowed list, you can use a fast model like Claude Haiku 4.5 as your topic classifier. This gives you more flexibility in defining the allowed domain without being constrained to 30 topics, and the natural-language definition can be more nuanced.

import asyncio
import json
import boto3

bedrock = boto3.client("bedrock-runtime", region_name="us-east-1")

CLASSIFIER_PROMPT = """
You are a topic classifier for a financial assistant.
Determine if the user's question is within the financial domain.

The financial domain includes: investments (stocks, bonds, ETFs), personal finance,
banking, credit, financial markets, and economic indicators.

Respond with JSON only: {"allowed": true} or {"allowed": false}
"""

async def classify_topic_with_llm(user_input: str) -> bool:
    response = await asyncio.to_thread(
        bedrock.converse,
        modelId="us.anthropic.claude-haiku-4-5-20251001-v1:0",
        system=[{"text": CLASSIFIER_PROMPT}],
        messages=[{"role": "user", "content": [{"text": user_input}]}],
        inferenceConfig={"maxTokens": 50, "temperature": 0},
    )
    result = json.loads(response["output"]["message"]["content"][0]["text"])
    return result.get("allowed", False)

async def topic_guard_hook(event: BeforeInvocationEvent) -> None:
    user_input = event.messages[-1]["content"]

    # Run allowlist check (LLM) and content safety check (Guardrails) concurrently
    is_allowed, safety_check = await asyncio.gather(
        classify_topic_with_llm(user_input),
        apply_guardrail_async(user_input, "content-safety-guardrail-id", "DRAFT", "INPUT"),
    )

    if not is_allowed:
        event.cancel = "I'm only able to help with financial topics. Please ask me about investments, banking, or personal finance."
        return

    if safety_check["action"] == "GUARDRAIL_INTERVENED":
        event.cancel = "I'm unable to respond to that request."

Pros: Pre-model enforcement (no LLM tokens wasted on blocked requests), concurrent execution keeps latency low, clean separation of allowlist logic from content safety, flexible classifier definition.

Cons: Adds a classifier LLM call to every request (cost and latency of Haiku is low but non-zero). The Guardrails inverted-logic variant might be cheaper but inherits the 30-topic limit.

Comparing the Four Approaches#

ApproachBest forEnforcement pointTopic scopeExtra latencyMain trade-off
System promptQuick prototypesInside the main LLMFlexibleNoneEasy, but relies on the model following instructions
Inverted GuardrailsSmall, well-defined domainsBefore the agent/modelUp to 30 denied topics+1 ApplyGuardrail callModel-independent from the main LLM, but still classifier-based
App/Strands hook + Inverted GuardrailsProduction agent flows with small domainsBefore the agent loopUp to 30 denied topics+1 ApplyGuardrail call; can run alongside safety checksClean lifecycle integration, but inherits Guardrails topic limits
App/Strands hook + LLM classifierBroader or nuanced domainsBefore the agent loopFlexible+1 lightweight model call; can run alongside safety checksMore flexible, but adds cost and another classifier to evaluate

Note: Strands hooks are not required for the inverted Guardrails pattern. They are an integration point for Strands-based agents, letting you run the check before the agent loop starts and cancel the invocation when needed.

Closing Thoughts#

Bedrock Guardrails does not currently provide a native “allow only these topics” control. But by combining ApplyGuardrail, inverted denied-topic logic, and Strands hooks, you can enforce topic boundaries before the agent loop starts.

The key design choice is whether your allowed domain is small enough to enumerate. If it is, inverted Guardrails are simple and model-independent. If it is not, a lightweight classifier gives you more flexibility, at the cost of an extra model call.

In either case, treat the allowlist check and safety guardrails as separate layers. Topic scoping decides whether the request belongs to your product domain. Safety guardrails decide whether the request is acceptable to process at all.

If you’ve hit this constraint before or found another workaround that was not covered here, we’d love to hear about it.

References#

Originally published on Loka Engineering on Medium.

Tags

Topics