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, which carries an input strength only because it runs on prompts and not on responses;
- PII anonymisation for the
EMAILandPHONEentity 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.
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 runs to completion, so guarded stays false and 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. You could instead return response["trace"], which the "trace": "enabled" setting is already populating and the handler currently throws away, but do not ship that: the trace’s match field carries the original PII value rather than the masked one, by design, so logging the trace puts back exactly what the guardrail took out.
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.
stopReasonbecomesguardrail_intervenedand 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. Anonymisation does not show up there; the call completes normally with the values substituted. - 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
- 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.- Denied topics, content filters and PII rules run on both the prompt and the response; prompt-attack detection is input-only and grounding checks are output-only.
stopReason == "guardrail_intervened"is how the runtime signals a block; read it rather than pattern-matching the text. A masked prompt is not a block, and the call completes normally.- Apply a numbered version in production, so changing the policy takes a deliberate publish-and-repoint rather than an edit to the draft.
- The safety layer is infrastructure: it deploys, versions, and tears down with the stack, not as an afterthought in the prompt.