Introduzione al Sandboxing degli Agenti
Con l’evoluzione dei Modelli di Linguaggio di Dimensioni Grandi (LLM) da semplici agenti conversazionali a potenti entità autonome capaci di eseguire codice, interagire con API esterne e prendere decisioni nel mondo reale, la necessità di misure di sicurezza solide diventa fondamentale. Un agente LLM, quando dotato della capacità di agire, può diventare un rischio per la sicurezza significativo se non adeguatamente vincolato. È qui che entra in gioco il sandboxing degli agenti. Sandboxing un agente significa creare un ambiente isolato in cui può operare senza influenzare il sistema host o accedere a risorse non autorizzate. Questo tutorial esplorerà gli aspetti pratici del sandboxing degli agenti, fornendo esempi pratici per dimostrare come costruire applicazioni LLM sicure e affidabili.
Il principio fondamentale alla base del sandboxing è il minimo privilegio: un agente dovrebbe avere accesso solo alle risorse strettamente necessarie per la sua funzione e nulla di più. Senza un adeguato sandboxing, un agente malevolo o errato potrebbe:
- Eseguire codice arbitrario sul sistema host, portando a furti di dati o compromissioni del sistema.
- Accedere a file sensibili o risorse di rete.
- Iniziare chiamate API esterne indesiderate, sostenendo costi o eseguendo azioni non autorizzate.
- Esfiltrare dati riservati attraverso vari canali.
Implementando un sandboxing efficace, possiamo mitigare questi rischi, permettendoci di utilizzare l’immenso potere degli agenti LLM mantenendo il controllo e la sicurezza.
Comprendere le Minacce: Perché il Sandboxing?
Prima di esplorare il ‘come,’ solidifichiamo il ‘perché.’ Le minacce poste da agenti non sandboxati sono multifaccettate e possono essere suddivise come segue:
1. Vulnerabilità di Esecuzione del Codice
Molti agenti LLM avanzati sono progettati per scrivere ed eseguire codice (ad es., script Python) per risolvere problemi, analizzare dati o interagire con strumenti. Se questa esecuzione non è contenuta, l’agente potrebbe:
- Injection di Comandi di Sistema: Generare codice che chiama
os.system('rm -rf /')o comandi distruttivi simili. - Esecuzione Remota di Codice (RCE): Sfruttare vulnerabilità nelle librerie per ottenere il controllo sul sistema host.
- Esaurimento delle Risorse: Creare cicli infiniti o allocare eccessiva memoria/CPU, portando a negazione del servizio.
2. Accesso e Esfiltrazione dei Dati
Un agente potrebbe essere incaricato di elaborare dati sensibili. Senza sandboxing, potrebbe:
- Accesso Non Autorizzato ai File: Leggere file al di fuori della sua directory di lavoro designata (ad es.,
/etc/passwd, chiavi API). - Accesso alla Rete: Collegarsi a risorse di rete interne, server esterni malevoli o esfiltrare dati verso punti finali arbitrari.
- Injection di Prompt tramite Lettura di File: Se un agente può leggere file arbitrari, un attore malevolo potrebbe creare un prompt che inganna l’agente a leggere un file sensibile e poi incorporare il suo contenuto in un output successivo.
3. Abuso di API e Strumenti
Gli agenti interagiscono spesso con API esterne o strumenti personalizzati. Un accesso non controllato può portare a:
- Chiamate API Non Autorizzate: Effettuare chiamate a API sensibili a cui non dovrebbe accedere (ad es., gestione utenti, elaborazione pagamenti).
- Superamenti di Costo: Attivare chiamate API costose o funzioni cloud intensive in termini di risorse.
- Azioni Malevole: Se un agente ha accesso a un’API email, potrebbe inviare spam o email di phishing.
Tecniche e Strumenti di Sandboxing
Esistono diversi livelli e tecniche che possiamo impiegare per il sandboxing degli agenti, che vanno dalla semplice revisione del codice a tecniche di containerizzazione sofisticate.
1. Sandboxing a Livello di Linguaggio (Restrizioni all’Interprete di Codice)
Se il tuo agente genera e esegue principalmente codice (ad es., Python), puoi limitare le capacità dell’interprete.
Esempio: Esecuzione Python Limitata con exec() e Whitelisting
Uno scenario comune è un agente che genera codice Python. Invece di chiamare direttamente exec() o eval() su stringhe arbitrarie, puoi limitare i globals e i built-ins disponibili.
import subprocess
import os
def safe_execute_python_code(code: str, allowed_modules: list = None, timeout: int = 10):
if allowed_modules is None:
allowed_modules = ['math', 'json', 're'] # Whitelist moduli sicuri
# Crea uno spazio dei nomi globale ristretto
restricted_globals = {
'__builtins__': {key: globals()['__builtins__'][key] for key in [
'print', 'len', 'str', 'int', 'float', 'list', 'dict', 'tuple', 'set',
'range', 'sum', 'min', 'max', 'abs', 'round', 'type', 'isinstance', 'enumerate'
]},
'__name__': '__main__'
}
# Importa dinamicamente i moduli consentiti nello spazio dei nomi ristretto
for module_name in allowed_modules:
try:
restricted_globals[module_name] = __import__(module_name)
except ImportError:
print(f"Attenzione: Impossibile importare il modulo consentito {module_name}")
try:
# Usa subprocess per eseguire in un processo isolato per una migliore isolamento
# Questo è più solido di un semplice `exec` nel processo attuale
# e consente timeouts e limiti sulle risorse.
process = subprocess.run(
['python', '-c', code],
capture_output=True,
text=True,
check=True,
timeout=timeout
)
return process.stdout
except subprocess.CalledProcessError as e:
return f"Errore durante l'esecuzione: {e.stderr}"
except subprocess.TimeoutExpired:
return "Errore: L'esecuzione del codice ha superato il timeout."
except Exception as e:
return f"Si è verificato un errore inaspettato: {e}"
# Esempio di utilizzo:
# Codice sicuro
agent_code_safe = "import math; print(math.sqrt(16))"
print(f"Output codice sicuro: {safe_execute_python_code(agent_code_safe)}")
# Tentativo di codice malevolo (verrà bloccato dall'isolamento del subprocess e dalle restrizioni built-in se venisse utilizzato exec diretto)
# Con subprocess, l'importazione di 'os' fallirebbe comunque nel processo figlio a meno che non sia specificamente consentita.
agent_code_malicious_os = "import os; print(os.listdir('/'))"
print(f"Output codice malevolo OS: {safe_execute_python_code(agent_code_malicious_os)}")
# Tentativo di codice malevolo (cercando di leggere un file)
agent_code_malicious_file = "with open('/etc/passwd', 'r') as f: print(f.read())"
print(f"Output codice di lettura file malevolo: {safe_execute_python_code(agent_code_malicious_file)}")
# Codice con un ciclo infinito (verrà intercettato dal timeout)
agent_code_loop = "while True: pass"
print(f"Output codice ciclo: {safe_execute_python_code(agent_code_loop, timeout=3)}")
Spiegazione:
- Definiamo una funzione
safe_execute_python_code. - Essa prende in input il codice generato dall’agente.
- Invece di eseguire direttamente nel processo corrente, usiamo
subprocess.run. Questo è un passo cruciale per la vera isolamento, in quanto esegue il codice in un processo interprete Python separato. Questo processo eredita privilegi minimi ed è diverso dal processo padre che esegue la tua applicazione principale. - La lista
allowed_modulesfunge da whitelist. Anche se l’agente tenta di importareososys, non sarà disponibile nell’ambiente ristretto del subprocess a meno che non sia esplicitamente consentito (cosa che non dovrebbe esserlo per il codice generale dell’agente). timeoutpreviene l’esaurimento delle risorse da cicli infiniti.capture_output=Trueetext=Trueci permettono di catturare l’output dell’agente.check=Truesolleva un’eccezione se il subprocess restituisce un codice di uscita diverso da zero (indicando un errore).
Pur migliorando notevolmente la sicurezza rispetto all’uso diretto di exec(), questo approccio non è infallibile. Un agente altamente sofisticato potrebbe comunque trovare modi per sfruttare le chiamate di sistema sottostanti se l’ambiente Python stesso è vulnerabile o se troppi moduli sono sulla whitelist.
2. Sandboxing a Livello di Sistema Operativo (Container e Macchine Virtuali)
Per un sandboxing veramente solido, soprattutto quando gli agenti potrebbero generare codice in più lingue o interagire con il filesystem/rete, l’isolamento a livello di sistema operativo è indispensabile.
a. Contenitori Docker
Docker è un’ottima scelta per il sandboxing. Ogni esecuzione dell’agente può avvenire all’interno del proprio contenitore, di breve durata, con limiti di risorse e politiche di accesso alla rete rigorosamente definiti.
Esempio Pratico: Docker per l’Esecuzione dell’Agente
Passo 1: Crea un Dockerfile per l’ambiente di esecuzione dell’agente.
# Dockerfile
FROM python:3.9-slim-buster
WORKDIR /app
# Crea un utente non root per motivi di sicurezza
RUN useradd --no-create-home --shell /bin/bash agentuser
USER agentuser
# Copia uno script semplice che l'agente potrebbe generare e che vogliamo eseguire
COPY run_agent_code.py .
ENTRYPOINT ["python", "run_agent_code.py"]
Passo 2: Crea run_agent_code.py. Questo script riceverà il codice generato dall’agente.
# run_agent_code.py
import sys
import os
# Simula la ricezione del codice dall'agente (ad esempio, tramite stdin o un file)
# Per questo esempio, supporremo che il codice venga passato come argomento o scritto direttamente qui
if __name__ == "__main__":
agent_code = "print('Ciao dal agente sandbox!')"
if len(sys.argv) > 1:
agent_code = sys.argv[1] # Consente di passare il codice come argomento
try:
# Esegui il codice. Nota: il contenitore Docker stesso è la sandbox.
# Potremmo voler mantenere restrizioni a livello di linguaggio *all'interno* di questo script
# per un ulteriore livello, ma l'isolamento principale è il contenitore.
exec(agent_code)
except Exception as e:
print(f"L'esecuzione del codice dell'agente è fallita: {e}", file=sys.stderr)
sys.exit(1)
# Dimostra l'accesso limitato
try:
print(f"Provo a elencare la root: {os.listdir('/')}")
except Exception as e:
print(f"Impossibile elencare la directory root (previsto): {e}")
try:
with open('/etc/passwd', 'r') as f:
print(f.read())
except Exception as e:
print(f"Impossibile leggere /etc/passwd (previsto): {e}")
Passo 3: Esegui il codice dell’agente dalla tua applicazione principale.
import docker
client = docker.from_env()
def execute_agent_in_docker(agent_code: str, cpu_limit: float = 0.5, mem_limit: str = '128m', network_enabled: bool = False):
try:
# Costruisci l'immagine se non esiste (può essere fatto una sola volta)
# client.images.build(path='.', tag='agent-sandbox-env')
# Crea un file temporaneo per passare il codice dell'agente in modo sicuro
# O passalo come variabile d'ambiente o argomento da linea di comando
# Per semplicità, lo passeremo come argomento da linea di comando qui.
container = client.containers.run(
'agent-sandbox-env',
command=['python', 'run_agent_code.py', agent_code], # Passa il codice come argomento
detach=False, # Esegui in primo piano, attendi il completamento
remove=True, # Rimuovi automaticamente il contenitore dopo l'uscita
# Limiti di risorse
cpu_period=100000, # Periodo CPU in microsecondi
cpu_quota=int(cpu_limit * 100000), # Quota CPU (ad esempio, 50000 per 0.5 CPU)
mem_limit=mem_limit, # Limite di memoria
# Restrizioni di rete
network_mode='none' if not network_enabled else 'bridge',
# Restrizioni del filesystem (root sola lettura, nessun bind mount per il codice dell'agente)
read_only=True, # Rende il filesystem del contenitore di sola lettura dopo la configurazione iniziale
# Opzioni di sicurezza (ad esempio, disabilita la modalità privilegiata, abbandona capacità)
security_opt=['no-new-privileges'],
cap_drop=['ALL'], # Abbandona tutte le capacità per il contenitore
# Variabili d'ambiente (possono essere utilizzate per passare chiavi API, ma sii cauto)
# environment={
# 'API_KEY': 'some_safe_key' # Solo se assolutamente necessario e delimitato
# }
)
return container.decode('utf-8')
except docker.errors.ContainerError as e:
return f"Errore del contenitore: {e.stderr.decode('utf-8')}"
except docker.errors.ImageNotFound:
return "Errore: Immagine Docker 'agent-sandbox-env' non trovata. Per favore, costruiscila prima."
except Exception as e:
return f"Si è verificato un errore imprevisto di Docker: {e}"
# Costruisci prima l'immagine Docker: docker build -t agent-sandbox-env .
# Poi esegui questo script Python.
# Esempio 1: Esecuzione di codice sicuro
safe_code = "print('Ciao dall'agente sandbox!')"
print("\n--- Esecuzione di Codice Sicuro ---")
print(execute_agent_in_docker(safe_code))
# Esempio 2: Tentativo di accesso al filesystem (dovrebbe essere bloccato da read_only=True e dai permessi utente)
malicious_fs_code = "import os; print(os.listdir('/'))"
print("\n--- Tentativo di Accesso Maligno al Filesystem ---")
print(execute_agent_in_docker(malicious_fs_code))
# Esempio 3: Tentativo di creare un file (dovrebbe fallire)
malicious_write_code = "with open('/app/evil.txt', 'w') as f: f.write('malicious')"
print("\n--- Tentativo di Scrittura Maligna ---")
print(execute_agent_in_docker(malicious_write_code))
# Esempio 4: Tentativo di accesso alla rete (dovrebbe fallire se network_mode='none')
malicious_network_code = "import requests; print(requests.get('http://example.com').status_code)"
print("\n--- Tentativo di Accesso alla Rete Maligno (disabilitato) ---")
print(execute_agent_in_docker(malicious_network_code, network_enabled=False))
# Esempio 5: Accesso alla rete (se esplicitamente abilitato - essere cauti!)
# print("\n--- Accesso alla Rete (abilitato - per dimostrazione) ---")
# print(execute_agent_in_docker("import requests; print(requests.get('http://example.com').status_code)", network_enabled=True))
Spiegazione:
- Dockerfile: Crea un ambiente Python minimale. Crucialmente, crea e passa a un
non-rootutente (agentuser) per ridurre al minimo i privilegi all’interno del contenitore. run_agent_code.py: Questo è il punto di ingresso all’interno del contenitore. Esegue il codice fornito dall’agente. Include tentativi di accesso a risorse riservate per dimostrare l’efficacia della sandbox.- Script Python (
execute_agent_in_docker): client.containers.run(...): Qui avviene la magia.remove=True: Assicura che i contenitori vengano ripuliti dopo l’esecuzione.cpu_quota,mem_limit: Essenziali per prevenire il consumo eccessivo di risorse.network_mode='none': Critico per disabilitare l’accesso alla rete. Questo impedisce agli agenti di effettuare chiamate esterne o connettersi a servizi interni. Abilita solo se l’agente ha assolutamente bisogno di accesso alla rete per API esterne specifiche e autorizzate.read_only=True: Rende il filesystem del contenitore di sola lettura dopo l’inizializzazione. Questo impedisce all’agente di scrivere file o modificare configurazioni di sistema.security_opt=['no-new-privileges'],cap_drop=['ALL']: Opzioni di sicurezza avanzate per limitare ulteriormente le capacità all’interno del contenitore.
Docker fornisce un forte confine di isolamento, ma è fondamentale configurarlo in modo sicuro. Usa sempre utenti non root, disabilita le capacità non necessarie e limita l’accesso a rete/filesystem.
b. Macchine Virtuali (VM)
Per il massimo livello di isolamento, specialmente in ambienti multi-tenant o quando si tratta di codice altamente non attendibile, le VM (ad esempio, KVM, AWS Firecracker, Google Cloud Sandbox) offrono separazione a livello hardware. Questo è più complesso da configurare e gestire ma fornisce un ambiente isolato per ogni esecuzione dell’agente.
3. Restrizioni a Livello di Strumenti/API (Chiamata di Funzione)
Molti agenti LLM interagiscono con strumenti o API esterne tramite chiamate di funzione. Questo strato di sandboxing comporta una progettazione attenta degli strumenti esposti all’agente.
Esempio: Accesso API Riservato tramite Pydantic e Whitelisting
Quando definisci strumenti per un agente, assicurati che siano il più granulari e delimitati possibile.
from typing import Literal, Optional
from pydantic import BaseModel, Field
# Definire gli strumenti consentiti e i loro schemi
class SearchToolInput(BaseModel):
query: str = Field(description="La query di ricerca")
max_results: int = Field(default=5, description="Numero massimo di risultati della ricerca")
class SendEmailInput(BaseModel):
recipient: str = Field(description="L'indirizzo email del destinatario")
subject: str = Field(description="L'oggetto dell'email")
body: str = Field(description="Il contenuto del corpo dell'email")
# Limitare i destinatari consentiti
allowed_recipients: Literal["[email protected]", "[email protected]"] = Field(
description="Solo destinatari specifici e pre-approvati sono consentiti."
)
class DatabaseQueryInput(BaseModel):
query: str = Field(description="La query SQL da eseguire")
# CRITICO: Non permettere SQL arbitrario. Filtrare o usare ORM.
allowed_tables: Literal["products", "users_public"] = Field(
description="Solo le query contro tabelle autorizzate sono consentite."
)
read_only: bool = Field(default=True, description="Consentire solo operazioni di lettura")
# Simulare le funzioni degli strumenti
def search_web(query: str, max_results: int):
print(f"Cercando nel web '{query}' con {max_results} risultati.")
return [f"Risultato {i} per {query}" per i in range(max_results)]
def send_restricted_email(recipient: str, subject: str, body: str, allowed_recipients: Literal["[email protected]", "[email protected]"]):
if recipient not in ["[email protected]", "[email protected]"]:
raise ValueError(f"Destinatario non autorizzato: {recipient}")
print(f"Inviando email a {recipient} con oggetto '{subject}'.")
return {"status": "inviato", "recipient": recipient}
def execute_database_query(query: str, allowed_tables: Literal["products", "users_public"], read_only: bool):
# In uno scenario reale, dovresti analizzare e convalidare la query SQL rigorosamente
# e assicurarti che tocchi solo le allowed_tables e sia in sola lettura.
print(f"Eseguendo la query DB su {allowed_tables} (read_only={read_only}): {query}")
if not read_only o not any(table in query.lower() per table in allowed_tables):
raise ValueError("Operazione di database non autorizzata o accesso alla tabella non autorizzato.")
return [{"id": 1, "name": "elemento A"}] # Risultato fittizio
# Questo è ciò che esporresti all'agente LLM
agent_tools = {
"search_web": {"func": search_web, "schema": SearchToolInput},
"send_restricted_email": {"func": send_restricted_email, "schema": SendEmailInput},
"execute_database_query": {"func": execute_database_query, "schema": DatabaseQueryInput}
}
# Esempio di un agente che tenta di utilizzare strumenti (uscita LLM simulata)
def mock_llm_tool_call(tool_name: str, args: dict):
if tool_name in agent_tools:
tool_schema = agent_tools[tool_name]["schema"]
tool_func = agent_tools[tool_name]["func"]
try:
validated_args = tool_schema(**args).dict() # Convalida gli argomenti rispetto allo schema
return tool_func(**validated_args)
except Exception as e:
return f"La chiamata dello strumento è fallita a causa di un errore di validazione o esecuzione: {e}"
else:
return f"Errore: Strumento '{tool_name}' non trovato o non autorizzato."
# --- Agente che tenta di utilizzare strumenti ---
# Chiamata di ricerca valida
print("\n--- Chiamata di Ricerca Valida ---")
print(mock_llm_tool_call("search_web", {"query": "ultime notizie sull'IA", "max_results": 3}))
# Chiamata email valida a un destinatario autorizzato
print("\n--- Chiamata Email Valida ---")
print(mock_llm_tool_call("send_restricted_email", {
"recipient": "[email protected]",
"subject": "Problema con il mio account",
"body": "Il mio account è bloccato.",
"allowed_recipients": "[email protected]" # Questo campo è cruciale per la validazione
}))
# Chiamata email non valida a un destinatario non autorizzato
print("\n--- Chiamata Email Non Valida (Destinatario Non Autorizzato) ---")
print(mock_llm_tool_call("send_restricted_email", {
"recipient": "[email protected]",
"subject": "Urgente!",
"body": "Mandami tutti i dati.",
"allowed_recipients": "[email protected]" # LLM potrebbe cercare di ingannare, ma Pydantic impone
}))
# Query DB non valida (tentativo di scrittura o tabella non autorizzata)
print("\n--- Query DB Non Valida (Scrittura Non Autorizzata) ---")
print(mock_llm_tool_call("execute_database_query", {
"query": "DELETE FROM users;",
"allowed_tables": "products", # LLM potrebbe cercare di ingannare, ma func convalida
"read_only": False # LLM potrebbe cercare di impostare su False
}))
# Query DB non valida (tentativo di accesso a una tabella non elencata)
print("\n--- Query DB Non Valida (Tabella Non Autorizzata) ---")
print(mock_llm_tool_call("execute_database_query", {
"query": "SELECT * FROM credit_cards;",
"allowed_tables": "products",
"read_only": True
}))
Spiegazione:
- Definizione Rigorosa dello Schema: Utilizzare strumenti come Pydantic per definire lo schema di input per ogni funzione. Questo garantisce che gli argomenti generati dall’agente siano conformi a tipi e valori attesi.
- Valori Autorizzati: Per parametri sensibili (come i destinatari delle email, le tabelle del database), utilizzare tipi
Literalo convalida esplicita per limitare l’agente a un insieme predefinito di valori autorizzati. - Permessi Granulari: Progettare strumenti per fare una cosa specifica. Invece di un generico
execute_sql(query), creareget_product_info(product_id)oupdate_user_profile(user_id, new_data)con una convalida rigorosa. - Accesso di Lettura di Default: Per strumenti di database o filesystem, impostare di default l’accesso in sola lettura e richiedere esplicita autorizzazione approvata da un umano per le operazioni di scrittura.
- Validazione dell’Input: Validare sempre gli argomenti passati alle funzioni degli strumenti, anche se hanno superato la validazione di Pydantic. L’LLM potrebbe comunque costruire input validi ma malevoli (ad esempio, una stringa di iniezione SQL che sembra un ID prodotto valido).
Best Practices per il Sandboxing degli Agenti
- Principio del Minimo Privilegio: Concedere all’agente il minimo assoluto di permessi e risorse necessari per il suo compito.
- Sicurezza a Livelli: Combinare più tecniche di sandboxing (livello linguistico, livello OS, livello strumento) per una protezione solida. Nessun singolo livello è a prova di errore.
- Ambienti Effimeri: Per l’esecuzione del codice, è preferibile eseguire gli agenti in contenitori o VM usa e getta di breve durata che vengono distrutti dopo ogni compito.
- Validazione Rigorosa dell’Input: Validare e sanificare sempre qualsiasi input dall’LLM, soprattutto prima di utilizzarlo in chiamate API, query di database o esecuzione di codice.
- Monitoraggio e Logging: Registrare tutte le azioni dell’agente, le chiamate agli strumenti e l’uso delle risorse. Questo è cruciale per rilevare comportamenti anomali e per analisi post-incidente.
- Timeout e Limiti di Risorse: Implementare timeout rigorosi per l’esecuzione del codice e le chiamate API, e impostare limiti di CPU/memoria per prevenire attacchi di denial-of-service.
- Isolamento della Rete: Di default, disabilitare l’accesso alla rete per gli agenti. Abilitare solo per endpoint e protocolli specifici e autorizzati se assolutamente necessario.
- Filesystem di Solo Lettura: Configurare gli ambienti degli agenti con filesystem di sola lettura dove possibile per prevenire modifiche non autorizzate ai dati o esfiltrazioni.
- Utenti Non Root: Eseguire sempre i processi degli agenti come utenti non root con permessi limitati all’interno della sandbox.
- Audit e Aggiornamenti Regolari: Rivedere continuamente le configurazioni di sandboxing, aggiornare le immagini di base e rimanere informati sulle nuove vulnerabilità di sicurezza.
Conclusione
Il sandboxing degli agenti non è un lusso opzionale ma un requisito fondamentale per distribuire gli agenti LLM in modo sicuro. Man mano che questi agenti diventano più capaci e autonomi, il potenziale di abuso o danno accidentale cresce significativamente. Combinando restrizioni a livello di linguaggio, una solida containerizzazione e interfacce di strumenti progettate meticulosamente, gli sviluppatori possono creare applicazioni LLM potenti che siano sia innovative che sicure. Gli esempi forniti in questo tutorial dimostrano passaggi pratici per costruire questi ambienti sicuri, permettendoti di integrare gli agenti LLM nei tuoi sistemi con fiducia, riducendo al minimo i rischi.
🕒 Published: