English · Español
Lab 02 — Contención de sandbox de una herramienta malvada¶
Lee
theory/03-sandboxing.md. No consultessolutions/.
Objetivo¶
Construye tres herramientas deliberadamente mal portadas (bucle infinito, devoradora de memoria, fork bomb), regístralas con el agente y verifica que el sandbox SUBPROCESS contiene a cada una. El proceso agente padre debe sobrevivir a las tres; cada herramienta mal portada debe devolver un ToolError limpio. Este es el test de seguridad de la Fase 32.
Setup¶
Un módulo nuevo: src/miniagent/sandbox.py. Tests en tests/test_sandbox.py. Las herramientas malvadas viven en tests/evil_tools/ (deliberadamente no en src/, para que nunca se registren accidentalmente para uso real).
Tareas¶
Tarea 1 — implementa run_under_sandbox¶
def run_under_sandbox(
tool: Tool,
args: dict,
policy: SandboxPolicy = SandboxPolicy.PERMISSIVE,
timeout_s: float = 5.0,
memory_mb: int = 256,
) -> ToolResult:
match policy:
case SandboxPolicy.PERMISSIVE:
try:
return ToolResult.ok(tool(**args))
except Exception as e:
return ToolResult.error(f"in-process: {e}")
case SandboxPolicy.SUBPROCESS:
return _call_in_subprocess(tool, args, timeout_s, memory_mb)
case SandboxPolicy.CONTAINER:
raise NotImplementedError(...)
Para SUBPROCESS:
- Lanza un hijo vía
subprocess.Popen([sys.executable, ...]). - Usa
preexec_fnpara fijarRLIMIT_CPU(segundos de CPU),RLIMIT_AS(bytes de memoria virtual),RLIMIT_NPROC(máx. procesos de usuario),RLIMIT_FSIZE(tamaño máx. de archivo). - Usa
subprocess.run(timeout=timeout_s)como backstop de reloj-de-pared. - Marshall args vía JSON; deserializa el resultado de la misma forma.
- Ante cualquier fallo (timeout, exit no-cero, salida malformada), devuelve
ToolResult.error("sandbox: <reason>")— nunca propagues el crash al padre.
Tarea 2 — escribe las tres herramientas malvadas¶
En tests/evil_tools/:
# tests/evil_tools/infinite_loop.py
def evil_infinite_loop():
while True:
pass
# tests/evil_tools/memory_eater.py
def evil_memory_eater():
x = bytearray(2 * 10**9) # 2 GB
return len(x)
# tests/evil_tools/fork_bomb.py
import os
def evil_fork_bomb():
while True:
os.fork()
Cada una es una sola función que se porta mal de una forma específica. Mantenlas obvias — esto no es un CTF.
Tarea 3 — escribe los tests de contención¶
Para cada herramienta malvada:
def test_infinite_loop_is_terminated():
result = run_under_sandbox(
evil_infinite_loop, {},
policy=SandboxPolicy.SUBPROCESS,
timeout_s=2.0,
)
assert result.is_error
assert "timeout" in result.error.lower()
# Critical: parent process is still alive after this.
assert os.getpid() == initial_pid
Repite para memory_eater (espera MemoryError o muerte por RLIMIT_AS en el hijo) y fork_bomb (espera que RLIMIT_NPROC muerda).
Para cada test, asserta:
- El resultado es un error (
result.is_error == True). - La cadena de error indica el límite relevante (timeout / memoria / fork).
- El proceso padre sobrevivió (
os.getpid()sin cambio, el padre no crasheó). - El test se completó en ~5 segundos de reloj-de-pared (el sandbox no colgó el runner de tests).
Tarea 4 — mide el techo de recursos¶
Para cada herramienta malvada, mide:
- Tiempo hasta la terminación (con qué rapidez el sandbox detecta + mata).
- Pico de memoria observado en el hijo (usa
resource.getrusagedespués de que salga el hijo). - Si la muerte fue vía señal (SIGKILL, SIGTERM), vía
RLIMIT_*(lanza en el hijo) o víasubprocess.TimeoutExpired.
Guarda en experiments/<date>-phase-32-sandbox-eval/containment.csv con columnas tool, kill_mechanism, time_to_kill_s, peak_memory_mb.
Tarea 5 — registra con el agente, ejecuta con el tutor real¶
Añade las tres herramientas malvadas a un servidor MCP separado (claramente etiquetado "evil"). Haz que el Planner del agente (en un test fixture) emita un tool_call para una de ellas, despachado vía sandbox SUBPROCESS. Verifica:
- El bucle del agente captura el
ToolError. - El scratchpad registra la llamada fallida limpiamente.
- El agente termina con un resultado de "fallo de herramienta" elegante, no con un crash.
Este es el test de sandbox closed-loop: no solo "¿podemos meter una función en sandbox?", sino "¿maneja el bucle completo del agente una herramienta mal portada con elegancia?".
Medidas a capturar¶
- 3 herramientas malvadas: cada una contenida, padre sobrevive, error devuelto.
- Tiempo hasta la muerte para cada una.
- Pico de memoria observado.
- Mecanismo de muerte por herramienta.
- Comportamiento del agente cuando una llamada sandboxed falla (elegante, no crashea).
Aceptación¶
-
src/miniagent/sandbox.pyimplementa las políticasPERMISSIVEySUBPROCESS. - 3 herramientas malvadas escritas en
tests/evil_tools/. - 3 tests de contención pasan.
- Medidas de contención guardadas en CSV.
- El test de sandbox closed-loop (Tarea 5) pasa.
- Ningún PID de herramienta malvada queda colgado tras terminar la suite de tests.
Trampas a esperar¶
- El fork bomb derrota a
RLIMIT_NPROC.RLIMIT_NPROClimita procesos por usuario, no por árbol de procesos. Si tu usuario de test ya tiene muchos procesos, el límite puede morder a otros tests. Ejecuta el test del fork-bomb bajo unsetrlimitlo bastante bajo para hacer efecto pero aislando el impacto (p. ej., fija el límite a actual+5). RLIMIT_ASno siempre muerde. En macOS,RLIMIT_ASno se hace cumplir; usaRLIMIT_DATAo sáltatelo en macOS. En Linux,RLIMIT_ASsí se cumple. Documenta el comportamiento condicional por OS.- Procesos zombi. Un subproceso sandboxed que se termina vía
SIGKILLpuede no tener su exit code recogido, dejando un zombi. Usasubprocess.Popen.wait()después de un kill para recoger. - Granularidad de timeout.
subprocess.run(timeout=...)tiene granularidad de ~10 ms. No fijestimeout=0.001— no tiene sentido. - Nota de seguridad sobre
preexec_fn.preexec_fnno es thread-safe. Si el padre alguna vez usa hilos, prefierestart_new_session=True+ terminación basada en señales. Para la Fase 32, single-threaded está bien.
Siguiente: 03-failure-mode-tour.md