Skip to content

Commit 6ff5d95

Browse files
committed
fix(sandbox-envs): store no egress policy when no allowed hosts
Address PR-review findings on the per-environment egress policy feature. - An empty/untouched egress editor now stores egress_policy=None instead of an explicit deny-all, so re-saving a network-enabled env no longer changes its runtime network behavior. To block all egress, use Network off. - Reject credential header names with control chars / CR-LF and values with newlines, guarding against header injection into the sidecar. - Remove references to the nonexistent DAIV_SANDBOX_EGRESS_PROXY_ENABLED setting from the error message, model comment, docs, and tests. - Log egress-proxy-unavailable distinctly (still fail-closed) when a fresh session aborts, instead of the generic seed-failure message. - Trim the CHANGELOG entry to the user-facing feature (Keep a Changelog); correct the "empty hosts = no outbound access" overstatement in docs. - Drop dead code (has_egress property, blankHost label) and extract _empty_egress_state() and the _MASK_SENTINEL constant to remove duplicated literals.
1 parent 79fbd48 commit 6ff5d95

11 files changed

Lines changed: 154 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12-
- Added per-environment network egress policy for sandbox environments. A `SandboxEnvironment` can now carry an `egress_policy` (default/intercept + per-host rules) and encrypted `egress_secrets` (named header credentials); when set, the agent provisions them onto the daiv-sandbox MITM egress proxy at session start so credentials are injected by the sidecar and never enter the sandbox container. The egress proxy is mandatory for network-enabled sessions: if the sandbox deployment has no egress proxy configured, a network-enabled run is rejected rather than falling back to unrestricted network. Note: a network-enabled environment **without** an egress policy receives the sidecar's default deny-all (no connectivity) — configure an egress policy on those environments. The egress policy is now configurable from the sandbox-environment form UI (shown only when **Network** is enabled): define allowed hosts (glob patterns), inject per-host credentials (HTTP header name + value, encrypted at rest and never rendered back), and set the `default` (deny/allow) and `intercept` (all/credentialed) modes. Fail-closed: an environment with no allowed hosts has no outbound access.
12+
- Added a per-environment **network egress policy** for sandbox environments, configurable from the environment form when network is enabled. Restrict outbound traffic to an allow-list of hosts (glob patterns), attach per-host HTTP credentials (header name + value, encrypted at rest and never rendered back), and choose the `default` (deny/allow) and `intercept` (all/credentialed) traffic modes. Credentials are injected by the daiv-sandbox egress proxy and never enter the sandbox container. The egress proxy is mandatory for network-enabled sessions: if the sandbox deployment has no egress proxy configured, a network-enabled run is rejected rather than falling back to unrestricted network.
1313
- Added `release_orphan_queued_threads` management command to recover `QUEUED` activities left behind on threads with no active sibling (rare TOCTOU loss).
1414
- Added per-domain auth headers for the `web_fetch` tool to the configuration UI under **Web Fetch → Per-domain auth headers**. Each row pairs a domain (exact match) with an HTTP header name and a header value (encrypted at rest). Replaces the previous env-only `AUTOMATION_WEB_FETCH_AUTH_HEADERS` setting; the new env override is `DAIV_WEB_FETCH_AUTH_HEADERS` (same JSON shape). Operators using the old name must rename it.
1515
- Added a "Start a run" page at `/dashboard/runs/new/` for launching new agent runs from the UI, and a "Retry" button on terminal non-webhook activities that pre-fills the form with the original prompt, repository, ref, and max-mode flag.

daiv/automation/agent/middlewares/sandbox.py

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -506,18 +506,21 @@ async def _session_exists(client: DAIVSandboxClient, session_id: str) -> bool:
506506
async def _provision_egress(client: DAIVSandboxClient, session_id: str, sb: SandboxRuntime | None) -> None:
507507
"""Provision the sidecar egress policy for a network-enabled session that requires it.
508508
509-
No-op when there is no resolved sandbox runtime, the env defines no egress policy, or network
510-
is off. Only a **404** (egress proxy not configured on the sandbox — no shared egress CA,
511-
unambiguous and permanent) is converted to the actionable ``SandboxEgressUnavailableError``.
512-
On a fresh create this 404 is effectively unreachable: a network-enabled ``start_session`` is
513-
rejected up front (400) when the sandbox has no egress proxy, so the run aborts before reaching
514-
here. It survives for warm reuse — if the shared CA is rotated out between the session's start
515-
and a later turn's re-provision, ``configure_egress`` 404s and we still want the actionable
516-
diagnosis. Every other status propagates unchanged, including a 409 — on the egress endpoint 409
517-
is ambiguous ("Session is busy" transient lock contention, or "Session has no egress proxy") and
518-
the project already classifies 409 as transient (``TRANSIENT_SANDBOX_STATUS``), so it must not be
519-
mislabeled as a permanent "configure the proxy" diagnosis. A propagated error still fails closed:
520-
the caller's setup path aborts the run.
509+
No-op when there is no resolved sandbox runtime, the env defines no egress policy
510+
(``sb.egress is None``), or network is off. Note the fail-closed deny-all that
511+
``row_to_override`` substitutes for an unusable stored config is a *non-None*
512+
``EgressConfigRequest`` and is deliberately provisioned here. Only a **404** (egress proxy not
513+
configured on the sandbox — no shared egress CA, unambiguous and permanent) is converted to the
514+
actionable ``SandboxEgressUnavailableError``. On a fresh create this 404 is effectively
515+
unreachable: a network-enabled ``start_session`` is rejected up front (400) when the sandbox has
516+
no egress proxy, so the run aborts before reaching here. It survives for warm reuse — if the
517+
shared CA is rotated out between the session's start and a later turn's re-provision,
518+
``configure_egress`` 404s and we still want the actionable diagnosis. Every other status
519+
propagates unchanged, including a 409 — on the egress endpoint 409 is ambiguous ("Session is
520+
busy" transient lock contention, or "Session has no egress proxy") and the project already
521+
classifies 409 as transient (``TRANSIENT_SANDBOX_STATUS``), so it must not be mislabeled as a
522+
permanent "configure the proxy" diagnosis. A propagated error still fails closed: the caller's
523+
setup path aborts the run.
521524
"""
522525
if sb is None or sb.egress is None or not sb.network_enabled:
523526
return
@@ -582,8 +585,13 @@ async def abefore_agent(self, state: StateT, runtime: Runtime[RuntimeCtx]) -> di
582585
)
583586
await client.seed_session(session_id, repo_archive=repo_archive, skills_archive=skills_archive)
584587
await self._provision_egress(client, session_id, sb)
585-
except Exception:
586-
logger.exception("Failed to build or seed sandbox session %s", session_id)
588+
except Exception as exc:
589+
# Distinguish the actionable egress-misconfiguration from a generic seed failure so the
590+
# operator-facing diagnosis survives in the logs (both still force-close + fail-closed).
591+
if isinstance(exc, SandboxEgressUnavailableError):
592+
logger.error("Egress proxy unavailable for sandbox session %s; aborting run (fail-closed)", session_id)
593+
else:
594+
logger.exception("Failed to build or seed sandbox session %s", session_id)
587595
try:
588596
await client.close_session(session_id, force=True)
589597
except Exception:

daiv/core/sandbox/client.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@ async def configure_egress(self, session_id: str, request: EgressConfigRequest)
237237
``httpx.HTTPStatusError`` on a non-2xx; the caller decides how to react. Status meanings:
238238
404 = egress proxy disabled on the sandbox (permanent); 409 = either "Session is busy"
239239
(transient lock contention — see ``TRANSIENT_SANDBOX_STATUS``) or "Session has no egress
240-
proxy"; other 5xx are transient.
240+
proxy"; other 5xx are transient. The returned ``ok`` flag is informational only: the sandbox
241+
signals provisioning failures via these status codes, not via ``ok=False`` on a 2xx.
241242
"""
242243
payload = {
243244
"policy": request.policy.model_dump(mode="json"),

daiv/sandbox_envs/forms.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import logging
5+
import re
56
import uuid
67

78
from django import forms
@@ -16,6 +17,17 @@
1617
_MEMORY_UNITS = {"MiB": MIB, "GiB": GIB}
1718
_NETWORK_TO_CHOICE = {None: "default", True: "on", False: "off"}
1819
_CHOICE_TO_NETWORK = {"default": None, "on": True, "off": False}
20+
# Valid HTTP field-name token (RFC 7230 §3.2.6). Rejects whitespace, ':' and control
21+
# chars so a crafted header name can't smuggle a CR/LF header-injection into the sidecar.
22+
_HEADER_NAME_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
23+
# Placeholder shown for an unchanged secret; a submitted value equal to this (or "") means
24+
# "keep the stored value" on edit. The JS editor and MCP read path emit the same string.
25+
_MASK_SENTINEL = "******"
26+
27+
28+
def _empty_egress_state() -> dict:
29+
"""The egress editor's no-policy initial state (deny-all, no hosts). A fresh dict per call."""
30+
return {"default": "deny", "intercept": "all", "hosts": []}
1931

2032

2133
class SandboxEnvironmentForm(forms.ModelForm):
@@ -119,7 +131,7 @@ def _initial_egress_json(self) -> str:
119131
:meth:`_preserve_unchanged_egress_secrets`)."""
120132
from core.encryption import DecryptionError
121133

122-
empty = json.dumps({"default": "deny", "intercept": "all", "hosts": []})
134+
empty = json.dumps(_empty_egress_state())
123135
if self.instance.pk is None or self.instance.egress_policy is None:
124136
return empty
125137
policy = self.instance.egress_policy
@@ -219,7 +231,7 @@ def clean_repo_ids_json(self):
219231
def clean_egress_json(self):
220232
raw = (self.cleaned_data.get("egress_json") or "").strip()
221233
if not raw:
222-
return {"default": "deny", "intercept": "all", "hosts": []}
234+
return _empty_egress_state()
223235
try:
224236
parsed = json.loads(raw)
225237
except json.JSONDecodeError as err:
@@ -248,14 +260,19 @@ def clean_egress_json(self):
248260
if not methods:
249261
methods = ["*"]
250262
header = (entry.get("header") or "").strip()
263+
if header:
264+
if not _HEADER_NAME_RE.match(header):
265+
raise forms.ValidationError(_("Invalid credential header name at index %d.") % idx)
266+
value = entry.get("value", "")
267+
if isinstance(value, str) and ("\r" in value or "\n" in value):
268+
raise forms.ValidationError(_("Credential value at index %d must not contain newlines.") % idx)
251269
hosts.append({
252270
"host": host,
253271
"methods": methods,
254272
"header": header,
255273
"value": entry.get("value", ""),
256274
"secret_name": (entry.get("secret_name") or "").strip(),
257275
"has_existing_value": bool(entry.get("has_existing_value")),
258-
"has_credential": bool(header),
259276
})
260277
return {"default": default, "intercept": intercept, "hosts": hosts}
261278

@@ -269,7 +286,7 @@ def clean(self):
269286
self.add_error("memory_value", _("Enter a memory value or switch back to default."))
270287
if cleaned.get("cpu_mode") == "custom" and cleaned.get("cpus") is None:
271288
self.add_error("cpus", _("Enter a CPU value or switch back to default."))
272-
egress_parsed = cleaned.get("egress_json") or {"default": "deny", "intercept": "all", "hosts": []}
289+
egress_parsed = cleaned.get("egress_json") or _empty_egress_state()
273290
cleaned["egress_policy"], cleaned["egress_secrets"] = self._build_egress(egress_parsed)
274291
return cleaned
275292

@@ -295,20 +312,28 @@ def save(self, commit: bool = True) -> SandboxEnvironment:
295312
return instance
296313

297314
@staticmethod
298-
def _build_egress(parsed: dict) -> tuple[dict, dict]:
315+
def _build_egress(parsed: dict) -> tuple[dict | None, dict]:
299316
"""Translate the normalised egress editor state into the stored
300317
``(egress_policy, egress_secrets)`` shapes. A host with a credential
301318
synthesises a named secret (``inject``); hosts without one get
302-
``inject=None``. Always returns an explicit policy (never None)."""
319+
``inject=None``.
320+
321+
Returns ``(None, {})`` when no allowed-host rules are defined. An egress
322+
policy is meaningful only as an allow-list, so an untouched editor stores
323+
no policy rather than a deny-all — which would otherwise change a
324+
network-enabled env's behavior on every save. To block all outbound
325+
traffic, set **Network** to off instead."""
303326
rules: list[dict] = []
304327
secrets: dict[str, dict] = {}
305328
for host in parsed["hosts"]:
306329
inject = None
307-
if host["has_credential"]:
330+
if host["header"]:
308331
name = host["secret_name"] or f"s_{uuid.uuid4().hex}"
309332
secrets[name] = {"header": host["header"], "value": host["value"]}
310333
inject = name
311334
rules.append({"host": host["host"], "methods": host["methods"], "inject": inject})
335+
if not rules:
336+
return None, {}
312337
policy = {"default": parsed["default"], "intercept": parsed["intercept"], "rules": rules}
313338
return policy, secrets
314339

@@ -333,7 +358,7 @@ def _preserve_unchanged_egress_secrets(instance: SandboxEnvironment, submitted:
333358
) from err
334359
out: dict[str, dict] = {}
335360
for name, sec in submitted.items():
336-
if sec.get("value", "") in ("", "******") and name in existing:
361+
if sec.get("value", "") in ("", _MASK_SENTINEL) and name in existing:
337362
out[name] = {**sec, "value": existing[name].get("value", "")}
338363
else:
339364
out[name] = sec
@@ -364,7 +389,7 @@ def _preserve_unchanged_secrets(instance: SandboxEnvironment, submitted_rows: li
364389
out: list[dict] = []
365390
for row in submitted_rows:
366391
name = row.get("name")
367-
if row.get("is_secret") and row.get("value") in ("", "******") and name in existing:
392+
if row.get("is_secret") and row.get("value") in ("", _MASK_SENTINEL) and name in existing:
368393
row = {**row, "value": existing[name]}
369394
out.append(row)
370395
return out

daiv/sandbox_envs/models.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,10 @@ class SandboxEnvironment(TimeStampedModel):
105105
is_default = models.BooleanField(_("is default"), default=False)
106106

107107
# Per-env egress policy provisioned to the daiv-sandbox sidecar proxy when network is enabled.
108-
# ``None`` = no egress policy configured for this env. The daiv-sandbox egress proxy is mandatory
109-
# for network-enabled sessions: a network-enabled env on a sandbox with no egress proxy configured
110-
# (no shared egress CA) is rejected at session start — there is no raw-network fallback.
108+
# ``None`` = no egress policy configured for this env; a policy is stored only when the env defines
109+
# at least one allowed-host rule. The daiv-sandbox egress proxy is mandatory for network-enabled
110+
# sessions: a network-enabled env on a sandbox with no egress proxy configured (no shared egress CA)
111+
# is rejected at session start — there is no raw-network fallback.
111112
# NOTE: when the proxy IS configured, a network-enabled env with egress_policy=None gets the
112113
# sidecar's default deny-all (no connectivity) — configure a policy to grant connectivity.
113114
egress_policy = models.JSONField(_("egress policy"), null=True, blank=True, default=None)
@@ -194,11 +195,6 @@ def is_global_default(self) -> bool:
194195
"""
195196
return self.scope == Scope.GLOBAL and bool(self.is_default)
196197

197-
@property
198-
def has_egress(self) -> bool:
199-
"""True iff this env carries an egress policy (``egress_policy is not None``)."""
200-
return self.egress_policy is not None
201-
202198
def can_delete(self) -> tuple[bool, str | None]:
203199
"""Row-level invariant gate for delete. ``promote_as_default`` must run
204200
first if this is the global default."""

daiv/sandbox_envs/static/sandbox_envs/js/egress-editor.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ function egressEditorState(initial = {}, labels = {}) {
2222
default: initial.default === "allow" ? "allow" : "deny",
2323
intercept: initial.intercept === "credentialed" ? "credentialed" : "all",
2424
labels: {
25-
blankHost: labels.blankHost || "Host is required",
2625
duplicateHost: labels.duplicateHost || "Duplicate host",
2726
},
2827
hosts: hosts.map((h) => {

daiv/sandbox_envs/templates/sandbox_envs/_form_body.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,12 +200,11 @@
200200
{# Network access (egress) — only meaningful when network is on #}
201201
<fieldset x-show="effectiveNetworkOn" x-cloak class="mt-5"
202202
x-data="egressEditor({{ form.egress_json.value|default:form.egress_json.initial }}, {
203-
blankHost: '{% translate 'Host is required'|escapejs %}',
204203
duplicateHost: '{% translate 'Duplicate host'|escapejs %}',
205204
})">
206205
<legend class="text-[15px] font-medium text-gray-300">{% translate "Network access" %}</legend>
207206
<p class="mt-1 text-sm text-gray-400">
208-
{% translate "Controls which hosts the sandbox can reach. Applies only when network is enabled; with no allowed hosts the sandbox can reach nothing." %}
207+
{% translate "Restricts which hosts the sandbox can reach. With Default set to deny, only the listed hosts are reachable. Leave the list empty to apply no egress policy; to block all outbound access, set Network to off." %}
209208
</p>
210209

211210
<div class="mt-3 flex flex-wrap gap-6">

docs/features/sandbox-environments.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ Environment-variable values are **Fernet-encrypted before they are stored** and
6060
When **Network** is enabled, the **Egress** section appears at the bottom of the form. It lets you define exactly which outbound connections the sandbox container may make, with optional per-host credentials injected by the MITM proxy — credentials never enter the container.
6161

6262
!!! info "Requires the egress proxy"
63-
Egress policy is provisioned by the daiv-sandbox MITM egress proxy (`DAIV_SANDBOX_EGRESS_PROXY_ENABLED`). If the sandbox does not have the proxy enabled, starting a run that uses an egress-configured environment will **abort** (fail-closed) rather than fall back to unrestricted network.
63+
Egress policy is provisioned by the daiv-sandbox MITM egress proxy. If the sandbox does not have the egress proxy enabled, starting a run that uses an egress-configured environment will **abort** (fail-closed) rather than fall back to unrestricted network.
6464

6565
#### Allowed hosts
6666

@@ -72,7 +72,7 @@ Each row in the **Allowed hosts** table defines one outbound rule:
7272
| **Methods** | HTTP methods this rule applies to (leave empty to match all methods) |
7373
| **Credential** | Optional: an HTTP header name and value injected by the proxy for every request to this host (see below) |
7474

75-
**Fail-closed:** if the allowed hosts list is empty, no outbound requests are permitted at all (equivalent to network off, even though networking is technically enabled).
75+
An egress policy takes effect only when you add at least one allowed-host rule. With **Default** set to `deny`, only the listed hosts are reachable and every other outbound request is blocked. Leaving the allowed-hosts list empty stores **no** egress policy on the environment (the section is treated as untouched) — it does not block traffic. To disable networking entirely, set **Network** to **Off**.
7676

7777
#### Per-host credentials (encrypted at rest)
7878

tests/unit_tests/core/sandbox/test_schemas.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ def test_egress_config_request_rejects_dangling_inject():
9999
EgressConfigRequest(policy=EgressPolicy(rules=[EgressRule(host="x", inject="missing")]), secrets={})
100100

101101

102+
def test_empty_egress_config_request_is_deny_all():
103+
"""The fail-closed fallback in ``row_to_override`` substitutes a bare ``EgressConfigRequest()``
104+
for an unusable stored config. That is only safe while the empty request denies everything — a
105+
future flip of ``EgressPolicy.default`` to ``allow`` would silently turn the fallback fail-open."""
106+
from core.sandbox.schemas import EgressConfigRequest
107+
108+
req = EgressConfigRequest()
109+
assert req.policy.default == "deny"
110+
assert req.policy.rules == []
111+
assert req.secrets == {}
112+
113+
102114
def test_egress_secret_value_is_redacted_in_repr():
103115
from pydantic import SecretStr
104116

0 commit comments

Comments
 (0)