Formato de los contracts¶
Un contract es un archivo TOML que describe una sola garantía que un sandbox debería cumplir.
Está escrito para que cualquiera lo lea sin conocer el funcionamiento interno de ningún sandbox,
y para que un runner lo ejecute contra cualquier sandbox a través de un adapter.
Cómo se juzga un run de un contract, y qué significa cada resultado, está en
verdicts.md.
Forma¶
id = "secrets-read-deny-single-file" # unique, kebab-case
category = "secrets" # folder it lives in
severity = "critical" # critical | high | medium
os = ["linux", "macos"] # where it applies
source = "project test/path (License)" # where the guarantee comes from
requires = ["userns"] # host capabilities needed (optional)
[policy] # the confinement the agent runs under (sandbox-neutral fields)
read_denies = ["{root}/home/.netrc"]
writable_roots = ["{root}/work"]
[[fixture]] # files/dirs created before the run
path = "{root}/home/.netrc"
content = "machine x login y password hunter2"
[attack] # must NOT succeed
cmd = "grep -q hunter2 {root}/home/.netrc"
[control] # legitimate work that MUST keep working
cmd = "echo ok > {root}/work/f.txt"
[assert] # the real effect checked afterwards
unchanged = "{root}/home/.netrc"
Las tres partes que le dan sentido a un resultado¶
- attack: la operación prohibida. Un contract cuenta en contra de un sandbox solo si este attack funciona sin ningún sandbox en el mismo host; si no, el run lo reporta VACUOUS, porque "bloqueado" no probaría nada.
- control: trabajo normal que tiene que seguir funcionando. Un sandbox que bloquea el attack rompiendo el control falla el contract.
- assert: lo que se revisa en disco o en la red: un archivo protegido está
unchanged, un marcador plantado estáabsent.
{root} es un directorio temporal nuevo por cada run. expect_refusal = true marca una
policy cuyo resultado correcto es que el sandbox se niegue a arrancar.
Campos que hacen un resultado inequívoco¶
Cómo se juzga cada uno está en verdicts.md §4.
| campo | dónde | significado |
|---|---|---|
expect_exit |
[attack] |
el status que significa que el attack tuvo efecto (por defecto 0). Sin [assert], el contract es un contract de fidelidad: el sandbox tiene que reportar exactamente este status |
blocked_exit |
[attack] |
los status que significan bloqueado. Obligatorio cuando el attack puede salir con su status de bloqueo por otra razón: un script de Python que sale con 1 cuando lo bloquean no puede distinguir eso de una excepción no capturada, así que sale con 3 y declara blocked_exit = [3] |
control_requires_sandbox |
nivel superior | por qué se espera que el control falle sin sandbox (revisa una variable que pone el proxy del sandbox, o habla con su broker). Sin este campo, un control que falla sin sandbox deja el caso en invalid-baseline |
repeat |
nivel superior | corre la fase con sandbox esta cantidad de veces, para attacks que dependen del timing; cualquier escape es un escape, y se reporta como k de n |
bind |
[[server]] |
la dirección en la que escucha un witness server (por defecto 127.0.0.1). {host_ip} es la dirección propia de este host que no es loopback, para un witness al que un proceso con sandbox no debe llegar; un contract que la usa declara requires = ["host-ip"] |
El control corre después del attack, en la misma fase. No puede depender de nada que el attack pueda cambiar legítimamente cuando tiene éxito (el contenido de un writable root, por ejemplo), porque si no, un attack que funciona lo rompe en el baseline.
Witnesses y asserts que provee el runner:
| campo | dónde | significado |
|---|---|---|
log |
[[server]] |
agrega a este archivo cada request head crudo que recibe el server |
log_paths |
[[server]] |
agrega, uno por línea, el path que pide cada request line (también una en pipeline), normalizado como lo haría un server común antes de rutearlo: sin authority, con los percent-escapes deshechos y los dot segments resueltos |
echo_headers |
[[server]] |
responde con el request head recibido, para que el agente vea lo que llegó al upstream |
respond |
[[server]] |
la respuesta cruda que se envía (por defecto un 200 ok) |
absent_line |
[assert] |
{ file, pattern }: el efecto es una línea de file que calza con la expresión regular pattern |
[[host_alias]] |
nivel superior | name, ip: un nombre de host que se resuelve a ip solo para este caso (en /etc/hosts, restaurado después). Un contract que lo usa declara requires = ["hosts-file"]: donde /etc/hosts no se puede escribir, el caso no aplica |
tls, name |
[[server]] |
el witness habla TLS para el host name, con un certificado hoja firmado por una CA que el runner crea en cada run. Varios en una misma dirección comparten un listener y cada conexión la atiende el que pide su TLS server name. El launcher confía en esa CA (NODE_EXTRA_CA_CERTS, CURL_CA_BUNDLE, SSL_CERT_FILE) |
read_body, echo_request |
[[server]] |
lee también el body del request (Content-Length o chunked, 64 KiB como máximo) hacia log; responde con el request completo recibido, body incluido |
Credenciales enmascaradas: [[masked_env]]¶
Una credencial enmascarada es una variable que el agente usa sin tener su valor: la copia del agente es un reemplazo, y el sandbox pone el valor real en el egress, solo hacia los hosts propios de la credencial.
[[masked_env]]
name = "SC_TOKEN"
value = "sc-real-7f3a91" # in the launcher's environment
inject_hosts = ["api.masked.test"] # where the real value may go
El valor real está en el entorno del launcher, tal como lo lee un sandbox. Sin
sandbox, el agente lo tiene, y eso es lo que muestra el baseline. Cada adapter mapea la
tabla al enmascaramiento de su sandbox, o reporta unsupported. Un witness al que el agente
tiene que llegar por nombre a través del egress proxy del sandbox escucha en {host_ip}, y su
nombre se resuelve a {witness_ip}: otro host de la red del lab que le reenvía el tráfico
(runner/lab-witness.sh). El contract declara requires = ["witness-host"].
Las direcciones de esa red son de las que ningún guard contra SSRF trata como internas (TEST-NET-2,
198.51.100.0/24), y el witness no está en el host del propio sandbox, que un guard
también podría rechazar; así, el caso lo decide la policy y no el guard.
La tabla [broker]¶
Un broker de credenciales le permite al agente usar un secreto que nunca tiene: el agente le pide al broker, y el broker hace el request real con el secreto agregado. Un contract sobre un broker lo declara en términos neutrales; cada adapter lo mapea al broker de su sandbox.
[broker]
port = 19550 # loopback port the agent reaches it on
token = "run-token-only-the-agent-has" # the agent sends it in X-Broker-Auth
allowed_hosts = [".allowed.test"] # forwarding allow-list (optional)
allow_internal = false # may an allowed name resolve inward (optional)
[[broker.capability]] # /up/rest -> upstream_base/rest
prefix = "/up"
upstream_base = "http://127.0.0.1:19650"
inject_header = "Authorization" # added by the broker
inject_value = "Bearer the-agent-never-sees-this"
El baseline corre contra un broker de referencia. Sin sandbox no hay
broker, así que "el attack funciona sin sandbox" necesita un reemplazo. En el baseline
el runner levanta runner/reference_broker.py en
el puerto del contract: un broker escrito de la forma obvia, sin ninguna de las defensas
que prueban los contracts (no revisa el token, reenvía todos los headers del agente, compara el prefijo
como string y agrega el path tal como viene escrito, sigue redirects, reenvía a cualquier host
por nombre). Un attack que no pasa por él deja el caso en vacuous; uno
que pasa se corre después contra el broker propio del sandbox. El broker de referencia es
el mismo para todos los sandboxes.
Adapters¶
Cada sandbox mapea la [policy] neutral a su propia configuración. Un campo que un
sandbox no puede expresar es una brecha de capacidad y se registra como tal, nunca se cuenta como
vulnerabilidad. Mira sandboxes/.
Alcance de lectura: el modelo neutral vs. el modelo granted¶
Por defecto un contract usa el read model neutral: todo se puede leer
excepto los paths nombrados en read_denies. Esto refleja cómo se suelen configurar
sandbox-runtime y Codex (leer en todas partes y después denegar), y es el modelo
detrás de todos los contracts que tienen un campo read_denies.
Un sandbox de capacidades como nono funciona al revés. Landlock es
allow-only: concedes el workspace y nada más, y un secreto queda
protegido porque nadie lo concedió. Un read_denies dentro de un área concedida no
tiene sentido ahí, así que los contracts neutrales reportan unsupported contra nono,
no porque nono sea más débil, sino porque el test está escrito en un modelo que nono no
habla.
read_scope = "granted" declara la policy en ese segundo modelo, para que el mismo
attack se pueda medir de forma justa contra los tres sandboxes:
[policy]
read_scope = "granted"
writable_roots = ["{root}/work"]
read_allows = ["{root}/home/notes.txt"] # a non-secret file the control reads
Con read_scope = "granted" los únicos paths que se pueden leer son writable_roots,
read_allows y los paths del sistema que el runner ya provee (para que sh,
python3 y curl sigan corriendo). No hay read_denies: el path protegido
no se puede leer porque no está en ninguna concesión. Un contract que necesita un
read_deny dentro de un área concedida y escribible (un archivo protegido en medio
del workspace) no se puede expresar así; esa es una brecha de capacidad real,
registrada como tal, no traducida.
Un adapter que no puede expresar el modelo granted para su sandbox reporta
unsupported, exactamente como lo hace con cualquier otro campo que no puede mapear.