Skip to content

Commit 080ca66

Browse files
authored
feat(sandbox-envs): per-environment egress policy UI (#1336)
1 parent 27ff073 commit 080ca66

36 files changed

Lines changed: 2153 additions & 388 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ 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. Set programmatically via the ORM for now (admin/API/form UI to follow).
12+
- Sandbox environments now automatically allow and authenticate their repository's git platform (GitLab/GitHub) for git operations over HTTPS in the sandbox. DAIV injects an `Authorization: Basic` credential at the egress proxy, built from a short-lived token — the same project-scoped ephemeral clone token for GitLab, and a `contents:write` installation token for GitHub — so `git fetch`/clone/push of the repository works without configuring a per-environment host rule. Because DAIV pushes from inside the sandbox, this applies even to **Network Off** environments when a push token exists: the environment is opened solely for its git platform (everything else stays blocked) so publishing always works, while token-less eval/benchmark runs stay fully network-isolated. The rule is applied at runtime (never stored on the environment) and takes precedence over a user-defined rule for the same host.
13+
- 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), scope each host to **all methods** or **read-only** (`GET, HEAD, OPTIONS`), and set the `default` (deny/allow) reachability for unlisted hosts. The proxy always TLS-inspects every reachable host, so per-host credentials and method rules are always enforced. 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.
1314
- Added `release_orphan_queued_threads` management command to recover `QUEUED` activities left behind on threads with no active sibling (rare TOCTOU loss).
1415
- 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.
1516
- 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.
@@ -41,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4142

4243
- Hardened the agent's workspace file tools (`read_file`/`write_file`/`edit_file`/`ls`/`glob`/`grep`) against sandbox transport faults: a transient failure — most notably the `409 "Session is busy"` the sandbox returns when batched tool calls contend on the one-at-a-time session — now degrades to a retryable tool-result error instead of aborting the whole run, matching the `bash` tool. The `bash` tool's own classifier now also treats `409` as transient (retry once) rather than permanent.
4344
- Hardened MCP tool loading so a single broken or slow MCP server can no longer freeze chats and runs. Each server's tools are now loaded independently with a per-server timeout (`MCP_TOOL_LOAD_TIMEOUT`, default 30s); a server that times out or errors is skipped instead of blocking the whole agent.
45+
- GitLab clones now self-heal from a stale cached clone token. The short-lived project-scoped clone token is cached for a day, but the underlying project access token can die sooner (revoked, or the project/instance reset); previously the dead token was served to — and failed — every clone of that project until the cache window closed. A clone rejected for authentication while using the ephemeral token now drops it and retries once with a freshly minted one (the PAT fallback is not retried, since re-minting cannot change that credential).
4446
- Enforced "at most one active (`READY`/`RUNNING`) API/MCP Activity per `thread_id`" at the DB layer via a partial unique constraint. Concurrent submissions on the same thread cleanly fall back to `QUEUED` instead of both running.
4547
- Made the FIFO dispatcher race-safe: an atomic compare-and-swap (`UPDATE filter(status=QUEUED) → READY`) prevents two terminal events on the same thread from double-promoting the same queued sibling. Dispatch failures now set `finished_at` and iterate via a loop instead of recursive signal re-entry. The loop bails after `MAX_CONSECUTIVE_DISPATCH_FAILURES` (3) consecutive failures so a transient broker outage does not mass-fail an entire QUEUED backlog — remaining rows stay QUEUED for `release_orphan_queued_threads`.
4648
- Guarded the post-enqueue `task_result_id` save in both `asubmit_batch_runs` and `dispatch_next_in_thread`: a DB blip after a successful broker enqueue now marks the row FAILED (and releases queued siblings) instead of stranding it in READY with no task linkage.

daiv/accounts/templates/accounts/_sidebar.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{% load i18n icon_tags nav_tags %}
22

33
<aside data-testid="app-sidebar"
4-
class="hidden w-60 shrink-0 overflow-y-auto border-r border-white/[0.06] bg-[#030712] sm:flex sm:flex-col"
4+
class="shrink-0 overflow-y-auto bg-[#030712] {{ sidebar_root_class|default:'hidden w-60 border-r border-white/[0.06] sm:flex sm:flex-col' }}"
55
x-cloak>
66
<div class="flex items-center gap-2.5 px-5 pt-5 pb-5">
77
<span class="inline-block h-6 w-6 rounded-md bg-gradient-to-br from-violet-500 to-indigo-500"></span>

daiv/accounts/templates/base_app.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
x-transition:leave-start="translate-x-0"
1818
x-transition:leave-end="-translate-x-full"
1919
class="relative flex h-full w-[85%] max-w-[260px] flex-col bg-[#030712] shadow-2xl">
20-
{% include "accounts/_sidebar.html" %}
20+
{% include "accounts/_sidebar.html" with sidebar_root_class="flex w-full flex-col" %}
2121
</div>
2222
</div>
2323

daiv/automation/agent/git_manager.py

Lines changed: 9 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from git import GitCommandError
1111

1212
from automation.agent.constants import REPO_PATH
13+
from core.utils import is_git_auth_error_text
1314

1415
if TYPE_CHECKING:
1516
from git import Repo
@@ -366,31 +367,14 @@ class GitPushPermissionError(RuntimeError):
366367
class GitPushNetworkError(RuntimeError):
367368
"""Raised when pushing fails because the remote host is unreachable.
368369
369-
Typically a sandbox-authoritative run whose sandbox environment is configured with
370-
``network_enabled=False``: git runs (and therefore pushes) from inside the sandbox, so the
371-
sandbox must have network access for the push to reach ``origin``.
370+
git runs (and therefore pushes) from inside the sandbox, so the sandbox must be able to reach
371+
``origin``'s git platform. DAIV opens that host automatically whenever a platform token can be
372+
minted — even on a network-off env — so this typically means the egress proxy is unavailable, or
373+
the run is one that legitimately has no platform token (e.g. an eval/benchmark run) and so stays
374+
fully network-isolated.
372375
"""
373376

374377

375-
def _is_push_auth_error_text(output: str) -> bool:
376-
"""
377-
Check if git push output indicates an authentication or permission failure.
378-
"""
379-
text = output.lower()
380-
return any(
381-
marker in text
382-
for marker in (
383-
"returned error: 403",
384-
"authentication failed",
385-
"permission denied",
386-
"access denied",
387-
"http basic: access denied",
388-
"could not read username",
389-
"not authorized",
390-
)
391-
)
392-
393-
394378
def _is_push_network_error_text(output: str) -> bool:
395379
"""Check if git push output indicates the remote host was unreachable (network failure).
396380
@@ -420,7 +404,7 @@ def _raise_for_push_failure(push_args: list[str], result: _GitResult) -> None:
420404
Auth/permission → ``GitPushPermissionError``; an unreachable host → ``GitPushNetworkError``;
421405
anything else → the raw ``GitCommandError``. Auth is checked first so it always wins.
422406
"""
423-
if _is_push_auth_error_text(result.output):
407+
if is_git_auth_error_text(result.output):
424408
logger.warning("git push auth failure: %s", result.output)
425409
raise GitPushPermissionError(
426410
"Failed to push changes to the remote repository due to authentication or permission issues. "
@@ -432,7 +416,7 @@ def _raise_for_push_failure(push_args: list[str], result: _GitResult) -> None:
432416
logger.warning("git push network failure: %s", result.output)
433417
raise GitPushNetworkError(
434418
"Failed to push changes: the remote host is unreachable. Sandbox-authoritative auto-commit "
435-
"pushes from inside the sandbox, so the sandbox environment must run with network access "
436-
"(network_enabled=True)."
419+
"pushes from inside the sandbox, so the sandbox environment must run as an egress-enabled "
420+
"sandbox."
437421
)
438422
raise GitCommandError(["git", *push_args], result.exit_code, result.output)

daiv/automation/agent/middlewares/sandbox.py

Lines changed: 36 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from automation.agent.conf import settings as agent_settings
2121
from automation.agent.constants import BUILTIN_SKILLS_PATH
2222
from automation.agent.middlewares.file_system import SandboxFileBackend # noqa: TC001
23-
from codebase.context import RuntimeCtx, SandboxRuntime # noqa: TC001
23+
from codebase.context import RuntimeCtx # noqa: TC001
2424
from core.conf import settings
2525
from core.sandbox.client import DAIVSandboxClient, is_transient_sandbox_error
2626
from core.sandbox.command_parser import CommandParseError, parse_command
@@ -293,11 +293,19 @@ def _make_global_skills_archive() -> bytes | None:
293293

294294
class SandboxEgressUnavailableError(RuntimeError):
295295
"""Raised when a session's resolved egress policy cannot be provisioned because the sandbox has
296-
no egress proxy configured (HTTP 404 'Egress proxy not configured' — no shared egress CA).
296+
no egress proxy configured (no shared egress CA). daiv-sandbox rejects such a session up front
297+
with HTTP 400 and a detail naming the egress proxy (see its ``POST /session/`` handler).
297298
Fail-closed: an environment that requires a restricted egress policy must not run without that
298299
policy in force."""
299300

300301

302+
# Substring that distinguishes the "egress proxy not configured" 400 from any other create-time 400.
303+
# Couples to daiv-sandbox's FastAPI ``detail`` ("egress requires the egress proxy, which is not
304+
# configured on this deployment" — its ``POST /session/`` handler). The sandbox exposes no
305+
# machine-readable error code, so this is the single greppable point of that prose coupling.
306+
_EGRESS_PROXY_UNAVAILABLE_MARKER = "egress proxy"
307+
308+
301309
class BashFailure(Enum):
302310
"""Why a bash invocation produced no result, mapped to the guidance the agent gets.
303311
@@ -502,36 +510,6 @@ async def _session_exists(client: DAIVSandboxClient, session_id: str) -> bool:
502510
)
503511
return False
504512

505-
@staticmethod
506-
async def _provision_egress(client: DAIVSandboxClient, session_id: str, sb: SandboxRuntime | None) -> None:
507-
"""Provision the sidecar egress policy for a network-enabled session that requires it.
508-
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.
521-
"""
522-
if sb is None or sb.egress is None or not sb.network_enabled:
523-
return
524-
try:
525-
await client.configure_egress(session_id, sb.egress)
526-
except httpx.HTTPStatusError as exc:
527-
if exc.response.status_code == 404:
528-
raise SandboxEgressUnavailableError(
529-
"The resolved sandbox environment requires the egress proxy, but the sandbox returned "
530-
"404 for egress provisioning. Configure the shared egress CA (EGRESS_CA_CERT_FILE + "
531-
"EGRESS_CA_KEY_FILE) on the sandbox deployment to enable the egress proxy."
532-
) from exc
533-
raise
534-
535513
async def abefore_agent(self, state: StateT, runtime: Runtime[RuntimeCtx]) -> dict[str, str] | None:
536514
"""
537515
Bind a sandbox session — reusing the prior turn's warm session when possible.
@@ -555,34 +533,44 @@ async def abefore_agent(self, state: StateT, runtime: Runtime[RuntimeCtx]) -> di
555533
prior_session_id = state.get("session_id")
556534
if prior_session_id and await self._session_exists(client, prior_session_id):
557535
self._bind_session(prior_session_id)
558-
# Re-provision egress on every warm reuse so the fail-closed guarantee holds on resumed
559-
# turns too. A failure here propagates and aborts the run (fail-closed). Unlike the
560-
# fresh-create path below we deliberately do NOT force-close the container: it's a healthy
561-
# resumable session, the failure may be transient (e.g. a 409 "Session is busy"), and the
562-
# next turn re-runs this check (or the reaper reclaims it). Force-closing here would throw
563-
# away a good warm container on a transient blip.
564-
await self._provision_egress(client, prior_session_id, runtime.context.sandbox)
565536
logger.info("Reusing warm sandbox session %s", prior_session_id)
566537
return {"session_id": prior_session_id}
567538

568539
sb = runtime.context.sandbox
569-
session_id = await client.start_session(
570-
StartSessionRequest(
571-
base_image=sb.base_image,
572-
network_enabled=sb.network_enabled,
573-
memory_bytes=sb.memory_bytes,
574-
cpus=sb.cpus,
575-
environment=sb.env_vars or None,
540+
try:
541+
session_id = await client.start_session(
542+
StartSessionRequest(
543+
base_image=sb.base_image,
544+
egress=sb.egress,
545+
memory_bytes=sb.memory_bytes,
546+
cpus=sb.cpus,
547+
environment=sb.env_vars or None,
548+
)
576549
)
577-
)
550+
except httpx.HTTPStatusError as exc:
551+
# A network-enabled env on a sandbox with no egress proxy (no shared CA) is rejected up
552+
# front with HTTP 400 (see _EGRESS_PROXY_UNAVAILABLE_MARKER). Match that specific signal so
553+
# an unrelated 400 (e.g. an invalid base image) re-raises as-is instead of being mislabelled.
554+
detail = (exc.response.text or "").lower()
555+
if exc.response.status_code == 400 and _EGRESS_PROXY_UNAVAILABLE_MARKER in detail:
556+
logger.error(
557+
"Sandbox rejected egress-required session (400): the egress proxy/CA is not configured "
558+
"on the sandbox deployment. Aborting run (fail-closed)."
559+
)
560+
raise SandboxEgressUnavailableError(
561+
"The resolved sandbox environment requires the egress proxy, but the sandbox rejected "
562+
"the session (400). Configure the shared egress CA (EGRESS_CA_CERT_FILE + "
563+
"EGRESS_CA_KEY_FILE) on the sandbox deployment to enable the egress proxy."
564+
) from exc
565+
raise
578566
try:
579567
working_dir = Path(runtime.context.gitrepo.working_dir)
580568
repo_archive, skills_archive = await asyncio.gather(
581569
asyncio.to_thread(_make_repo_archive, str(working_dir)), asyncio.to_thread(_make_global_skills_archive)
582570
)
583571
await client.seed_session(session_id, repo_archive=repo_archive, skills_archive=skills_archive)
584-
await self._provision_egress(client, session_id, sb)
585572
except Exception:
573+
# Build/seed failure on an already-created session (the egress-unavailable case fails earlier).
586574
logger.exception("Failed to build or seed sandbox session %s", session_id)
587575
try:
588576
await client.close_session(session_id, force=True)

0 commit comments

Comments
 (0)