From 5bbcddec9c1b76e1ef77e6a199f75f7bcecb9e0a Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 21 Oct 2026 16:52:20 -0400 Subject: [PATCH 1/8] [AI-1663] Sandbox proxy: tunnel allowed domains, trust the CA per run, fit Daytona's netleash (WIP, oversize) - egress proxy: a CONNECT to one of the config's allowed domains (Record.Allowed) on 443 gets a blind TCP tunnel through internal/egress.DialPublic: no MITM, no token, nothing logged; granted hosts keep MITM + substitution, a host both granted and allowed is a grant's; anything else 403. - broker: the proxy URL with its credential goes to Daytona alone (OutboundProxyUrl); the sandbox env holds placeholders only; CA env vars (+ CURL_CA_BUNDLE) move to Mount.RunEnv, sent on every ExecuteCommand (WithCommandEnv) and CodeRun (CodeRunParams.Env); the bundle starts from /etc/daytona/netleash/ca.crt when present. A record keeps the allowed domains once every grant is withdrawn. - daytona: DomainAllowList = grants' hosts + config domains, never the proxy host; NO_PROXY and the proxy-in-CIDR path removed. session: network_allow_list refused with broker proxy. Co-Authored-By: Claude Opus 5.5 --- acceleration/internal/egress/public.go | 6 + acceleration/internal/sandbox/broker.go | 8 +- .../internal/sandbox/daytona/daytona.go | 100 ++------------ .../internal/sandbox/daytona/daytona_test.go | 26 +--- .../internal/sandbox/daytona/proxy_test.go | 71 ++++++---- .../internal/sandbox/daytona/secrets_test.go | 21 +++ .../internal/sandbox/egress/broker.go | 61 +++++--- .../internal/sandbox/egress/broker_test.go | 79 +++++++---- acceleration/internal/sandbox/egress/proxy.go | 61 +++++++- .../internal/sandbox/egress/proxy_test.go | 130 +++++++++++++++--- acceleration/internal/sandbox/egress/store.go | 26 +++- .../internal/session/sandbox_grants.go | 33 ++--- .../internal/session/sandbox_grants_test.go | 26 ++-- 13 files changed, 393 insertions(+), 255 deletions(-) diff --git a/acceleration/internal/egress/public.go b/acceleration/internal/egress/public.go index d57f12e76..ae7b4c204 100644 --- a/acceleration/internal/egress/public.go +++ b/acceleration/internal/egress/public.go @@ -304,6 +304,12 @@ func (p policy) publicAddresses(ctx context.Context, host string) error { return nil } +// DialPublic opens a connection to address, a host and a port, only when every address the host +// resolves to is public: the dial NewClient makes, for callers that carry bytes they do not read. +func DialPublic(ctx context.Context, network, address string) (net.Conn, error) { + return defaultPolicy.dialPublic(ctx, network, address) +} + // IsPublic says whether traffic to a tenant-supplied host may go to ip. It is the check // NewClient makes at dial, for callers that open their own connections. func IsPublic(ip netip.Addr) bool { return isPublic(ip) } diff --git a/acceleration/internal/sandbox/broker.go b/acceleration/internal/sandbox/broker.go index 446550073..ae3e7eabd 100644 --- a/acceleration/internal/sandbox/broker.go +++ b/acceleration/internal/sandbox/broker.go @@ -59,8 +59,12 @@ type Mount struct { // Env are env vars the sandbox is created with as they are, such as a placeholder the // broker's egress knows. Env map[string]string - // Proxy, when set, is the egress every request from the sandbox goes through, and the - // only host the sandbox may reach. + // RunEnv are env vars every command and run of code in the sandbox gets, such as the CA + // files it trusts. Set per run, they win over what the provider sets in the sandbox, which + // Env does not: Daytona's netleash replaces its proxy and CA env vars (live probe, 2026-10-21). + RunEnv map[string]string + // Proxy, when set, is the egress the provider sends every request from the sandbox through, + // with its credential in the userinfo. Proxy *url.URL // Files are written into the sandbox, by absolute path, before code runs in it. Files map[string][]byte diff --git a/acceleration/internal/sandbox/daytona/daytona.go b/acceleration/internal/sandbox/daytona/daytona.go index d080ccb51..5a9aa32ec 100644 --- a/acceleration/internal/sandbox/daytona/daytona.go +++ b/acceleration/internal/sandbox/daytona/daytona.go @@ -6,11 +6,8 @@ import ( "errors" "fmt" "log/slog" - "maps" "mime" - "net" "net/http" - "net/netip" "os" "path" "slices" @@ -118,7 +115,8 @@ func (s *Sandbox) Run(ctx context.Context, code string, outputs []string) (sandb } run, cancel := context.WithTimeout(ctx, s.runTimeout+5*time.Second) defer cancel() - ran, err := s.box.Process.CodeRun(run, code, options.WithCodeRunTimeout(s.runTimeout)) + ran, err := s.box.Process.CodeRun(run, code, options.WithCodeRunTimeout(s.runTimeout), + options.WithCodeRunParams(types.CodeRunParams{Env: s.mount.RunEnv})) if err != nil { return sandbox.Result{}, err } @@ -150,7 +148,7 @@ func (s *Sandbox) Write(ctx context.Context, name string, data []byte) error { return err } if dir := path.Dir(name); dir != "." { - made, err := s.box.Process.ExecuteCommand(ctx, "mkdir -p "+quoted(dir)) + made, err := s.box.Process.ExecuteCommand(ctx, "mkdir -p "+quoted(dir), options.WithCommandEnv(s.mount.RunEnv)) if err != nil { return stack.Wrap(err) } @@ -199,7 +197,7 @@ func (s *Sandbox) started(ctx context.Context) error { delete(s.files, name) } for len(s.setup) > 0 { - ran, err := s.box.Process.ExecuteCommand(ready, "sh -c "+quoted(s.setup[0])) + ran, err := s.box.Process.ExecuteCommand(ready, "sh -c "+quoted(s.setup[0]), options.WithCommandEnv(s.mount.RunEnv)) if err != nil { return stack.Wrap(err) } @@ -225,16 +223,12 @@ func (s *Sandbox) params() any { if s.mount.Proxy != nil { proxy := s.mount.Proxy.String() base.OutboundProxyUrl = &proxy - if direct := noProxy(s.config, s.grants); direct != "" { - base.EnvVars = maps.Clone(s.mount.Env) - base.EnvVars["NO_PROXY"], base.EnvVars["no_proxy"] = direct, direct - } } if len(domains) > 0 { joined := strings.Join(domains, ",") base.DomainAllowList = &joined } - if cidrs := s.ranges(); len(cidrs) > 0 { + if cidrs := s.config.NetworkAllowList; len(cidrs) > 0 { joined := strings.Join(cidrs, ",") base.NetworkAllowList = &joined } @@ -298,21 +292,19 @@ func (s *Sandbox) Grants() []sandbox.Grant { // domains are the config's allowed domains and, unless the config opens the whole network or // lists CIDR ranges, which Daytona takes instead of domains, the hosts of each grant the -// sandbox holds a secret for: a sandbox that may reach nothing else still reaches its grants. -// A sandbox behind an egress proxy reaches the proxy and the config's own domains less those -// that name a grant's host: code that leaves the proxy out reaches a grant's host only through -// it. +// sandbox holds a secret or, behind an egress proxy, a placeholder for: a sandbox that may reach +// nothing else still reaches its grants. Behind the proxy, Daytona's netleash refuses a CONNECT to +// a host its allow list leaves out (live probe, 2026-10-21: 403), and reaches the proxy itself, +// so the proxy's host is not in the list: Daytona refuses an IP address there. func (s *Sandbox) domains() []string { domains := slices.Clone(s.config.AllowedDomains) - if s.mount.Proxy != nil && len(s.config.NetworkAllowList) == 0 { - direct := slices.DeleteFunc(domains, func(domain string) bool { return granted(domain, s.grants) }) - return slices.Insert(direct, 0, s.mount.Proxy.Hostname()) - } if s.config.Network && len(domains) == 0 || len(s.config.NetworkAllowList) > 0 { return domains } for _, grant := range s.grants { - if _, held := s.mount.Secrets[grant.Env]; held { + _, secret := s.mount.Secrets[grant.Env] + _, placeholder := s.mount.Env[grant.Env] + if secret || placeholder { for _, host := range grant.Hosts { if !slices.Contains(domains, host) { domains = append(domains, host) @@ -322,71 +314,3 @@ func (s *Sandbox) domains() []string { } return domains } - -// ranges are the config's CIDR ranges and, behind an egress proxy, the proxy's address: Daytona -// takes one allow list, so the proxy joins the one the config uses. A proxy named by a host -// name cannot, and the session refuses that config. -func (s *Sandbox) ranges() []string { - ranges := s.config.NetworkAllowList - if len(ranges) == 0 || s.mount.Proxy == nil { - return ranges - } - address, err := netip.ParseAddr(s.mount.Proxy.Hostname()) - if err != nil { - return ranges - } - return append(slices.Clone(ranges), netip.PrefixFrom(address, address.BitLen()).String()) -} - -// noProxy is NO_PROXY for a sandbox behind an egress proxy: the config's own domains and -// ranges, which code reaches directly, less each domain whose entry would match a grant's host, -// since a request the proxy never sees keeps its placeholder. curl, Python requests and Go take -// an entry as that name and every name under it, and none takes "*.", so *.pypi.org is -// .pypi.org (https://curl.se/docs/manpage.html#ENVIRONMENT, -// https://pkg.go.dev/golang.org/x/net/http/httpproxy#Config). -func noProxy(config sandbox.Config, grants []sandbox.Grant) string { - var direct []string - for _, domain := range config.AllowedDomains { - if !granted(domain, grants) { - name, wildcard := domainName(domain) - if wildcard { - name = "." + name - } - direct = append(direct, name) - } - } - return strings.Join(append(direct, config.NetworkAllowList...), ",") -} - -// granted says whether a NO_PROXY entry for a config's domain would match a host one of grants -// covers. -func granted(domain string, grants []sandbox.Grant) bool { - name, _ := domainName(domain) - return slices.ContainsFunc(grants, func(grant sandbox.Grant) bool { return matches(name, grant.Hosts) }) -} - -// domainName is a config's domain as the name a NO_PROXY entry for it takes, and whether it -// names the names under it too. curl takes a trailing dot and Go's httpproxy a port and "**." -// as the same name (https://curl.se/docs/manpage.html#ENVIRONMENT, -// https://pkg.go.dev/golang.org/x/net/http/httpproxy#Config), so they are taken off. -func domainName(domain string) (name string, wildcard bool) { - domain = strings.ToLower(domain) - if host, _, err := net.SplitHostPort(domain); err == nil { - domain = host - } - domain = strings.TrimSuffix(domain, ".") - name = strings.TrimLeft(domain, "*.") - return name, name != domain -} - -// matches says whether a NO_PROXY entry for name matches a host one of hosts covers: hosts are -// exact names, or *. and a domain, which takes the domain and every name under it. -func matches(name string, hosts []string) bool { - for _, host := range hosts { - granted, wildcard := strings.CutPrefix(host, "*.") - if granted == name || strings.HasSuffix(granted, "."+name) || wildcard && strings.HasSuffix(name, "."+granted) { - return true - } - } - return false -} diff --git a/acceleration/internal/sandbox/daytona/daytona_test.go b/acceleration/internal/sandbox/daytona/daytona_test.go index fc9b38c64..533638c6c 100644 --- a/acceleration/internal/sandbox/daytona/daytona_test.go +++ b/acceleration/internal/sandbox/daytona/daytona_test.go @@ -40,6 +40,8 @@ type platform struct { // and commandExit the exit code each gives. commands []string commandExit int + // rawCommands are the request bodies of those commands. + rawCommands []string // steps are the file uploads ("upload") and shell commands ("command") in the order they came. steps []string // auth is the last Authorization header seen. @@ -133,7 +135,9 @@ func (p *platform) serve(w http.ResponseWriter, r *http.Request) { case strings.HasSuffix(r.URL.Path, "/process/execute"): var body struct{ Command string } - _ = json.NewDecoder(r.Body).Decode(&body) + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + p.rawCommands = append(p.rawCommands, string(raw)) p.steps = append(p.steps, "command") p.commands = append(p.commands, fmt.Sprintf("%s after %d runs", body.Command, len(p.ran))) w.Header().Set("Content-Type", "application/json") @@ -544,23 +548,3 @@ func (s *DaytonaSuite) TestOnlySoManyFilesComeBackFromOneRun() { s.Len(result.Files, sandbox.MaxFiles) s.Equal(wanted[sandbox.MaxFiles:], result.Missing) } - -// TestNoProxyLeavesOutEveryDomainWhoseEntryMatchesAGrantsHost: a request the egress proxy -// never sees keeps its placeholder, so no NO_PROXY entry may match a granted host. -func (s *DaytonaSuite) TestNoProxyLeavesOutEveryDomainWhoseEntryMatchesAGrantsHost() { - crm := sandbox.Grant{Hosts: []string{"api.crm.example", "*.files.example"}} - config := sandbox.Config{AllowedDomains: []string{"api.crm.example", "crm.example", "CRM.example", "v2.api.crm.example", - "files.example", "eu.files.example", "*.files.example", "example", "pypi.org", "*.pythonhosted.org", ".npmjs.org", - "notfiles.example"}, NetworkAllowList: []string{"10.0.0.0/8"}} - - s.Equal("v2.api.crm.example,pypi.org,.pythonhosted.org,.npmjs.org,notfiles.example,10.0.0.0/8", noProxy(config, []sandbox.Grant{crm})) - s.Equal("pypi.org", noProxy(sandbox.Config{AllowedDomains: []string{"pypi.org"}}, nil)) - s.Empty(noProxy(sandbox.Config{}, []sandbox.Grant{crm})) - s.Equal("pypi.org", noProxy(sandbox.Config{AllowedDomains: []string{"crm.example", "pypi.org"}}, []sandbox.Grant{github, crm}), - "every grant counts, not the first alone") - s.Equal("ithub.com", noProxy(sandbox.Config{AllowedDomains: []string{"ithub.com", ".crm.example"}}, []sandbox.Grant{github, crm}), - "a domain matches at a dot, and a leading dot names the domain") - s.Equal("pypi.org,.pythonhosted.org,pypi.org", noProxy(sandbox.Config{AllowedDomains: []string{"GitHub.com.", "api.github.com:443", - "**.github.com", "pypi.org.", "**.pythonhosted.org", "pypi.org:443"}}, []sandbox.Grant{github}), - "curl and Go take a trailing dot, a port and **. as the name") -} diff --git a/acceleration/internal/sandbox/daytona/proxy_test.go b/acceleration/internal/sandbox/daytona/proxy_test.go index 19056971d..7a452ad04 100644 --- a/acceleration/internal/sandbox/daytona/proxy_test.go +++ b/acceleration/internal/sandbox/daytona/proxy_test.go @@ -6,9 +6,14 @@ import ( "context" "crypto/sha256" "encoding/hex" + "encoding/json" "log/slog" + "maps" "os" + "regexp" + "slices" "strconv" + "strings" "testing" "github.com/redis/rueidis" @@ -67,28 +72,26 @@ func (s *ProxySuite) proxied(config sandbox.Config, proxy string) *Sandbox { return box } -// TestTheSandboxReachesTheProxyAndTheConfigsOwnHosts: the proxy joins the config's allow list, -// HTTP(S)_PROXY names it, the config's own hosts go around it (NO_PROXY) and a grant's host -// never does, not even when the config names it, and the sandbox trusts the proxy's CA, with -// its own roots, before any code runs. -func (s *ProxySuite) TestTheSandboxReachesTheProxyAndTheConfigsOwnHosts() { +// TestTheSandboxGoesThroughTheProxyToItsGrantsAndAllowedDomains: Daytona holds the proxy URL with +// the sandbox's credential, the allow list is the grants' hosts and the config's domains and +// never the proxy's host, the sandbox's own env holds the placeholder alone, and every command +// and run gets the CA env, which Daytona's netleash would replace in the sandbox's. The CA file +// and its bundle come before any code, once per sandbox. +func (s *ProxySuite) TestTheSandboxGoesThroughTheProxyToItsGrantsAndAllowedDomains() { const proxy = "http://proxy.stg.example:8091" cases := []struct { - config sandbox.Config - proxy string - domains, ranges, noProxied string + config sandbox.Config + proxy string + domains string }{ - {sandbox.Config{}, proxy, "proxy.stg.example", "", ""}, - {sandbox.Config{}, "http://203.0.113.7:8091", "203.0.113.7", "", ""}, + {sandbox.Config{}, proxy, "api.github.com,uploads.github.com"}, + {sandbox.Config{}, "http://203.0.113.7:8091", "api.github.com,uploads.github.com"}, {sandbox.Config{Network: true, AllowedDomains: []string{"pypi.org", "api.github.com", "github.com", "*.pythonhosted.org"}}, proxy, - "proxy.stg.example,pypi.org,*.pythonhosted.org", "", "pypi.org,.pythonhosted.org"}, - {sandbox.Config{NetworkAllowList: []string{"10.0.0.0/8"}}, "http://203.0.113.7:8091", "", "10.0.0.0/8,203.0.113.7/32", - "10.0.0.0/8"}, - {sandbox.Config{NetworkAllowList: []string{"10.0.0.0/8"}}, "http://[2001:db8::7]:8091", "", "10.0.0.0/8,2001:db8::7/128", - "10.0.0.0/8"}, + "pypi.org,api.github.com,github.com,*.pythonhosted.org,uploads.github.com"}, } for _, c := range cases { box := s.proxied(c.config, c.proxy) + s.Require().NoError(box.Write(s.ctx, "dir/f.txt", []byte("x"))) _, err := box.Run(s.ctx, "print(1)", nil) s.Require().NoError(err) _, err = box.Run(s.ctx, "print(2)", nil) @@ -97,27 +100,37 @@ func (s *ProxySuite) TestTheSandboxReachesTheProxyAndTheConfigsOwnHosts() { for i, created := range s.platform.requests() { c := cases[i] - env, _ := created["env"].(map[string]any) - s.Regexp(`^va_ph_[A-Z2-7]{26}$`, env["GITHUB_TOKEN"]) - s.Regexp(`^http://sbx:[A-Z2-7]{26}@`, created["outboundProxyUrl"], i) - s.Equal(created["outboundProxyUrl"], env["HTTPS_PROXY"]) + s.Equal([]string{"GITHUB_TOKEN"}, slices.Collect(maps.Keys(created["env"].(map[string]any))), i) + s.Regexp(`^va_ph_[A-Z2-7]{26}$`, created["env"].(map[string]any)["GITHUB_TOKEN"]) + s.Regexp(`^http://sbx:[A-Z2-7]{26}@`+regexp.QuoteMeta(strings.TrimPrefix(c.proxy, "http://"))+`$`, created["outboundProxyUrl"], i) s.Equal(c.domains, valueOr(created, "domainAllowList"), i) - s.Equal(c.ranges, valueOr(created, "networkAllowList"), i) - s.Equal(c.noProxied, valueOr(env, "NO_PROXY"), i) - s.Equal(c.noProxied, valueOr(env, "no_proxy"), i) + s.NotContains(created, "networkAllowList") s.Equal(false, created["networkBlockAll"]) s.NotContains(created, "secrets") } s.Len(s.platform.requests(), len(cases)) - s.Equal([]string{"/tmp/va-egress-ca.pem after 0 runs", "/tmp/va-egress-ca.pem after 2 runs", "/tmp/va-egress-ca.pem after 4 runs", - "/tmp/va-egress-ca.pem after 6 runs", "/tmp/va-egress-ca.pem after 8 runs"}, s.platform.uploads, "once per sandbox, before its first run") + bundle := "/tmp/va-egress-ca-bundle.pem" + runEnv := map[string]any{"SSL_CERT_FILE": bundle, "REQUESTS_CA_BUNDLE": bundle, "CURL_CA_BUNDLE": bundle, "GIT_SSL_CAINFO": bundle, + "NODE_EXTRA_CA_CERTS": "/tmp/va-egress-ca.pem"} + s.Len(s.platform.executed(), 2*len(cases)) + for _, ran := range s.platform.executed() { + s.Equal(runEnv, ran["envs"], "every run") + } + s.Len(s.platform.rawCommands, 2*len(cases)) + for _, raw := range s.platform.rawCommands { + var command struct{ Envs map[string]any } + s.Require().NoError(json.Unmarshal([]byte(raw), &command)) + s.Equal(runEnv, command.Envs, "every command") + } + s.Equal([]string{"/tmp/va-egress-ca.pem after 0 runs", "dir/f.txt after 0 runs", "/tmp/va-egress-ca.pem after 2 runs", + "dir/f.txt after 2 runs", "/tmp/va-egress-ca.pem after 4 runs", "dir/f.txt after 4 runs"}, s.platform.uploads, + "once per sandbox, before its first file and run") s.Equal(caCert, string(s.platform.files["/tmp/va-egress-ca.pem"])) - s.Require().Len(s.platform.commands, len(cases)) for i := range cases { - s.Equal([]string{"upload", "command"}, s.platform.steps[2*i:2*i+2], "the CA file is written before the setup uses it") - } - for i, command := range s.platform.commands { - s.Regexp(`^sh -c '.*> /tmp/va-egress-ca-bundle\.pem.*' after `+strconv.Itoa(2*i)+` runs$`, command, "once per sandbox, after the CA file") + s.Equal([]string{"upload", "command", "command", "upload"}, s.platform.steps[4*i:4*i+4], + "the CA file is written before the setup uses it, and both before the sandbox's first file") + s.Regexp(`^sh -c '.*> /tmp/va-egress-ca-bundle\.pem.*' after `+strconv.Itoa(2*i)+` runs$`, s.platform.commands[2*i], + "once per sandbox, after the CA file") } } diff --git a/acceleration/internal/sandbox/daytona/secrets_test.go b/acceleration/internal/sandbox/daytona/secrets_test.go index 725f185ac..373bbcfcb 100644 --- a/acceleration/internal/sandbox/daytona/secrets_test.go +++ b/acceleration/internal/sandbox/daytona/secrets_test.go @@ -8,6 +8,7 @@ import ( "log/slog" "maps" "net/http" + "regexp" "slices" "strings" "testing" @@ -100,6 +101,26 @@ func (s *SecretsSuite) granted(prefix string, config sandbox.Config, grants ...s return box } +// TestDaytonasBrokerSendsDaytonaWhatItDidBefore is the control: no per-run env and no proxy. +// The expected bodies are a probe of base 98410fe9b. +func (s *SecretsSuite) TestDaytonasBrokerSendsDaytonaWhatItDidBefore() { + box := s.granted("stg", sandbox.Config{AllowedDomains: []string{"pypi.org"}}, github) + s.Require().NoError(box.Write(s.ctx, "dir/f.txt", []byte("x"))) + _, err := box.Run(s.ctx, "print(1)", nil) + s.Require().NoError(err) + + created, err := json.Marshal(s.platform.requests()) + s.Require().NoError(err) + ran, err := json.Marshal(s.platform.executed()) + s.Require().NoError(err) + s.Equal(`[{"domainAllowList":"pypi.org,api.github.com,uploads.github.com","labels":{"code-toolbox-language":"python"},`+ + `"networkBlockAll":false,"public":false,"secrets":[{"GITHUB_TOKEN":"SECRET"}],"spot":false}]`, + regexp.MustCompile(`va_stg_session-1_github_[0-9a-f]{8}`).ReplaceAllString(string(created), "SECRET")) + s.Equal(`[{"authorization":"Bearer key-1","code":"print(1)","language":"python","path":"/toolbox/sandbox-2/process/code-run","timeout":30}]`, + string(ran)) + s.Equal([]string{"{\"command\":\"mkdir -p 'dir'\"}\n"}, s.platform.rawCommands) +} + func (s *SecretsSuite) TestTheSandboxIsCreatedWithTheSecretMadeForItsGrant() { _, err := s.box.Run(s.ctx, "print(1)", nil) s.Require().NoError(err) diff --git a/acceleration/internal/sandbox/egress/broker.go b/acceleration/internal/sandbox/egress/broker.go index be45d6c3e..5e605cf87 100644 --- a/acceleration/internal/sandbox/egress/broker.go +++ b/acceleration/internal/sandbox/egress/broker.go @@ -8,6 +8,7 @@ import ( "fmt" "log/slog" "net/url" + "strings" "sync" "time" @@ -25,10 +26,12 @@ const ( bundlePath = "/tmp/va-egress-ca-bundle.pem" ) -// bundle writes bundlePath from the first system roots file the sandbox's image has, the ones -// Go reads on Linux (https://go.dev/src/crypto/x509/root_linux.go), and fails without one: a -// host the sandbox reaches directly, around the proxy, is verified with the image's roots. -const bundle = `for roots in /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt /etc/ssl/ca-bundle.pem ` + +// bundle writes bundlePath from the first roots file the sandbox has, and fails without one: +// Daytona's netleash roots, which are Mozilla's and netleash's own CA (live probe, 2026-10-21), +// then the system roots files Go reads on Linux (https://go.dev/src/crypto/x509/root_linux.go). +// A host of the config's allowed domains, which the proxy tunnels to as it is, is verified with +// them. +const bundle = `for roots in /etc/daytona/netleash/ca.crt /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/certs/ca-bundle.crt /etc/ssl/ca-bundle.pem ` + `/etc/pki/tls/cacert.pem /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem /etc/ssl/cert.pem; do ` + `if [ -s "$roots" ]; then { cat "$roots"; echo; cat ` + caPath + `; } > ` + bundlePath + ` && exit 0; exit 1; fi; done; ` + `echo "no system CA bundle" >&2; exit 1` @@ -38,14 +41,10 @@ const bundle = `for roots in /etc/ssl/certs/ca-certificates.crt /etc/pki/tls/cer // cannot get back on its next run. const recordTTL = sandbox.MaxTimeout + time.Minute -// proxyEnv are the env vars HTTP clients read a proxy from: curl reads only the lowercase -// http_proxy (https://curl.se/docs/manpage.html#ENVIRONMENT), Python's urllib and Go either. -var proxyEnv = []string{"HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"} - -// bundleEnv are the env vars that point OpenSSL, Python requests and git at the roots they -// trust instead of the system's, so they name the bundle. Node's NODE_EXTRA_CA_CERTS adds to its -// own roots, so it names the CA alone. -var bundleEnv = []string{"SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "GIT_SSL_CAINFO"} +// bundleEnv are the env vars that point OpenSSL, Python requests, curl and git at the roots they +// trust instead of the system's, so they name the bundle: the ones Daytona's netleash sets (live +// probe, 2026-10-21). Node's NODE_EXTRA_CA_CERTS adds to its own roots, so it names the CA alone. +var bundleEnv = []string{"SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO"} // BrokerOptions are what Broker needs: the proxy and its store, and the session's tokens. type BrokerOptions struct { @@ -56,8 +55,10 @@ type BrokerOptions struct { // CACert is the PEM certificate of the CA the proxy signs each host's certificate with, // which the sandbox trusts. Never its key. CACert []byte - // Session is the session the sandbox is for. + // Session is the session the sandbox is for, and Allowed its agent config's allowed_domains, + // which the proxy tunnels to without a token. Session string + Allowed []string Tokens sandbox.Tokens Logger *slog.Logger } @@ -68,6 +69,8 @@ type BrokerOptions struct { type Broker struct { options BrokerOptions proxy *url.URL + // allowed are options.Allowed as a Record's hosts. + allowed []string mu sync.Mutex // credential is the sandbox's proxy credential while its record is in the store, and held @@ -96,7 +99,21 @@ func NewBroker(o BrokerOptions) (*Broker, error) { if o.Logger == nil { o.Logger = slog.Default() } - return &Broker{options: o, proxy: proxy}, nil + var allowed []string + for _, domain := range o.Allowed { + allowed = append(allowed, allowedHost(domain)) + } + return &Broker{options: o, proxy: proxy, allowed: allowed}, nil +} + +// allowedHost is a config's allowed domain as a Record's host: lower case, without a trailing +// dot, and a leading *. or . as *., which takes the domain and every name under it. +func allowedHost(domain string) string { + domain = strings.TrimSuffix(strings.ToLower(domain), ".") + if name := strings.TrimLeft(domain, "*."); name != domain { + return "*." + name + } + return domain } func (b *Broker) Prepare(ctx context.Context, grants []sandbox.Grant) (sandbox.Mount, error) { @@ -136,16 +153,15 @@ func (b *Broker) Prepare(ctx context.Context, grants []sandbox.Grant) (sandbox.M return sandbox.Mount{}, err } b.credential, b.held = credential, prepared + // The proxy credential goes to the provider alone: Daytona's netleash sends the sandbox's + // requests on to the proxy with it, and the sandbox's HTTPS_PROXY names netleash. proxy := *b.proxy proxy.User = url.UserPassword("sbx", credential) - for _, name := range proxyEnv { - env[name] = proxy.String() - } + run := map[string]string{"NODE_EXTRA_CA_CERTS": caPath} for _, name := range bundleEnv { - env[name] = bundlePath + run[name] = bundlePath } - env["NODE_EXTRA_CA_CERTS"] = caPath - return sandbox.Mount{Env: env, Proxy: &proxy, Files: map[string][]byte{caPath: b.options.CACert}, Setup: []string{bundle}}, nil + return sandbox.Mount{Env: env, RunEnv: run, Proxy: &proxy, Files: map[string][]byte{caPath: b.options.CACert}, Setup: []string{bundle}}, nil } // BeforeRun withdraws a grant whose connection gives sandbox.ErrNoCredential and stores the @@ -174,7 +190,8 @@ func (b *Broker) BeforeRun(ctx context.Context) error { } kept = append(kept, h) } - if len(kept) == 0 { + // The record keeps the allowed domains reachable once no grant is left. + if len(kept) == 0 && len(b.allowed) == 0 { return b.release(ctx) } if err := b.options.Store.Put(ctx, b.credential, b.record(kept), recordTTL); err != nil { @@ -205,7 +222,7 @@ func (b *Broker) release(ctx context.Context) error { } func (b *Broker) record(grants []held) Record { - record := Record{Session: b.options.Session} + record := Record{Session: b.options.Session, Allowed: b.allowed} for _, h := range grants { record.Grants = append(record.Grants, h.entry) } diff --git a/acceleration/internal/sandbox/egress/broker_test.go b/acceleration/internal/sandbox/egress/broker_test.go index 63c84cc77..b471a5232 100644 --- a/acceleration/internal/sandbox/egress/broker_test.go +++ b/acceleration/internal/sandbox/egress/broker_test.go @@ -13,13 +13,14 @@ import ( "fmt" "io" "log/slog" + "maps" "net" "net/http" "net/http/httptest" - "net/url" "os" "os/exec" "path/filepath" + "slices" "strings" "sync" "testing" @@ -146,33 +147,32 @@ func (s *BrokerSuite) TestTheSandboxReachesAGrantedHostWithTheTokenInPlaceOfItsP s.NotContains(string(raw), s.token) } -// TestTheEnvNamesTheProxyAndTheCA: every common client finds the proxy, with the sandbox's -// credential, and trusts the proxy's CA: Node in addition to its own roots, from a file that -// holds the CA's certificate alone, and the rest from a bundle of the sandbox's roots and it. -func (s *BrokerSuite) TestTheEnvNamesTheProxyAndTheCA() { - proxy := s.mount.Proxy.String() +// TestTheProviderHoldsTheProxyCredentialAndEveryRunTrustsTheCA: the proxy URL with the +// sandbox's credential goes to the provider alone, the sandbox's env holds the placeholder +// alone, and every run's env has each common client trust the proxy's CA: Node in addition to +// its own roots, from a file that holds the CA's certificate alone, and the rest from a bundle +// of the sandbox's roots and it. +func (s *BrokerSuite) TestTheProviderHoldsTheProxyCredentialAndEveryRunTrustsTheCA() { credential, _ := s.mount.Proxy.User.Password() s.Len(credential, 26, "128 random bits in base32") - s.Equal(strings.Replace(s.rig.proxy.URL, "http://", "http://sbx:"+credential+"@", 1), proxy) - for _, name := range []string{"HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"} { - s.Equal(proxy, s.mount.Env[name], name) - } - for _, name := range []string{"SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "GIT_SSL_CAINFO"} { - s.Equal("/tmp/va-egress-ca-bundle.pem", s.mount.Env[name], name) - } - s.Equal("/tmp/va-egress-ca.pem", s.mount.Env["NODE_EXTRA_CA_CERTS"]) + s.Equal(strings.Replace(s.rig.proxy.URL, "http://", "http://sbx:"+credential+"@", 1), s.mount.Proxy.String()) + s.Equal([]string{"GITHUB_TOKEN"}, slices.Collect(maps.Keys(s.mount.Env))) + s.Regexp(`^va_ph_[A-Z2-7]{26}$`, s.mount.Env["GITHUB_TOKEN"]) + bundle := "/tmp/va-egress-ca-bundle.pem" + s.Equal(map[string]string{"SSL_CERT_FILE": bundle, "REQUESTS_CA_BUNDLE": bundle, "CURL_CA_BUNDLE": bundle, + "GIT_SSL_CAINFO": bundle, "NODE_EXTRA_CA_CERTS": "/tmp/va-egress-ca.pem"}, s.mount.RunEnv) s.Equal(map[string][]byte{"/tmp/va-egress-ca.pem": s.rig.caCert}, s.mount.Files) s.Len(s.mount.Setup, 1) - s.Len(s.mount.Env, 9) } -// TestTheBundleHoldsTheSandboxsRootsAndTheCA: a host the sandbox reaches around the proxy is -// verified with the image's own roots, from the first file of them it has, and an image with -// none runs no code. The setup runs in a shell here, under a directory standing for the -// sandbox's root. +// TestTheBundleHoldsTheSandboxsRootsAndTheCA: a host of the config's allowed domains, which +// the proxy tunnels to as it is, is verified with Daytona's netleash roots or else the image's +// own, from the first file of them it has, and an image with none runs no code. The setup runs +// in a shell here, under a directory standing for the sandbox's root. func (s *BrokerSuite) TestTheBundleHoldsTheSandboxsRootsAndTheCA() { _, debian, _ := newCA(s.T(), "debian roots") _, alpine, _ := newCA(s.T(), "alpine roots") + _, netleash, _ := newCA(s.T(), "netleash roots") setup := func(roots map[string][]byte) ([]string, error) { root := s.T().TempDir() for name, data := range roots { @@ -187,7 +187,7 @@ func (s *BrokerSuite) TestTheBundleHoldsTheSandboxsRootsAndTheCA() { if err := exec.Command("sh", "-c", command).Run(); err != nil { return nil, err } - bundle, err := os.ReadFile(root + s.mount.Env["SSL_CERT_FILE"]) + bundle, err := os.ReadFile(root + s.mount.RunEnv["SSL_CERT_FILE"]) s.Require().NoError(err) return subjects(s.T(), bundle), nil } @@ -195,6 +195,9 @@ func (s *BrokerSuite) TestTheBundleHoldsTheSandboxsRootsAndTheCA() { held, err := setup(map[string][]byte{"/etc/ssl/certs/ca-certificates.crt": debian, "/etc/ssl/cert.pem": alpine}) s.Require().NoError(err) s.Equal([]string{"debian roots", "router sandbox CA"}, held) + held, err = setup(map[string][]byte{"/etc/daytona/netleash/ca.crt": netleash, "/etc/ssl/certs/ca-certificates.crt": debian}) + s.Require().NoError(err) + s.Equal([]string{"netleash roots", "router sandbox CA"}, held) held, err = setup(map[string][]byte{"/etc/ssl/cert.pem": bytes.TrimSuffix(alpine, []byte("\n"))}) s.Require().NoError(err) s.Equal([]string{"alpine roots", "router sandbox CA"}, held, "a roots file without a last newline") @@ -332,6 +335,30 @@ func (s *BrokerSuite) TestARunWithEveryGrantWithdrawnEndsTheCredential() { s.Zero(exists) } +// TestTheRecordHoldsTheConfigsAllowedDomains: as hosts, read as grants' hosts are, and kept +// once every grant is withdrawn, so code still reaches them. +func (s *BrokerSuite) TestTheRecordHoldsTheConfigsAllowedDomains() { + broker, err := egress.NewBroker(egress.BrokerOptions{Store: s.rig.store, URL: s.rig.proxy.URL, CACert: s.rig.caCert, + Session: "session-1", Tokens: s.tokens, Allowed: []string{"PyPI.org.", "*.PythonHosted.org", ".npmjs.org", "**.x.org", "y.org"}}) + s.Require().NoError(err) + s.T().Cleanup(func() { _ = broker.Close(s.ctx) }) + mount, err := broker.Prepare(s.ctx, []sandbox.Grant{github}) + s.Require().NoError(err) + credential, _ := mount.Proxy.User.Password() + allowed := []string{"pypi.org", "*.pythonhosted.org", "*.npmjs.org", "*.x.org", "y.org"} + record, found := s.rig.record(s.T(), key(credential)) + s.Require().True(found) + s.Equal(allowed, record.Allowed) + s.fail(sandbox.ErrNoCredential) + + s.Require().NoError(broker.BeforeRun(s.ctx)) + + record, found = s.rig.record(s.T(), key(credential)) + s.Require().True(found) + s.Empty(record.Grants) + s.Equal(allowed, record.Allowed) +} + // TestClosingEndsTheProxyCredential: the next CONNECT is a 407, and a run after Close does // not bring the credential back. func (s *BrokerSuite) TestClosingEndsTheProxyCredential() { @@ -569,17 +596,15 @@ func (r *rig) record(t *testing.T, key string) (egress.Record, bool) { return record, true } -// sandboxClient is an HTTP client set up from what the sandbox was created with alone. +// sandboxClient is an HTTP client set up from what the sandbox was created with alone: the +// provider's egress sends it to the proxy with the sandbox's credential, as Daytona's netleash +// does, and each run's env names the CA file. func sandboxClient(t *testing.T, mount sandbox.Mount) *http.Client { - proxy, err := url.Parse(mount.Env["HTTPS_PROXY"]) - if err != nil { - t.Fatal(err) - } roots := x509.NewCertPool() - if !roots.AppendCertsFromPEM(mount.Files[mount.Env["NODE_EXTRA_CA_CERTS"]]) { + if !roots.AppendCertsFromPEM(mount.Files[mount.RunEnv["NODE_EXTRA_CA_CERTS"]]) { t.Fatal("the sandbox's CA file holds no certificate") } - return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxy), TLSClientConfig: &tls.Config{RootCAs: roots}}} + return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(mount.Proxy), TLSClientConfig: &tls.Config{RootCAs: roots}}} } // redisGate passes connections on to Redis until shut, which cuts every one and refuses the diff --git a/acceleration/internal/sandbox/egress/proxy.go b/acceleration/internal/sandbox/egress/proxy.go index 9e597d072..25423da41 100644 --- a/acceleration/internal/sandbox/egress/proxy.go +++ b/acceleration/internal/sandbox/egress/proxy.go @@ -1,7 +1,8 @@ // Package egress is the router's egress proxy for code in a sandbox: the sandbox holds a // placeholder, and the proxy puts the connection's token in its place on the way out, only // in the Authorization header of a request to one of the grant's hosts. The token never -// enters the sandbox. +// enters the sandbox. A host of the config's allowed domains gets the sandbox's bytes as they +// are. package egress import ( @@ -68,7 +69,10 @@ type Options struct { // Transport sends a request on to its host. Nil is internal/egress's, which dials public // addresses only and verifies the host with the system's roots. Transport http.RoundTripper - Logger *slog.Logger + // Dial opens a tunnel to an allowed domain. Nil is internal/egress's DialPublic, which dials + // public addresses only. + Dial func(ctx context.Context, network, address string) (net.Conn, error) + Logger *slog.Logger // HandshakeTimeout, IdleTimeout and HeaderTimeout replace handshakeTimeout, idleTimeout // and headerTimeout when set, for tests. HandshakeTimeout, IdleTimeout, HeaderTimeout time.Duration @@ -82,6 +86,7 @@ type Proxy struct { caKey crypto.Signer leafKey *ecdsa.PrivateKey transport http.RoundTripper + dial func(ctx context.Context, network, address string) (net.Conn, error) logger *slog.Logger handshakeTimeout, idleTimeout time.Duration @@ -105,14 +110,18 @@ func New(options Options) (*Proxy, error) { if transport == nil { transport = publicegress.NewClient(0, nil).Transport } + dial := options.Dial + if dial == nil { + dial = publicegress.DialPublic + } return &Proxy{store: options.Store, tokens: options.Tokens, ca: pair.Leaf, caKey: caKey, leafKey: leafKey, - transport: outbound{next: transport, headerTimeout: cmp.Or(options.HeaderTimeout, headerTimeout)}, logger: options.Logger, + transport: outbound{next: transport, headerTimeout: cmp.Or(options.HeaderTimeout, headerTimeout)}, dial: dial, logger: options.Logger, handshakeTimeout: cmp.Or(options.HandshakeTimeout, handshakeTimeout), idleTimeout: cmp.Or(options.IdleTimeout, idleTimeout)}, nil } -// ServeHTTP opens a tunnel to port 443 of a host the sandbox's grants cover, and nothing -// else: a request that is not CONNECT, a sandbox without a valid proxy credential, an IP -// literal, a host outside its grants or another port is refused. +// ServeHTTP opens a tunnel to port 443 of a host the sandbox's grants or allowed domains cover, +// and nothing else: a request that is not CONNECT, a sandbox without a valid proxy credential, +// an IP literal, a host outside both or another port is refused. A host both cover is a grant's. func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodConnect { w.Header().Set("Allow", http.MethodConnect) @@ -140,7 +149,8 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { // A grant names hosts, never addresses, so an address cannot be told apart from another // host's (core.SandboxRule). A * is no host's name: a wildcard grant would match it, and // its certificate would be one for every name under the domain. - if net.ParseIP(host) != nil || strings.Contains(host, "*") || !record.granted(host) { + granted := record.granted(host) + if net.ParseIP(host) != nil || strings.Contains(host, "*") || !granted && !record.allowed(host) { p.logger.Warn("the sandbox egress proxy refused a host", "session", record.Session, "host", host) http.Error(w, "egress: the sandbox has no grant for this host", http.StatusForbidden) return @@ -151,6 +161,10 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "egress: only port 443 is proxied", http.StatusForbidden) return } + if !granted { + p.passthrough(w, r, record.Session, host) + return + } conn, buffered, err := http.NewResponseController(w).Hijack() if err != nil { http.Error(w, "egress: the connection cannot be taken over", http.StatusInternalServerError) @@ -164,6 +178,39 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { p.tunnel(&readFirst{Conn: conn, first: bytes.NewReader(early)}, tunnel{credential: credential, host: host}) } +// passthrough tunnels the sandbox's bytes to port 443 of host, an allowed domain, as they are: +// no certificate of the router's, no token, nothing read or logged. A host that resolves to a +// non-public address is not dialed (502). +func (p *Proxy) passthrough(w http.ResponseWriter, r *http.Request, session, host string) { + upstream, err := p.dial(r.Context(), "tcp", net.JoinHostPort(host, httpsPort)) + if err != nil { + // A dial's error holds no byte the host sent. + p.logger.Warn("the sandbox egress proxy could not reach an allowed host", "session", session, "host", host, "error", err) + http.Error(w, "egress: the host cannot be reached", http.StatusBadGateway) + return + } + conn, buffered, err := http.NewResponseController(w).Hijack() + if err != nil { + _ = upstream.Close() + http.Error(w, "egress: the connection cannot be taken over", http.StatusInternalServerError) + return + } + if _, err := io.WriteString(conn, "HTTP/1.1 200 Connection established\r\n\r\n"); err != nil { + _ = conn.Close() + _ = upstream.Close() + return + } + early, _ := buffered.Reader.Peek(buffered.Reader.Buffered()) + sandbox := &readFirst{Conn: conn, first: bytes.NewReader(early)} + // Either side ending ends the tunnel: closing both stops the other copy. + done := make(chan struct{}, 2) + go func() { _, _ = io.Copy(upstream, sandbox); done <- struct{}{} }() + go func() { _, _ = io.Copy(conn, upstream); done <- struct{}{} }() + <-done + _ = conn.Close() + _ = upstream.Close() +} + // tunnel is one CONNECT: the sandbox's credential and the host it asked for. type tunnel struct { credential, host string diff --git a/acceleration/internal/sandbox/egress/proxy_test.go b/acceleration/internal/sandbox/egress/proxy_test.go index 3edfbc8ca..9fee6d961 100644 --- a/acceleration/internal/sandbox/egress/proxy_test.go +++ b/acceleration/internal/sandbox/egress/proxy_test.go @@ -134,27 +134,26 @@ func (s *ProxySuite) TearDownTest() { } } -// newProxy is a proxy over store; change, when given, changes its options first. +// newProxy is a proxy over store; change, when given, changes its options first. Its transport +// and its tunnels to an allowed domain dial the test's hosts. func (s *ProxySuite) newProxy(store *egress.Store, change ...func(*egress.Options)) *egress.Proxy { - dial := &net.Dialer{} + dialer := &net.Dialer{} + dial := func(ctx context.Context, network, address string) (net.Conn, error) { + s.mu.Lock() + s.addresses = append(s.addresses, address) + to := s.answer + s.mu.Unlock() + switch { + case to != "": + case strings.HasPrefix(address, "outside.test:"): + to = s.outside.server.Listener.Addr().String() + default: + to = s.granted.server.Listener.Addr().String() + } + return dialer.DialContext(ctx, network, to) + } options := egress.Options{Store: store, Tokens: s.tokens, CACert: s.caCert, CAKey: s.caKey, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{RootCAs: s.upstream}, - DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { - s.mu.Lock() - s.addresses = append(s.addresses, address) - to := s.answer - s.mu.Unlock() - switch { - case to != "": - case strings.HasPrefix(address, "outside.test:"): - to = s.outside.server.Listener.Addr().String() - default: - to = s.granted.server.Listener.Addr().String() - } - return dial.DialContext(ctx, network, to) - }, - }, + Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: s.upstream}, DialContext: dial}, Dial: dial, Logger: slog.New(slog.NewTextHandler(s.logs, nil))} for _, change := range change { change(&options) @@ -445,6 +444,99 @@ func (s *ProxySuite) TestAHostOutsideTheGrantsIsRefusedAndNeverReached() { s.Zero(s.asked) } +// allow puts the test's record again with the grant for api.github.test and allowed. +func (s *ProxySuite) allow(allowed ...string) { + s.put(s.credential, egress.Record{Session: s.session, Grants: []egress.Grant{s.grant(s.placeholder, "api.github.test")}, + Allowed: allowed}) +} + +// direct is code in the sandbox that trusts the hosts' own CA and not the router's: a tunnel +// it can use is the host's, not one the proxy answers. +func (s *ProxySuite) direct() *http.Client { + client := s.sandbox(s.credential) + client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{RootCAs: s.upstream} + return client +} + +// TestAnAllowedDomainIsTunnelledAsItIs: the host's own certificate, the placeholder as the +// sandbox sent it, no token asked for, and nothing of the request in the logs. +func (s *ProxySuite) TestAnAllowedDomainIsTunnelledAsItIs() { + s.allow("outside.test") + + response, body, err := s.send(s.direct(), s.request(http.MethodGet, "https://outside.test/echo", "Bearer "+s.placeholder, "")) + + s.Require().NoError(err) + s.Equal(http.StatusOK, response.StatusCode) + s.Equal("hosts CA", response.TLS.PeerCertificates[0].Issuer.CommonName) + s.Equal([]string{"Bearer " + s.placeholder}, s.outside.authorizations()) + s.Contains(body, s.placeholder) + s.Zero(s.asked) + s.Equal([]string{"outside.test:443"}, s.dialed()) + s.NotContains(s.logs.String(), s.placeholder) + _, _, err = s.send(s.sandbox(s.credential), s.request(http.MethodGet, "https://outside.test/echo", "", "")) + s.ErrorContains(err, "certificate", "the router's CA signs nothing for an allowed domain") +} + +// TestAHostBothGrantedAndAllowedIsAGrants: the proxy answers it and puts the token in. +func (s *ProxySuite) TestAHostBothGrantedAndAllowedIsAGrants() { + s.allow("*.github.test", "api.github.test") + + response, _ := s.get("Bearer " + s.placeholder) + + s.Equal(http.StatusOK, response.StatusCode) + s.Equal([]string{"Bearer gho_first-token-of-the-connection"}, s.granted.authorizations()) +} + +// TestOnlyAnAllowedDomainOnPort443IsTunnelled: the allowed domains are read as grants' hosts +// are, and another port, an IP literal or another host is refused and never dialed. +func (s *ProxySuite) TestOnlyAnAllowedDomainOnPort443IsTunnelled() { + s.allow("outside.test", "*.pypi.test", "127.0.0.1") + status := func(target string) int { return s.connectStatus(target, basic("sbx", s.credential)).StatusCode } + + s.Equal(http.StatusForbidden, status("outside.test:8443")) + s.Equal(http.StatusForbidden, status("evil.outside.test:443")) + s.Equal(http.StatusForbidden, status("outside.test.evil.test:443")) + s.Equal(http.StatusForbidden, status("127.0.0.1:443")) + s.Equal(http.StatusForbidden, status("*.pypi.test:443")) + s.Empty(s.dialed()) + s.Equal(http.StatusOK, status("files.pypi.test:443")) + s.Equal(http.StatusOK, status("Outside.test:443")) + s.Equal([]string{"files.pypi.test:443", "outside.test:443"}, s.dialed()) +} + +// TestAnAllowedDomainResolvingToANonPublicAddressIsNotDialed is A11 for a tunnel: the dial the +// router uses refuses it. +func (s *ProxySuite) TestAnAllowedDomainResolvingToANonPublicAddressIsNotDialed() { + s.proxy = httptest.NewServer(s.newProxy(s.store, func(options *egress.Options) { options.Dial = nil })) + s.T().Cleanup(s.proxy.Close) + s.allow("localhost") + + s.Equal(http.StatusBadGateway, s.connectStatus("localhost:443", basic("sbx", s.credential)).StatusCode) + s.Contains(s.logs.String(), "resolved to a non-public address") +} + +// TestATunnelToAnAllowedDomainKeepsTheTLSHelloAndEndsWithTheHost: the hello sent with the +// CONNECT reaches the host, and the host closing the connection closes the sandbox's. +func (s *ProxySuite) TestATunnelToAnAllowedDomainKeepsTheTLSHelloAndEndsWithTheHost() { + s.allow("outside.test") + conn, err := net.Dial("tcp", s.proxy.Listener.Addr().String()) + s.Require().NoError(err) + defer conn.Close() + s.Require().NoError(conn.SetDeadline(time.Now().Add(5 * time.Second))) + eager := &eagerConn{Conn: conn, connect: fmt.Sprintf("CONNECT outside.test:443 HTTP/1.1\r\nHost: outside.test:443\r\n"+ + "Proxy-Authorization: %s\r\n\r\n", basic("sbx", s.credential))} + eager.reader = bufio.NewReader(conn) + client := tls.Client(eager, &tls.Config{ServerName: "outside.test", RootCAs: s.upstream}) + s.Require().NoError(client.HandshakeContext(s.ctx)) + _, err = io.WriteString(client, "GET /echo HTTP/1.1\r\nHost: outside.test\r\nConnection: close\r\n\r\n") + s.Require().NoError(err) + + answer, err := io.ReadAll(client) + + s.Require().NoError(err) + s.Contains(string(answer), "200 OK") +} + // TestAWildcardGrantTakesTheDomainAndTheNamesUnderItOnly: *. as core.SandboxRule reads it. func (s *ProxySuite) TestAWildcardGrantTakesTheDomainAndTheNamesUnderItOnly() { s.put(s.credential, egress.Record{Session: s.session, Grants: []egress.Grant{s.grant(s.placeholder, "*.github.test")}}) diff --git a/acceleration/internal/sandbox/egress/store.go b/acceleration/internal/sandbox/egress/store.go index 7bf285f51..7781ec107 100644 --- a/acceleration/internal/sandbox/egress/store.go +++ b/acceleration/internal/sandbox/egress/store.go @@ -16,11 +16,14 @@ import ( "github.com/GetStream/Vision-Agents/acceleration/internal/stack" ) -// Record is what one sandbox's proxy credential lets it reach: the session it belongs to and -// its grants. +// Record is what one sandbox's proxy credential lets it reach: the session it belongs to, its +// grants, and the config's allowed domains, which the proxy tunnels to as they are. type Record struct { Session string `json:"session"` Grants []Grant `json:"grants"` + // Allowed are the agent config's allowed_domains as hosts: exact hosts, or *. and a domain, + // in lower case without a trailing dot. + Allowed []string `json:"allowed,omitempty"` } // Grant is one connection whose token the proxy puts in place of Placeholder, only in a @@ -40,12 +43,17 @@ type Grant struct { // covers says whether host is one of the grant's hosts. func (g Grant) covers(host string) bool { - for _, granted := range g.Hosts { - if domain, wildcard := strings.CutPrefix(granted, "*."); wildcard { + return covers(g.Hosts, host) +} + +// covers says whether host is one of hosts: exact hosts, or *. and a domain. +func covers(hosts []string, host string) bool { + for _, named := range hosts { + if domain, wildcard := strings.CutPrefix(named, "*."); wildcard { if host == domain || strings.HasSuffix(host, "."+domain) { return true } - } else if host == granted { + } else if host == named { return true } } @@ -62,6 +70,11 @@ func (r Record) granted(host string) bool { return false } +// allowed says whether host is one of the config's allowed domains. +func (r Record) allowed(host string) bool { + return covers(r.Allowed, host) +} + // Store keeps each sandbox's Record in Redis under its proxy credential, so any router // replica serves the sandbox. type Store struct { @@ -93,7 +106,8 @@ func (s *Store) Put(ctx context.Context, credential string, record Record, ttl t return stack.Wrap(s.redis.Do(ctx, s.redis.B().Set().Key(key(credential)).Value(string(raw)).Px(ttl).Build()).Error()) } -// Delete ends credential at once, tunnels it opened included. +// Delete ends credential at once, the tunnels to a grant's host it opened included. A tunnel to an +// allowed domain, which carries no token, lasts until either end closes it. func (s *Store) Delete(ctx context.Context, credential string) error { return stack.Wrap(s.redis.Do(ctx, s.redis.B().Del().Key(key(credential)).Build()).Error()) } diff --git a/acceleration/internal/session/sandbox_grants.go b/acceleration/internal/session/sandbox_grants.go index 2c18c58b0..52bc1e451 100644 --- a/acceleration/internal/session/sandbox_grants.go +++ b/acceleration/internal/session/sandbox_grants.go @@ -4,8 +4,6 @@ import ( "context" "errors" "fmt" - "net/netip" - "net/url" "github.com/GetStream/Vision-Agents/acceleration/internal/connectors/core" "github.com/GetStream/Vision-Agents/acceleration/internal/connectors/resolver" @@ -47,11 +45,12 @@ func (m *Manager) sandboxGrant(binding store.ConnectorBinding, connection store. // broker is the code_sandbox.broker setting's broker for the session's grants. func (m *Manager) broker(spec Spec, d *dispatcher) (sandbox.CredentialBroker, error) { if m.options.SandboxBroker == egress.BrokerName { - if err := behindProxy(spec.SandboxOptions, m.options.SandboxProxy.URL); err != nil { + if err := behindProxy(spec.SandboxOptions); err != nil { return nil, err } options := m.options.SandboxProxy - options.Session, options.Tokens, options.Logger = spec.ID, m.sandboxTokens(d.schemes), m.logger + options.Session, options.Allowed = spec.ID, spec.SandboxOptions.AllowedDomains + options.Tokens, options.Logger = m.sandboxTokens(d.schemes), m.logger return egress.NewBroker(options) } if m.options.SandboxSecretPrefix == "" { @@ -65,31 +64,23 @@ func (m *Manager) broker(spec Spec, d *dispatcher) (sandbox.CredentialBroker, er Tokens: m.sandboxTokens(d.schemes), Logger: m.logger}) } -// behindProxy refuses a sandbox config the egress proxy cannot serve. HTTPS_PROXY sends every -// request there and the proxy refuses a host no grant names, so an open network would reach -// nothing but the grants. Daytona takes one allow list, so with CIDR ranges the proxy must be an -// address in them. -func behindProxy(config sandbox.Config, proxy string) error { +// behindProxy refuses a sandbox config the egress proxy cannot serve. Every request goes there, +// and the proxy reaches the grants' hosts and the config's allowed domains by name only, so an +// open network or CIDR ranges would reach nothing but the grants. +func behindProxy(config sandbox.Config) error { if config.Network && len(config.AllowedDomains) == 0 && len(config.NetworkAllowList) == 0 { return stack.Wrap(errors.New("session: the agent config's sandbox_options.network is true, and this router's " + "code_sandbox.broker is proxy, which sends every request from the sandbox through the egress proxy, and the " + - "proxy reaches only connectors' hosts: list the domains code needs in sandbox_options.allowed_domains instead")) + "proxy reaches only connectors' hosts and sandbox_options.allowed_domains: list the domains code needs in sandbox_options.allowed_domains instead")) } - parsed, err := url.Parse(proxy) - if len(config.NetworkAllowList) > 0 && (err != nil || !isAddress(parsed.Hostname())) { - return stack.Wrap(errors.New("session: the agent config's sandbox_options.network_allow_list must hold the " + - "egress proxy's address, and this router's code_sandbox.proxy_public_url names it by a host name: list " + - "sandbox_options.allowed_domains instead, or give the proxy's IP address")) + if len(config.NetworkAllowList) > 0 { + return stack.Wrap(errors.New("session: the agent config's sandbox_options.network_allow_list is set, and this " + + "router's code_sandbox.broker is proxy, which reaches hosts by name only: list the domains code needs in " + + "sandbox_options.allowed_domains instead")) } return nil } -// isAddress says whether host is an IP address, which a CIDR range can hold. -func isAddress(host string) bool { - _, err := netip.ParseAddr(host) - return err == nil -} - // sandboxTokens resolves a grant's credential and reads it as its scheme sends it: the token // of the Authorization header Wrap puts on a request to the grant's first host. A connection // that is not connected, or was deleted, gives sandbox.ErrNoCredential; a renewal that failed diff --git a/acceleration/internal/session/sandbox_grants_test.go b/acceleration/internal/session/sandbox_grants_test.go index fc84f8be7..58215195a 100644 --- a/acceleration/internal/session/sandbox_grants_test.go +++ b/acceleration/internal/session/sandbox_grants_test.go @@ -248,9 +248,10 @@ func (s *SandboxGrantsSuite) TestASandboxBindingNeedsABrokerThereIs() { const proxyCA = "-----BEGIN CERTIFICATE-----\nY2E=\n-----END CERTIFICATE-----\n" // TestTheProxyBrokerSendsTheSandboxThroughTheProxy: with code_sandbox.broker proxy, run_code's -// sandbox reaches the proxy alone, holds a placeholder, and the proxy finds the connection's -// token behind it, for this session. No secret prefix is needed. A connection deleted -// mid-session is withdrawn, in the session's log. +// sandbox goes through the proxy to its grants' hosts and the config's allowed domains, holds a +// placeholder, and the proxy finds the connection's token behind it, for this session, and the +// allowed domains. No secret prefix is needed. A connection deleted mid-session is withdrawn, +// in the session's log. func (s *SandboxGrantsSuite) TestTheProxyBrokerSendsTheSandboxThroughTheProxy() { address := os.Getenv("ROUTER_REDIS_ADDR") if address == "" { @@ -266,7 +267,7 @@ func (s *SandboxGrantsSuite) TestTheProxyBrokerSendsTheSandboxThroughTheProxy() s.sandboxRevision() id := s.connection("", "primary") spec := s.spec(s.config(sandboxed(s.fixed("crm", id, "whoami"))), "", nil) - spec.ID, spec.Sandbox = uuid.NewString(), daytonaProvider + spec.ID, spec.Sandbox, spec.SandboxOptions = uuid.NewString(), daytonaProvider, sandbox.Config{AllowedDomains: []string{"PyPI.org"}} d, _, _, err := s.attach(spec) s.Require().NoError(err) box, err := s.manager.box(spec, d) @@ -276,7 +277,7 @@ func (s *SandboxGrantsSuite) TestTheProxyBrokerSendsTheSandboxThroughTheProxy() _, err = box.Run(s.ctx, "print(1)", nil) s.Require().NoError(err) - s.Equal("proxy.stg.example", api.created["domainAllowList"]) + s.Equal("PyPI.org,api.crm.example,*.crm.example", api.created["domainAllowList"]) proxy, err := url.Parse(api.created["outboundProxyUrl"].(string)) s.Require().NoError(err) credential, _ := proxy.User.Password() @@ -286,6 +287,7 @@ func (s *SandboxGrantsSuite) TestTheProxyBrokerSendsTheSandboxThroughTheProxy() var record egress.Record s.Require().NoError(json.Unmarshal(raw, &record)) s.Equal(spec.ID, record.Session) + s.Equal([]string{"pypi.org"}, record.Allowed) s.Require().Len(record.Grants, 1) s.Equal(api.created["env"].(map[string]any)["CRM_TOKEN"], record.Grants[0].Placeholder) token, err := egress.ResolverTokens(s.resolver, s.registry.Schemes)(s.ctx, record.Grants[0]) @@ -308,9 +310,9 @@ func (s *SandboxGrantsSuite) TestAManagerWithTheProxyBrokerNeedsItsStore() { s.ErrorContains(err, "the proxy sandbox broker needs the store") } -// TestTheProxyBrokerRefusesAConfigItCannotServe: an open network would reach the grants alone, -// and Daytona's one CIDR allow list cannot hold a proxy named by a host name. Daytona's broker -// takes both, and the proxy takes a network narrowed to domains, or ranges with its address. +// TestTheProxyBrokerRefusesAConfigItCannotServe: the proxy reaches hosts by name, so an open +// network or CIDR ranges would reach the grants alone. Daytona's broker takes both, and the +// proxy takes a network narrowed to domains. func (s *SandboxGrantsSuite) TestTheProxyBrokerRefusesAConfigItCannotServe() { open := sandbox.Config{Network: true} ranges := sandbox.Config{NetworkAllowList: []string{"10.0.0.0/8"}} @@ -322,12 +324,10 @@ func (s *SandboxGrantsSuite) TestTheProxyBrokerRefusesAConfigItCannotServe() { err := s.boxed(open) s.ErrorContains(err, "sandbox_options.network is true") s.ErrorContains(err, "code_sandbox.broker is proxy") - s.ErrorContains(s.boxed(ranges), "sandbox_options.network_allow_list must hold the egress proxy's address") + s.ErrorContains(s.boxed(ranges), "sandbox_options.network_allow_list is set") + s.ErrorContains(s.boxed(sandbox.Config{Network: true, NetworkAllowList: []string{"10.0.0.0/8"}}), "sandbox_options.network_allow_list is set") s.NoError(s.boxed(sandbox.Config{Network: true, AllowedDomains: []string{"pypi.org"}})) - s.manager.options.SandboxProxy.URL = "http://203.0.113.7:8091" - s.NoError(s.boxed(ranges)) - s.NoError(s.boxed(sandbox.Config{Network: true, NetworkAllowList: []string{"10.0.0.0/8"}}), - "a network narrowed to ranges is not open") + s.NoError(s.boxed(sandbox.Config{})) } // daytonaAPI stands in for Daytona's API: it creates sandboxes, runs code, and keeps the From 2aebff4b506b28ced231c32bc5a4d00bcbf57510 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 21 Oct 2026 16:55:36 -0400 Subject: [PATCH 2/8] [AI-1663] Docs: the proxy broker under Daytona's netleash, and sandbox code has root Co-Authored-By: Claude Opus 5.5 --- .claude/skills/connectors/SKILL.md | 43 +++++++++++-------- .../internal/sandbox/egress/AGENTS.md | 16 ++++--- 2 files changed, 35 insertions(+), 24 deletions(-) diff --git a/.claude/skills/connectors/SKILL.md b/.claude/skills/connectors/SKILL.md index 620c8efc1..3f69bb385 100644 --- a/.claude/skills/connectors/SKILL.md +++ b/.claude/skills/connectors/SKILL.md @@ -206,23 +206,32 @@ over HTTPS to a host in `sandbox.hosts`. Daytona's egress swaps it for the token router with this broker serves the proxy too, so it also needs `ROUTER_CODE_SANDBOX_PROXY_ADDR` and `ROUTER_CODE_SANDBOX_PROXY_CA_KEY`; without them it does not start. It needs no secret prefix. -- Behind the proxy, the sandbox gets a placeholder (`va_ph_…`) in the env var, `HTTPS_PROXY` - with its own proxy credential, and the proxy's CA certificate at `/tmp/va-egress-ca.pem` - (`NODE_EXTRA_CA_CERTS`). Before any code runs, the router writes `/tmp/va-egress-ca-bundle.pem`: - the image's own roots and that CA (`SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `GIT_SSL_CAINFO`), so a - host reached directly is still verified. An image with no system roots file runs no code. The proxy puts - the token in place of the placeholder in `Authorization` (Bearer, `token`, or Basic), so git - works too. -- Network behind the proxy: the allow list is the proxy and the config's `allowed_domains` (or its - `network_allow_list` and the proxy's IP address). `NO_PROXY` holds the config's own domains, so - they go direct; a domain that matches a grant's host is left out, so its requests still go - through the proxy, and it is left out of the allow list too. The proxy refuses (403) any name - no grant covers. So a config domain that a grant's host also matches (for example `github.com` - in the config beside a grant for `api.github.com`) is reachable neither directly nor through the - proxy. A config with `network: true` and no - allow list is refused when a session with a sandbox binding starts: every request goes to the - proxy, which refuses hosts no grant names. So is a `network_allow_list` when the proxy URL names - a host, not an IP address. +- Behind the proxy, the sandbox gets a placeholder (`va_ph_…`) in the env var. Daytona gets the + proxy URL with the sandbox's own proxy credential (`OutboundProxyUrl`). Daytona's netleash holds + that credential: the sandbox's `HTTPS_PROXY` names netleash, not the proxy. Netleash replaces the + proxy and CA env vars a sandbox is created with, but not those of one command or run of code + (live probes, 2026-10-21). So the router sets the CA env vars on every command and run: + `SSL_CERT_FILE`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE` and `GIT_SSL_CAINFO` name + `/tmp/va-egress-ca-bundle.pem`, and `NODE_EXTRA_CA_CERTS` names the proxy's CA certificate, + `/tmp/va-egress-ca.pem`. Before any code runs, the router writes the bundle: netleash's + `ca.crt` (or the image's own roots) and that CA. An image with no roots file runs no code. The + proxy puts the token in place of the placeholder in `Authorization` (Bearer, `token`, or Basic), + so git works too. +- Network behind the proxy: the sandbox's domain allow list is the grants' hosts and the config's + `allowed_domains`. Only that list makes Daytona force every connection through netleash; the + proxy URL alone does not. The proxy then does one of three things with a `CONNECT`: + - a host a grant covers: it ends TLS with its own CA and puts the token in; + - a host of `allowed_domains` only: it sends the bytes on as they are, with no token, and reads + none of them; + - any other host, a non-public address or a port other than 443: it refuses. + A host both cover is a grant's. A config with `network: true` and no `allowed_domains`, or with + a `network_allow_list`, is refused when the session starts: the proxy reaches hosts by name + only. Unverified until the staging E2E (AI-1663): that netleash sends the allowed hosts on to + the proxy. +- Sandbox code has root (`sudo` with no password, live probe 2026-10-21). No control inside the + sandbox protects anything: code can change its env, its CA files and its routes. The controls + are the token, which stays in the router; Daytona's domain allow list; and the proxy's grant + check. - A connection the proxy cannot get a token for just now (a provider outage) keeps its grant; a connection that needs a reconnect gives the sandbox nothing, and refuses a `required` binding. - Revocation through the proxy is immediate: it resolves the token on every request and keeps no diff --git a/acceleration/internal/sandbox/egress/AGENTS.md b/acceleration/internal/sandbox/egress/AGENTS.md index 833d87fad..a6bcb4ce8 100644 --- a/acceleration/internal/sandbox/egress/AGENTS.md +++ b/acceleration/internal/sandbox/egress/AGENTS.md @@ -1,12 +1,12 @@ # internal/sandbox/egress -The router's egress proxy for code in a sandbox. The sandbox holds a placeholder in an env var; the proxy puts the connection's token in its place on the way out. The token never enters the sandbox. `cmd/router` serves it on `code_sandbox.proxy_addr` (`ROUTER_CODE_SANDBOX_PROXY_ADDR`) when that is set. +The router's egress proxy for code in a sandbox. The sandbox holds a placeholder in an env var; the proxy puts the connection's token in its place on the way out. The token never enters the sandbox. A host of the agent config's `allowed_domains` gets the sandbox's bytes as they are. `cmd/router` serves it on `code_sandbox.proxy_addr` (`ROUTER_CODE_SANDBOX_PROXY_ADDR`) when that is set. ## A request through it -1. The sandbox sends `CONNECT host:443` with `Proxy-Authorization: Basic` whose password is its proxy credential (`http://sbx:@proxy:8091` in `HTTPS_PROXY`). +1. The sandbox sends `CONNECT host:443` to Daytona's netleash. Netleash sends it on to the proxy with `Proxy-Authorization: Basic` whose password is the sandbox's proxy credential (`http://sbx:@proxy:8091`, the sandbox's `OutboundProxyUrl`). The sandbox never sees the credential. That netleash sends each allowed host on to the proxy is unverified until the staging E2E (AI-1663). 2. The proxy reads the sandbox's `Record` from Redis under the credential's SHA-256 (`Store`). None, or expired: 407. -3. The host must be a name one of the record's grants covers, and the port 443. An IP literal, any other host or any other port: 403, nothing is tunnelled. +3. The host must be a name one of the record's grants or allowed domains covers, and the port 443. An IP literal, any other host or any other port: 403, nothing is tunnelled. A host the allowed domains cover and no grant does gets a blind tunnel: the proxy dials it only when every address it resolves to is public (else 502), and copies bytes both ways. It reads none of them and puts in no token. A host both cover is a grant's, so steps 4 to 6 follow. 4. The proxy answers the TLS handshake with a certificate for the host, signed by the router's CA (`code_sandbox.proxy_ca_cert`, `proxy_ca_key`). The sandbox trusts that CA's certificate. 5. For each request in the tunnel it reads the record again (a `Delete` ends open tunnels too), checks `Host` is the CONNECT host (else 421), and looks for a grant's placeholder in `Authorization`: `Bearer `, `token `, or Basic whose password is ``. Nowhere else: not the body, the query or another header. 6. With one, it asks `Tokens` for the token (the connections resolver, on every request: no copy is kept), puts it in the placeholder's place, and sends the request over TLS verified with the system's roots. The response's headers and body have the token turned back into the placeholder: as sent, in a JSON string, URL-encoded, in base64 and in Basic's encoded form. `Range` and `If-Range` are dropped from the request, so no echo comes back in pieces. A response in a `Content-Encoding` the transport did not undo is refused (502), and its trailers and any 1xx response are not sent on. @@ -21,16 +21,18 @@ The router's egress proxy for code in a sandbox. The sandbox holds a placeholder - A host that echoes the token in a form the rewrite does not know (another escape, split across headers, its case changed) gives it to the sandbox. Grant only hosts that do not. - A host that keeps the token it was sent and returns it later, in the response to a request that carries no placeholder, gives it to the sandbox: only a response to a request with the token is rewritten. -- A sandbox's proxy credential is in its `HTTPS_PROXY`, and so in what Daytona is asked to create the sandbox with. Should Daytona answer a create with an error that quotes that request, the router logs the error and the model reads it in `run_code`'s result. That is a proxy credential, never a token, and it is as exposed as the sandbox's env already is (next item); whether Daytona quotes it is unverified. +- Sandbox code has root (passwordless `sudo`, live probe 2026-10-21). No control inside the sandbox protects anything: code can change its env, its CA files and its routes. The controls are the token, which stays in the router; Daytona's domain allow list (the grants' hosts and the allowed domains), which forces every connection through netleash; and the grant check in step 3. +- A `Delete` ends the tunnels to a grant's host. A blind tunnel to an allowed domain carries no token and lasts until either end closes it. +- A sandbox's proxy credential is in its `OutboundProxyUrl`, and so in what Daytona is asked to create the sandbox with. Should Daytona answer a create with an error that quotes that request, the router logs the error and the model reads it in `run_code`'s result. That is a proxy credential, never a token, and it is held by netleash, not the sandbox's env; whether Daytona quotes it is unverified. - A valid proxy credential works from wherever the proxy is reachable: the proxy cannot tell the sandbox from someone who copied the credential out of it. The accepted controls are the network and the credential's life, not code here: the port is reachable only from the sandbox provider's egress (AI-1582), and each sandbox gets its own random credential that expires with its session (`Store.Put`'s ttl) and ends on `Delete`. ## The broker `Broker` (`broker.go`) is the `sandbox.CredentialBroker` for `code_sandbox.broker: proxy`. -- `Prepare` gives each grant a placeholder (`va_ph_`) in its env var, stores one `Record` under a new proxy credential, and returns the proxy URL with that credential (HTTP(S)_PROXY), the CA certificate file and the CA env vars. Only a connection that gives `sandbox.ErrNoCredential` leaves its grant out (or refuses a required one); one that cannot give a token just now keeps it, since the proxy asks on every request. -- The CA file holds the setting's `CERTIFICATE` blocks alone. Node adds it to its roots (`NODE_EXTRA_CA_CERTS`); OpenSSL, Python requests and git replace theirs with a file, so `Mount.Setup` writes `/tmp/va-egress-ca-bundle.pem` from the image's own roots and the CA before any code, and fails, running no code, on an image with no roots file. -- `BeforeRun` withdraws a grant whose connection needs a reconnect and stores the record again, which renews its TTL. With no grant left, or after a `Close` the store refused, it deletes the record instead. +- `Prepare` gives each grant a placeholder (`va_ph_`) in its env var, stores one `Record` under a new proxy credential, and returns the proxy URL with that credential (`Mount.Proxy`, Daytona's `OutboundProxyUrl`), the CA certificate file and the CA env vars. The CA env vars are `Mount.RunEnv`, set on every command and run of code: netleash replaces the ones a sandbox is created with, and not those (live probes, 2026-10-21). Only a connection that gives `sandbox.ErrNoCredential` leaves its grant out (or refuses a required one); one that cannot give a token just now keeps it, since the proxy asks on every request. +- The CA file holds the setting's `CERTIFICATE` blocks alone. Node adds it to its roots (`NODE_EXTRA_CA_CERTS`); OpenSSL, Python requests and git replace theirs with a file, so `Mount.Setup` writes `/tmp/va-egress-ca-bundle.pem` from netleash's `/etc/daytona/netleash/ca.crt` (Mozilla's roots and netleash's CA), else the image's own roots, and the CA before any code, and fails, running no code, on an image with no roots file. +- `BeforeRun` withdraws a grant whose connection needs a reconnect and stores the record again, which renews its TTL. With no grant left and no allowed domain, or after a `Close` the store refused, it deletes the record instead. - `Close` deletes the record: the next request through the proxy is a 407. ## Tests From a65d0999ac696f804c391a1c25547d6bb2b9f6f6 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 21 Oct 2026 17:10:30 -0400 Subject: [PATCH 3/8] [AI-1663] Test that a tunnel to an allowed domain ends when the host ends Co-Authored-By: Claude Opus 5.5 --- acceleration/internal/sandbox/egress/proxy_test.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/acceleration/internal/sandbox/egress/proxy_test.go b/acceleration/internal/sandbox/egress/proxy_test.go index 9fee6d961..e23dc83bc 100644 --- a/acceleration/internal/sandbox/egress/proxy_test.go +++ b/acceleration/internal/sandbox/egress/proxy_test.go @@ -535,6 +535,9 @@ func (s *ProxySuite) TestATunnelToAnAllowedDomainKeepsTheTLSHelloAndEndsWithTheH s.Require().NoError(err) s.Contains(string(answer), "200 OK") + // TLS ended with the host's close_notify; the proxy then closes the sandbox's connection. + _, err = eager.reader.ReadByte() + s.ErrorIs(err, io.EOF) } // TestAWildcardGrantTakesTheDomainAndTheNamesUnderItOnly: *. as core.SandboxRule reads it. From fd62047691ed74ac511c43e7ad100a41aebd4de6 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 21 Oct 2026 17:47:01 -0400 Subject: [PATCH 4/8] AI-1663: Match proxy hosts without a trailing dot; test the sandbox ending a tunnel and a withdrawn grant on an allowed host Co-Authored-By: Claude Sonnet 5.5 --- acceleration/internal/sandbox/egress/proxy.go | 5 +- .../internal/sandbox/egress/proxy_test.go | 66 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/acceleration/internal/sandbox/egress/proxy.go b/acceleration/internal/sandbox/egress/proxy.go index 25423da41..23494e967 100644 --- a/acceleration/internal/sandbox/egress/proxy.go +++ b/acceleration/internal/sandbox/egress/proxy.go @@ -145,12 +145,13 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { http.Error(w, "egress: CONNECT takes a host and a port", http.StatusBadRequest) return } - host = strings.ToLower(host) + // A name with a trailing dot is the name without it: matching and dialing take the same host. + host = strings.TrimSuffix(strings.ToLower(host), ".") // A grant names hosts, never addresses, so an address cannot be told apart from another // host's (core.SandboxRule). A * is no host's name: a wildcard grant would match it, and // its certificate would be one for every name under the domain. granted := record.granted(host) - if net.ParseIP(host) != nil || strings.Contains(host, "*") || !granted && !record.allowed(host) { + if host == "" || net.ParseIP(host) != nil || strings.Contains(host, "*") || !granted && !record.allowed(host) { p.logger.Warn("the sandbox egress proxy refused a host", "session", record.Session, "host", host) http.Error(w, "egress: the sandbox has no grant for this host", http.StatusForbidden) return diff --git a/acceleration/internal/sandbox/egress/proxy_test.go b/acceleration/internal/sandbox/egress/proxy_test.go index e23dc83bc..563b2d929 100644 --- a/acceleration/internal/sandbox/egress/proxy_test.go +++ b/acceleration/internal/sandbox/egress/proxy_test.go @@ -540,6 +540,59 @@ func (s *ProxySuite) TestATunnelToAnAllowedDomainKeepsTheTLSHelloAndEndsWithTheH s.ErrorIs(err, io.EOF) } +// TestATunnelToAnAllowedDomainEndsWithTheSandbox: the host side sees the end when the sandbox +// closes its connection, and the sandbox's bytes reach the host as they are. +func (s *ProxySuite) TestATunnelToAnAllowedDomainEndsWithTheSandbox() { + listener, err := net.Listen("tcp", "127.0.0.1:0") + s.Require().NoError(err) + defer listener.Close() + s.answerAt(listener.Addr().String()) + s.allow("outside.test") + accepted := make(chan net.Conn, 1) + go func() { + conn, _ := listener.Accept() + accepted <- conn + }() + conn, err := net.Dial("tcp", s.proxy.Listener.Addr().String()) + s.Require().NoError(err) + defer conn.Close() + _, err = fmt.Fprintf(conn, "CONNECT outside.test:443 HTTP/1.1\r\nHost: outside.test:443\r\nProxy-Authorization: %s\r\n\r\n", + basic("sbx", s.credential)) + s.Require().NoError(err) + response, err := http.ReadResponse(bufio.NewReader(conn), nil) + s.Require().NoError(err) + s.Require().Equal(http.StatusOK, response.StatusCode) + upstream := <-accepted + s.Require().NotNil(upstream) + defer upstream.Close() + _, err = io.WriteString(conn, "hello") + s.Require().NoError(err) + s.Require().NoError(upstream.SetReadDeadline(time.Now().Add(5 * time.Second))) + got := make([]byte, 5) + _, err = io.ReadFull(upstream, got) + s.Require().NoError(err) + s.Equal("hello", string(got)) + + s.Require().NoError(conn.Close()) + + _, err = upstream.Read(got) + s.ErrorIs(err, io.EOF) +} + +// TestAHostWithATrailingDotIsTheHostWithout: matched and dialed as the host without it, and a +// wildcard that names no domain, which no broker stores, takes nothing. +func (s *ProxySuite) TestAHostWithATrailingDotIsTheHostWithout() { + status := func(target string) int { return s.connectStatus(target, basic("sbx", s.credential)).StatusCode } + s.allow("*.", "pypi.test") + + s.Equal(http.StatusForbidden, status("evil.test.:443")) + s.Equal(http.StatusForbidden, status("evil.test:443")) + s.Equal(http.StatusForbidden, status(".:443")) + s.Empty(s.dialed()) + s.Equal(http.StatusOK, status("PyPI.test.:443")) + s.Equal([]string{"pypi.test:443"}, s.dialed()) +} + // TestAWildcardGrantTakesTheDomainAndTheNamesUnderItOnly: *. as core.SandboxRule reads it. func (s *ProxySuite) TestAWildcardGrantTakesTheDomainAndTheNamesUnderItOnly() { s.put(s.credential, egress.Record{Session: s.session, Grants: []egress.Grant{s.grant(s.placeholder, "*.github.test")}}) @@ -618,6 +671,19 @@ func (s *ProxySuite) TestAGrantThatEndsEndsItsOpenTunnelToo() { s.Equal(http.StatusProxyAuthRequired, s.connectStatus("api.github.test:443", basic("sbx", s.credential)).StatusCode) } +// TestAGrantWithdrawnOnAnAllowedHostEndsItsOpenTunnel: the host stays allowed, so a new CONNECT +// to it is a blind tunnel, but a tunnel the grant opened refuses its next request. +func (s *ProxySuite) TestAGrantWithdrawnOnAnAllowedHostEndsItsOpenTunnel() { + s.allow("api.github.test") + client := s.sandbox(s.credential) + s.Require().Equal(http.StatusOK, s.again(client)) + + s.put(s.credential, egress.Record{Session: s.session, Allowed: []string{"api.github.test"}}) + + s.Equal(http.StatusForbidden, s.again(client)) + s.Equal(http.StatusOK, s.connectStatus("api.github.test:443", basic("sbx", s.credential)).StatusCode) +} + // TestACredentialThatIsNotASandboxsIsAskedForOne is A14: none, one never issued, one in // another scheme and one expired get 407 with the challenge RFC 9110 asks for. func (s *ProxySuite) TestACredentialThatIsNotASandboxsIsAskedForOne() { From 84d0d3c82745d9b9a9bce557e26f7a4b07784227 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 21 Oct 2026 17:47:01 -0400 Subject: [PATCH 5/8] AI-1663: Skip an allowed domain with no name; test Close and no-credential grants with allowed domains Co-Authored-By: Claude Sonnet 5.5 --- .../internal/sandbox/egress/broker.go | 14 +++-- .../internal/sandbox/egress/broker_test.go | 52 ++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/acceleration/internal/sandbox/egress/broker.go b/acceleration/internal/sandbox/egress/broker.go index 5e605cf87..c656f8a2c 100644 --- a/acceleration/internal/sandbox/egress/broker.go +++ b/acceleration/internal/sandbox/egress/broker.go @@ -101,19 +101,25 @@ func NewBroker(o BrokerOptions) (*Broker, error) { } var allowed []string for _, domain := range o.Allowed { - allowed = append(allowed, allowedHost(domain)) + // A domain with no name in it (*, *., ..) takes no host, since *. would take any name + // that ends in a dot. + if host := allowedHost(domain); host != "" { + allowed = append(allowed, host) + } } return &Broker{options: o, proxy: proxy, allowed: allowed}, nil } // allowedHost is a config's allowed domain as a Record's host: lower case, without a trailing -// dot, and a leading *. or . as *., which takes the domain and every name under it. +// dot, and a leading *. or . as *., which takes the domain and every name under it. A domain +// with no name left is "". func allowedHost(domain string) string { domain = strings.TrimSuffix(strings.ToLower(domain), ".") - if name := strings.TrimLeft(domain, "*."); name != domain { + name := strings.TrimLeft(domain, "*.") + if name != domain && name != "" { return "*." + name } - return domain + return name } func (b *Broker) Prepare(ctx context.Context, grants []sandbox.Grant) (sandbox.Mount, error) { diff --git a/acceleration/internal/sandbox/egress/broker_test.go b/acceleration/internal/sandbox/egress/broker_test.go index b471a5232..664595f4d 100644 --- a/acceleration/internal/sandbox/egress/broker_test.go +++ b/acceleration/internal/sandbox/egress/broker_test.go @@ -339,7 +339,8 @@ func (s *BrokerSuite) TestARunWithEveryGrantWithdrawnEndsTheCredential() { // once every grant is withdrawn, so code still reaches them. func (s *BrokerSuite) TestTheRecordHoldsTheConfigsAllowedDomains() { broker, err := egress.NewBroker(egress.BrokerOptions{Store: s.rig.store, URL: s.rig.proxy.URL, CACert: s.rig.caCert, - Session: "session-1", Tokens: s.tokens, Allowed: []string{"PyPI.org.", "*.PythonHosted.org", ".npmjs.org", "**.x.org", "y.org"}}) + Session: "session-1", Tokens: s.tokens, Allowed: []string{"PyPI.org.", "*.PythonHosted.org", ".npmjs.org", "**.x.org", "y.org", + "*", "*.", "**", "..", "*.*", ""}}) s.Require().NoError(err) s.T().Cleanup(func() { _ = broker.Close(s.ctx) }) mount, err := broker.Prepare(s.ctx, []sandbox.Grant{github}) @@ -359,6 +360,33 @@ func (s *BrokerSuite) TestTheRecordHoldsTheConfigsAllowedDomains() { s.Equal(allowed, record.Allowed) } +// TestClosingEndsTheCredentialOfARecordWithAllowedDomains: the allowed domains outlive no +// Close, so the next CONNECT to one is a 407. +func (s *BrokerSuite) TestClosingEndsTheCredentialOfARecordWithAllowedDomains() { + broker, mount := s.allowing("session-allowed", "pypi.test") + credential, _ := mount.Proxy.User.Password() + _, found := s.rig.record(s.T(), key(credential)) + s.Require().True(found) + + s.Require().NoError(broker.Close(s.ctx)) + + _, found = s.rig.record(s.T(), key(credential)) + s.False(found) + _, err := sandboxClient(s.T(), mount).Get("https://pypi.test/") + s.ErrorContains(err, "Proxy Authentication Required") +} + +// allowing is a broker of session with the allowed domains, and the mount it gave the grant. +func (s *BrokerSuite) allowing(session string, allowed ...string) (*egress.Broker, sandbox.Mount) { + broker, err := egress.NewBroker(egress.BrokerOptions{Store: s.rig.store, URL: s.rig.proxy.URL, CACert: s.rig.caCert, + Session: session + "/" + s.rig.run, Tokens: s.tokens, Allowed: allowed}) + s.Require().NoError(err) + s.T().Cleanup(func() { _ = broker.Close(s.ctx) }) + mount, err := broker.Prepare(s.ctx, []sandbox.Grant{github}) + s.Require().NoError(err) + return broker, mount +} + // TestClosingEndsTheProxyCredential: the next CONNECT is a 407, and a run after Close does // not bring the credential back. func (s *BrokerSuite) TestClosingEndsTheProxyCredential() { @@ -443,6 +471,19 @@ func (s *BrokerSuite) TestNoGrantWithACredentialGivesNoProxy() { s.Zero(exists, "a run without a credential stores nothing") } +// TestNoGrantWithACredentialGivesNoProxyWhateverIsAllowed: allowed domains alone make no +// record, and the sandbox is created without a proxy. +func (s *BrokerSuite) TestNoGrantWithACredentialGivesNoProxyWhateverIsAllowed() { + s.fail(sandbox.ErrNoCredential) + _, mount := s.allowing("session-allowed", "pypi.test") + + s.Equal(sandbox.Mount{}, mount) + for _, key := range s.rig.keys() { + record, _ := s.rig.record(s.T(), key) + s.NotEqual("session-allowed/"+s.rig.run, record.Session) + } +} + // rig is a real proxy over Redis, whose hosts are one httptest TLS server, and the store the // brokers write through, behind a gate that can refuse. type rig struct { @@ -563,6 +604,15 @@ func (r *rig) held(t *testing.T, mount sandbox.Mount, ref string) (string, []str return strings.TrimPrefix(sent[0], "Bearer "), hosts, true } +// keys are every record's key. +func (r *rig) keys() []string { + keys, err := r.redis.Do(context.Background(), r.redis.B().Keys().Pattern("sandbox:egress:*").Build()).AsStrSlice() + if err != nil { + panic(err) + } + return keys +} + // left are the placeholders in every record of this test's sessions. func (r *rig) left() []string { ctx := context.Background() From 84f76ea3a91036f2addc8accf3107f0055eaa066 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 21 Oct 2026 17:47:01 -0400 Subject: [PATCH 6/8] AI-1663: Test that a grant left out stays out of the allow list; mark netleash reaching the proxy unverified Co-Authored-By: Claude Sonnet 5.5 --- .../internal/sandbox/daytona/daytona.go | 4 +-- .../internal/sandbox/daytona/proxy_test.go | 27 ++++++++++++++++--- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/acceleration/internal/sandbox/daytona/daytona.go b/acceleration/internal/sandbox/daytona/daytona.go index 5a9aa32ec..663cf5894 100644 --- a/acceleration/internal/sandbox/daytona/daytona.go +++ b/acceleration/internal/sandbox/daytona/daytona.go @@ -294,8 +294,8 @@ func (s *Sandbox) Grants() []sandbox.Grant { // lists CIDR ranges, which Daytona takes instead of domains, the hosts of each grant the // sandbox holds a secret or, behind an egress proxy, a placeholder for: a sandbox that may reach // nothing else still reaches its grants. Behind the proxy, Daytona's netleash refuses a CONNECT to -// a host its allow list leaves out (live probe, 2026-10-21: 403), and reaches the proxy itself, -// so the proxy's host is not in the list: Daytona refuses an IP address there. +// a host its allow list leaves out (live probe, 2026-10-21: 403). That it reaches the proxy itself, so that the +// proxy's host is not in the list, is unverified until the staging E2E (AI-1663); Daytona refuses an IP address there. func (s *Sandbox) domains() []string { domains := slices.Clone(s.config.AllowedDomains) if s.config.Network && len(domains) == 0 || len(s.config.NetworkAllowList) > 0 { diff --git a/acceleration/internal/sandbox/daytona/proxy_test.go b/acceleration/internal/sandbox/daytona/proxy_test.go index 7a452ad04..c9c93c1f2 100644 --- a/acceleration/internal/sandbox/daytona/proxy_test.go +++ b/acceleration/internal/sandbox/daytona/proxy_test.go @@ -19,6 +19,7 @@ import ( "github.com/redis/rueidis" "github.com/stretchr/testify/suite" + "github.com/GetStream/Vision-Agents/acceleration/internal/connectors/core" "github.com/GetStream/Vision-Agents/acceleration/internal/sandbox" "github.com/GetStream/Vision-Agents/acceleration/internal/sandbox/egress" _ "github.com/GetStream/Vision-Agents/acceleration/internal/testenv" @@ -53,19 +54,26 @@ func (s *ProxySuite) SetupTest() { s.T().Cleanup(s.platform.server.Close) } +// disconnected is a grant whose connection gives the sandbox no credential. +var disconnected = sandbox.Grant{Connection: core.ConnectionRef{CustomerID: "app", ConnectionID: "connection-2"}, + Binding: "linear", Env: "LINEAR_TOKEN", Hosts: []string{"api.linear.app"}} + // caCert is the proxy CA's certificate as the broker is given it. const caCert = "-----BEGIN CERTIFICATE-----\nY2E=\n-----END CERTIFICATE-----\n" // proxied is a sandbox of config whose github grant the egress proxy broker at proxy holds. -func (s *ProxySuite) proxied(config sandbox.Config, proxy string) *Sandbox { +func (s *ProxySuite) proxied(config sandbox.Config, proxy string, more ...sandbox.Grant) *Sandbox { broker, err := egress.NewBroker(egress.BrokerOptions{Store: egress.NewStore(s.redis), URL: proxy, CACert: []byte(caCert), Session: "session-1", - Tokens: func(context.Context, sandbox.Grant) (sandbox.Token, error) { + Tokens: func(_ context.Context, grant sandbox.Grant) (sandbox.Token, error) { + if grant.Binding == disconnected.Binding { + return sandbox.Token{}, sandbox.ErrNoCredential + } return sandbox.Token{Value: "gho_first", Revision: 1}, nil }, Logger: slog.New(slog.DiscardHandler)}) s.Require().NoError(err) - box, err := New(Options{APIKey: "key-1", APIURL: s.platform.server.URL + "/api", Config: config, Grants: []sandbox.Grant{github}, + box, err := New(Options{APIKey: "key-1", APIURL: s.platform.server.URL + "/api", Config: config, Grants: append([]sandbox.Grant{github}, more...), Broker: broker}) s.Require().NoError(err) s.T().Cleanup(func() { _ = box.Close() }) @@ -134,6 +142,19 @@ func (s *ProxySuite) TestTheSandboxGoesThroughTheProxyToItsGrantsAndAllowedDomai } } +// TestAGrantTheBrokerLeavesOutIsNotInTheAllowList: its host is not reachable, and its env var is +// not set. +func (s *ProxySuite) TestAGrantTheBrokerLeavesOutIsNotInTheAllowList() { + box := s.proxied(sandbox.Config{}, "http://proxy.stg.example:8091", disconnected) + + _, err := box.Run(s.ctx, "print(1)", nil) + + s.Require().NoError(err) + created := s.platform.requests()[0] + s.Equal("api.github.com,uploads.github.com", valueOr(created, "domainAllowList")) + s.Equal([]string{"GITHUB_TOKEN"}, slices.Collect(maps.Keys(created["env"].(map[string]any)))) +} + // TestASetupThatFailsRunsNoCode: a sandbox whose image has no roots to bundle the CA with runs // nothing, and the setup runs again before the next run. func (s *ProxySuite) TestASetupThatFailsRunsNoCode() { From aa6c2230ec4d744cec7cc88f5bf50541cbff7c80 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 21 Oct 2026 17:47:01 -0400 Subject: [PATCH 7/8] AI-1663: Test that every allowed domain reaches the proxy record and the allow list Co-Authored-By: Claude Sonnet 5.5 --- acceleration/internal/session/sandbox_grants_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/acceleration/internal/session/sandbox_grants_test.go b/acceleration/internal/session/sandbox_grants_test.go index 58215195a..61dd0c309 100644 --- a/acceleration/internal/session/sandbox_grants_test.go +++ b/acceleration/internal/session/sandbox_grants_test.go @@ -267,7 +267,7 @@ func (s *SandboxGrantsSuite) TestTheProxyBrokerSendsTheSandboxThroughTheProxy() s.sandboxRevision() id := s.connection("", "primary") spec := s.spec(s.config(sandboxed(s.fixed("crm", id, "whoami"))), "", nil) - spec.ID, spec.Sandbox, spec.SandboxOptions = uuid.NewString(), daytonaProvider, sandbox.Config{AllowedDomains: []string{"PyPI.org"}} + spec.ID, spec.Sandbox, spec.SandboxOptions = uuid.NewString(), daytonaProvider, sandbox.Config{AllowedDomains: []string{"PyPI.org", "Files.PythonHosted.org"}} d, _, _, err := s.attach(spec) s.Require().NoError(err) box, err := s.manager.box(spec, d) @@ -277,7 +277,7 @@ func (s *SandboxGrantsSuite) TestTheProxyBrokerSendsTheSandboxThroughTheProxy() _, err = box.Run(s.ctx, "print(1)", nil) s.Require().NoError(err) - s.Equal("PyPI.org,api.crm.example,*.crm.example", api.created["domainAllowList"]) + s.Equal("PyPI.org,Files.PythonHosted.org,api.crm.example,*.crm.example", api.created["domainAllowList"]) proxy, err := url.Parse(api.created["outboundProxyUrl"].(string)) s.Require().NoError(err) credential, _ := proxy.User.Password() @@ -287,7 +287,7 @@ func (s *SandboxGrantsSuite) TestTheProxyBrokerSendsTheSandboxThroughTheProxy() var record egress.Record s.Require().NoError(json.Unmarshal(raw, &record)) s.Equal(spec.ID, record.Session) - s.Equal([]string{"pypi.org"}, record.Allowed) + s.Equal([]string{"pypi.org", "files.pythonhosted.org"}, record.Allowed) s.Require().Len(record.Grants, 1) s.Equal(api.created["env"].(map[string]any)["CRM_TOKEN"], record.Grants[0].Placeholder) token, err := egress.ResolverTokens(s.resolver, s.registry.Schemes)(s.ctx, record.Grants[0]) From ecd97b92be321f9c4c96756ea899ae9762d8de15 Mon Sep 17 00:00:00 2001 From: Kanat Date: Wed, 21 Oct 2026 17:47:01 -0400 Subject: [PATCH 8/8] AI-1663: Fix the docs on when open networks are refused and on the netleash bypass Co-Authored-By: Claude Sonnet 5.5 --- .claude/skills/connectors/SKILL.md | 6 +++--- acceleration/internal/sandbox/egress/AGENTS.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude/skills/connectors/SKILL.md b/.claude/skills/connectors/SKILL.md index 3f69bb385..72f2c1da2 100644 --- a/.claude/skills/connectors/SKILL.md +++ b/.claude/skills/connectors/SKILL.md @@ -225,13 +225,13 @@ over HTTPS to a host in `sandbox.hosts`. Daytona's egress swaps it for the token none of them; - any other host, a non-public address or a port other than 443: it refuses. A host both cover is a grant's. A config with `network: true` and no `allowed_domains`, or with - a `network_allow_list`, is refused when the session starts: the proxy reaches hosts by name + a `network_allow_list`, is refused when a session with a sandbox grant starts: the proxy reaches hosts by name only. Unverified until the staging E2E (AI-1663): that netleash sends the allowed hosts on to the proxy. - Sandbox code has root (`sudo` with no password, live probe 2026-10-21). No control inside the sandbox protects anything: code can change its env, its CA files and its routes. The controls - are the token, which stays in the router; Daytona's domain allow list; and the proxy's grant - check. + are the token, which stays in the router; Daytona's domain allow list, which forces every + connection through netleash (a route change as root: unverified); and the proxy's grant check. - A connection the proxy cannot get a token for just now (a provider outage) keeps its grant; a connection that needs a reconnect gives the sandbox nothing, and refuses a `required` binding. - Revocation through the proxy is immediate: it resolves the token on every request and keeps no diff --git a/acceleration/internal/sandbox/egress/AGENTS.md b/acceleration/internal/sandbox/egress/AGENTS.md index a6bcb4ce8..3e638d323 100644 --- a/acceleration/internal/sandbox/egress/AGENTS.md +++ b/acceleration/internal/sandbox/egress/AGENTS.md @@ -21,7 +21,7 @@ The router's egress proxy for code in a sandbox. The sandbox holds a placeholder - A host that echoes the token in a form the rewrite does not know (another escape, split across headers, its case changed) gives it to the sandbox. Grant only hosts that do not. - A host that keeps the token it was sent and returns it later, in the response to a request that carries no placeholder, gives it to the sandbox: only a response to a request with the token is rewritten. -- Sandbox code has root (passwordless `sudo`, live probe 2026-10-21). No control inside the sandbox protects anything: code can change its env, its CA files and its routes. The controls are the token, which stays in the router; Daytona's domain allow list (the grants' hosts and the allowed domains), which forces every connection through netleash; and the grant check in step 3. +- Sandbox code has root (passwordless `sudo`, live probe 2026-10-21). No control inside the sandbox protects anything: code can change its env, its CA files and its routes. The controls are the token, which stays in the router; Daytona's domain allow list (the grants' hosts and the allowed domains), which forces every connection through netleash (that code with root cannot bypass it by changing its routes, NET_ADMIN: unverified, netleash probe report §4); and the grant check in step 3. - A `Delete` ends the tunnels to a grant's host. A blind tunnel to an allowed domain carries no token and lasts until either end closes it. - A sandbox's proxy credential is in its `OutboundProxyUrl`, and so in what Daytona is asked to create the sandbox with. Should Daytona answer a create with an error that quotes that request, the router logs the error and the model reads it in `run_code`'s result. That is a proxy credential, never a token, and it is held by netleash, not the sandbox's env; whether Daytona quotes it is unverified. - A valid proxy credential works from wherever the proxy is reachable: the proxy cannot tell the sandbox from someone who copied the credential out of it. The accepted controls are the network and the credential's life, not code here: the port is reachable only from the sandbox provider's egress (AI-1582), and each sandbox gets its own random credential that expires with its session (`Store.Put`'s ttl) and ends on `Delete`.