Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Network-enabled 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, project-scoped token — the same ephemeral clone token for GitLab, and a read-scoped installation token for GitHub — so `git fetch`/clone of the repository works without configuring a per-environment host rule. The rule is applied at runtime (never stored on the environment) and takes precedence over a user-defined rule for the same host.
- 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.
- Added `release_orphan_queued_threads` management command to recover `QUEUED` activities left behind on threads with no active sibling (rare TOCTOU loss).
- 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.
Expand Down
47 changes: 47 additions & 0 deletions daiv/codebase/clients/base.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
from __future__ import annotations

import abc
import base64
import functools
import logging
from contextlib import contextmanager
from dataclasses import dataclass
from enum import StrEnum
from functools import cached_property
from typing import TYPE_CHECKING, Any

from pydantic import SecretStr

from codebase.base import (
Discussion,
GitPlatform,
Expand Down Expand Up @@ -41,6 +45,29 @@ class WebhookSetupResult(StrEnum):
SKIPPED = "skipped"


@dataclass(frozen=True)
class GitEgressCredential:
"""Egress-proxy contribution for a repo's git platform: which host to allow and the
``Authorization`` header to inject so git-over-HTTPS in the sandbox is authenticated.

``value`` is ``None`` when no token could be provisioned — the host is still returned so
reachability works (the repo's origin remote authenticates via its ``.git/config`` token)."""

host: str
header: str = "Authorization"
value: SecretStr | None = None

@classmethod
def for_token(cls, *, host: str, token: str | None) -> GitEgressCredential:
"""Build a credential injecting ``Authorization: Basic base64("oauth2:<token>")`` —
the same shape DAIV's clone URL uses. ``value`` is ``None`` when ``token`` is falsy."""
value = None
if token:
encoded = base64.b64encode(f"oauth2:{token}".encode()).decode()
value = SecretStr(f"Basic {encoded}")
return cls(host=host, value=value)


class RepoClient(abc.ABC):
"""
Abstract class for repository clients.
Expand Down Expand Up @@ -110,6 +137,26 @@ def set_repository_webhooks(
def load_repo(self, repository: Repository, sha: str) -> Iterator[Repo]:
pass

def get_git_egress_credential(self, repository: Repository) -> GitEgressCredential | None:
"""Egress allow-rule + credential for this repo's git platform, or ``None`` for platforms
that need none. Resolved per run because the host is repo-derived and the token is
short-lived — never stored on the environment.

The shared shape lives here — derive the host from the clone URL, then build a
``Basic oauth2:<token>`` credential. Platforms supply only the token via
:meth:`_git_egress_token` (overriding this method is unnecessary)."""
from urllib.parse import urlparse

host = urlparse(repository.clone_url).hostname
if not host:
return None
return GitEgressCredential.for_token(host=host, token=self._git_egress_token(repository))

def _git_egress_token(self, repository: Repository) -> str | None:
"""Short-lived token authenticating git-over-HTTPS for this repo's platform, or ``None`` for
platforms that need no credential (host-only reachability). Overridden by GitLab/GitHub."""
return None

# Issue
@abc.abstractmethod
def get_issue(self, repo_id: str, issue_id: int) -> Issue:
Expand Down
6 changes: 6 additions & 0 deletions daiv/codebase/clients/github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,12 @@ def load_repo(self, repository: Repository, sha: str) -> Iterator[Repo]:
self._configure_commit_identity(repo)
yield repo

def _git_egress_token(self, repository: Repository) -> str | None:
"""Mint a short-lived installation token scoped to ``contents: read`` (read-only git ops;
push is policy-blocked in the sandbox)."""
access_token = self._integration.get_access_token(self.client_installation.id, permissions={"contents": "read"})
return access_token.token

# Issue
def get_issue(self, repo_id: str, issue_id: int) -> Issue:
"""
Expand Down
5 changes: 5 additions & 0 deletions daiv/codebase/clients/gitlab/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,11 @@ def _get_clone_token(self, repository: Repository) -> str:
logger.info("Cloning %s with the configured PAT (ephemeral clone token unavailable)", repository.slug)
return self.client.private_token

def _git_egress_token(self, repository: Repository) -> str | None:
"""Reuse the same short-lived, project-scoped clone token as the clone (PAT fallback; see
:meth:`_get_clone_token`); ``None`` (host-only reachability) when neither is available."""
return get_ephemeral_clone_token(self.client, repository.pk) or self.client.private_token or None

# Issue
def get_issue(self, repo_id: str, issue_id: int) -> Issue:
"""
Expand Down
4 changes: 4 additions & 0 deletions daiv/codebase/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ async def set_runtime_ctx(
RuntimeCtx: The runtime context
"""
from sandbox_envs.services import (
augment_sandbox_with_platform_egress,
get_global_default,
merge_sandbox_runtime,
resolve_env_for_run,
Expand All @@ -167,6 +168,9 @@ async def set_runtime_ctx(
per_run = row_to_override(auto_env) if auto_env is not None else None
global_default = await get_global_default()
sandbox = merge_sandbox_runtime(per_run=per_run, global_default=global_default)
# Always reach + authenticate the repo's git platform for git-over-HTTPS in the sandbox.
# Runtime-only (never stored on the env); no-op when the sandbox is disabled or network is off.
sandbox = augment_sandbox_with_platform_egress(sandbox, repo_client, repository)

# Own the sandbox transport for the whole run: one httpx connection pool, injected into the
# backend + middlewares by create_daiv_agent (and read by the manager recovery path). Opening
Expand Down
3 changes: 2 additions & 1 deletion daiv/sandbox_envs/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,8 @@ def _initial_egress_json(self) -> str:
"host": rule.get("host", ""),
"methods": rule.get("methods") or ["*"],
"secret_name": inject or "",
"header": secrets.get(inject, {}).get("header", "") if has_cred else "",
# An absent/unknown inject makes the nested .get fall through to "" on its own.
"header": secrets.get(inject, {}).get("header", ""),
"value": "",
"has_existing_value": has_cred,
})
Expand Down
23 changes: 22 additions & 1 deletion daiv/sandbox_envs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,13 @@ class SandboxEnvironment(TimeStampedModel):
# A non-null value means this env is networked: the session is routed through the egress proxy
# and the policy controls which outbound hosts are permitted.
# ``None`` means no network access — the sandbox container runs with network disabled.
# The egress proxy is mandatory for networked sessions: a networked env on a sandbox with no egress
# proxy configured (no shared egress CA) is rejected at session start — there is no raw-network fallback.
# DAIV additionally injects a runtime-only, never-stored allow-rule for the run's git platform on
# networked sessions, so the repo's platform is reachable for git-over-HTTPS — credentialed whenever a
# token can be minted, reachability-only otherwise. Its secret name ``__daiv_git_platform__`` is reserved
# (``_validate_egress`` rejects an env that uses it); the rule is prepended so it wins by first-match over
# a same-host user rule, and is not shown in the UI.
egress_policy = models.JSONField(_("egress policy"), null=True, blank=True, default=None)
_egress_secrets_encrypted = models.TextField(blank=True, null=True, editable=False) # noqa: DJ001 — NULL = no secrets
egress_secrets = EncryptedJSONFieldDescriptor("egress_secrets")
Expand Down Expand Up @@ -325,10 +332,24 @@ def _validate_egress(self) -> None:

# Authoritative shape + inject-resolves check (count/size caps handled above): build the wire request.
try:
EgressConfigRequest.from_stored(self.egress_policy, secrets_raw)
request = EgressConfigRequest.from_stored(self.egress_policy, secrets_raw)
except (PydanticValidationError, TypeError, ValueError) as err:
raise ValidationError({"egress_policy": _("Invalid egress configuration: %s") % err}) from err

# The git-platform secret name is reserved: apply_platform_egress overwrites that key (and
# prepends a rule injecting it) at runtime, so a stored secret/inject of the same name would be
# silently displaced. Reject it at save time. Checked on the validated request so the rule/secret
# shape stays owned by from_stored rather than re-walked here.
from sandbox_envs.services import PLATFORM_EGRESS_SECRET_NAME

if PLATFORM_EGRESS_SECRET_NAME in request.secrets or any(
rule.inject == PLATFORM_EGRESS_SECRET_NAME for rule in request.policy.rules
):
raise ValidationError({
"egress_secrets": _("The egress secret name '%s' is reserved for DAIV's git-platform credential.")
% PLATFORM_EGRESS_SECRET_NAME
})

def promote_as_default(self) -> None:
"""Atomically demote any other GLOBAL default and mark this env as default.

Expand Down
48 changes: 48 additions & 0 deletions daiv/sandbox_envs/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@
if TYPE_CHECKING:
from activity.services import RepoTarget

from codebase.base import Repository
from codebase.clients import RepoClient
from codebase.clients.base import GitEgressCredential
from codebase.context import SandboxRuntime
from core.sandbox.schemas import EgressConfigRequest

logger = logging.getLogger("daiv.sandbox_envs")

PLATFORM_EGRESS_SECRET_NAME = "__daiv_git_platform__" # noqa: S105


@dataclass(frozen=True)
class SandboxEnvOverride:
Expand Down Expand Up @@ -72,6 +77,49 @@ def row_to_override(env: SandboxEnvironment) -> SandboxEnvOverride:
)


def apply_platform_egress(
egress: EgressConfigRequest | None, credential: GitEgressCredential | None
) -> EgressConfigRequest | None:
"""Prepend the DAIV-managed git-platform allow-rule (and add its credential) to ``egress``.

Runtime-only — the result is provisioned to the sidecar but never stored on the environment.
The rule is **prepended** so it wins under the sidecar's first-match (always reachable;
credentialed when the credential carries a token). ``credential is None`` → ``egress`` unchanged.
With no base policy, the base is a
deny-all (``default="deny"``, ``intercept="all"``); an existing policy's ``default``/``intercept``
and rules are preserved. The secret is added (overwriting any same-named user key) only when the
credential carries a token; otherwise the rule is reachability-only (``inject=None``)."""
from core.sandbox.schemas import EgressConfigRequest, EgressPolicy, EgressRule, EgressSecret

if credential is None:
return egress

base_policy = egress.policy if egress is not None else EgressPolicy()
secrets = dict(egress.secrets) if egress is not None else {}

inject = None
if credential.value is not None:
inject = PLATFORM_EGRESS_SECRET_NAME
secrets[PLATFORM_EGRESS_SECRET_NAME] = EgressSecret(header=credential.header, value=credential.value)

platform_rule = EgressRule(host=credential.host, methods=["*"], inject=inject)
policy = base_policy.model_copy(update={"rules": [platform_rule, *base_policy.rules]})
return EgressConfigRequest(policy=policy, secrets=secrets)


def augment_sandbox_with_platform_egress(
sandbox: SandboxRuntime, repo_client: RepoClient, repository: Repository
) -> SandboxRuntime:
"""Layer the git-platform allow-rule + credential onto ``sandbox.egress`` for networked runs.
No-op when the sandbox is disabled or network is off (``sandbox.egress is None``). Returns a new
``SandboxRuntime`` (frozen); the platform contribution is resolved per run via ``repo_client`` and
never stored."""
if not sandbox.enabled or sandbox.egress is None:
return sandbox
credential = repo_client.get_git_egress_credential(repository)
return replace(sandbox, egress=apply_platform_egress(sandbox.egress, credential))


async def resolve_sandbox_env(env_id: str | None) -> SandboxEnvOverride | None:
"""Load the explicit per-run env.

Expand Down
2 changes: 1 addition & 1 deletion daiv/sandbox_envs/templates/sandbox_envs/_form_body.html
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@
})">
<legend class="text-[15px] font-medium text-gray-300">{% translate "Network access" %}</legend>
<p class="mt-1 text-sm text-gray-400">
{% 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." %}
{% translate "Restricts which hosts the sandbox can reach. With Default set to deny, only the listed hosts are reachable — plus this environment's own git platform, which DAIV always allows for git operations. Leave the list empty to apply no egress policy; to block all outbound access, set Network to off." %}
</p>

<div class="mt-3 flex flex-wrap gap-6">
Expand Down
24 changes: 24 additions & 0 deletions tests/unit_tests/codebase/clients/github/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,3 +426,27 @@ def test_get_merge_request_by_branches_returns_none_when_empty(self, github_clie
result = github_client.get_merge_request_by_branches("owner/repo", "feat-x", "main")

assert result is None

def test_get_git_egress_credential_uses_installation_token(self, github_client):
import base64
from unittest.mock import Mock

github_client._integration.get_access_token.return_value = Mock(token="ghs-install") # noqa: S106
repository = Repository(
pk=1,
slug="owner/repo",
name="repo",
clone_url="https://github.com/owner/repo.git",
html_url="https://github.com/owner/repo",
default_branch="main",
git_platform=GitPlatform.GITHUB,
)

cred = github_client.get_git_egress_credential(repository)

assert cred.host == "github.com"
assert cred.header == "Authorization"
assert cred.value.get_secret_value() == "Basic " + base64.b64encode(b"oauth2:ghs-install").decode()
github_client._integration.get_access_token.assert_called_once_with(
github_client.client_installation.id, permissions={"contents": "read"}
)
55 changes: 55 additions & 0 deletions tests/unit_tests/codebase/clients/gitlab/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@
_is_source_branch_missing_error,
)


def test_git_egress_credential_for_token_builds_basic_oauth2_header():
import base64

from codebase.clients.base import GitEgressCredential

cred = GitEgressCredential.for_token(host="gitlab.example.com", token="tok-123") # noqa: S106
assert cred.host == "gitlab.example.com"
assert cred.header == "Authorization"
assert cred.value.get_secret_value() == "Basic " + base64.b64encode(b"oauth2:tok-123").decode()


def test_git_egress_credential_for_token_none_when_no_token():
from codebase.clients.base import GitEgressCredential

cred = GitEgressCredential.for_token(host="gitlab.example.com", token=None)
assert cred.host == "gitlab.example.com"
assert cred.value is None


_POSITION = {
"position_type": "text",
"base_sha": "aaa",
Expand Down Expand Up @@ -611,3 +631,38 @@ def test_get_merge_request_by_branches_returns_none_when_empty(self, gitlab_clie
result = gitlab_client.get_merge_request_by_branches("group/repo", "feat-x", "main")

assert result is None

def _egress_repo(self):
return Repository(
pk=42,
slug="group/repo",
name="repo",
clone_url="https://gitlab.example.com/group/repo.git",
html_url="https://gitlab.example.com/group/repo",
default_branch="main",
git_platform=GitPlatform.GITLAB,
)

def test_get_git_egress_credential_uses_ephemeral_clone_token(self, gitlab_client):
import base64

with patch("codebase.clients.gitlab.client.get_ephemeral_clone_token", return_value="glpat-eph"):
cred = gitlab_client.get_git_egress_credential(self._egress_repo())
assert cred.host == "gitlab.example.com"
assert cred.header == "Authorization"
assert cred.value.get_secret_value() == "Basic " + base64.b64encode(b"oauth2:glpat-eph").decode()

def test_get_git_egress_credential_falls_back_to_pat(self, gitlab_client):
import base64

gitlab_client.client.private_token = "pat-token" # noqa: S105
with patch("codebase.clients.gitlab.client.get_ephemeral_clone_token", return_value=None):
cred = gitlab_client.get_git_egress_credential(self._egress_repo())
assert cred.value.get_secret_value() == "Basic " + base64.b64encode(b"oauth2:pat-token").decode()

def test_get_git_egress_credential_no_token_returns_host_only(self, gitlab_client):
gitlab_client.client.private_token = None
with patch("codebase.clients.gitlab.client.get_ephemeral_clone_token", return_value=None):
cred = gitlab_client.get_git_egress_credential(self._egress_repo())
assert cred.host == "gitlab.example.com"
assert cred.value is None
Loading