Skip to content

English · Español

03 — Sandboxing de herramientas no confiables

🇪🇸 Las herramientas de Fase 31 son puramente de lectura — no necesitan sandbox. Pero si alguna vez añadimos una herramienta que ejecute código del usuario, navegue la web o invoque una API externa, tiene que correr aislada. Esta fase construye el patrón ahora, mientras el coste es bajo, para que esté listo cuando importe.

El modelo de amenaza

Una herramienta que el agente invoca es, en general, código no confiable:

  • Puede colgarse (bucle infinito), agotando el presupuesto de pasos del agente sin buena razón.
  • Puede saltar por OOM (asignando gigabytes), tumbando el proceso del agente.
  • Puede hacer shell, leer archivos sensibles, escribir en disco, enviar request de red.
  • Puede crashear, dejando el proceso del agente en un estado indefinido.

Para la Fase 32, las herramientas registradas (conjugate, lookup_irregular_verb, check_subject_verb_agreement, lookup_spanish) son búsquedas puramente de datos contra la tabla de verbos §A13. No pueden portarse mal. Pero la infraestructura del sandbox debe construirse ahora, mientras el coste es cero, para no añadirla bajo fuego cuando una herramienta mal portada llegue en Fase 33+.

Este es exactamente el principio de la Fase 22 (KV cache): construye la disciplina antes de necesitarla.

Tres niveles de contención

PERMISSIVE — sin sandbox

def call(tool, args): return tool(**args)

In-process, sin frontera. Úsalo para herramientas de datos puros que escribiste tú y en las que confías.

Para las herramientas de la Fase 32, este es el default. Todas son PERMISSIVE.

SUBPROCESS — proceso separado, recursos acotados

def call(tool, args):
    proc = subprocess.run(
        [sys.executable, "-c", f"import json; from {tool.module} import {tool.name}; "
                              f"print(json.dumps({tool.name}(**{args!r})))"],
        capture_output=True, timeout=tool.timeout_s, text=True,
    )
    return json.loads(proc.stdout)

La herramienta corre en un proceso hijo. Timeout de reloj-de-pared vía subprocess.run(timeout=...). Límites de CPU y memoria vía resource.setrlimit en el preexec hook del hijo. Los crashes no matan al padre; los timeouts terminan limpiamente.

Úsalo para herramientas que deberían ser seguras pero podrían portarse mal (p. ej., un conjugador de verbos que parsea entrada no estructurada, donde una entrada mal formada podría disparar una ruta lenta).

CONTAINER — aislamiento completo

Para herramientas que ejecutan código de usuario arbitrario o hacen shell, ejecuta la herramienta dentro de un contenedor Docker / Firejail / nsjail sin red, filesystem read-only, capabilities dropeadas.

Fuera de alcance para la Fase 32 (aún no tenemos tales herramientas). Mencionado por completitud — y para que Borja vea la abstracción correcta (un enum de política que selecciona la estrategia adecuada por herramienta) aunque solo implementemos dos niveles.

El enum SandboxPolicy y el dispatcher

from enum import Enum

class SandboxPolicy(Enum):
    PERMISSIVE = "permissive"   # in-process
    SUBPROCESS = "subprocess"   # separate process, timeout + rlimits
    CONTAINER  = "container"    # full isolation (Phase 32: NotImplementedError)


def run_under_sandbox(
    tool: Tool,
    args: dict,
    policy: SandboxPolicy = SandboxPolicy.PERMISSIVE,
) -> ToolResult:
    match policy:
        case SandboxPolicy.PERMISSIVE:
            return _call_in_process(tool, args)
        case SandboxPolicy.SUBPROCESS:
            return _call_in_subprocess(tool, args)
        case SandboxPolicy.CONTAINER:
            raise NotImplementedError("CONTAINER policy planned for Phase 33+")

El agente llama a run_under_sandbox con la política declarada en los metadatos de registro de la herramienta. Esto desacopla la política del loop — añadir una nueva herramienta requiere solo su declaración.

Límites de recursos — la receta SUBPROCESS

En el preexec_fn del proceso hijo (Unix):

import resource

def child_setup():
    # CPU time: 5 seconds.
    resource.setrlimit(resource.RLIMIT_CPU, (5, 5))
    # Address space (virtual memory): 256 MB.
    resource.setrlimit(resource.RLIMIT_AS, (256 * 1024 * 1024, 256 * 1024 * 1024))
    # No core dumps.
    resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
    # File size: 10 MB.
    resource.setrlimit(resource.RLIMIT_FSIZE, (10 * 1024 * 1024, 10 * 1024 * 1024))

Nota: RLIMIT_AS es específico de Linux. En macOS usa RLIMIT_DATA o salta el límite de memoria. setrlimit no mata el proceso — hace que el kernel deniegue asignaciones excesivas, causando que la herramienta lance MemoryError. Esto es más limpio que matar.

El timeout de reloj-de-pared (subprocess.run(timeout=...)) es el backstop duro: si los rlimits no muerden por algún motivo, el timeout lo hará.

Política de red

Por defecto, la política SUBPROCESS no restringe el acceso a la red — el hijo hereda el namespace de red del padre. Para imponer una política de red, usa una de:

  • firejail --net=none python -c ... — deniega el acceso a la red por completo.
  • unshare -n — aislamiento por namespaces de Linux; el hijo tiene su propio namespace de red (vacío).
  • Docker / nsjail — para aislamiento completo (política CONTAINER).

Para la Fase 32, todas las herramientas son búsquedas locales de datos; sin red necesaria. Por defecto "sin restricción de red" en SUBPROCESS y documentamos que las herramientas que requieran red y sandboxing deben usar CONTAINER.

Probando el sandbox — herramientas adversarias

Para demostrar que el sandbox funciona, necesitamos herramientas mal comportadas contra las que testear. El lab 02 introduce tres:

  • evil_infinite_loop()while True: pass. Debería ser terminada por RLIMIT_CPU o por el timeout de subprocess.run.
  • evil_memory_eater()x = bytearray(10**10). Debería lanzar MemoryError bajo RLIMIT_AS.
  • evil_fork_bomb() — equivalente a :(){ :|:& };:. Debería quedar limitada por RLIMIT_NPROC.

Para cada una: confirma que el agente padre sobrevive, que la herramienta devuelve un ToolError("sandbox: timeout") (o similar) y que el loop continúa al siguiente paso (o termina con un error de presupuesto agotado).

Este es un test real, no un hipotético. Borja escribirá y ejecutará estas herramientas. El punto es sentir al sandbox atrapar un programa que se porta mal — construyendo confianza en que la abstracción funciona.

Sandbox vs confianza — un principio de seguridad

El sandbox es una defensa en profundidad, no un sustituto de la confianza:

  • No ejecutes código sandboxed sobre datos de producción.
  • No asumas que el sandboxing hace que código de usuario arbitrario sea seguro — existen CVEs de kernel.
  • No caigas en "lo metí en un sandbox, así que está bien" — los sandboxes tienen vectores de escape.

Para un tutor de gramática §A13, el modelo de amenaza es bajo (sin código no confiable). El sandbox es infraestructura para el futuro: cuando la Fase 33+ añada herramientas que puedan ser no confiables, el patrón está listo.

Una nota sobre la objeción "el sandbox no importa"

Un lector podría decir: "Las herramientas de la Fase 32 son datos puros; esto es sobreingeniería".

Respuesta: construir el sandbox ahora cuesta ~200 líneas de código. Añadirlo bajo fuego más tarde (cuando una herramienta empiece a portarse mal en producción) es mucho más caro — estarás depurando un agente colgado en mitad de una sesión corrupta, con usuarios quejándose. La elección Pareto-frontera es construir la versión ligera ahora.

Este es el mismo argumento que "por qué molestarse con el SBOM en la Fase 0" o "por qué molestarse con el manifest.json en cada experimento". Las disciplinas que son baratas de mantener y caras de retrofittear deberían instalarse a la primera oportunidad.

Lo que este archivo NO cubre

  • Política de red en modo CONTAINER. Docker / firejail se mencionan pero no se implementan. Fase 33+.
  • Capability dropping (p. ej., prctl(PR_SET_NO_NEW_PRIVS)). Útil para sandboxing más profundo; fuera de alcance.
  • gVisor, Firecracker, VMs ligeras. Aislamiento de grado producción. Fuera de alcance.

Siguiente: ../lab/00-planner-by-mask.md