Módulo 4: Bedrock Guardrails And Defense In Depth
8. Proyecto: la capa de guardrails de Andes Cargo
Descripción
Las siete lecciones anteriores construyeron, por partes, la defensa en profundidad completa de extract-shipment-manifest-fields: las seis políticas explicadas (lección 2) y declaradas en HCL real (lección 3), la razón exacta por la que eso no basta solo (lección 4), y los dos chequeos propios, deterministas (lecciones 5 y 6), con el límite honesto de lo que ninguno de los dos puede probar (lección 7). Este proyecto integra las tres piezas —guardrail gestionado, scrubber de PII, validador de esquema— en un solo flujo ejecutable, corrido de verdad, y cierra con el ledger de honestidad que distingue, sin ambigüedad, qué de este módulo corrió de verdad y qué quedó representativo.
Conexión con el módulo
Como cada proyecto de cierre de esta guía, este entregable no introduce ningún concepto nuevo — reutiliza, integrado, exactamente lo que las siete lecciones anteriores ya construyeron y probaron por separado. El Módulo 5 retoma este mismo guardrail y estos mismos dos scripts para extenderlos con el gate de seguridad heredado de cloud-security-and-guardrails-guide.
Paso 1 — El guardrail gestionado, en su forma final
bedrock.tf (lección 3), sin cambios respecto a lo ya construido — el módulo manifest_extractor_guardrail con las seis entradas activas:
module "manifest_extractor_guardrail" {
source = "./modules/bedrock-guardrail"
name = "andes-cargo-manifest-extractor-guardrail"
blocked_input_messaging = "This input is not allowed due to content policy violations."
blocked_outputs_messaging = "This output is not allowed due to content policy violations."
content_filters = [
{ type = "PROMPT_ATTACK", input_strength = "HIGH", output_strength = "NONE" }
]
pii_entities = [
{ type = "EMAIL", action = "ANONYMIZE" },
{ type = "PHONE", action = "ANONYMIZE" }
]
denied_topics = [
{
name = "ProhibitedShipmentGuidance"
definition = "Guidance, instructions, or advice about smuggling, evading customs inspections, or shipping illegal, prohibited, or undeclared goods."
examples = [
"How do I hide undeclared goods from customs inspection?",
"What is the best way to avoid a customs check on this shipment?",
]
}
]
grounding_filters = [
{ type = "GROUNDING", threshold = 0.75 },
{ type = "RELEVANCE", threshold = 0.75 },
]
managed_word_lists = ["PROFANITY"]
custom_words = ["undisclosed cargo", "avoid inspection"]
tags = local.common_tags
}
modules/bedrock-guardrail/ completo —dos políticas heredadas del Módulo 3, tres agregadas en la lección 3 de este módulo—, verificado con la misma secuencia de siempre:
cd andes-cargo-infra/
terraform fmt -check -recursive; echo "fmt exit: $?"
terraform validate
terraform plan -input=false -no-color -out=tfplan-m4-final
Qué esperar (literal — corrido de verdad, sin LocalStack, sin cuenta AWS, en este entorno, para cerrar este módulo):
fmt exit: 0
Success! The configuration is valid.
Plan: 17 to add, 0 to change, 0 to destroy.
El mismo número exacto que el Módulo 3 y la lección 3 de este módulo ya confirmaron — ninguna infraestructura nueva, solo profundidad de políticas dentro del recurso ya declarado.
Paso 2 — Los dos chequeos propios, integrados en un solo flujo
guardrails/pre_invoke_checks.py y guardrails/post_invoke_checks.py (lecciones 5 y 6) son, cada uno, independientes y probados por separado. Este proyecto los une en guardrails/defense_in_depth_flow.py, el script que refleja el orden exacto en que extract-shipment-manifest-fields los usaría — con la llamada a Bedrock en el medio representada, nunca ejecutada:
#!/usr/bin/env python3
"""defense_in_depth_flow.py -- ties pre_invoke_checks.py and post_invoke_checks.py
around the exact point where extract-shipment-manifest-fields would call
bedrock:InvokeModel, for Module 4's capstone project (lesson 8).
This script demonstrates INTEGRATION, not invocation. The "model response" is
always a fixed, hardcoded, representative dict supplied by the caller -- never
the output of a real Bedrock call. See Module 4, lesson 7 for why no real
call happens anywhere in this guide. Everything BEFORE and AFTER that
hardcoded stand-in -- the PII scrub, the ShipmentFields schema validation, and
the final decision of whether the candidate would be written to Shipments --
runs for real, is 100% deterministic, and is covered by pytest below.
"""
from __future__ import annotations
from dataclasses import dataclass
from post_invoke_checks import validate_shipment_fields
from pre_invoke_checks import scrub_pii
@dataclass(frozen=True)
class ExtractionOutcome:
scrub_found_pii: bool
redacted_text: str
schema_valid: bool
schema_errors: tuple[str, ...]
would_write_to_shipments: bool
def run_defense_in_depth(raw_manifest_text: str, representative_model_response: dict) -> ExtractionOutcome:
"""Mirrors the order extract-shipment-manifest-fields would run in:
1. pre_invoke_checks.scrub_pii() over the raw manifest text -- REAL, always.
2. [NOT RUN HERE] bedrock:InvokeModel, with the guardrail of lesson 3
referenced -- represented by the caller-supplied
`representative_model_response` dict, never invoked.
3. post_invoke_checks.validate_shipment_fields() over that response --
REAL, always. This function has no idea whether its input came from a
real invocation or a fixture; that independence is the entire point of
testing it in isolation in lesson 6.
4. The write-to-Shipments decision: only if step 3 passed.
"""
scrub_result = scrub_pii(raw_manifest_text)
validation_result = validate_shipment_fields(representative_model_response)
return ExtractionOutcome(
scrub_found_pii=scrub_result.found_pii,
redacted_text=scrub_result.redacted_text,
schema_valid=validation_result.is_valid,
schema_errors=validation_result.errors,
would_write_to_shipments=validation_result.is_valid,
)
def format_outcome(outcome: ExtractionOutcome) -> str:
lines = [
f"pre_invoke_checks: {'PII FOUND (redacted)' if outcome.scrub_found_pii else 'CLEAN'}",
f"post_invoke_checks: {'PASS' if outcome.schema_valid else 'FAIL'}",
]
for err in outcome.schema_errors:
lines.append(f" - {err}")
decision = "WRITE to Shipments" if outcome.would_write_to_shipments else "DO NOT WRITE to Shipments"
lines.append(f"DECISION: {decision}")
return "\n".join(lines)
Fíjate en el comentario del paso 2 dentro de run_defense_in_depth(): [NOT RUN HERE] — la etiqueta más explícita posible, en el código mismo, del punto exacto donde una invocación real ocurriría en producción, y donde este proyecto se detiene con toda intención.
Paso 3 — Dos escenarios, corridos de verdad
if __name__ == "__main__":
# Scenario A: the deterministic path's own manifest (4471), no PII, and a
# representative model response that DOES match ShipmentFields exactly.
scenario_a = run_defense_in_depth(
raw_manifest_text=(
"shipmentId=4471\noriginCountry=Peru\ndestinationCountry=Chile\n"
"carrier=AndesExpress\nweightKg=120"
),
representative_model_response={
"shipmentId": "4471", "originCountry": "Peru",
"destinationCountry": "Chile", "carrier": "AndesExpress",
"weightKg": "120",
},
)
print("=== Scenario A: well-formed manifest, valid representative response ===")
print(format_outcome(scenario_a))
print()
# Scenario B: a free-text manifest carrying an incidental email, and a
# representative model response missing weightKg -- the exact Brecha 2
# case from lesson 4/6.
scenario_b = run_defense_in_depth(
raw_manifest_text=(
"Hi team, following up on shipment AC-4471. Contact me at "
"ana.rojas@andescargo.com or call +51 987 654 321 if you need "
"anything. AndesExpress handles pickup Thursday."
),
representative_model_response={
"shipmentId": "4471", "originCountry": "Peru",
"destinationCountry": "Chile", "carrier": "AndesExpress",
},
)
print("=== Scenario B: free-text manifest with PII, incomplete representative response ===")
print(format_outcome(scenario_b))
python3 guardrails/defense_in_depth_flow.py
Qué esperar (literal — corrido de verdad, mismo entorno de esta guía):
=== Scenario A: well-formed manifest, valid representative response ===
pre_invoke_checks: CLEAN
post_invoke_checks: PASS
DECISION: WRITE to Shipments
=== Scenario B: free-text manifest with PII, incomplete representative response ===
pre_invoke_checks: PII FOUND (redacted)
post_invoke_checks: FAIL
- missing required field(s): weightKg
DECISION: DO NOT WRITE to Shipments
El Escenario B es la confirmación práctica de una regla que vale la pena decir en voz alta: encontrar PII nunca, por sí solo, detiene la decisión de escribir —el correo se redacta y el flujo continúa, exactamente como la lección 5 diseñó pre_invoke_checks.py—; lo que sí la detiene es la forma incorrecta de la respuesta, sin relación alguna con la presencia de PII en la entrada. Las dos capas evalúan cosas distintas, y esta corrida lo demuestra con datos reales, no en abstracto.
Paso 4 — El suite completo de pytest, las tres piezas juntas
cd guardrails/
pytest -v -p no:randomly
Qué esperar (literal — corrido de verdad; primeros y últimos casos mostrados, 34 en total):
============================= test session starts ==============================
platform darwin -- Python 3.14.0, pytest-9.1.1, pluggy-1.6.0
collected 34 items
test_defense_in_depth_flow.py::test_well_formed_manifest_with_valid_response_writes_to_shipments PASSED [ 2%]
test_defense_in_depth_flow.py::test_pii_in_input_never_blocks_a_valid_response_from_being_written PASSED [ 5%]
test_defense_in_depth_flow.py::test_incomplete_response_is_never_written_regardless_of_input PASSED [ 8%]
test_defense_in_depth_flow.py::test_pii_and_incomplete_response_together_still_blocks_the_write PASSED [ 11%]
test_defense_in_depth_flow.py::test_redacted_text_never_contains_the_original_email PASSED [ 14%]
test_defense_in_depth_flow.py::test_format_outcome_reports_write_decision PASSED [ 17%]
test_defense_in_depth_flow.py::test_format_outcome_reports_do_not_write_decision PASSED [ 20%]
test_post_invoke_checks.py::test_valid_candidate_passes PASSED [ 23%]
...
test_pre_invoke_checks.py::test_cli_end_to_end_with_pii PASSED [ 97%]
test_pre_invoke_checks.py::test_cli_end_to_end_clean PASSED [100%]
============================== 34 passed in 0.02s ==============================
Siete casos nuevos de integración (test_defense_in_depth_flow.py) sumados a los once de pre_invoke_checks.py (lección 5) y los dieciséis de post_invoke_checks.py (lección 6) — 34 en total, cero fallos, en menos de tres centésimas de segundo. Vale la pena nombrar el caso más importante de los siete nuevos: test_pii_and_incomplete_response_together_still_blocks_the_write — ejecuta, en un solo test, exactamente el Escenario B del Paso 3, confirmando en código lo que ese ejemplo ya mostró de forma narrativa.
Paso 5 — El ledger de honestidad de este módulo
Igual que el Módulo 3 cerró con una tabla de seis filas, este proyecto cierra con la misma disciplina:
| Pieza | Comando | Estado | Razón exacta |
|---|---|---|---|
| Guardrail gestionado, 6 mecanismos / 5 políticas | terraform fmt/validate/plan | Ejecutado | Recurso nuevo — nunca necesita red (Módulo 3, lección 1) |
pre_invoke_checks.py (scrubber de PII) | pytest (11 casos) | Ejecutado | Python puro, regex, cero dependencias externas |
post_invoke_checks.py (validador de ShipmentFields) | pytest (16 casos) | Ejecutado | Python puro, cero dependencias externas |
defense_in_depth_flow.py (integración de los dos) | pytest (7 casos) + corrida directa | Ejecutado | La orquestación es real; el diccionario que representa la respuesta del modelo es un dato fijo, etiquetado |
| Bloqueo real de un ataque de prompt | — | Representativo | Requiere invocar bedrock:InvokeModel/Converse de verdad (lección 7) |
| Enmascarado real de una fuga de PII por el guardrail gestionado | — | Representativo | Misma razón — solo una invocación real lo confirmaría (lección 7) |
tflocal apply del guardrail contra LocalStack | — | Representativo | Bedrock — "Included in Plans: Ultimate" (Módulo 3, lección 6; reconfirmado lección 7 de este módulo) |
Ninguna fila de esta tabla invoca un modelo de Bedrock, en ningún punto — la regla más estricta de esta guía, sin una sola excepción, ni siquiera en este proyecto de cierre.
Errores comunes
Presentar defense_in_depth_flow.py como si demostrara que la extracción real de Andes Cargo funciona (de perder de vista que el diccionario de entrada es fijo, no generado). Qué pasa: alguien, mostrando este proyecto en un portafolio, describe el Paso 3 como "el extractor funcionando de punta a punta". Cómo detectarlo: si tu descripción de este proyecto no distingue entre "la orquestación de los dos chequeos es real" y "el diccionario de respuesta es un dato fijo, escrito a mano". Cómo corregirlo: el comentario del propio script ya lo dice, en el código —representative_model_response, con representative en el nombre del parámetro mismo, no un accidente—. La frase correcta: "construí y probé la integración de los dos chequeos propios alrededor del punto donde Bedrock se invocaría" — nunca "probé el extractor completo".
Concluir, del Escenario B del Paso 3, que encontrar PII "no importa" porque no bloqueó nada (de simplificar de más una regla con intención específica). Qué pasa: alguien, viendo que pre_invoke_checks: PII FOUND no cambió la decisión final en el Escenario B, concluye que el chequeo de PII es cosmético. Cómo detectarlo: si tu resumen de este proyecto es "el chequeo de PII no afecta nada, el que realmente decide es el de esquema". Cómo corregirlo: relee la lección 5 — el chequeo de PII sí actúa: redacta el texto antes de que avance, protegiendo cualquier log o almacenamiento intermedio de ese dato sensible. Que no bloquee la escritura final es una decisión de diseño explícita (evitar duplicar lo que ANONYMIZE ya hace en el guardrail gestionado), no evidencia de que el chequeo no haga nada.
Olvidar que los 17 recursos del Paso 1 son el proyecto andes-cargo-infra/ COMPLETO, no solo lo de este módulo (de perder el contexto acumulativo). Qué pasa: alguien, leyendo Plan: 17 to add, asume que este módulo, por sí solo, agrega 17 recursos nuevos. Cómo detectarlo: si tu conteo mental de "qué construyó el Módulo 4" incluye buckets S3, tablas DynamoDB, o funciones Lambda. Cómo corregirlo: como el Módulo 3, lección 8 ya estableció, 17 to add es el conteo del proyecto entero, heredado de los tres módulos anteriores — este módulo específico no agregó ningún recurso nuevo, solo tres bloques de política dentro de un recurso ya existente (lección 3). El número se repite exactamente igual, módulo tras módulo, precisamente porque nada se rompió ni se duplicó en el camino.
Ejercicios
Ejercicio 1 — Verifica, tú mismo, que cada fila del ledger de honestidad del Paso 5 coincide con la lección específica de este módulo que la sostiene.
Ver solución
Fila 1 (guardrail gestionado) → lección 3. Filas 2 y 3 (los dos chequeos propios) → lecciones 5 y 6, respectivamente. Fila 4 (la integración) → este mismo proyecto, Pasos 2-4. Filas 5 y 6 (bloqueo/enmascarado real) → lección 7. Fila 7 (apply contra LocalStack) → Módulo 3, lección 6, reconfirmada en la lección 7 de este módulo. Si alguna fila no encuentra su lección exacta, revisa que no se haya introducido una afirmación nueva sin evidencia — la misma disciplina de trazabilidad que el Módulo 3, lección 8 ya exigió de sí mismo.
Ejercicio 2 — Modifica, tú mismo, el Escenario A del Paso 3 para que la respuesta representativa tenga un campo confidenceScore adicional. Predice el resultado antes de correrlo.
Ver solución
schema_valid pasaría a False, con unexpected_fields = ("confidenceScore",) — exactamente el caso test_unexpected_extra_field_fails que la lección 6 ya probó de forma aislada, ahora visible a través de la integración completa. pre_invoke_checks seguiría reportando CLEAN (el manifiesto de entrada del Escenario A no cambió), pero la decisión final cambiaría a DO NOT WRITE to Shipments — la confirmación de que el chequeo de esquema, no el de PII, es el que gobierna la decisión final en este caso específico.
Ejercicio 3 — Explica, a un entrevistador técnico hipotético, qué demuestra este proyecto sobre la arquitectura de defensa en profundidad, sin usar la palabra "representativo" más de una vez.
Ver solución
Una respuesta completa suena, más o menos, así: "Este proyecto integra un guardrail gestionado de AWS, con seis mecanismos de seguridad de contenido declarados y verificados con terraform plan, junto a dos chequeos propios y deterministas que corren completamente por fuera del control de AWS — uno antes de la llamada al modelo, uno después—. Los 34 tests que corren de verdad demuestran que la orquestación entre las tres capas funciona exactamente como se diseñó: encontrar información sensible en la entrada nunca bloquea, por sí solo, la decisión final; una respuesta con la forma incorrecta sí la bloquea, sin importar qué tan limpia esté la entrada. Lo único que este entorno específico no puede probar es la clasificación real de un modelo de lenguaje ante un ataque genuino — un límite del laboratorio, declarado con la misma honestidad en cada lección, nunca escondido."
Resumen y siguiente paso
Este proyecto integró las tres piezas de este módulo —el guardrail gestionado de seis mecanismos (lecciones 2-3), el scrubber de PII propio (lección 5), el validador de esquema propio (lección 6)— en un solo flujo ejecutable, corrido de verdad en dos escenarios completos, con 34 casos de pytest confirmando el comportamiento correcto, incluida la regla central de esta arquitectura: PII en la entrada se redacta y continúa, una respuesta con forma incorrecta se rechaza sin excepción. Cerraste con un ledger de siete filas que distingue, sin ambigüedad, cuatro piezas ejecutadas de verdad de tres representativas, cada una con su propia razón exacta.
Antes de avanzar deberías poder: correr defense_in_depth_flow.py con tus propios datos de prueba y predecir el resultado antes de ejecutarlo; explicar, de memoria, por qué PII en la entrada nunca bloquea por sí sola la decisión final; y defender, frente a cualquier pregunta, la diferencia entre "orquestación probada" y "extractor probado de punta a punta".
Con el guardrail declarado y los dos chequeos propios integrados, el Módulo 4 completo —ocho lecciones, desde la tesis de "capas, no sustitutos" hasta este proyecto— queda atrás. El Módulo 5 retoma exactamente este mismo bedrock.tf y este mismo rol BedrockManifestExtractorRole (Módulo 3) para extenderlos con el security gate heredado de cloud-security-and-guardrails-guide: bedrock-least-privilege.rego, evaluado contra el mismo plan que este módulo ya confirmó limpio.
Recursos
- Módulo 3, lección 8 de esta guía (
08-project-andes-cargos-ai-infrastructure-declared.md) — el mismo patrón de proyecto de cierre y ledger de honestidad que este proyecto reaplica. - Este módulo, lecciones 2 a 7 — la fuente completa de cada afirmación del ledger de honestidad del Paso 5.
- Terraform Registry —
aws_bedrock_guardrail— documentación oficial del recurso central de este módulo. - AWS — Amazon Bedrock Guardrails — panorama oficial de las seis políticas que este módulo entero desarrolló.
- pytest Docs — referencia general de la herramienta usada en los 34 casos de este proyecto.