Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,11 +284,10 @@ 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). 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"
# Sandbox integration tests (requires Docker). TestMain pins the active docker
# context's endpoint as DOCKER_HOST before it swaps in a fixture HOME, so Docker
# Desktop is reached too.
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
28 changes: 28 additions & 0 deletions cmd/odek/dockerhost_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package main

import (
"context"
"os"
"os/exec"
"strings"
"time"
)

// pinDockerHostForTests exports the active docker context's endpoint as
// DOCKER_HOST while HOME is still the operator's, so the docker CLI keeps
// reaching the daemon after TestMain swaps in a fixture HOME. An explicit
// DOCKER_HOST or DOCKER_CONTEXT, or no docker CLI, leaves the env alone.
func pinDockerHostForTests() {
if os.Getenv("DOCKER_HOST") != "" || os.Getenv("DOCKER_CONTEXT") != "" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, "docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}").Output()
if err != nil {
return
}
if host := strings.TrimSpace(string(out)); host != "" {
os.Setenv("DOCKER_HOST", host)
}
}
5 changes: 4 additions & 1 deletion cmd/odek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2660,7 +2660,10 @@ func setupSandbox(tools []odek.Tool, cfg sandboxConfig) (containerName string, c
if nerr != nil {
return "", nil, nerr
}
if gerr := checkProjectNetworkGrant(cfg.ResolvedNetworks); gerr != nil {
if gerr := checkAllowedNetworkIDs(cfg); gerr != nil {
return "", nil, gerr
}
if gerr := checkProjectNetworkGrant(cfg); gerr != nil {
return "", nil, gerr
}
resolvedNets := cfg.ResolvedNetworks
Expand Down
103 changes: 64 additions & 39 deletions cmd/odek/project_sandbox_approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,18 +413,11 @@ func resolveOverrideNetworks(o config.ProjectSandboxOverride) ([]sandbox.Network
return cfg.ResolvedNetworks, nil
}

// projectNetworkGrant records, for one project directory, the networks the
// operator approved: the names and Compose project the project requested and
// the IDs they resolved to when approval was given.
type projectNetworkGrant struct {
names map[string]bool
compose string
ids map[string]bool
}

// projectNetworkGrants records, per project directory, the IDs of the
// networks the operator approved for the project's own network request.
var (
projectNetworkGrantsMu sync.Mutex
projectNetworkGrants = map[string]projectNetworkGrant{}
projectNetworkGrants = map[string]map[string]bool{}
)

// recordProjectNetworkGrant remembers the networks behind an approval of a
Expand All @@ -433,39 +426,46 @@ func recordProjectNetworkGrant(projectDir string, o config.ProjectSandboxOverrid
if !o.HasNetworks && !o.HasComposeProject {
return
}
g := projectNetworkGrant{names: map[string]bool{}, ids: map[string]bool{}}
if o.HasNetworks {
for _, n := range o.Networks {
g.names[n] = true
}
}
if o.HasComposeProject {
g.compose = o.ComposeProject
}
ids := map[string]bool{}
for _, n := range nets {
g.ids[n.ID] = true
ids[n.ID] = true
}
projectNetworkGrantsMu.Lock()
defer projectNetworkGrantsMu.Unlock()
projectNetworkGrants[projectDir] = g
projectNetworkGrants[projectDir] = ids
}

// resetProjectNetworkGrants forgets every recorded grant.
func resetProjectNetworkGrants() {
projectNetworkGrantsMu.Lock()
defer projectNetworkGrantsMu.Unlock()
projectNetworkGrants = map[string]projectNetworkGrant{}
projectNetworkGrants = map[string]map[string]bool{}
}

// checkProjectNetworkGrant enforces the project network approval at the
// point of container creation. Networks are resolved again at setup, so
// every network the project contributes (by name or through its Compose
// project) must have an ID the operator approved: a network that joined the
// Compose project, or was recreated under an approved name, after the prompt
// is refused. Networks configured by the operator need no grant, and
// ODEK_APPROVE_PROJECT_SANDBOX=1 approves whatever the project requests.
func checkProjectNetworkGrant(nets []sandbox.NetworkInfo) error {
if os.Getenv("ODEK_APPROVE_PROJECT_SANDBOX") == "1" || len(nets) == 0 {
// every network the project config contributes (by name or through its
// Compose project) must have an ID the operator approved in this process: a
// network that joined the Compose project, or was recreated under an
// approved name, after the prompt is refused, and so is a project request
// that was never approved here. Networks configured by the operator need no
// grant, and ODEK_APPROVE_PROJECT_SANDBOX=1 approves whatever the project
// requests.
func checkProjectNetworkGrant(cfg sandboxConfig) error {
if os.Getenv("ODEK_APPROVE_PROJECT_SANDBOX") == "1" {
return nil
}
requested := map[string]bool{}
for _, n := range cfg.ProjectNetworks {
requested[n] = true
}
var owned []sandbox.NetworkInfo
for _, n := range cfg.ResolvedNetworks {
if requested[n.Name] || (cfg.ProjectComposeProject != "" && n.ComposeProject == cfg.ProjectComposeProject) {
owned = append(owned, n)
}
}
if len(owned) == 0 {
return nil
}
projectDir, err := os.Getwd()
Expand All @@ -476,21 +476,46 @@ func checkProjectNetworkGrant(nets []sandbox.NetworkInfo) error {
return fmt.Errorf("%w: project sandbox networks: working directory: %w", errSandboxConfig, err)
}
projectNetworkGrantsMu.Lock()
g, ok := projectNetworkGrants[projectDir]
ids, granted := projectNetworkGrants[projectDir]
projectNetworkGrantsMu.Unlock()
if !ok {
return nil
}
var unapproved []string
for _, n := range nets {
owned := g.names[n.Name] || (g.compose != "" && n.ComposeProject == g.compose)
if owned && !g.ids[n.ID] {
for _, n := range owned {
if !ids[n.ID] {
unapproved = append(unapproved, n.Name)
}
}
if len(unapproved) > 0 {
return fmt.Errorf("%w: project sandbox networks changed since they were approved: %s; run again to review and approve them",
errSandboxConfig, danger.SanitizeInline(strings.Join(unapproved, ", ")))
if len(unapproved) == 0 {
return nil
}
names := danger.SanitizeInline(strings.Join(unapproved, ", "))
if !granted {
return fmt.Errorf("%w: project sandbox networks were not approved: %s; run again to review and approve them",
errSandboxConfig, names)
}
return fmt.Errorf("%w: project sandbox networks changed since they were approved: %s; run again to review and approve them",
errSandboxConfig, names)
}

// checkAllowedNetworkIDs limits a sub-agent to the networks its parent
// attached: every resolved network must still carry one of the stamped IDs.
// A network recreated under the parent's name has a new ID and is refused.
func checkAllowedNetworkIDs(cfg sandboxConfig) error {
if len(cfg.AllowedNetworkIDs) == 0 {
return nil
}
allowed := map[string]bool{}
for _, id := range cfg.AllowedNetworkIDs {
allowed[id] = true
}
var changed []string
for _, n := range cfg.ResolvedNetworks {
if !allowed[n.ID] {
changed = append(changed, n.Name)
}
}
if len(changed) > 0 {
return fmt.Errorf("%w: sandbox networks changed since the parent sandbox resolved them: %s",
errSandboxConfig, danger.SanitizeInline(strings.Join(changed, ", ")))
}
return nil
}
34 changes: 34 additions & 0 deletions cmd/odek/red_fd_handoff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package main

import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)

// readKeyFromInheritedFD and readFrameNonceFromInheritedFD take ownership of
// the descriptor named in their env var and close it. A test that names a
// descriptor still owned by an *os.File gets it closed twice: the file's
// finalizer later closes whatever the number has been reused for — in a
// parallel test often the directory fd os.RemoveAll is walking, which then
// fails with "unlinkat: bad file descriptor". Tests must hand over a dup.
func TestRED_FDHandoffTestsPassADuplicatedDescriptor(t *testing.T) {
files, err := filepath.Glob("*_test.go")
if err != nil {
t.Fatal(err)
}
raw := regexp.MustCompile(`t\.Setenv\((keyFDEnvVar|frameFDEnvVar)[^\n]*\.Fd\(\)`)
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, m := range raw.FindAllString(string(data), -1) {
if !strings.Contains(m, "Dup") && !strings.Contains(m, "dupFD") {
t.Errorf("%s hands an owned descriptor to the fd reader: %s (use dupFD)", f, m)
}
}
}
}
97 changes: 97 additions & 0 deletions cmd/odek/red_network_residuals_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package main

import (
"errors"
"io"
"os"
"reflect"
"strings"
"testing"

odek "github.com/BackendStack21/odek"
"github.com/BackendStack21/odek/internal/config"
"github.com/BackendStack21/odek/internal/sandbox"
)

// A project that requests networks is held to an approval recorded in this
// process: setup that finds no grant refuses instead of attaching them.
func TestRED_ProjectNetworkGrant_MissingGrantIsRefused(t *testing.T) {
approvalEnv(t)
resetProjectNetworkGrants()
d := &mutableComposeDocker{project: "app", nets: map[string]fakeNet{
"app_default": {id: id("a"), internal: true, compose: "app"},
}}
d.install(t)
resolved := config.ResolvedConfig{
SandboxComposeProject: "app",
ProjectSandboxOverride: config.ProjectSandboxOverride{HasComposeProject: true, ComposeProject: "app"},
}
cfg, err := resolveSandboxNetworks(sandboxConfigFromResolved(resolved), io.Discard)
if err != nil {
t.Fatal(err)
}
err = checkProjectNetworkGrant(cfg)
if err == nil || !errors.Is(err, errSandboxConfig) {
t.Fatalf("project networks without a recorded approval must be refused, got %v", err)
}
t.Setenv("ODEK_APPROVE_PROJECT_SANDBOX", "1")
if err := checkProjectNetworkGrant(cfg); err != nil {
t.Fatalf("env approval covers setup: %v", err)
}
}

// The parent stamps the IDs of the networks it attached; a child attaches a
// network only while it still has one of those IDs.
func TestRED_SubagentNetworks_StampedIDsAreEnforced(t *testing.T) {
d := &delegateTasksTool{}
recordChildSandbox([]odek.Tool{d}, sandboxConfig{
Networks: []string{"db"},
ResolvedNetworks: []sandbox.NetworkInfo{{Name: "db", ID: id("a")}},
})
_, posture := d.childPosture()
if posture == nil || !reflect.DeepEqual(posture.NetworkIDs, []string{id("a")}) {
t.Fatalf("parent must stamp the resolved network IDs, got %+v", posture)
}

resolved := config.ResolvedConfig{SandboxNetworks: []string{"db"}}
applyTaskSandboxPosture(&resolved, &taskSandboxPosture{
Networks: []string{"db"}, NetworkIDs: []string{id("a"), "not-an-id", strings.ToUpper(id("b"))},
})
cfg := sandboxConfigFromResolved(resolved)
if !reflect.DeepEqual(cfg.AllowedNetworkIDs, []string{id("a")}) {
t.Fatalf("child must keep only valid stamped IDs, got %v", cfg.AllowedNetworkIDs)
}

cfg.ResolvedNetworks = []sandbox.NetworkInfo{{Name: "db", ID: id("a")}}
if err := checkAllowedNetworkIDs(cfg); err != nil {
t.Fatalf("the parent's network must be accepted: %v", err)
}
cfg.ResolvedNetworks = []sandbox.NetworkInfo{{Name: "db", ID: id("c")}}
err := checkAllowedNetworkIDs(cfg)
if err == nil || !errors.Is(err, errSandboxConfig) || !strings.Contains(err.Error(), "db") {
t.Fatalf("a network recreated under the parent's name must be refused, got %v", err)
}
// No stamped IDs: nothing to enforce (the child of an unsandboxed or
// network-less parent attaches no networks anyway).
if err := checkAllowedNetworkIDs(sandboxConfig{ResolvedNetworks: cfg.ResolvedNetworks}); err != nil {
t.Fatalf("no stamped IDs must not refuse: %v", err)
}
}

func TestRED_SetupSandbox_ChecksStampedNetworkIDs(t *testing.T) {
src := mustReadFile(t, "main.go")
i := strings.Index(src, "func setupSandbox(")
j := strings.Index(src[i:], "sandbox.BuildRunArgsChecked(")
if i < 0 || j < 0 || !strings.Contains(src[i:i+j], "checkAllowedNetworkIDs(") {
t.Fatal("setupSandbox must check stamped network IDs before creating the container")
}
}

func mustReadFile(t *testing.T, name string) string {
t.Helper()
data, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(data)
}
15 changes: 8 additions & 7 deletions cmd/odek/red_project_network_grant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,15 @@ func (m *mutableComposeDocker) install(t *testing.T) {
t.Cleanup(func() { sandbox.DockerRunner = old })
}

// setupNets resolves networks the way setupSandbox does for cfg.
func setupNets(t *testing.T, cfg sandboxConfig) []sandbox.NetworkInfo {
// setupNets resolves networks the way setupSandbox does for cfg and returns
// the config setupSandbox checks.
func setupNets(t *testing.T, cfg sandboxConfig) sandboxConfig {
t.Helper()
got, err := resolveSandboxNetworks(cfg, io.Discard)
if err != nil {
t.Fatal(err)
}
return got.ResolvedNetworks
return got
}

func composeOverride(project string) config.ResolvedConfig {
Expand All @@ -70,7 +71,7 @@ func TestRED_ProjectNetworkGrant_NetworkJoiningAfterApprovalIsRefused(t *testing
if err := approveProjectSandboxWithTTY(composeOverride("app"), strings.NewReader("y\n"), io.Discard, true); err != nil {
t.Fatal(err)
}
cfg := sandboxConfig{ComposeProject: "app"}
cfg := sandboxConfig{ComposeProject: "app", ProjectComposeProject: "app"}

if err := checkProjectNetworkGrant(setupNets(t, cfg)); err != nil {
t.Fatalf("the approved networks must be accepted at setup: %v", err)
Expand All @@ -96,7 +97,7 @@ func TestRED_ProjectNetworkGrant_RecreatedNetworkIsRefused(t *testing.T) {
t.Fatal(err)
}
d.nets["shared"] = fakeNet{id: id("d"), internal: false}
if err := checkProjectNetworkGrant(setupNets(t, sandboxConfig{Networks: []string{"shared"}})); err == nil {
if err := checkProjectNetworkGrant(setupNets(t, sandboxConfig{Networks: []string{"shared"}, ProjectNetworks: []string{"shared"}})); err == nil {
t.Fatal("a network recreated under the approved name must be refused")
}
}
Expand All @@ -117,7 +118,7 @@ func TestRED_ProjectNetworkGrant_PersistedTrustIsCheckedToo(t *testing.T) {
}
// …and its setup is still held to the networks that trust covered.
d.nets["app_admin"] = fakeNet{id: id("c"), compose: "app"}
if err := checkProjectNetworkGrant(setupNets(t, sandboxConfig{ComposeProject: "app"})); err == nil {
if err := checkProjectNetworkGrant(setupNets(t, sandboxConfig{ComposeProject: "app", ProjectComposeProject: "app"})); err == nil {
t.Fatal("setup after a persisted approval must still refuse an unapproved network")
}
}
Expand All @@ -136,7 +137,7 @@ func TestRED_ProjectNetworkGrant_OperatorNetworksAndEnvApprovalUnaffected(t *tes
if err := approveProjectSandboxWithTTY(netOverride("opnet"), strings.NewReader(""), io.Discard, false); err != nil {
t.Fatal(err)
}
if err := checkProjectNetworkGrant(setupNets(t, sandboxConfig{Networks: []string{"opnet"}})); err != nil {
if err := checkProjectNetworkGrant(setupNets(t, sandboxConfig{Networks: []string{"opnet"}, ProjectNetworks: []string{"opnet"}})); err != nil {
t.Fatalf("env approval must cover setup: %v", err)
}
}
Expand Down
Loading
Loading