Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 4 additions & 1 deletion .github/workflows/real-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ jobs:
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
Expand Down Expand Up @@ -308,6 +308,7 @@ jobs:
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
Expand Down Expand Up @@ -398,6 +399,7 @@ jobs:
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
Expand Down Expand Up @@ -490,6 +492,7 @@ jobs:
execd_image = "opensandbox/execd:local"
[egress]
image = "opensandbox/egress:local"
mode = "dns+nft"
[docker]
network_mode = "bridge"
[storage]
Expand Down
21 changes: 21 additions & 0 deletions components/egress/credential_vault_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ func TestCredentialVaultDeleteRequiresReady(t *testing.T) {

func TestCredentialVaultWriteRequiresTLSOrLoopback(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft)
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
Expand Down Expand Up @@ -234,6 +235,7 @@ func TestCredentialVaultWriteRequiresTLSOrLoopback(t *testing.T) {

func TestCredentialVaultWriteAllowsForwardedProto(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft)
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
Expand All @@ -252,6 +254,7 @@ func TestCredentialVaultWriteAllowsForwardedProto(t *testing.T) {

func TestCredentialVaultWriteSkipsTLSCheckByDefault(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsNft)
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
Expand All @@ -265,3 +268,21 @@ func TestCredentialVaultWriteSkipsTLSCheckByDefault(t *testing.T) {

require.Equal(t, http.StatusCreated, w.Result().StatusCode)
}

func TestCredentialVaultWriteRejectsDNSOnlyEnforcement(t *testing.T) {
t.Setenv(constants.EnvMitmproxyTransparent, "true")
t.Setenv(constants.EnvEgressMode, constants.PolicyDnsOnly)
initial := testCredentialVaultPolicy(t, `{"defaultAction":"deny","egress":[{"action":"allow","target":"code.example.com"}]}`)
srv := &policyServer{
proxy: &stubProxy{updated: initial},
credentialVault: credentialvault.NewStore(nil, func() bool { return true }),
}

req := httptest.NewRequest(http.MethodPost, "/credential-vault", strings.NewReader(`{"credentials":[],"bindings":[]}`))
req.RemoteAddr = "127.0.0.1:4321"
w := httptest.NewRecorder()
srv.handleCredentialVault(w, req)

require.Equal(t, http.StatusPreconditionFailed, w.Result().StatusCode)
require.Contains(t, w.Body.String(), "dns+nft")
}
6 changes: 6 additions & 0 deletions components/egress/pkg/credentialvault/vault.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,13 +377,19 @@ func (v *Store) Ready(ctx context.Context) error {
if constants.IsTruthy(os.Getenv(constants.EnvMitmproxySslInsecure)) {
return fmt.Errorf("credential vault rejects insecure upstream TLS mode")
}
if !constants.ModeUsesNft(os.Getenv(constants.EnvEgressMode)) {
return fmt.Errorf("credential vault requires dns+nft egress enforcement")
Comment thread
hellomypastor marked this conversation as resolved.
}
if v.mitmGate != nil && !v.mitmGate.WaitReady(ctx) {
return fmt.Errorf("credential proxy is not ready")
}
return nil
}

func (v *Store) validateCandidate(credentials map[string]record, bindings map[string]Binding, pol *policy.NetworkPolicy) error {
if len(bindings) > 0 && (pol == nil || pol.DefaultAction != policy.ActionDeny) {
return fmt.Errorf("credential vault bindings require an egress policy with defaultAction=deny")
}
for _, b := range bindings {
if err := validateBindingCredentialRefs(b, credentials); err != nil {
return err
Expand Down
6 changes: 3 additions & 3 deletions components/egress/pkg/credentialvault/vault_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,20 +75,20 @@ func TestCredentialVaultCreateSanitizesAndRendersActiveSnapshot(t *testing.T) {
require.Contains(t, payload.Redactions, "secret-token")
}

func TestCredentialVaultAllowsDefaultAllowWithoutExplicitRules(t *testing.T) {
func TestCredentialVaultRejectsDefaultAllowPolicy(t *testing.T) {
store := NewStore(nil, func() bool { return true })
pol := testCredentialPolicy(t, `{"defaultAction":"allow","egress":[]}`)

_, err := store.Create(testCredentialVaultRequest(), pol)
require.NoError(t, err, "defaultAction allow should not require explicit egress rules")
require.ErrorContains(t, err, "defaultAction=deny")
}

func TestCredentialVaultDefaultAllowRespectsExplicitDenyRule(t *testing.T) {
store := NewStore(nil, func() bool { return true })
pol := testCredentialPolicy(t, `{"defaultAction":"allow","egress":[{"action":"deny","target":"code.example.com"}]}`)

_, err := store.Create(testCredentialVaultRequest(), pol)
require.ErrorContains(t, err, "not allowed by egress policy")
require.ErrorContains(t, err, "defaultAction=deny")
}

func TestCredentialVaultRejectsReservedAndDuplicateHeaderNamesCaseInsensitively(t *testing.T) {
Expand Down
10 changes: 7 additions & 3 deletions docs/guides/credential-vault.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ Credential Vault is OpenSandbox's outbound credential broker for sandboxed agent
- Go SDK >= 1.0.3
- C# SDK >= 0.1.3
- Server config sets `[egress].image`.
- Server config sets `[egress].mode = "dns+nft"`. Credential Vault refuses to
activate in DNS-only mode because direct-IP connections can bypass DNS policy.
- Sandbox create request includes an outbound network policy.
- The outbound network policy uses `defaultAction="deny"` and explicitly allows
every host referenced by a credential binding.
- Sandbox create request enables Credential Proxy.
- Sandbox pods are not running with an additional transparent service-mesh sidecar (for example Istio/Envoy injection) in the same network namespace. Credential Vault currently assumes the OpenSandbox egress sidecar is the only transparent outbound interception layer in the pod.
- The sandbox image has the tools you want to run. For Claude Code, use an image
Expand Down Expand Up @@ -59,8 +63,8 @@ Use one of these operator patterns instead:

For the underlying egress-sidecar limitation, see [Egress](/components/egress#service-mesh-compatibility).

Credential bindings are intentionally precise. Prefer a default-deny egress
policy and a narrow path match, for example `/v1/*` for Anthropic API calls.
Credential bindings are intentionally precise. A default-deny egress policy is
required. Use a narrow path match, for example `/v1/*` for Anthropic API calls.

## Auth Types

Expand Down Expand Up @@ -299,7 +303,7 @@ curl -fsS https://api.example.com/v1/projects/123/variables
## Binding Guidance

- Use `defaultAction="deny"` and only allow the service hosts required by the
tool.
tool. Credential Vault rejects default-allow policies.
- Scope bindings by path whenever possible, for example `/v1/*`.
- Avoid overlapping bindings at the same precedence; ambiguous matches are
rejected.
Expand Down
1 change: 1 addition & 0 deletions scripts/common/kubernetes-e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ configToml: |

[egress]
image = "${EGRESS_IMG}"
mode = "dns+nft"

[kubernetes]
namespace = "${E2E_NAMESPACE}"
Expand Down
10 changes: 8 additions & 2 deletions server/opensandbox_server/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -507,8 +507,14 @@ def validate_source_and_entrypoint(self) -> "CreateSandboxRequest":
self.snapshot_id = None
return self

if self.credential_proxy and self.credential_proxy.enabled and self.network_policy is None:
raise ValueError("credentialProxy.enabled requires networkPolicy.")
if self.credential_proxy and self.credential_proxy.enabled:
if self.network_policy is None:
raise ValueError("credentialProxy.enabled requires networkPolicy.")
default_action = (self.network_policy.default_action or "deny").strip().lower()
if default_action != "deny":
raise ValueError(
"credentialProxy.enabled requires networkPolicy.defaultAction=deny."
)

has_image = self.image is not None and bool(self.image.uri.strip())
has_snapshot = bool((self.snapshot_id or "").strip())
Expand Down
7 changes: 6 additions & 1 deletion server/opensandbox_server/services/docker/networking.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,11 @@
build_egress_auth_headers,
merge_endpoint_headers,
)
from opensandbox_server.services.validators import ensure_egress_configured, ensure_egress_runtime_compatible
from opensandbox_server.services.validators import (
ensure_credential_proxy_configured,
ensure_egress_configured,
ensure_egress_runtime_compatible,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -139,6 +143,7 @@ def _ensure_network_policy_support(self, request) -> None:

# Common validation: egress.image must be configured
ensure_egress_configured(request.network_policy, self.app_config.egress)
ensure_credential_proxy_configured(request.credential_proxy, self.app_config.egress)
ensure_egress_runtime_compatible(request.network_policy, self.app_config.secure_runtime)

def _ensure_secure_access_support(self, request) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
)
from opensandbox_server.services.sandbox_service import SandboxService
from opensandbox_server.services.validators import (
ensure_credential_proxy_configured,
ensure_entrypoint,
ensure_egress_configured,
ensure_egress_runtime_compatible,
Expand Down Expand Up @@ -262,6 +263,7 @@ def _ensure_network_policy_support(self, request: CreateSandboxRequest) -> None:
and that the secure runtime supports the iptables nat table needed by the sidecar.
"""
ensure_egress_configured(request.network_policy, self.app_config.egress)
ensure_credential_proxy_configured(request.credential_proxy, self.app_config.egress)
ensure_egress_runtime_compatible(request.network_policy, self.app_config.secure_runtime)

def _ensure_image_auth_support(self, request: CreateSandboxRequest) -> None:
Expand Down
22 changes: 21 additions & 1 deletion server/opensandbox_server/services/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from opensandbox_server.services.constants import RESERVED_LABEL_PREFIX, SandboxErrorCodes

if TYPE_CHECKING:
from opensandbox_server.api.schema import NetworkPolicy, OSSFS, PlatformSpec, Volume
from opensandbox_server.api.schema import CredentialProxyConfig, NetworkPolicy, OSSFS, PlatformSpec, Volume
from opensandbox_server.config import EgressConfig, SecureRuntimeConfig


Expand Down Expand Up @@ -600,6 +600,25 @@ def ensure_egress_configured(
)


def ensure_credential_proxy_configured(
credential_proxy: Optional["CredentialProxyConfig"],
egress_config: Optional["EgressConfig"],
) -> None:
"""Require network-layer enforcement when Credential Proxy is enabled."""
if not credential_proxy or not credential_proxy.enabled:
return

egress_mode = egress_config.mode if egress_config else None
if egress_mode != "dns+nft":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": SandboxErrorCodes.INVALID_PARAMETER,
"message": 'credentialProxy.enabled requires server [egress].mode = "dns+nft".',
},
)


_GVISOR_NAT_INCOMPATIBLE_RUNTIMES = frozenset({"gvisor"})


Expand Down Expand Up @@ -732,6 +751,7 @@ def ensure_volumes_valid(
"ensure_platform_valid",
"ensure_metadata_labels",
"ensure_egress_configured",
"ensure_credential_proxy_configured",
"ensure_valid_volume_name",
"ensure_valid_mount_path",
"ensure_valid_sub_path",
Expand Down
26 changes: 24 additions & 2 deletions server/tests/k8s/test_kubernetes_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@
SANDBOX_MANUAL_CLEANUP_LABEL,
SandboxErrorCodes,
)
from opensandbox_server.api.schema import ImageAuth, ListSandboxesRequest, NetworkPolicy, PlatformSpec
from opensandbox_server.api.schema import (
CredentialProxyConfig,
ImageAuth,
ListSandboxesRequest,
NetworkPolicy,
PlatformSpec,
)
from opensandbox_server.config import (
EGRESS_MODE_DNS,
EGRESS_MODE_DNS_NFT,
Expand Down Expand Up @@ -82,7 +88,23 @@ def test_init_with_k8s_client_failure_raises_http_exception(self, k8s_app_config
assert exc_info.value.detail["code"] == SandboxErrorCodes.K8S_INITIALIZATION_ERROR

class TestKubernetesSandboxServiceCreate:


def test_credential_proxy_requires_dns_nft_mode(
self, k8s_service, create_sandbox_request
):
create_sandbox_request.network_policy = NetworkPolicy(default_action="deny", egress=[])
create_sandbox_request.credential_proxy = CredentialProxyConfig(enabled=True)
k8s_service.app_config.egress = EgressConfig(
image="opensandbox/egress:v1.1.2", mode=EGRESS_MODE_DNS
)

with pytest.raises(HTTPException) as exc_info:
k8s_service._ensure_network_policy_support(create_sandbox_request)

assert exc_info.value.status_code == 400
assert exc_info.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
assert "dns+nft" in exc_info.value.detail["message"]

@pytest.mark.asyncio
async def test_create_sandbox_with_valid_request_succeeds(
self, k8s_service, create_sandbox_request, mock_workload
Expand Down
34 changes: 33 additions & 1 deletion server/tests/test_docker_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,38 @@ async def test_network_policy_requires_egress_image(mock_docker):
assert exc.value.status_code == status.HTTP_400_BAD_REQUEST
assert exc.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER


@pytest.mark.asyncio
@patch("opensandbox_server.services.docker.docker_service.docker")
async def test_credential_proxy_requires_dns_nft_mode(mock_docker):
mock_client = MagicMock()
mock_client.containers.list.return_value = []
mock_docker.from_env.return_value = mock_client

cfg = _app_config()
cfg.docker.network_mode = "bridge"
cfg.egress = EgressConfig(image="egress:latest", mode="dns")
service = DockerSandboxService(config=cfg)

request = CreateSandboxRequest(
image=ImageSpec(uri="python:3.11"),
timeout=120,
resourceLimits=ResourceLimits(root={}),
env={},
metadata={},
entrypoint=["python"],
networkPolicy=NetworkPolicy(default_action="deny", egress=[]),
credentialProxy=CredentialProxyConfig(enabled=True),
)

with pytest.raises(HTTPException) as exc:
await service.create_sandbox(request)

assert exc.value.status_code == status.HTTP_400_BAD_REQUEST
assert exc.value.detail["code"] == SandboxErrorCodes.INVALID_PARAMETER
assert "dns+nft" in exc.value.detail["message"]


@pytest.mark.asyncio
@patch("opensandbox_server.services.docker.docker_service.docker")
async def test_egress_sidecar_injection_and_capabilities(mock_docker):
Expand Down Expand Up @@ -782,7 +814,7 @@ def host_cfg_side_effect(**kwargs):

cfg = _app_config()
cfg.docker.network_mode = "bridge"
cfg.egress = EgressConfig(image="egress:latest")
cfg.egress = EgressConfig(image="egress:latest", mode="dns+nft")
service = DockerSandboxService(config=cfg)

req = CreateSandboxRequest(
Expand Down
17 changes: 16 additions & 1 deletion server/tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,22 @@ def test_credential_proxy_requires_network_policy(self):
)
assert "credentialProxy.enabled requires networkPolicy" in str(exc_info.value)

def test_credential_proxy_requires_default_deny_policy(self):
with pytest.raises(ValidationError) as exc_info:
CreateSandboxRequest.model_validate(
{
"image": {"uri": "python:3.11"},
"timeout": 3600,
"resourceLimits": {"cpu": "500m", "memory": "512Mi"},
"entrypoint": ["python", "-c", "print('hello')"],
"networkPolicy": {"defaultAction": "allow", "egress": []},
"credentialProxy": {"enabled": True},
}
)
assert "credentialProxy.enabled requires networkPolicy.defaultAction=deny" in str(
exc_info.value
)

def test_request_with_empty_volumes(self):
request = CreateSandboxRequest(
image=ImageSpec(uri="python:3.11"),
Expand Down Expand Up @@ -697,4 +713,3 @@ def test_pool_mode_ignores_blank_pool_ref(self):
CreateSandboxRequest(
extensions={"poolRef": " "},
)

Loading
Loading