Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6311af0
feat(sandbox): treat sandbox config errors as fatal instead of fallin…
jkyberneees Oct 25, 2026
9ac683b
feat(sandbox): fail closed on invalid network modes with lenient esca…
jkyberneees Oct 25, 2026
86284cf
feat(sandbox): default pids limit, init and nosuid tmpfs with effecti…
jkyberneees Oct 25, 2026
4103b01
refactor(sandbox): build sandbox config from one shared helper
jkyberneees Oct 25, 2026
2e07bf4
feat(sandbox): digest-pinned default image with dependabot and supply…
jkyberneees Oct 25, 2026
884676f
feat(sandbox): attach sandbox to validated user-defined docker networks
jkyberneees Oct 25, 2026
183c7a2
feat(sandbox): resolve compose project networks via --sandbox-compose
jkyberneees Oct 25, 2026
673e7e7
feat(sandbox): bind network approvals to network ids and narrow sub-a…
jkyberneees Oct 25, 2026
3aa58ab
feat(sandbox): tell the agent its sandbox environment and document ne…
jkyberneees Oct 25, 2026
e9939ac
fix(sandbox): list compose service aliases as sandbox service DNS names
jkyberneees Oct 25, 2026
7570ca0
fix(sandbox): describe attached networks instead of reporting network…
jkyberneees Oct 25, 2026
a5d757a
fix(sandbox): hold container setup to the approved project network IDs
jkyberneees Oct 25, 2026
2c3f065
docs(sandbox): document network flags, the agent's environment block …
jkyberneees Oct 25, 2026
f3ce3d0
docs(agents): run Docker-gated sandbox tests with DOCKER_HOST on Dock…
jkyberneees Oct 25, 2026
1f4dbd5
fix(sandbox): attach a network only when inspect returns the requeste…
jkyberneees Oct 25, 2026
28abeed
fix(sandbox): treat image and container start failures as config erro…
jkyberneees Oct 25, 2026
1e2d0bc
fix(sandbox): list Docker-reported network and service names as untru…
jkyberneees Oct 25, 2026
c435d50
fix(sandbox): read the default image from the embedded Dockerfile
jkyberneees Oct 25, 2026
5ccf777
fix(sandbox): give sandboxed sub-agents the execution-environment block
jkyberneees Oct 25, 2026
705241b
docs(sandbox): state precisely how sub-agents narrow attached networks
jkyberneees Oct 25, 2026
303a349
fix(sandbox): keep the implicit fallback for image pull failures
jkyberneees Oct 25, 2026
c7f83a4
fix(sandbox): wrap Docker-reported names as external data without an …
jkyberneees Oct 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ updates:
groups:
golang-x:
patterns: ["golang.org/x/*"]
- package-ecosystem: "docker"
directory: "/internal/sandbox"
schedule:
interval: "weekly"
12 changes: 9 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ cmd/odek/
serve_supervision.go WebUI task supervision: sub-agent state frames, cancel, safe recovery
serve_artifacts.go Artifact registry + delivery over the serve surface
serve_workspace.go WebUI workspace/session-state access endpoints
serve_diagnostics.go Diagnostics endpoints (/api/diagnostics)
serve_diagnostics.go Diagnostics helpers for the serve surface
serve_retention.go Session/artifact retention enforcement on the serve surface
serve_bg_sandbox.go Sandbox follow-up handling for background jobs (pidfile kill)
repl.go Interactive REPL with multi-turn session support
Expand Down Expand Up @@ -80,6 +80,9 @@ cmd/odek/
audit.go Per-turn audit + `odek audit` subcommand (divergence heuristic)
sandbox_file.go Sandbox-aware file-tool bridge
sandbox_cleanup.go Sandbox teardown/cleanup helpers
sandbox_compose.go Resolves sandbox_networks + sandbox_compose_project into inspected networks (IDs)
sandbox_environment.go Execution-environment block (system prompt + shell description) and batch-card
network note, built only from validated config and inspect fields
ssrf_guard.go URL / SSRF validation helpers
vision_tool.go Vision / image-input tool
vision_provider.go Provider-backed vision backend (reuses the providers.<id> registry)
Expand Down Expand Up @@ -281,8 +284,11 @@ ODEK_E2E=true go test -v -count=1 ./cmd/odek/ -run "TestE2E_"
# MCP E2E tests (builds fakeserver from source at test time)
ODEK_E2E=true go test -v -count=1 ./cmd/odek/ -run "TestMCPClientE2E_"

# Sandbox integration tests (requires Docker)
go test -v -count=1 ./cmd/odek/ -run "TestSandbox"
# Sandbox integration tests (requires Docker). Tests swap HOME, which hides
# Docker Desktop's CLI context, so point DOCKER_HOST at the daemon or the
# Docker-gated tests skip as "Docker daemon unavailable".
DOCKER_HOST=$(docker context inspect --format '{{.Endpoints.docker.Host}}') \
ODEK_E2E=true go test -v -count=1 ./cmd/odek/ -run "TestSandbox|TestE2E_Sandbox"

# Prompt-injection red-team E2E (on-demand only — NOT on CI; its own gate,
# ODEK_PI_E2E, is separate from ODEK_E2E). Builds the binary, plants IPI
Expand Down
140 changes: 140 additions & 0 deletions cmd/odek/docs_sandbox_networks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package main

import (
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)

func readRepoFile(t *testing.T, rel string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("..", "..", rel))
if err != nil {
t.Fatalf("read %s: %v", rel, err)
}
return string(b)
}

func TestDocs_SandboxingNetworkModes(t *testing.T) {
doc := readRepoFile(t, "docs/SANDBOXING.md")
for _, stale := range []string{
"no access to `localhost:port` on the host",
"isolates it from the host's network stack",
"are downgraded to `none` at container start",
"forces `none` with a warning",
} {
if strings.Contains(doc, stale) {
t.Errorf("SANDBOXING.md still carries the old claim %q", stale)
}
}
for _, want := range []string{
"sandbox_networks", "ODEK_SANDBOX_NETWORKS", "--sandbox-networks",
"sandbox_compose_project", "--sandbox-compose",
"ODEK_SANDBOX_NETWORK_LENIENT", "sandbox_pids_limit",
} {
if !strings.Contains(doc, want) {
t.Errorf("SANDBOXING.md does not document %q", want)
}
}
// bridge reaches host services: the doc must say so.
if !regexp.MustCompile(`(?i)host\.docker\.internal|metadata|IMDS|169\.254`).MatchString(doc) {
t.Error("SANDBOXING.md must state that bridge can reach host-side services / the metadata endpoint")
}
}

func TestDocs_SandboxingDefaultImagePinned(t *testing.T) {
doc := readRepoFile(t, "docs/SANDBOXING.md")
const digest = "sha256:2e0b81d60597f449baa56dc25d355367869d2dca4cd57e866f1e2b52af3eb55f"
if !strings.Contains(doc, digest) {
t.Errorf("SANDBOXING.md must record the pinned default image digest %s", digest)
}
if !strings.Contains(doc, "docker buildx imagetools inspect alpine:3.22") {
t.Error("SANDBOXING.md must record how the digest was looked up")
}
for _, line := range strings.Split(doc, "\n") {
if strings.Contains(line, "`sandbox_image` | `ODEK_SANDBOX_IMAGE`") && strings.Contains(line, "alpine:latest") {
t.Errorf("sandbox_image default still documented as alpine:latest: %s", line)
}
if strings.Contains(line, "Neither") && strings.Contains(line, "alpine:latest") {
t.Errorf("image priority still falls back to alpine:latest: %s", line)
}
}
}

func TestDocs_ConfigAndSecurityCoverNewKnobs(t *testing.T) {
cfgDoc := readRepoFile(t, "docs/CONFIG.md")
for _, want := range []string{"sandbox_networks", "ODEK_SANDBOX_NETWORKS", "--sandbox-networks",
"sandbox_compose_project", "ODEK_SANDBOX_COMPOSE_PROJECT", "--sandbox-compose",
"sandbox_pids_limit", "ODEK_SANDBOX_PIDS_LIMIT"} {
if !strings.Contains(cfgDoc, want) {
t.Errorf("CONFIG.md does not document %q", want)
}
}
sec := readRepoFile(t, "docs/SECURITY.md")
for _, want := range []string{"sandbox_networks", "sandbox_compose_project", "ODEK_SANDBOX_NETWORK_LENIENT"} {
if !strings.Contains(sec, want) {
t.Errorf("SECURITY.md does not mention %q", want)
}
}
if strings.Contains(sec, "`host` is coerced back to `none` with a warning") {
t.Error("SECURITY.md still claims host is coerced to none")
}
}

func TestDocs_AgentsGuide(t *testing.T) {
agents := readRepoFile(t, "AGENTS.md")
if strings.Contains(agents, "/api/diagnostics") {
t.Error("AGENTS.md mentions a /api/diagnostics endpoint that does not exist")
}
for _, want := range []string{"sandbox_environment.go", "sandbox_compose.go", "sandbox_networks"} {
if !strings.Contains(agents, want) {
t.Errorf("AGENTS.md does not mention %q", want)
}
}
}

// Comments describe behaviour, not plan tickets.
func TestSandboxNetworkSources_NoPlanTicketIDs(t *testing.T) {
ticket := regexp.MustCompile(`\b(SL0\d|AC-\d+|REQ-\d+|PR [1-6]\b)`)
for _, rel := range []string{
"cmd/odek/sandbox_environment.go", "cmd/odek/sandbox_compose.go",
"internal/sandbox/networks.go", "internal/sandbox/compose.go", "internal/sandbox/network.go",
} {
if loc := ticket.FindString(readRepoFile(t, rel)); loc != "" {
t.Errorf("%s names a plan ticket %q", rel, loc)
}
}
}

// introspect.go keeps standard-library imports in their own group, ahead of
// module imports.
func TestIntrospectImportGrouping(t *testing.T) {
src := readRepoFile(t, "cmd/odek/introspect.go")
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "introspect.go", src, parser.ImportsOnly)
if err != nil {
t.Fatal(err)
}
lastLine := 0
seenModule := false
for _, imp := range f.Imports {
path := strings.Trim(imp.Path.Value, `"`)
line := fset.Position(imp.Pos()).Line
isModule := strings.Contains(strings.SplitN(path, "/", 2)[0], ".")
if isModule {
seenModule = true
} else if seenModule {
t.Errorf("standard import %q follows a module import (line %d)", path, line)
}
if !isModule && lastLine != 0 && line != lastLine+1 {
t.Errorf("standard import %q is separated from the standard group (line %d)", path, line)
}
if !isModule {
lastLine = line
}
}
}
6 changes: 6 additions & 0 deletions cmd/odek/external_ref.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ func continuePinnedFlag(f runFlags) string {
return "--sandbox-memory"
case f.SandboxCPUs != "":
return "--sandbox-cpus"
case f.SandboxComposeProject != "":
return "--sandbox-compose"
case len(f.SandboxNetworks) > 0:
return "--sandbox-networks"
case f.SandboxPidsLimit != "":
return "--sandbox-pids-limit"
case f.SandboxUser != "":
return "--sandbox-user"
case f.Session != nil:
Expand Down
30 changes: 23 additions & 7 deletions cmd/odek/introspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/BackendStack21/odek/internal/budget"
"github.com/BackendStack21/odek/internal/config"
"github.com/BackendStack21/odek/internal/redact"
"github.com/BackendStack21/odek/internal/sandbox"
)

// ── shared view builders (REST + tools) ──────────────────────────────────
Expand Down Expand Up @@ -59,13 +60,16 @@ func buildConfigView(resolved config.ResolvedConfig) map[string]any {
"interaction_mode": resolved.InteractionMode,
"no_agents_md": resolved.NoAgents,
"sandbox": map[string]any{
"enabled": resolved.Sandbox,
"image": resolved.SandboxImage,
"network": resolved.SandboxNetwork,
"readonly": resolved.SandboxReadonly,
"memory": resolved.SandboxMemory,
"cpus": resolved.SandboxCPUs,
"user": resolved.SandboxUser,
"enabled": resolved.Sandbox,
"image": resolved.SandboxImage,
"network": resolved.SandboxNetwork,
"readonly": resolved.SandboxReadonly,
"memory": resolved.SandboxMemory,
"cpus": resolved.SandboxCPUs,
"user": resolved.SandboxUser,
"pids_limit": sandboxPostureView(resolved)["pids_limit"],
"networks": validNetworkNames(resolved.SandboxNetworks),
"compose_project": resolved.SandboxComposeProject,
},
"memory": map[string]any{
"enabled": boolPtr(resolved.Memory.Enabled),
Expand Down Expand Up @@ -499,3 +503,15 @@ func buildIntrospectionState(resolved config.ResolvedConfig) IntrospectionState
MCPServers: redactMCPServersView(buildMCPServersView(resolved)),
}
}

// validNetworkNames returns the names that pass sandbox network validation,
// so the view never carries text a hostile config smuggled in.
func validNetworkNames(names []string) []string {
out := []string{}
for _, n := range names {
if v, err := sandbox.NormalizeNetworks([]string{n}); err == nil {
out = append(out, v...)
}
}
return out
}
Loading
Loading