ADR 0014: Workflow Dify come servizio (analyze / compile / run) con esecuzione sicura a tre livelli
Estende il servizio ragspine esistente con tre endpoint Dify a livello di fiducia crescente — analyze e compile sempre attivi, run disabilitato per impostazione predefinita dietro un gate statico sugli import, una sandbox in-process ristretta e (su Linux) isolamento in sottoprocesso; il provider è sempre deciso lato server.
Stato: proposto · Data: 2026-06-24
Registro immutabile. Esente dal tracciamento della deriva (nessun
covers). Sostituire con un nuovo record, non modificare.
Si basa su 0013 (il compilatore da YAML Dify → Python puro + l'advisor di
ottimizzazione statica). È correlato a 0009 (nessun lock-in verso i framework +
solo licenze permissive), a 0010 (disaccoppiamento della sicurezza) e
all'ADR sui confini della famiglia Spine (repository di famiglia, docs/adr/0001).
Rilasciato in rag-spine 0.5.0.
Contesto
ADR 0013 compila un .yml Dify in Python puro leggibile
(compile_dify_yaml / analyze). Il passo successivo è mettere questa capacità dietro un
servizio, così che i consumatori possano raggiungerla via HTTP: ottenere suggerimenti di
ottimizzazione, ottenere il codice leggibile e persino eseguire direttamente l'artefatto
compilato. Eseguire codice inviato da qualcun altro è un problema di confine di fiducia — il
design deve trovare un default sicuro tra
"funziona subito senza configurazione" e "mai exec alla leggera".
Vincoli: riutilizzare il servizio ragspine esistente (app factory / DI / coda RQ / deploy) —
nessun secondo servizio; i test girano senza alcuna API LLM reale (TestClient + MockProvider); il
provider è deciso dall'env lato server (i client non possono iniettare un provider_expr — sarebbe
una superficie di code injection); la famiglia è completamente sincrona; mypy --strict,
filterwarnings=error, pydantic solo al confine.
Decisione
Estendere il servizio ragspine con tre endpoint a livello di fiducia crescente; run è disattivato per
impostazione predefinita ed esegue attraverso tre livelli di sicurezza.
I tre endpoint (src/ragspine/service/api/routes.py, compilatore importato in modalità lazy)
POST /v1/dify/analyze— solo analisi statica di ottimizzazione (nessuna generazione di codice, nessuna esecuzione). Sempre disponibile; assolutamente sicuro.POST /v1/dify/compile— compilazione in una stringa di codice Python puro (mai eseguita). Sempre disponibile; sicuro.POST /v1/dify/runePOST /v1/dify/run/jobs(asincrono) — compilazione + esecuzione ristretta. Questo è il confine di fiducia: disattivato per impostazione predefinita (RAGSPINE_DIFY_RUN_ENABLED=false→ 403), solo un opt-in esplicito via env lo sblocca; il provider è iniettato lato server.
Modellazione degli errori: DifyCompileError (codice dify.*) → 400; il
DifyUnsafeError del gate statico L0 (nulla è stato eseguito) → 422; esecuzione / timeout
DifyRunError / DifyTimeoutError → 400; funzionalità non abilitata → 403. Le esecuzioni asincrone riutilizzano il
GET /v1/jobs/{id} esistente.
I tre livelli di sicurezza (src/ragspine/service/dify/{safety,runner}.py + scripts/run_dify_workflow.py)
- L0 — gate statico (non aggirabile, prima dell'esecuzione). ① un
GeneratedCode.warningsnon vuoto (uno scheletroNotImplementedError/ un nodo non supportato) → rifiuto (422); ② un'analisi AST sugli import del sorgente generato — qualsiasi modulo radice di primo livello fuori dall'allowlist (solo__future__/dataclasses/typing/string/concurrent/json/corespine/ragspine/spineagent) → rifiuto. L'allowlist deliberatamente non contiene alcun modulo di I/O / processo / rete (os / sys / subprocess / socket / shutil / pathlib / importlib). - L1 — sandbox in-process ristretta. Un dizionario allowlist esplicito di builtin sicuri
(calcolo puro / contenitori / serializzazione + i tipi di eccezione usati dal codice generato),
il dunder necessario per eseguire i corpi di classi/moduli (
__build_class__) e un__import__ristretto (solo i moduli radice presenti nell'allowlist L0 — persino un__import__('os')dinamico è bloccato);open/eval/exec/compile/inpute le altre superfici di evasione semplicemente non sono mai inserite nell'allowlist. Il modulo generato viene registrato insys.modules(la risoluzione delle annotazioni stringa dei dataclass sottofrom __future__ import annotationsne ha bisogno). Soft timeout basato su thread (multipiattaforma, nessuna dipendenza daSIGALRM). - L2 — isolamento in sottoprocesso.
isolation='subprocess'→ su Linux, un processo figlio separato (Popen(cwd=private tmp)+communicate(timeout)+kill()= SIGKILL per un processo fuori controllo, piùresource.setrlimitche limita il tempo di CPU / lo spazio di indirizzamento / la creazione di file); il figlio applica comunque la sandbox ristretta L1 al suo interno. Le piattaforme non Linux (macOS / Windows) ricadono automaticamente su L1 (lì rlimit è inaffidabile, quindi l'isolamento forte offre poco).
Sicurezza del provider e provider auto-costruiti in processi isolati / worker
I client non possono passare un provider_expr (lo schema non ha tale campo; i campi extra sono
ignorati da pydantic). Il codice generato fissa il provider offline predefinito; a runtime il
server lo sovrascrive tramite run_workflow(provider=...). Il sottoprocesso / worker RQ costruisce da
sé il proprio provider a partire da build_provider(provider_config_dict(config)) — mai un'istanza o un'espressione di provider
passata — così il provider è sempre deciso dall'env lato server.
Punti di atterraggio: src/ragspine/service/dify/{safety,runner}.py,
service/tasks/jobs.py:run_dify_workflow_job, scripts/run_dify_workflow.py,
service/api/{routes,schemas}.py e service/config.py
(dify_run_enabled / dify_run_timeout_s / dify_run_isolation + provider_config_dict).
Default di sicurezza (tutti accettati in questo giro, in attesa di revisione)
- Tutti i default di sicurezza accettati: il gate statico L0 + la sandbox ristretta L1 + l'isolamento
in sottoprocesso L2 sono tutti implementati;
runè disattivato per impostazione predefinita e offline-mock per impostazione predefinita. - Nessuna autenticazione nell'MVP: il livello HTTP non effettua alcuna autenticazione (coerentemente con
l'esistente
/v1/ask). L'esposizione pubblica deve anteporre un'autenticazione o un'allowlist di rete (reverse proxy / ingress) — segnalato in modo evidente in tutta la documentazione di deploy (README /.env.example/ compose / valori helm). - Confine onesto della sandbox: i builtin ristretti di L1 sono un livello di difesa in profondità, non una sandbox completa; l'isolamento forte proviene dal sottoprocesso L2 + SIGKILL + (Linux) rlimit. Le piattaforme non Linux ricadono su L1 senza limiti rigidi sulle risorse — dichiarato esplicitamente nel codice e nella documentazione.
Alternative considerate (respinte)
/runattivo per impostazione predefinita. Respinta — eseguire l'artefatto compilato di qualcun altro è un confine di fiducia; il default sicuro deve essere disattivato. Il valore immediato "out of the box" è coperto da analyze / compile, che sono assolutamente sicuri.- Builtin ristretti in stile blocklist (rimuovere i nomi pericolosi). Respinta — troppo facile
dimenticarne uno (la prima bozza aveva dimenticato
__build_class__, rompendo le definizioni di classe sotto exec). Usare invece un dizionario allowlist esplicito: solo i nomi noti come sicuri, tutto il resto negato per impostazione predefinita. signal.SIGALRMper il timeout L1. Respinta — solo Unix e incompatibile con i thread. Al suo posto, un soft timeout basato su thread (multipiattaforma) + il timeout rigido con SIGKILL del sottoprocesso L2.os.chdir(tmp)in L1. Respinta (rimossa dopo esserci inciampati in fase di implementazione) —os.chdirè un effetto collaterale a livello di intero processo; un soft timeout basato su thread non può uccidere il thread fuori controllo, il che lascerebbe la cwd dell'intero processo dentro una directory tmp cancellata e avvelenerebbe i successivi spawn di sottoprocessi. All'interno della sandbox ristretta,open/os/pathlibsono comunque irraggiungibili, quindi chdir non offre nulla a fronte di un rischio a livello di processo. La semantica "eseguire dentro tmp" si sposta nelPopen(cwd=private tmp)del sottoprocesso L2.- Permettere ai client di passare
provider_expr. Respinta — una superficie diretta di code injection. Il provider è deciso dall'env lato server; i processi isolati / worker si auto-costruiscono tramitebuild_provider.
Conseguenze
- Il servizio acquisisce tre endpoint (analyze / compile sicuri e sempre attivi; run un confine di fiducia, disattivato per impostazione predefinita); riutilizza l'app factory / DI / la coda RQ / il deploy — nessun nuovo servizio, nessun nuovo sistema di configurazione.
- L'extra
[service]aggiunge PyYAML (gli endpoint dify importano il compilatore in modalità lazy; pydantic arriva con fastapi); il coredependenciesresta intatto. L'immagine di deploy installa.[service,vector,dify]. - L'esecuzione ristretta passa sempre per L0 + L1 (+ L2 su Linux); il provider è sempre deciso lato server e non può mai essere iniettato da un client.
- Questo record è proposto: i default di sicurezza (MVP senza autenticazione / il confine onesto della sandbox) attendono revisione; qualsiasi modifica segue il principio "sostituire, non modificare" (un nuovo record o un cambio di stato).
ADR 0013: compilatore YAML di workflow Dify → Python puro + advisor statico di ottimizzazione
Compilare un file .yml di workflow Dify in Python imperativo indipendente dai framework attraverso una IR de-Dify-izzata (parse → IR → codegen), con un advisor statico di ottimizzazione a otto regole — PyYAML dietro un nuovo extra [dify], completamente sincrono, zero nuove dipendenze nel core.
ADR 0015: Estrazione di relazioni — uno slot opt-in con marcatori di provenienza model-derived / unverified
Aggiunge uno slot Protocol RelationExtractor accanto a build_relation_graph; il default resta identico byte per byte, gli archi estratti dall'LLM sono marcati model-derived + unverified, filtrati attraverso la SecurityGate e mai considerati attendibili in modo silente.