8 min de lectura

Interpolación del shell: evita que las entradas del agente se conviertan en comandos

La interpolación del shell puede convertir rutas y nombres de recursos controlados por un agente en sintaxis ejecutable. Crea acciones locales y SSH más seguras con arreglos y validación.

Interpolación del shell: evita que las entradas del agente se conviertan en comandos

Un agente no necesita una herramienta de shell para provocar una ejecución en el shell. Dale una ruta, una rama, una etiqueta de imagen, un nombre de host o un nombre de recurso; después inserta ese valor en una cadena de comandos en algún punto posterior y habrás dado a texto no confiable la oportunidad de cambiar el programa que se ejecuta.

La solución no es una función de comillas más ingeniosa. Hay que dejar de tratar la construcción de comandos como un simple formateo de cadenas. Una acción debe identificar una operación fija, aceptar campos tipados con reglas estrictas, invocar un programa mediante un vector real de argumentos y mantener la autorización separada del análisis sintáctico. He revisado suficientes informes de incidentes como para decirlo claramente: una herramienta que acepta un comando libre porque «solo es para nuestro agente» acabará aceptando también las instrucciones de otra persona.

Los agentes hacen que este fallo sea más fácil de alcanzar. Consumen texto de incidencias, contenido de repositorios, resultados de herramientas, páginas web y mensajes de chat. Cualquiera de esas entradas puede influir en el siguiente argumento. Que el agente pretendiera causar daño es irrelevante. El límite que llega a un sistema operativo o a un host remoto debe asumir que un atacante puede controlar su entrada.

La interpolación del shell convierte los datos en sintaxis

La interpolación del shell ocurre cuando el código construye texto para un intérprete de comandos en lugar de proporcionar argumentos separados a una API de procesos. Cuando un shell recibe ese texto, aplica sus propias reglas de lenguaje. Los separadores pueden iniciar otro comando, las sustituciones de comandos pueden ejecutar un comando anidado, las redirecciones pueden modificar archivos y las expansiones pueden convertir un valor aparente en varias palabras.

Imagina que un agente debe inspeccionar una rama antes de una versión. Este controlador parece inocente:

branch = request["branch"]
command = f"git show {branch}"
subprocess.run(command, shell=True, check=True)

Si branch es release; id, el shell ve dos comandos. La primera parte pide a Git que muestre una revisión. La segunda ejecuta id. El mismo error aparece en JavaScript mediante exec, en Ruby al pasar una cadena a system, en Go mediante sh -c y en scripts de CI que colocan directamente la salida del agente en una variable del shell sin controlar su uso posterior.

El carácter peligroso no tiene que llegar en el prompt del agente. Puede estar en el título de una solicitud de incorporación, en un mensaje de error, en un manifiesto de paquetes o en un nombre de archivo devuelto por un análisis del repositorio. El agente puede incluir ese contenido en una llamada posterior porque su tarea le pidió inspeccionarlo o repararlo. Una revisión que solo comprueba si el modelo obedece una instrucción pasa por alto el punto donde el texto se convierte en sintaxis ejecutable.

El lenguaje de comandos del shell POSIX describe una secuencia que incluye expansiones, división en campos cuando corresponde, expansión de nombres de ruta y eliminación de comillas. Ese orden importa. Un desarrollador suele decir «lo puse entre comillas», como si las comillas crearan una cadena segura genérica. No lo hacen. Limitan reglas de análisis concretas en un shell concreto y en un momento concreto de su secuencia de expansión.

Un shell también puede recibir entradas de varias formas. Un valor puede afectar al texto del comando, a una asignación de entorno, al destino de una redirección, a una sustitución de comandos, a un archivo de inicio del shell o al código que otro intérprete evalúa después. Escapar un valor para el primer shell no protege automáticamente al siguiente analizador.

Conviene distinguir estos casos:

  • La inyección de comandos ocurre cuando la entrada cambia la sintaxis que interpreta un lenguaje de comandos.
  • La inyección de argumentos ocurre cuando la entrada sigue siendo un solo argumento, pero cambia la forma en que el programa llamado interpreta ese argumento, a menudo como una opción o una expresión.
  • El fallo de autorización ocurre cuando un argumento válido solicita una acción fuera del alcance permitido al solicitante.

Usar un vector de argumentos detiene la primera clase. No resuelve la segunda ni la tercera. Una ruta que sale del espacio de trabajo, una revisión de Git que selecciona un objeto no previsto y una URL que llega a un servicio interno pueden ser argumentos perfectamente separados.

Una cadena de comandos entrecomillada aún atraviesa demasiados analizadores

Entrecomillar la entrada antes de concatenarla en un comando del shell es una reparación popular porque parece pequeña. También es frágil, ya que el comando suele atravesar más de una gramática.

Supón que un programa local construye una llamada SSH y que el sistema remoto ejecuta un shell. Un desarrollador puede entrecomillar la rama para el shell local, volver a entrecomillarla para SSH y hacerlo una tercera vez para el shell remoto. Cada capa parte de supuestos distintos sobre espacios, comillas, barras invertidas, sustituciones de comandos y codificación. Un cambio posterior puede eliminar una capa de comillas o colocar la cadena ya entrecomillada en un contexto nuevo. El código sigue pareciendo cuidadoso, aunque la protección haya desaparecido.

Este patrón es especialmente problemático:

const command = `git checkout '${branch}'`;
execFile("ssh", [host, command], callback);

execFile protege la invocación local de ssh, lo cual está bien. No hace que command sea seguro en el lado remoto. SSH normalmente proporciona al lado remoto una cadena de comandos. La cuenta remota suele entregarla a un shell. Por tanto, la rama entra en la sintaxis del shell un salto más tarde.

Las comillas simples no son una respuesta general. Una entrada que contenga una comilla simple puede cerrar la región entrecomillada prevista. Una rutina de comillas escrita para el shell POSIX será incorrecta para PowerShell. Una rutina correcta para una palabra del shell será incorrecta si el valor acaba en una redirección, una expresión aritmética o un intérprete de lenguaje. Una rutina correcta hoy seguirá siendo una carga de mantenimiento cuando alguien cambie git show por git log --format=... y reorganice la plantilla.

No confundas serializar una lista de argumentos para los registros con reconstruirla para ejecutarla. Una línea de registro como git show release-42 resulta útil para una persona, pero no demuestra dónde estaban los límites originales de los argumentos. No es una representación ejecutable segura. Guarda los argumentos como un arreglo estructurado y muéstralos solo para lectura, con reglas de escape claras.

Las variables de entorno crean otra trampa. Esto es más seguro que interpolar:

BRANCH="$branch" git show "$BRANCH"

pero solo sigue siendo seguro si el script controla ambas expansiones entrecomilladas y nunca evalúa el valor después. Si un script posterior usa eval, construye otra cadena de comandos o pasa el valor a un motor de plantillas con funciones de ejecución, el trabajo inicial no habrá servido de mucho. Un límite mejor evita entregar directamente al shell el valor del agente.

Los ejecutables fijos y los arreglos de argumentos eliminan la gramática del shell

Para las acciones locales, llama a un ejecutable fijo con un arreglo de argumentos y deja desactivada la ejecución del shell. El sistema operativo entrega entonces al proceso hijo las cadenas individuales que proporcionaste. No reinterpreta punto y coma, signos de dólar, espacios, paréntesis ni caracteres comodín como lenguaje del shell.

La documentación de subprocess de Python recomienda una secuencia de argumentos e indica que shell=False es el valor predeterminado. Usa ese valor de forma deliberada, en lugar de depender de él por accidente:

import subprocess

result = subprocess.run(
    ["git", "status", "--porcelain=v1"],
    cwd=repo_dir,
    text=True,
    capture_output=True,
    check=True,
)
print(result.stdout)

Esta acción no permite que el agente controle el ejecutable ni la plantilla del comando. Realiza una operación conocida en un directorio aprobado. La salida es texto simple, con cada registro de estado en su propia línea. Puedes mejorar aún más la interfaz analizando esa salida y devolviendo registros estructurados, en lugar de entregar texto sin procesar a otra decisión del agente.

Cuando una operación necesita una entrada, mantén fijos el ejecutable y la operación y añade los valores validados como elementos separados:

subprocess.run(
    ["tool", "fetch-resource", resource_id],
    cwd=workspace,
    check=True,
    shell=False,
)

En Node.js, prefiere spawn o execFile con un arreglo de argumentos. No actives la opción shell para hacer más cómodo un comando compuesto. En Go, usa exec.Command y proporciona cada argumento por separado. En Rust, usa Command y llamadas repetidas a arg. Las API cambian, pero la regla no: ninguna entrada debe entrar en un lenguaje de comandos.

Este es un buen momento para cuestionar una recomendación habitual: «Usa un script de shell como envoltorio seguro». Un script fijo puede ser un límite de compatibilidad aceptable, pero no es más seguro por el mero hecho de ser un script. Solo es seguro si recibe valores mediante parámetros posicionales fijos o entrada estándar, entrecomilla todas las expansiones del script, evita eval y no construye una segunda cadena de comandos. Normalmente, un programa pequeño escrito con una API de procesos es más fácil de auditar.

Hay funciones legítimas del shell: canalizaciones, flujo condicional, redirecciones y elementos integrados. Coloca esas funciones en un script estático revisado cuando no puedas eliminarlas. No dejes que el agente ensamble la canalización. Dale una acción llamada collect_build_logs y permite que el envoltorio ejecute su canalización fija con ubicaciones de archivo fijas y parámetros acotados.

El ejecutable también forma parte de la política. Nunca lo elijas a partir de la entrada del agente, de un archivo de configuración modificable o buscando en un PATH controlado por un atacante. Usa una ruta absoluta bajo control del administrador cuando el entorno lo requiera. Establece un directorio de trabajo conocido y un entorno reducido, en lugar de heredar variables arbitrarias del proceso del agente.

La validación define qué puede significar una acción

Los arreglos de argumentos protegen el límite del analizador. La validación protege el límite de la acción. Si una acción dice read_file, el servicio debe decidir qué archivos se pueden leer con ella. Una API de procesos no puede tomar esa decisión por ti.

Empieza con un esquema estricto. Un nombre de despliegue puede admitir letras minúsculas, dígitos y separadores internos individuales, con un límite de longitud. Un selector de rama puede ser un nombre de rama dentro de un subconjunto elegido de las reglas de Git. Un identificador de recurso puede ser un UUID o un entero del inventario. Un destino de servidor puede ser un ID que el servicio convierta en un host almacenado, en lugar de un nombre de host arbitrario.

Evita listas negras genéricas como «rechazar punto y coma y ampersand». Se construyen alrededor de un analizador y dejan intacto el abuso semántico. Además, crecen hasta que los usuarios ya no pueden predecir qué funciona. Una gramática de permitidos ofrece un contrato al solicitante y un conjunto acotado de entradas que el revisor puede analizar.

Para un campo de rama en un flujo de versiones, una gramática limitada deliberadamente puede ser mejor que aceptar todas las referencias que Git permite:

import re

BRANCH = re.compile(r"[A-Za-z0-9][A-Za-z0-9._/]{0,127}")

def release_ref(value: str) -> str:
    if not isinstance(value, str):
        raise ValueError("branch must be text")
    if not BRANCH.fullmatch(value):
        raise ValueError("branch has unsupported characters")
    if "/./" in value or "//" in value or value.endswith("/"):
        raise ValueError("branch has an unsupported path form")
    return "refs/heads/" + value

Este código no pretende implementar toda la gramática de referencias de Git. Define intencionadamente una gramática más pequeña para una acción de publicación. Esa suele ser la decisión de ingeniería correcta. Si los usuarios necesitan espacios u otra forma inusual, añádela porque el flujo lo requiere, pruébala y documéntala. No heredes todos los casos extremos de un sistema de control de versiones general cuando tu acción solo publica ramas de versión.

El valor devuelto tiene un prefijo de espacio de nombres fijo. Eso importa. Una expresión de revisión arbitraria puede referirse a etiquetas, antecesores de commits, reflogs o sintaxis que un comando de Git interpreta de forma especial. Una acción de ramas de versión no debe convertirse silenciosamente en «mostrar cualquier objeto de Git que el agente pueda describir». Usa una biblioteca o el modo de validación de ramas de Git cuando necesites compatibilidad completa; después, asigna el resultado aceptado al espacio de nombres permitido.

Los nombres de recursos necesitan la misma disciplina. Si un agente solicita un objeto en la nube, guarda fuera del campo libre la cuenta, la región, el bucket o el proyecto permitidos. Permite que proporcione un nombre de objeto que siga la única gramática admitida. No aceptes una URL completa y la llames nombre de recurso. Una URL completa elige el protocolo, el host, el puerto, la ruta y, a veces, las credenciales. Es una decisión de autorización de red disfrazada de cadena.

Los límites de longitud forman parte de la validación. Limitan el crecimiento de los registros, evitan límites accidentales de la línea de comandos y dificultan los ataques de denegación de servicio. Las reglas de codificación de caracteres también forman parte de ella. Rechaza caracteres de control en los campos de texto salvo que la acción los necesite realmente. Rechaza siempre NUL en los argumentos de procesos, porque las interfaces de procesos del sistema operativo no pueden transportarlo como parte de un argumento.

Las rutas requieren comprobaciones de contención, no filtros de caracteres

Conserva las pruebas de cada llamada
El registro Activity conserva las llamadas individuales del registro de auditoría cifrado y encadenado mediante hashes.

Una ruta puede ser segura para el shell y peligrosa para el sistema de archivos. ../../secrets.env no contiene ninguno de los metacaracteres del shell que preocupan a las funciones de comillas. Aun así, puede sacar una operación de archivos fuera del espacio de trabajo.

Una política segura de rutas empieza por declarar un directorio raíz para la acción. Resuelve la ruta relativa solicitada respecto a esa raíz y exige que el objetivo resuelto permanezca dentro de la raíz resuelta. No aceptes una ruta absoluta cuando la acción solo necesita archivos del espacio de trabajo. No normalices una cadena y supongas que su forma resultante indica adónde irá el sistema de archivos.

Una comprobación conceptual sería esta:

from pathlib import Path

workspace = Path("/srv/agent-workspaces/repo-a").resolve()

def permitted_file(relative_name: str) -> Path:
    if not isinstance(relative_name, str) or relative_name.startswith("/"):
        raise ValueError("file must be a relative path")
    candidate = (workspace / relative_name).resolve(strict=True)
    if candidate == workspace or workspace not in candidate.parents:
        raise ValueError("file is outside the workspace")
    if not candidate.is_file():
        raise ValueError("requested target is not a regular file")
    return candidate

La llamada a resolve detecta recorridos habituales y sigue los enlaces simbólicos existentes, lo cual es mejor que comprobar si la cadena contiene ... Aun así, no es la respuesta completa para una escritura sensible. Un atacante que pueda modificar el árbol de directorios podría sustituir un componente de la ruta por un enlace simbólico después de la comprobación y antes de la apertura posterior. Esa separación entre comprobar y usar es una condición de carrera de tipo time of check to time of use.

Para lecturas en un espacio de trabajo controlado, una comprobación de contención tras resolver la ruta puede ajustarse al riesgo. Para escrituras, eliminaciones, cambios de permisos o archivos comprimidos que puedan ser manipulados por atacantes, usa operaciones del sistema de archivos que mantengan descriptores de directorio y rechacen el recorrido de enlaces simbólicos cuando el sistema operativo ofrezca esos controles. Mantén separados los espacios de trabajo modificables por los agentes y el código que decide las raíces permitidas. Si el modelo de amenaza incluye a un atacante local capaz de provocar carreras en el sistema de archivos, una biblioteca de rutas de alto nivel no basta.

Decide también si los archivos ocultos, los metadatos del repositorio, los directorios de dependencias generados y los enlaces simbólicos forman parte del alcance. Una regla imprecisa como «cualquier cosa dentro del repositorio» suele exponer archivos de credenciales, cachés de compilación o configuraciones que el flujo nunca necesitó. El permiso de lectura debe seguir el propósito de la acción, no la comodidad de un glob recursivo.

La extracción de archivos comprimidos merece una advertencia aparte. Valida cada miembro del archivo después de unirlo a la raíz de destino y trata como potencialmente hostiles los enlaces que contenga. El recorrido de rutas no se limita a solicitudes hechas directamente por el agente. Una herramienta que extrae un artefacto seleccionado por el agente puede realizar la misma evasión en su nombre.

Las opciones y expresiones siguen siendo peligrosas sin un shell

Un vector de argumentos mantiene un valor separado de la sintaxis del shell, pero el programa llamado aún analiza ese valor. Muchos programas tratan como opciones los argumentos que empiezan por un guion. Algunos aceptan expresiones, referencias de configuración, sintaxis para incluir archivos, complementos o ganchos de comandos. Pasar una entrada no confiable como un solo argumento no hace inofensivos esos significados.

Considera un envoltorio que llama a un programa de búsqueda con un patrón proporcionado por el usuario. El envoltorio puede proporcionar correctamente ["search", pattern, directory]. Si el programa trata el patrón como una expresión regular, una expresión patológica puede consumir una cantidad enorme de CPU. Si admite una opción que lee un archivo de configuración y el patrón acaba en la posición equivocada, el solicitante puede cambiar el comportamiento del programa. Si el lenguaje del programa admite evaluación de código, habrás entregado al agente otro intérprete.

Coloca las opciones fijas antes de los datos y usa el separador convencional de fin de opciones cuando el programa lo admita. En texto, ese separador son dos guiones. Indica a muchos analizadores de líneas de comandos que los valores posteriores son operandos y no opciones. No dependas de él como validación. Algunas herramientas no lo respetan y un operando puede seguir siendo peligroso desde el punto de vista semántico.

Git tiene una superficie de comandos muy amplia, por eso los agentes necesitan acciones de Git limitadas, no una salida de escape genérica de «ejecutar Git». Una acción show_release_branch puede aceptar un nombre de rama restringido, convertirlo en refs/heads/ más ese nombre e invocar un subcomando fijo de Git. Una acción checkout_anything que acepte sintaxis de revisión arbitraria tiene un significado más amplio y necesita una decisión de autorización más amplia. Son productos distintos, aunque ambos inicien un proceso de Git.

La misma regla se aplica a gestores de paquetes, clientes de bases de datos, herramientas multimedia, archivadores y comandos de infraestructura. Pregunta qué gramática analizará el programa de destino después de que el sistema operativo entregue el argumento. Una línea de comandos suele ser solo el primer analizador de una cadena.

Las expresiones regulares requieren especial cuidado. Un patrón no es código de shell, pero sí es trabajo ejecutable dentro de un motor de expresiones regulares. Si el agente debe buscar texto, selecciona un motor con un rendimiento predecible cuando sea posible, limita el patrón y la entrada y ofrece la búsqueda literal como acción predeterminada. No conviertas cada solicitud de búsqueda en un lenguaje de expresiones sin restricciones solo porque un desarrollador quiere un endpoint flexible.

SSH convierte un comando local en un problema de análisis remoto

Aprueba una nueva ejecución del agente
Un nuevo proceso de agente recibe una tarjeta de aprobación firmada antes de realizar su primera llamada.

SSH crea un segundo límite de ejecución. Puedes invocar el cliente SSH local con un arreglo de argumentos perfecto y aun así enviar una cadena de comandos insegura al host remoto. Muchas implementaciones de SSH ejecutan el comando remoto solicitado mediante el shell de la cuenta remota o un analizador de comandos equivalente.

No construyas comandos remotos a partir de campos del agente. Esto está mal aunque la llamada local use un arreglo:

remote = "deploy " + environment + " " + branch
subprocess.run(["ssh", host, remote], check=True)

Una disposición segura envía un comando remoto constante y mueve los datos a un canal estructurado. Por ejemplo, la cuenta remota puede exponer un programa revisado en una ruta fija. El lado local inicia ese programa fijo, envía una solicitud JSON por la entrada estándar y el programa remoto valida environment y branch antes de ejecutar cualquier operación local.

import json
import subprocess

request = {"environment": "staging", "branch": "release/42"}
subprocess.run(
    ["ssh", "deploy.internal", "/usr/local/libexec/receive-deploy-request"],
    input=json.dumps(request) + "\n",
    text=True,
    check=True,
)

El comando remoto de este ejemplo es constante. El JSON son datos en la entrada estándar, no texto incrustado en el comando remoto. El receptor remoto aún debe rechazar campos desconocidos, aplicar límites de longitud, validar cada valor y usar un vector de argumentos para cualquier proceso hijo. El transporte estructurado elimina un laberinto de comillas. No concede confianza.

Normalmente, el nombre de host debe seleccionarse de una configuración almacenada mediante un ID de entorno opaco. Permitir que un agente proporcione host significa que puede elegir dónde se envían las credenciales y qué límite de red atraviesa la acción. Si un flujo realmente necesita varios hosts, asigna nombres aprobados como staging y production a configuraciones de conexión conocidas. Mantén activa la verificación del host. Una herramienta que la desactiva para evitar dificultades de configuración ha eliminado una parte importante del límite.

No envíes credenciales en el comando remoto, la línea de comandos ni la solicitud JSON. Usa el mecanismo de autenticación existente de la cuenta remota y limita esa cuenta a la acción que debe realizar. Un receptor de despliegues que solo pueda desplegar una aplicación es más fácil de analizar que un agente con una sesión de shell de amplias capacidades.

La aprobación no puede reparar un diseño de acción inseguro

Revoca una sesión del agente
El registro Sessions guarda las ejecuciones de los agentes y te permite revocar una de inmediato.

La aprobación humana y una pista de auditoría son controles útiles, pero responden a preguntas distintas de la validación. La aprobación indica si ese solicitante puede intentar una acción ahora. La validación indica si la solicitud tiene una forma definida y permitida. El registro indica qué ocurrió y si el registro cambió después. Tratar cualquiera de ellos como sustituto de los demás produce herramientas débiles con pantallas atractivas.

Una aprobación de sesión única puede ser adecuada para una pasarela de acciones fija. No es una revisión cuidadosa de cada cadena interpolada que un proceso pueda entregar a un shell durante el resto de la sesión. La aprobación por llamada ofrece a una persona más oportunidades de detectar un objetivo extraño, pero nadie debería tener que auditar reglas de comillas anidadas o descubrir un homoglifo Unicode malicioso en un diálogo denso.

Haz que la tarjeta de aprobación muestre la solicitud semántica: deploy branch release/42 to staging, no un comando de shell reconstruido a partir de una plantilla. El receptor debe poder comparar el entorno y la rama mostrados con el esquema de la solicitud. Si un comando necesita un campo de script sin procesar para explicarse, la acción es demasiado amplia para una aprobación fiable.

Registra hechos estructurados en el límite de la acción. Conserva la identidad del solicitante o del proceso del agente, el nombre de la acción aprobada, los argumentos o campos de solicitud separados, el objetivo seleccionado, las marcas de tiempo, el estado de salida y las referencias a la salida, de acuerdo con tus reglas de conservación de datos. Registra también los fallos de validación. Las cadenas de recorrido rechazadas o los valores parecidos a opciones que se repiten pueden revelar un prompt defectuoso, un error de integración o un sondeo activo.

Para los equipos que usan Sallyport, la autorización por sesión, la aprobación de claves por llamada y sus registros separados Sessions y Activity pueden conservar el control y las pruebas alrededor de las acciones de los agentes, pero la plantilla de comandos aún debe seguir las reglas de diseño anteriores.

Un registro resistente a manipulaciones ayuda después de un incidente solo si conserva los límites relevantes. Una cadena como ssh deploy internal deploy staging release 42 es ambigua a posteriori. Un evento con la identidad separada del host, la ruta fija del receptor, los campos JSON, el resultado de la autorización y el estado de salida del receptor puede respaldar una investigación.

Prueba el rechazo con la misma intensidad que el caso correcto

La mayoría de las pruebas de inyección de comandos solo demuestran que funciona una rama normal como release/42. Esa es la entrada menos interesante. El conjunto de pruebas debe demostrar que el controlador rechaza los valores peligrosos antes de iniciar un proceso hijo o abrir una ruta.

Crea una tabla pequeña para cada tipo de campo. Las entradas exactas dependen de la gramática, pero las categorías deben incluir separadores del shell, sustituciones de comandos, caracteres de comillas, espacios y saltos de línea, prefijos parecidos a opciones, componentes de recorrido, valores vacíos, caracteres de control, valores demasiado grandes y sintaxis válida que solicite algo fuera del alcance de la acción. Para una acción de ramas, prueba también un nombre parecido a una etiqueta y una expresión de revisión que la interfaz no admita.

Usa un ejecutor de procesos falso en las pruebas unitarias. Comprueba el ejecutable y cada argumento como valores separados. Comprueba que una entrada no válida nunca invoque al ejecutor. Una prueba que solo compare una cadena de comandos mostrada puede pasar por alto precisamente el límite que intentas proteger.

class RecordingRunner:
    def __init__(self):
        self.calls = []

    def run(self, argv, **kwargs):
        self.calls.append((argv, kwargs))

def test_rejects_branch_separator():
    runner = RecordingRunner()
    try:
        release_ref("release/42; id")
    except ValueError:
        pass
    else:
        raise AssertionError("expected rejection")
    assert runner.calls == []

Esa prueba comprueba la función de política, no el shell. Añade también una prueba de integración para el límite del proceso. Ejecuta un programa de prueba inofensivo que imprima los argumentos recibidos con índices y longitudes explícitos. Entrégale entradas que contengan espacios, comillas, caracteres comodín, paréntesis y saltos de línea. Confirma que cada valor llega como un solo argumento y que no se inicia un segundo programa.

El fuzzing resulta útil una vez que la acción tiene un esquema. Genera Unicode aleatorio dentro y fuera del conjunto permitido y comprueba uno de dos resultados: el validador lo rechaza o la operación fija lo recibe como un campo único y limitado. No hagas fuzzing contra un objetivo de producción. La finalidad es encontrar supuestos del analizador en la pasarela y el receptor antes de que el agente llegue a un entorno real con credenciales.

Por último, revisa todos los lugares que convierten de nuevo la entrada estructurada en texto. Incluye visores de registros con comandos en los que se puede hacer clic, fragmentos de shell generados, configuración de CI, informes de errores, notificaciones de chat y envoltorios remotos. Los errores de inyección suelen volver cuando una solicitud estructurada segura se convierte en una cadena práctica en un componente posterior.

La primera acción que conviene corregir suele ser la más flexible. Sustituye run_command(command) por una operación con nombre, un esquema de solicitud pequeño, un ejecutable fijo y un conjunto de pruebas lleno de entradas que nadie escribiría durante una demostración normal. Ese cambio elimina toda una clase de comportamientos del modelo de tu modelo de amenazas y te deja reglas que realmente puedes inspeccionar.

FAQ

¿Basta con usar comillas en el shell para evitar la inyección de comandos?

Las comillas solo cambian la forma en que un shell interpreta un valor. No determinan si ese valor representa un archivo, una rama, un host o un recurso de API permitido. Trata las comillas como una medida de higiene del analizador y la validación como una decisión de permisos: necesitas ambas.

¿Debería permitirse alguna vez que un agente de IA use sh -c?

Por lo general, no. Usa una API de procesos que acepte un ejecutable y un arreglo de argumentos, con la ejecución del shell desactivada. Así eliminas la gramática del shell, pero aún debes validar los argumentos según la semántica del programa que llamas.

¿Una ruta aparentemente segura aún puede causar daños?

Una ruta como ../../private/config no contiene metacaracteres del shell y aun así puede salir del directorio previsto. Resuelve la ruta respecto a una raíz aprobada, comprueba la contención después de resolverla y ten en cuenta los enlaces simbólicos antes de abrirla o actuar sobre ella.

¿Son seguros los arreglos de argumentos cuando el comando se ejecuta mediante SSH?

No. Un argumento separado sigue siendo un dato para el proceso local, pero SSH suele enviar una cadena de comandos que interpreta un shell remoto. Envía un comando remoto constante y pasa la entrada estructurada mediante la entrada estándar; después, vuelve a validarla en el host remoto.

¿Cómo debería validar una rama de Git proporcionada por un agente?

Rechaza los nombres que estén fuera de la gramática que realmente admite tu flujo de trabajo y convierte el nombre aceptado en un espacio de nombres fijo, como refs/heads/ seguido del nombre. No aceptes revisiones arbitrarias, expresiones de referencias ni opciones de comandos solo porque Git pueda interpretarlas.

¿Por qué importa la inyección indirecta de prompts para los comandos del shell?

La inyección de prompts no tiene que parecer maliciosa. Un archivo del repositorio, una descripción de incidencia, un registro de compilación o una respuesta de API puede indicar al agente que pase un valor peligroso a una herramienta. El límite de la herramienta debe aplicar sus propias reglas aunque el agente siga las instrucciones fielmente.

¿Los diálogos de aprobación impiden los comandos inseguros de los agentes?

Una aprobación indica que un proceso puede realizar una llamada. No convierte una plantilla de comandos insegura en segura ni demuestra que el argumento esté dentro del alcance permitido. Diseña la acción de forma segura antes de pedir a una persona que la apruebe.

¿Qué debería registrar una auditoría de un comando de agente?

Un registro útil incluye el ejecutable, cada argumento como un campo separado, la identidad del solicitante, el contexto del objetivo, el resultado y un resumen criptográfico o una copia protegida de la entrada estructurada. Una sola línea de comandos reconstruida pierde la información de límites que necesitas durante una investigación.

¿Debería bloquear los caracteres especiales o usar una lista de permitidos?

Rechazar cualquier carácter inusual solo es seguro si tu producto puede funcionar con una gramática tan limitada. Prefiere una lista de permitidos que corresponda al dominio real, como nombres sencillos de despliegue o ramas de repositorio, y documenta las formas admitidas. No finjas que una lista negra genérica comprende todos los analizadores involucrados.

¿Cómo puedo probar una pasarela de acciones de agentes contra la inyección de comandos?

Empieza con una herramienta que acepte una operación acotada, una solicitud estructurada y ningún campo de comando de shell. Pruébala con separadores, sustituciones, caracteres de opción iniciales, rutas de recorrido, saltos de línea y valores que sean sintaxis válida pero estén fuera del alcance. Si algún caso llega a una acción no prevista, la interfaz es demasiado amplia.

Sallyport

Sallyport ejecuta llamadas de API y comandos SSH por tu agente de IA. Las claves se quedan en una bóveda local de tu Mac; tú apruebas cada ejecución y cada acción queda en un registro sellado.

© 2026 Sallyport · Código abierto bajo Apache-2.0 · Oleg Sotnikov