Skip to content

English · Español

Lab 02 — Contención de sandbox de una herramienta malvada

Lee theory/03-sandboxing.md. No consultes solutions/.

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_fn para fijar RLIMIT_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:

  1. El resultado es un error (result.is_error == True).
  2. La cadena de error indica el límite relevante (timeout / memoria / fork).
  3. El proceso padre sobrevivió (os.getpid() sin cambio, el padre no crasheó).
  4. 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.getrusage despué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ía subprocess.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.py implementa las políticas PERMISSIVE y SUBPROCESS.
  • 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_NPROC limita 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 un setrlimit lo bastante bajo para hacer efecto pero aislando el impacto (p. ej., fija el límite a actual+5).
  • RLIMIT_AS no siempre muerde. En macOS, RLIMIT_AS no se hace cumplir; usa RLIMIT_DATA o sáltatelo en macOS. En Linux, RLIMIT_AS sí se cumple. Documenta el comportamiento condicional por OS.
  • Procesos zombi. Un subproceso sandboxed que se termina vía SIGKILL puede no tener su exit code recogido, dejando un zombi. Usa subprocess.Popen.wait() después de un kill para recoger.
  • Granularidad de timeout. subprocess.run(timeout=...) tiene granularidad de ~10 ms. No fijes timeout=0.001 — no tiene sentido.
  • Nota de seguridad sobre preexec_fn. preexec_fn no es thread-safe. Si el padre alguna vez usa hilos, prefiere start_new_session=True + terminación basada en señales. Para la Fase 32, single-threaded está bien.

Siguiente: 03-failure-mode-tour.md