Exam Room · Advanced Generative AI Developer

Lab: Put a Guardrail in Front of a Bedrock Model

· 13 min read

Generative AI Development · part of The Exam Room

This is one of the hands-on labs that run alongside these posts. The idea is simple: you get a working base and build the part that matters. This is the second lab, and the scaffolding is still high, you fill one small gap. Later labs hand you less, until the last one gives you only data and a requirement.

The full lab, CloudFormation and scripts, is in lab-02-guardrail.zip. Download it, unpack, and follow the README; this post is the walk-through and the why.

Before your first lab, do the one-time, once-per-account setup: run the zip’s preflight.sh to confirm your account is ready, then deploy the lab reaper, a standing backstop that auto-deletes any lab you forget to tear down after 24 hours.

The scenario

The invoke-a-model function from Lab 01 works: a Lambda takes a prompt, calls a Bedrock model through the Converse API, and returns the answer. Now it needs a safety layer. Compliance will not sign off while the app still answers questions about which stock to buy, and support tickets pasted into prompts sometimes carry customer emails and phone numbers that should never reach the model or the logs.

An Amazon Bedrock Guardrail screens both directions in one place, independent of the model. You configure it once and apply it on every call.

What you’re given

The CloudFormation template builds the Lab 01 function plus an AWS::Bedrock::Guardrail with three things switched on:

  • a denied topic, FinancialAdvice, given a short definition and a couple of sample phrases (five is the maximum);
  • content filters for hate and violence at high strength, plus the prompt-attack filter, high on input and NONE on output because that filter evaluates prompts and not responses (CloudFormation requires both strength fields on every filter, whatever the type);
  • PII anonymisation for the EMAIL and PHONE entity types.

A second resource, AWS::Bedrock::GuardrailVersion, publishes version 1 of it. That resource is separate because the guardrail resource itself only ever reports DRAFT; versions are numbered snapshots, from 1 upwards. The template also grants the function bedrock:ApplyGuardrail scoped to that one guardrail ARN, alongside the bedrock:InvokeModel it already had. The guardrail id and version arrive at the function as environment variables. Everything is built. The one thing missing is the wiring.

Lab 02 solution architecture A CloudFormation stack contains a Lambda function, an IAM execution role, and an Amazon Bedrock Guardrail published as version 1. The Lambda calls Converse with a guardrailConfig, so the guardrail screens the prompt on the way in to Nova Lite and screens the completion on the way back. The model sits outside the stack in Amazon Bedrock, serverless and billed per token. CloudFormation stack: genai-lab-02 Amazon Bedrock serverless, billed per token A prompt, HTTP-shaped JSON in and back out Lambda function handler.py Converse, guardrailConfig Guardrail denied topic, content filters, PII masking published as version 1 the prompt, screened the completion, screened Nova Lite or any model id you pass Execution role bedrock:InvokeModel, plus ApplyGuardrail on this guardrail

Your task

In src/handler.py, the Converse call is already there. You add the guardrail to it and check whether it fired. Two small edits: pass a guardrailConfig argument on the converse() call, carrying the guardrail identifier and version the stack hands you in environment variables, with the trace enabled so the guardrail’s assessments come back in the response. Then read the response’s stopReason; a blocked call reports guardrail_intervened there, and you return that as guarded alongside the answer so the caller can tell a blocked reply from a real one. The docstring in src/handler.py has the exact shapes.

That is the whole change. The guardrail now evaluates every prompt on the way in and every completion on the way out.

Deploy and prove it

cd lab-02-guardrail
./scripts/deploy.sh
./scripts/test.sh

The test script sends three prompts. A benign one (“what is Amazon Bedrock?”) gets a normal answer back, with guarded: false. A financial-advice one (“should I put my savings into Tesla stock?”) comes back with the configured blocked message and guarded: true, because the Denied topicsSubjects you describe in plain language that a Bedrock Guardrail refuses to discuss, whichever way a user phrases the request. matched on the input, and Bedrock discards the model call when an input is blocked.

The third one carries a fake email address and phone number and asks the model to repeat the line back word for word. What comes back is {EMAIL} and {PHONE}, the entity types the anonymise action substitutes for the values it detects. Masking is not blocking: the call completes and the content comes back with the values substituted rather than replaced by the configured message, so the echo shows you the version of the prompt the model was actually given. Getting the model to read its own input back is the simplest way to watch that happen. AWS documents stopReason becoming guardrail_intervened when the guardrail blocks, and does not publish what a mask-only call reports there, so treat guarded as a block signal and read masking off the trace, where the entity arrives with action set to ANONYMIZED rather than BLOCKED. The "trace": "enabled" setting is already populating response["trace"] and the handler throws it away; read that one field if you need it, but do not log the whole trace, because its match field carries the original PII value rather than the masked one, by design.

When you are done:

./scripts/teardown.sh

When you want the reference answer, deploy it without editing anything (SRC=solution ./scripts/deploy.sh), or unfold it here:

Show the answer
response = _bedrock.converse(
    modelId=MODEL_ID,
    messages=[{"role": "user", "content": [{"text": prompt}]}],
    inferenceConfig={"maxTokens": 512, "temperature": 0.2},
    guardrailConfig={
        "guardrailIdentifier": GUARDRAIL_ID,
        "guardrailVersion": GUARDRAIL_VERSION,
        "trace": "enabled",
    },
)

guarded = response.get("stopReason") == "guardrail_intervened"
answer = response["output"]["message"]["content"][0]["text"]

What the guardrail is actually doing

The two lines of Python matter less than the arrangement they create:

  • A guardrail is a separate resource from the model. The same guardrail sits in front of any model you call, and swapping the model does not change the safety policy. That separation is why applying it is its own IAM action, bedrock:ApplyGuardrail, which you can grant and scope on its own.
  • It works in both directions. Denied topics, content filters and PII rules all run on the prompt and on the response. Two policies are one-way: prompt-attack detection runs on the input only, and Contextual grounding checkA Guardrail check that tests an answer against the documents it was given and flags claims the source doesn’t support. need a model response to score, so they run on the output only.
  • The runtime reports a block. stopReason becomes guardrail_intervened and the content is replaced with the message you configured, so your app logs the intervention and shows the safe text rather than inferring a block from the wording. Masking is a different outcome: the values arrive substituted and the trace’s assessment reads ANONYMIZED, which is the field to look at when you need to tell the two apart.
  • Applying a published version, not DRAFT, is the production habit. A version is immutable, so editing the policy leaves your live app enforcing version 1 until you publish a new version and point the app at it.

What’s worth remembering

  1. Guardrails are model-independent. Configure one guardrail once, apply it on every call, and reuse it across models.
  2. ApplyGuardrail is its own permission. bedrock:ApplyGuardrail scopes guardrail use separately from bedrock:InvokeModel.
  3. Some checks are one-way. Denied topics, content filters and PII rules run on prompt and response; prompt-attack detection is input-only, grounding checks output-only.
  4. Read stopReason for a block. guardrail_intervened signals one; masking is not a block, and the trace’s ANONYMIZED action is where it shows.
  5. Apply a numbered version. Versions are immutable, so a policy change takes a deliberate publish and repoint, not an edit to the draft.
  6. Safety is infrastructure. The guardrail deploys, versions and tears down with the stack, not as a prompt afterthought.

These posts are LLM-aided. Backbone, original writing, and structure by Craig. Research and editing by Craig + LLM. Proof-reading by Craig.