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 can be talked into recommending 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, defined with a short description and a couple of examples; - content filters for hate, violence, and prompt attacks, set to high strength;
- PII anonymisation for email addresses and phone numbers.
It also publishes a GuardrailVersion (an immutable, numbered snapshot) and grants the function bedrock:ApplyGuardrail scoped to that one guardrail. The guardrail id and version arrive at the function as environment variables. Everything is built. The one thing missing is the wiring.
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 decisions get recorded. Then read the response’s stopReason; when the guardrail stepped in it says so, and you return that as guarded alongside the answer so the caller can tell a refusal from a real reply. The docstring in src/handler.py has the exact shapes.
That is the whole change. The guardrail now sees 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?”) answers normally with guarded: false. A financial-advice one (“should I put my savings into Tesla stock?”) comes back with the configured refusal 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.
caught it before the model ever answered.
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 guardrail anonymised both on the way in, so the echo shows you the version the model was actually given. Masking happens between your function and the model, out of sight, and getting the model to read its own input back is the cheapest way to watch it happen. The other way is to return response["trace"], which the "trace": "enabled" setting is already populating and the handler currently throws away.
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 point of the lab is the shape of the thing, not the two lines of Python:
- 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 and prompt-attack filters screen the input; content filters and Contextual grounding checkA Guardrail check that tests an answer against the documents it was given and flags claims the source doesn’t support. screen the output; PII rules apply either way. One resource, both sides of the call.
- The runtime tells you it acted.
stopReasonbecomesguardrail_intervened, and the content is replaced with your configured message. Your app logs the intervention and shows the safe text instead of the raw model output, rather than guessing from the wording. - Applying a published version, not
DRAFT, is the production habit. A version is immutable, so a policy edit cannot silently change what your live app enforces until you promote a new version.
What’s worth remembering
- A guardrail is model-independent: configure once, apply on every call, reuse across models.
bedrock:ApplyGuardrailis a distinct permission, so you can scope guardrail use separately from model invocation.- The guardrail screens input and output together: denied topics and prompt-attack filters in, content and grounding checks out, PII either way.
stopReason == "guardrail_intervened"is how the runtime signals a block or mask; read it rather than pattern-matching the text.- Apply a numbered version in production so a policy change is a deliberate promotion, not a silent edit.
- The safety layer is infrastructure: it deploys, versions, and tears down with the stack, not as an afterthought in the prompt.