ADR 0014: Dify workflow as a service (analyze / compile / run) with three-layer safe execution
Extend the existing ragspine service with three Dify endpoints of increasing trust — analyze and compile always on, run disabled by default behind a static import gate, a restricted in-process sandbox, and (Linux) subprocess isolation; the provider is always decided server-side.
Status: proposed · Date: 2026-06-24
Immutable record. Exempt from drift tracking (no
covers). Supersede, don't edit.
Builds on 0013 (the Dify YAML → pure-Python compiler + static
optimization advisor). Relates to 0009 (no framework lock-in +
permissive-license-only), 0010 (security decoupling), and the
spine-family boundaries ADR (family repo, docs/adr/0001).
Shipped in rag-spine 0.5.0.
Context
ADR 0013 compiles a Dify .yml into readable pure Python
(compile_dify_yaml / analyze). The next step is to put that capability behind a
service, so consumers can reach it over HTTP: get optimization suggestions, get the
readable code, and even run the compiled artifact directly. Running code someone else
submitted is a trust-boundary problem — the design must find a safe default between
"works out of the box" and "never exec lightly."
Constraints: reuse the existing ragspine service (app factory / DI / RQ queue / deploy) —
no second service; tests run with zero real LLM APIs (TestClient + MockProvider); the
provider is decided by server-side env (clients cannot inject a provider_expr — that
would be a code-injection surface); the family is fully synchronous; mypy --strict,
filterwarnings=error, pydantic only at the boundary.
Decision
Extend the ragspine service with three endpoints of increasing trust; run is off by
default and executes through three safety layers.
The three endpoints (src/ragspine/service/api/routes.py, compiler lazy-imported)
POST /v1/dify/analyze— static optimization analysis only (no codegen, no exec). Always available; absolutely safe.POST /v1/dify/compile— compile to a pure-Python code string (never executed). Always available; safe.POST /v1/dify/runandPOST /v1/dify/run/jobs(async) — compile + restricted execution. This is the trust boundary: off by default (RAGSPINE_DIFY_RUN_ENABLED=false→ 403), only an explicit env opt-in unlocks it; the provider is injected server-side.
Error shaping: DifyCompileError (code dify.*) → 400; the L0 static gate's
DifyUnsafeError (nothing was executed) → 422; execution / timeout
DifyRunError / DifyTimeoutError → 400; feature not enabled → 403. Async runs reuse the
existing GET /v1/jobs/{id}.
The three safety layers (src/ragspine/service/dify/{safety,runner}.py + scripts/run_dify_workflow.py)
- L0 — static gate (non-bypassable, before execution). ① a non-empty
GeneratedCode.warnings(aNotImplementedErrorskeleton / unsupported node) → reject (422); ② an AST walk over the generated source's imports — any top-level root module outside the allowlist (only__future__/dataclasses/typing/string/concurrent/json/corespine/ragspine/spineagent) → reject. The allowlist deliberately contains no I/O / process / network module (os / sys / subprocess / socket / shutil / pathlib / importlib). - L1 — restricted in-process sandbox. An explicit allowlist dict of safe builtins
(pure computation / containers / serialization + the exception types generated code uses),
the dunder required to execute class/module bodies (
__build_class__), and a restricted__import__(only L0-allowlisted root modules — even a dynamic__import__('os')is blocked);open/eval/exec/compile/inputand other escape surfaces are simply never allowlisted. The generated module is registered intosys.modules(dataclass string-annotation resolution underfrom __future__ import annotationsneeds it). Thread-based soft timeout (cross-platform, no reliance onSIGALRM). - L2 — subprocess isolation.
isolation='subprocess'→ on Linux, a separate child process (Popen(cwd=private tmp)+communicate(timeout)+kill()= SIGKILL for a runaway process, plusresource.setrlimitcapping CPU time / address space / file creation); the child still applies the L1 restricted sandbox inside. Non-Linux (macOS / Windows) automatically falls back to L1 (rlimit is unreliable there, so hard isolation buys little).
Provider safety & self-built providers in isolated processes / workers
Clients cannot pass a provider_expr (the schema has no such field; extra fields are
ignored by pydantic). Generated code pins the offline default provider; at run time the
server overrides it via run_workflow(provider=...). The subprocess / RQ worker builds its
own provider from build_provider(provider_config_dict(config)) — never a passed provider
instance or expression — so the provider is always decided by server-side env.
Landing points: 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, and service/config.py
(dify_run_enabled / dify_run_timeout_s / dify_run_isolation + provider_config_dict).
Security defaults (all accepted this round, pending review)
- All safety defaults accepted: the L0 static gate + L1 restricted sandbox + L2
subprocess isolation all land;
runis off by default and offline-mock by default. - No auth in the MVP: the HTTP layer does no authentication (consistent with the
existing
/v1/ask). Public exposure must put auth or a network allowlist in front (reverse proxy / ingress) — flagged prominently across deploy (README /.env.example/ compose / helm values). - Honest sandbox boundary: the L1 restricted builtins are one layer of defense in depth, not a complete sandbox; hard isolation comes from the L2 subprocess + SIGKILL + (Linux) rlimit. Non-Linux falls back to L1 with no hard resource caps — stated explicitly in code and docs.
Alternatives considered (rejected)
/runon by default. Rejected — running someone else's compiled artifact is a trust boundary; the safe default must be off. Out-of-the-box value is covered by analyze / compile, which are absolutely safe.- Blocklist-style restricted builtins (remove the dangerous names). Rejected — too easy
to miss one (the first draft missed
__build_class__, breaking class definitions under exec). Use an explicit allowlist dict instead: only names known safe, everything else denied by default. signal.SIGALRMfor the L1 timeout. Rejected — Unix-only and incompatible with threads. A thread-based soft timeout (cross-platform) + the L2 subprocess SIGKILL hard timeout instead.os.chdir(tmp)in L1. Rejected (removed after stepping on it in implementation) —os.chdiris a process-wide side effect; a thread soft timeout cannot kill the runaway thread, which would leave the whole process cwd inside a deleted tmp dir and poison later subprocess spawns. Inside the restricted sandbox,open/os/pathlibare unreachable anyway, so chdir buys nothing at process-level risk. The "run inside tmp" semantics move to the L2 subprocess'sPopen(cwd=private tmp).- Let clients pass
provider_expr. Rejected — a direct code-injection surface. The provider is decided by server-side env; isolated processes / workers self-build viabuild_provider.
Consequences
- The service gains three endpoints (analyze / compile safely always-on; run a trust boundary, off by default); it reuses the app factory / DI / RQ queue / deploy — no new service, no new configuration system.
- The
[service]extra adds PyYAML (the dify endpoints lazy-import the compiler; pydantic comes with fastapi); coredependenciesuntouched. The deploy image installs.[service,vector,dify]. - Restricted execution always passes L0 + L1 (+ L2 on Linux); the provider is always decided server-side and can never be injected by a client.
- This record is proposed: the security defaults (no-auth MVP / the honest sandbox boundary) await review; any change follows supersede-don't-edit (a new record or a status flip).
ADR 0013: Dify workflow YAML → pure-Python compiler + static optimization advisor
Compile a Dify workflow .yml into framework-free imperative Python through a de-Dify-ized IR (parse → IR → codegen), with an eight-rule static optimization advisor — PyYAML behind a new [dify] extra, fully synchronous, zero new core dependency.
ADR 0015: Relation extraction — an opt-in slot with model-derived / unverified provenance markers
Add a RelationExtractor Protocol slot beside build_relation_graph; the default stays byte-identical, LLM-extracted edges are stamped model-derived + unverified, screened through the SecurityGate, and never silently trusted.