Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
a63afe6
feat: execd sandbox integration
michalu Oct 20, 2026
5571c31
Fix based on coderabbit findings
michalu Oct 22, 2026
46e382b
SANDBOX_CALLBACK_URL env var configuration fix
michalu Oct 23, 2026
ffcfd08
Merge remote-tracking branch 'upstream/main' into feature/sandbox2-di…
michalu Oct 27, 2026
47f3b67
Fix issues raised during code review
michalu Oct 28, 2026
2033060
Fix issues raised during code review
michalu Oct 28, 2026
756d340
Merge remote-tracking branch 'upstream/main' into feature/sandbox2-di…
michalu Oct 28, 2026
990cca3
fix: address CodeRabbit review findings
michalu Oct 29, 2026
abf1273
Merge remote-tracking branch 'upstream/main' into feature/sandbox2-di…
michalu Oct 29, 2026
a8658ce
fix: address CodeRabbit review findings
michalu Oct 29, 2026
a53e7c5
fix: address CodeRabbit review findings
michalu Oct 29, 2026
969bf55
fix: address CodeRabbit review findings
michalu Oct 29, 2026
9d06262
SANDBOX_CALLBACK_URL deployment fix
michalu Oct 29, 2026
5bbe9eb
fix - 13. Rebuild the frontend bundle
michalu Oct 29, 2026
0035339
fix - 6. Mark the Code Sandbox section as experimental
michalu Oct 29, 2026
29b4e3c
fix - 10. Lazy imports.
michalu Oct 29, 2026
376d648
fix - 18. The new kernel check can be bypassed - aligned with local e…
michalu Oct 29, 2026
c90848d
fix - 15. The folder collision is still possible
michalu Oct 29, 2026
1975970
fix - 14. add the isolation note to the Manage text too
michalu Oct 29, 2026
2fe2e29
fix - 33. The new run_command timeout kills only /bin/sh
michalu Oct 29, 2026
02ac98e
fix - 23,27,31,32
michalu Oct 29, 2026
968b5f1
fix - Switching off can hang, DELETE /api/manage/sandbox switches off…
michalu Oct 29, 2026
2ea0823
fix - new issues from fixes - low
michalu Oct 29, 2026
2326a63
fix - 38 - code comment, headers are used only for logging
michalu Oct 29, 2026
4f77ed8
fix - 40 - readme update
michalu Oct 29, 2026
ba0baba
fix - ui sandbox - configuration desc
michalu Oct 29, 2026
e868c33
fix - unit tests - config reload in embedder cache test broke sandbox…
michalu Oct 29, 2026
df5d46f
Merge remote-tracking branch 'upstream/main' into feature/sandbox2-di…
michalu Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions deployment/helm/cuga/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,18 @@ spec:
- name: CUGA_DBS_DIR
value: "/data/dbs"
{{- end }}
{{- /* Address the execd sandbox calls tools back on. Only the chart knows the real
Service name, so it is derived per release; a non-empty env value overrides it.
The sandbox egress policy allows this host name, not pod IPs. Callback blocks
live in the memory of the pod that registered them, so with more than one
replica the Service needs session affinity or a per-pod address. */}}
{{- $callbackKey := "DYNACONF_ADVANCED_FEATURES__SANDBOX_CALLBACK_URL" }}
- name: {{ $callbackKey }}
value: {{ default (printf "http://%s.%s.svc.cluster.local:%v" (include "cuga.fullname" .) .Release.Namespace .Values.service.port) (get (.Values.env | default dict) $callbackKey) | quote }}
{{- $secret := .Values.existingSecret }}
{{- $sensitiveKeys := list "GROQ_API_KEY" "OIDC_CLIENT_SECRET" "OIDC_CLIENT_ID" "OIDC_DISCOVERY_URL" "OIDC_REDIRECT_URI" "DYNACONF_STORAGE__POSTGRES_URL" "VAULT_TOKEN" }}
{{- range $key, $value := .Values.env }}
{{- if ne $key $callbackKey }}
- name: {{ $key }}
{{- if and $secret (has $key $sensitiveKeys) }}
valueFrom:
Expand All @@ -45,6 +54,7 @@ spec:
value: {{ $value | quote }}
{{- end }}
{{- end }}
{{- end }}
livenessProbe:
httpGet:
path: /
Expand Down
7 changes: 7 additions & 0 deletions deployment/helm/cuga/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ env:
# DYNACONF_OBSERVABILITY__PRICING_JSON: ""
DYNACONF_OBSERVABILITY__LITELLM_LOCAL_MODEL_COST_MAP: "true"
OTEL_EXPORTER_OTLP_ENDPOINT: ""
# Sandbox tool-callback address. Leave unset: the chart derives it per release as
# http://<fullname>.<namespace>.svc.cluster.local:<service.port>, the host the sandbox egress
# policy allows. Set only to override.
# The default is plain HTTP: block tokens, arguments and results are unencrypted and a token
# stays valid while its block runs. Use it only on a trusted in-cluster network; elsewhere set
# an HTTPS address here (e.g. through a TLS proxy that still reaches this pod).
# DYNACONF_ADVANCED_FEATURES__SANDBOX_CALLBACK_URL: ""

# Name of an existing Secret containing sensitive env vars.
# When set, sensitive keys are sourced via secretKeyRef instead of plain value.
Expand Down
25 changes: 25 additions & 0 deletions docs/readme/configuration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,31 @@ E2B will automatically execute code in cloud sandboxes. You'll see logs indicati

</details>

<details>
<summary> Remote execd sandbox: isolation model</summary>

With **`[advanced_features] sandbox_mode = "execd"`**, or with the sandbox turned on in **Manage → Code sandbox**, generated Python, `run_command`, the filesystem tools and the workspace panel run in a remote sandbox behind sandbox-api. Connection settings (`execd_url`, `execd_api_key`, `sandbox_secret_dir`, `sandbox_callback_url`, `execd_package_index`) are described in [`settings.toml`](../../src/cuga/settings.toml).

The connection comes from the deployment: the sandbox secret mounted at `sandbox_secret_dir`, or `execd_url` / `execd_api_key`. **Manage → Code sandbox** appears only when one of them is configured, and only switches the sandbox on or off. It does not take a URL or key.

**What keeps conversations apart.** The sandbox does, not CUGA. Each conversation gets its own kernel (a separate process) and its own workspace, `/workspace/<thread_id>`. Code in one conversation cannot read, change or list another conversation's files, and cannot reach execd or Jupyter inside the sandbox. This requires a sandbox-api build that passes its workspace isolation test (`scripts/25_test_workspace_isolation.sh` in the sandbox-api repo).

**What does not.** Inside its own conversation, agent code runs with the same restrictions as under the local executor: safe builtins, the import allowlist, and only its own tools and variables, not the kernel's helpers. CUGA also answers only the tools a code block names, so tool approval sees every tool that runs. These restrictions run in the same process as the code, so they are not a boundary. Treat the sandbox's filesystem and network policy as the security boundary.

**Tools.** Tools run in CUGA, not in the sandbox. The sandbox calls them back at `{sandbox_callback_url}/sandbox/tools/call`. Each code block carries its own signed token, so one conversation cannot call another conversation's tools.

**Before you turn it on:**

- Allow egress from the sandbox only to `sandbox_callback_url` and to the package index. Egress is denied by default.
- Use a sandbox-api version that accepts `envs` on `POST /command`. CUGA sends the venv, the package index and the file-tool operands there.
- Do not put credentials in `execd_package_index`. Agent code can read it.
- Packages that a conversation installs go into its own venv. The image's packages are shared.
- Deleting a conversation releases its kernel. Turning the sandbox off releases the kernels this process holds; kernels from before a restart or on other replicas stay until sandbox-api's TTL. Workspace files stay in the sandbox either way.

See the [executors README](../../src/cuga/backend/cuga_graph/nodes/cuga_lite/executors/README.md#isolation-model-execd-mode) for details.

</details>

<details>
<summary> Code generation profiles - fast / balanced / accurate</summary>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@

from pydantic import BaseModel, Field

PythonBackend = Literal["local", "e2b"]
ShellBackend = Literal["none", "local", "native", "opensandbox", "e2b"]
FilesystemBackend = Literal["none", "host", "sandbox_remote"]
PythonBackend = Literal["local", "e2b", "execd"]
ShellBackend = Literal["none", "local", "native", "opensandbox", "e2b", "execd"]
FilesystemBackend = Literal["none", "host", "sandbox_remote", "sandbox_execd"]


class ExecutionPlan(BaseModel):
Expand All @@ -37,8 +37,8 @@ class ExecutionPlan(BaseModel):
@property
def split_execution_active(self) -> bool:
"""True when generated Python runs locally while shell/FS run remotely."""
remote_shell = self.shell_backend in ("native", "opensandbox", "e2b")
remote_fs = self.filesystem_backend == "sandbox_remote"
remote_shell = self.shell_backend in ("native", "opensandbox", "e2b", "execd")
remote_fs = self.filesystem_backend in ("sandbox_remote", "sandbox_execd")
return self.python_backend == "local" and (remote_shell or remote_fs)


Expand Down Expand Up @@ -82,9 +82,17 @@ def resolve(

# ── python_backend ──────────────────────────────────────────────────
e2b = bool(getattr(adv, "e2b_sandbox", False))
settings_choice: PythonBackend = (
explicit_python if explicit_python is not None else ("e2b" if e2b else "local")
)
# Local import avoids a circular import with code_executor.
from cuga.backend.cuga_graph.nodes.cuga_lite.executors.execd.connection import effective_sandbox_mode

# execd is chosen via sandbox_mode, but it decides where Python runs.
_mode_is_execd = effective_sandbox_mode(str(getattr(adv, "sandbox_mode", "") or "")) == "execd"
if explicit_python is not None:
settings_choice: PythonBackend = explicit_python
elif _mode_is_execd:
settings_choice = "execd"
else:
settings_choice = "e2b" if e2b else "local"
if mode is not None:
python_backend: PythonBackend = mode
if mode != settings_choice:
Expand Down Expand Up @@ -114,9 +122,11 @@ def resolve(
"Remove advanced_features.enable_shell_tool from your settings."
)
elif _legacy_shell_on:
sandbox_mode = str(getattr(adv, "sandbox_mode", "native") or "native")
sandbox_mode = effective_sandbox_mode(str(getattr(adv, "sandbox_mode", "native") or "native"))
shell_backend = (
sandbox_mode if sandbox_mode in ("local", "native", "opensandbox", "e2b") else "native"
sandbox_mode
if sandbox_mode in ("local", "native", "opensandbox", "e2b", "execd")
else "native"
)
else:
shell_backend = "none"
Expand All @@ -125,7 +135,12 @@ def resolve(
if explicit_fs is not None:
filesystem_backend: FilesystemBackend = explicit_fs
elif bool(getattr(adv, "enable_filesystem_tools", False)):
filesystem_backend = "sandbox_remote" if shell_backend == "opensandbox" else "host"
if _mode_is_execd:
# Mirrors resolve_runtime_backends: under execd the files live
# wherever the Python runs, whether or not the shell tool is on.
filesystem_backend = "sandbox_execd"
else:
filesystem_backend = "sandbox_remote" if shell_backend == "opensandbox" else "host"
else:
filesystem_backend = "none"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@

from types import SimpleNamespace

import pytest

from cuga.backend.cuga_graph.nodes.cuga_agent_core.policy.execution_policy import (
ExecutionPlan,
ExecutionRouter,
split_execution_note,
)

pytestmark = pytest.mark.unit


def _settings(**advanced) -> SimpleNamespace:
defaults = dict(
Expand Down Expand Up @@ -272,3 +276,37 @@ def test_no_deprecation_when_no_execution_section_and_legacy_flags_used() -> Non
assert plan.python_backend == "e2b"
# No deprecation without an execution.* override
assert not any("deprecated" in f.lower() for f in plan.fallbacks)


# ─── execd ──────────────────────────────────────────────────────────────────


def test_execd_mode_is_reported_as_the_python_backend():
"""The plan is what the logs and split-execution detection read.

execd arrives as ``advanced_features.sandbox_mode`` rather than an
``execution.*`` setting, but it decides where Python runs, so a plan that
still said ``local`` would describe the wrong environment.
"""
plan = ExecutionRouter.resolve(_settings(sandbox_mode="execd"))
assert plan.python_backend == "execd"


def test_execd_shell_and_filesystem_follow_the_python():
plan = ExecutionRouter.resolve(
_settings(sandbox_mode="execd", enable_shell_tool=True, enable_filesystem_tools=True)
)
assert plan.shell_backend == "execd"
assert plan.filesystem_backend == "sandbox_execd"
# Everything is in one sandbox, so nothing is split.
assert plan.split_execution_active is False


def test_execd_filesystem_is_sandboxed_without_the_shell_tool():
plan = ExecutionRouter.resolve(_settings(sandbox_mode="execd", enable_filesystem_tools=True))
assert plan.filesystem_backend == "sandbox_execd"


def test_explicit_python_backend_still_outranks_execd():
plan = ExecutionRouter.resolve(_settings_with_execution(python_backend="e2b", sandbox_mode="execd"))
assert plan.python_backend == "e2b"
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import pytest

import cuga.backend.cuga_graph.nodes.cuga_lite.executors.filesystem.factory as fs_factory
from cuga.backend.cuga_graph.nodes.cuga_agent_core.tools.runtime_tools import (
RuntimeBackends,
ToolBundle,
Expand Down Expand Up @@ -170,14 +171,14 @@ async def _rf():
return [_FakeTool("read_file", coroutine=_rf), _FakeTool("write_file", func=lambda: None)]

class FakeRemoteBackend:
def __init__(self, executor, thread_id=None):
created["remote_executor"] = executor
def __init__(self, thread_id=None):
created["remote_thread_id"] = thread_id

import cuga.backend.cuga_graph.nodes.cuga_lite.executors.filesystem as fs_pkg

monkeypatch.setattr(fs_pkg, "create_filesystem_tools", fake_create_fs)
monkeypatch.setattr(fs_pkg, "RemoteSandboxBackend", FakeRemoteBackend)
# The sandbox backend is now chosen in one place; this is that seam.
monkeypatch.setattr(fs_factory, "sandbox_workspace_backend", FakeRemoteBackend)

class FakeShellExecutor:
def __init__(self, label):
Expand Down Expand Up @@ -226,9 +227,10 @@ def test_host_fs_orchestrates_create_filesystem_tools(patch_packages):
assert [a.name for a in bundle.app_definitions] == ["filesystem"]


def test_sandbox_remote_fs_builds_remote_backend(patch_packages):
build_runtime_tools(thread_id="t9", backends=RuntimeBackends("sandbox_remote", "opensandbox"))
# RemoteSandboxBackend wired with the opensandbox executor + thread_id
@pytest.mark.parametrize("choice", ["sandbox_remote", "sandbox_execd"])
def test_sandbox_fs_asks_the_factory_for_the_backend(patch_packages, choice):
"""Both sandbox-backed choices go through one factory, bound to the thread."""
build_runtime_tools(thread_id="t9", backends=RuntimeBackends(choice, "none"))
assert patch_packages["fs_backend"] is not None
assert patch_packages["remote_thread_id"] == "t9"

Expand Down Expand Up @@ -299,3 +301,58 @@ async def test_skill_tool_func_is_awaitable_via_make_tool_awaitable():

result = await wrapped(name="my_skill")
assert "instructions" in result


# ─── execd: the sandbox holds the workspace, so the tools must follow it ────


def test_shell_execd_when_enabled():
b = resolve_runtime_backends(_settings(enable_shell_tool=True, sandbox_mode="execd"), {})
assert b.shell == "execd"


def test_execd_filesystem_is_sandbox_backed_without_the_shell_tool():
"""The coherence rule that motivates the whole execd wiring.

Under execd the agent's Python already runs in the sandbox and writes to
its workspace there. Host-backed files would mean generated code writes a
file that ``read_file`` cannot find — and whether the shell tool happens to
be enabled has nothing to do with where those files live.
"""
b = resolve_runtime_backends(
_settings(enable_filesystem_tools=True, enable_shell_tool=False, sandbox_mode="execd"), {}
)
assert b.filesystem == "sandbox_execd"
assert b.shell == "none"


def test_execd_filesystem_does_not_need_the_opensandbox_flag():
"""``opensandbox_sandbox`` describes a different daemon and must not gate execd."""
b = resolve_runtime_backends(
_settings(enable_filesystem_tools=True, sandbox_mode="execd", opensandbox_sandbox=False), {}
)
assert b.filesystem == "sandbox_execd"


def test_opensandbox_filesystem_gating_is_unchanged():
"""Guard against the execd branch leaking into the opensandbox path."""
b = resolve_runtime_backends(
_settings(enable_filesystem_tools=True, enable_shell_tool=False, sandbox_mode="opensandbox"),
{},
)
assert b.filesystem == "host"


def test_filesystem_execution_callables_carry_app_name_tag(patch_packages):
"""Filesystem callables must be tagged _cuga_app_name='filesystem'.

E2BExecutor._serialize_tools reads this attribute to emit the correct
call_api("filesystem", ...) stub. Without it the fallback splits on "_"
and guesses "write" / "read" / "list", producing HTTP 404s at runtime.
"""
bundle = build_runtime_tools(thread_id="t1", backends=RuntimeBackends("host", "none"))
for name in ("read_file", "write_file"):
fn = bundle.execution_callables[name]
assert getattr(fn, "_cuga_app_name", None) == "filesystem", (
f"execution_callable '{name}' missing _cuga_app_name='filesystem'"
)
Loading
Loading