Skip to content

Commit 7863a12

Browse files
committed
v0.32.2: sandbox network hardening + API key environ clearing
Sandbox network: - Default changed from bridge to none (most secure) - buildSandboxArgs rejects 'host' mode — forces 'none' + stderr warning - Flag docs, env docs, config examples, serve.go, SANDBOXING.md, CLI.md, CHEATSHEET.md all updated API key handling: - LoadConfig clears ODEK_API_KEY, DEEPSEEK_API_KEY, OPENAI_API_KEY from environ after resolving — prevents exposure via /proc/pid/environ 2 new tests: TestBuildSandboxArgs_RejectsHostNetwork, TestLoadConfig_ClearsAPIKeyFromEnviron
1 parent 184ced2 commit 7863a12

9 files changed

Lines changed: 93 additions & 19 deletions

File tree

cmd/odek/main.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ type runFlags struct {
293293

294294
// Sandbox-specific CLI flags
295295
SandboxImage string // Docker image (e.g. "node:20-alpine")
296-
SandboxNetwork string // Network mode: "bridge" | "none" | "host"
296+
SandboxNetwork string // Network mode: "none" | "bridge" | "host"
297297
SandboxMemory string // Memory limit (e.g. "512m", "2g")
298298
SandboxCPUs string // CPU limit (e.g. "0.5", "2")
299299
SandboxUser string // Container user (e.g. "1000:1000")
@@ -552,7 +552,7 @@ Skill commands:
552552
Sandbox flags:
553553
--sandbox Run in isolated Docker container
554554
--sandbox-image <img> Docker image (default: alpine:latest)
555-
--sandbox-network <m> Network mode: bridge (default) | none | host
555+
--sandbox-network <m> Network mode: none (default) | bridge | host
556556
--sandbox-readonly Mount working directory read-only
557557
--sandbox-memory <s> Memory limit (e.g. 512m, 2g)
558558
--sandbox-cpus <n> CPU limit (e.g. 0.5, 2, 4)
@@ -575,7 +575,7 @@ Environment variables:
575575
ODEK_NO_AGENTS true/false — skip AGENTS.md
576576
ODEK_SYSTEM System prompt override
577577
ODEK_SANDBOX_IMAGE Docker image for sandbox container
578-
ODEK_SANDBOX_NETWORK Network mode (bridge | none | host)
578+
ODEK_SANDBOX_NETWORK Network mode (none | bridge | host)
579579
ODEK_SANDBOX_READONLY true/false — mount read-only
580580
ODEK_SANDBOX_MEMORY Memory limit (e.g. 512m, 2g)
581581
ODEK_SANDBOX_CPUS CPU limit (e.g. 0.5, 2)
@@ -596,7 +596,7 @@ const defaultConfigTemplate = `{
596596
"system": "",
597597
"github_repo_directory": "",
598598
"sandbox_image": "",
599-
"sandbox_network": "bridge",
599+
"sandbox_network": "none",
600600
"sandbox_readonly": false,
601601
"sandbox_memory": "",
602602
"sandbox_cpus": "",
@@ -722,7 +722,7 @@ func initConfig(args []string) error {
722722
fmt.Println(" system System prompt override")
723723
fmt.Println(" github_repo_directory Local clone path of the project repo")
724724
fmt.Println(" sandbox_image Docker image (alpine:latest if empty)")
725-
fmt.Println(" sandbox_network Network mode (bridge | none | host)")
725+
fmt.Println(" sandbox_network Network mode (none | bridge | host)")
726726
fmt.Println(" sandbox_readonly Mount working directory read-only")
727727
fmt.Println(" sandbox_memory Memory limit (e.g. 512m, 2g)")
728728
fmt.Println(" sandbox_cpus CPU limit (e.g. 0.5, 2)")
@@ -1130,8 +1130,14 @@ func buildSandboxArgs(cfg sandboxConfig, containerName, workdir, image string) [
11301130
"--security-opt", "no-new-privileges",
11311131
}
11321132

1133-
// Network mode
1134-
args = append(args, "--network", cfg.Network)
1133+
// Network mode — "host" is forbidden (destroys container isolation).
1134+
// If explicitly set to "host", warn and force "none".
1135+
network := cfg.Network
1136+
if network == "host" {
1137+
fmt.Fprintf(os.Stderr, "odek: WARNING: --sandbox-network host destroys container isolation. Forcing 'none'.\n")
1138+
network = "none"
1139+
}
1140+
args = append(args, "--network", network)
11351141

11361142
// Read-only mount?
11371143
volume := workdir + ":/workspace"

cmd/odek/main_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"fmt"
67
"io"
@@ -1888,6 +1889,36 @@ func TestBuildSandboxArgs_ValidVolume(t *testing.T) {
18881889
}
18891890
}
18901891

1892+
// TestBuildSandboxArgs_RejectsHostNetwork verifies that --sandbox-network host
1893+
// is rejected and replaced with "none", protecting container isolation.
1894+
func TestBuildSandboxArgs_RejectsHostNetwork(t *testing.T) {
1895+
origStderr := os.Stderr
1896+
r, w, _ := os.Pipe()
1897+
os.Stderr = w
1898+
1899+
cfg := sandboxConfig{Network: "host"}
1900+
args := buildSandboxArgs(cfg, "odek-test", "/workspace", "alpine:latest")
1901+
1902+
w.Close()
1903+
var buf bytes.Buffer
1904+
io.Copy(&buf, r)
1905+
os.Stderr = origStderr
1906+
stderr := buf.String()
1907+
1908+
hostFound := false
1909+
for i, a := range args {
1910+
if a == "--network" && i+1 < len(args) && args[i+1] == "host" {
1911+
hostFound = true
1912+
}
1913+
}
1914+
if hostFound {
1915+
t.Error("--network host was NOT rejected — isolation destroyed")
1916+
}
1917+
if !strings.Contains(stderr, "WARNING") || !strings.Contains(stderr, "host") {
1918+
t.Errorf("expected stderr warning about host network, got: %s", stderr)
1919+
}
1920+
}
1921+
18911922
// ── loadMCPTools Tests ────────────────────────────────────────────────
18921923

18931924
func TestLoadMCPTools_EmptyServers(t *testing.T) {

cmd/odek/serve.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ Flags:
150150
--open Open browser automatically
151151
--sandbox Enable Docker sandbox for agent tool execution
152152
--sandbox-image image Docker image (default: alpine:latest or Dockerfile.odek)
153-
--sandbox-network net Docker network mode (default: bridge)
153+
--sandbox-network net Docker network mode (default: none)
154154
--sandbox-readonly Mount working directory read-only
155155
--sandbox-memory limit Container memory limit (e.g. 512m, 2g)
156156
--sandbox-cpus limit Container CPU limit (e.g. 0.5, 2, 4)

docs/CHEATSHEET.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ odek repl --sandbox --sandbox-image python:3.12
2727
"max_iterations": 30,
2828
"sandbox": true,
2929
"sandbox_image": "alpine:latest",
30-
"sandbox_network": "bridge",
30+
"sandbox_network": "none",
3131
"max_concurrency": 3,
3232

3333
"skills": {

docs/CLI.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ Use `odek skill reset-skips` to clear the skip list and re-enable suppressed sug
250250
| Flag | Default | Description |
251251
|------|---------|-------------|
252252
| `--sandbox-image <img>` | `alpine:latest` | Docker image |
253-
| `--sandbox-network <mode>` | `bridge` | Network: `bridge`/`none`/`host` |
253+
| `--sandbox-network <mode>` | `none` | Network: `none`/`bridge`/`host` |
254254
| `--sandbox-readonly` | false | Mount working directory read-only |
255255
| `--sandbox-memory <s>` | — | Memory limit (e.g. `512m`, `2g`) |
256256
| `--sandbox-cpus <n>` | — | CPU limit (e.g. `0.5`, `2`) |

docs/SANDBOXING.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ odek runs agent shell commands inside an **isolated Docker container** when `--s
55
## Quick start
66

77
```bash
8-
# Enable sandbox with internet access (default network: bridge)
8+
# Enable sandbox with no network (default: none)
99
odek run --sandbox "npm install && npm test"
1010

11+
# Enable sandbox with internet access
12+
odek run --sandbox --sandbox-network bridge "npm install && npm test"
13+
1114
# Use a specific base image
1215
odek run --sandbox --sandbox-image node:20-alpine "echo hello"
1316

@@ -28,7 +31,7 @@ All sandbox settings are available in `~/.odek/config.json`, `./kode.json`, `ODE
2831
{
2932
"sandbox": true,
3033
"sandbox_image": "node:20-alpine",
31-
"sandbox_network": "bridge",
34+
"sandbox_network": "none",
3235
"sandbox_readonly": false,
3336
"sandbox_memory": "512m",
3437
"sandbox_cpus": "2",
@@ -49,7 +52,7 @@ All sandbox settings are available in `~/.odek/config.json`, `./kode.json`, `ODE
4952
|-------|---------|----------|------|---------|-------------|
5053
| `sandbox` | `ODEK_SANDBOX` | `--sandbox` | bool | `false` | Enable/disable sandbox isolation |
5154
| `sandbox_image` | `ODEK_SANDBOX_IMAGE` | `--sandbox-image` | string | `alpine:latest` | Docker image for the sandbox container |
52-
| `sandbox_network` | `ODEK_SANDBOX_NETWORK` | `--sandbox-network` | string | `bridge` | Docker network mode |
55+
| `sandbox_network` | `ODEK_SANDBOX_NETWORK` | `--sandbox-network` | string | `none` | Docker network mode |
5356
| `sandbox_readonly` | `ODEK_SANDBOX_READONLY` | `--sandbox-readonly` | bool | `false` | Mount working directory read-only |
5457
| `sandbox_memory` | `ODEK_SANDBOX_MEMORY` | `--sandbox-memory` | string | `""` | Memory limit (e.g. `512m`, `2g`) |
5558
| `sandbox_cpus` | `ODEK_SANDBOX_CPUS` | `--sandbox-cpus` | string | `""` | CPU limit (e.g. `0.5`, `2`) |
@@ -79,7 +82,7 @@ ODEK_SANDBOX_USER=1000:1000 \
7982
odek run \
8083
--sandbox \
8184
--sandbox-image node:20-alpine \
82-
--sandbox-network bridge \
85+
--sandbox-network none \
8386
--sandbox-readonly \
8487
--sandbox-memory 512m \
8588
--sandbox-cpus 2 \
@@ -221,7 +224,7 @@ odek's sandbox follows the principle of **least privilege with progressive opt-i
221224
{
222225
"sandbox": true,
223226
"sandbox_image": "node:20-alpine",
224-
"sandbox_network": "bridge",
227+
"sandbox_network": "none",
225228
"sandbox_readonly": false,
226229
"sandbox_memory": "2g",
227230
"sandbox_env": {
@@ -240,7 +243,7 @@ odek's sandbox follows the principle of **least privilege with progressive opt-i
240243
{
241244
"sandbox": true,
242245
"sandbox_image": "golang:1.24-alpine",
243-
"sandbox_network": "bridge",
246+
"sandbox_network": "none",
244247
"sandbox_readonly": false,
245248
"sandbox_memory": "4g",
246249
"sandbox_cpus": "4",

docs/SECURITY.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,19 @@ Without `--sandbox`, the `shell` tool runs commands directly on the host with th
5252
With `--sandbox`, each session is fully contained in a Docker container:
5353

5454
- **No filesystem access** beyond the working directory (mounted read-only if configured)
55-
- **No network** when `--sandbox-network none` is set
55+
- **No network by default** `sandbox_network` defaults to `none`. `host` mode is rejected (forces `none` with a warning) to prevent bypassing isolation
5656
- **No capabilities** — even root inside the container has zero kernel capabilities
5757
- **No privilege escalation**`setuid` binaries are neutered
5858
- **No persistence** — container destroyed on exit
5959
- **No executable temp files**`/tmp` is mounted `noexec`
6060

61+
Set `sandbox_network: bridge` explicitly if the agent needs outbound internet access (e.g. `npm install`).
62+
6163
See [Sandboxing](SANDBOXING.md) for the full reference.
6264

6365
## API key handling
6466

65-
API keys are read from environment variables or explicit config. odek never logs, stores, or transmits your key beyond the HTTPS request to the LLM endpoint.
67+
API keys are read from environment variables (`ODEK_API_KEY`, `DEEPSEEK_API_KEY`, `OPENAI_API_KEY`) or explicit config. After the key is resolved, the environment variables are **cleared** to prevent exposure via `/proc/<pid>/environ` — any process on the same machine can no longer read the key from the process environment. odek never logs, stores, or transmits your key beyond the HTTPS request to the LLM endpoint.
6668

6769
---
6870

internal/config/loader.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ type ResolvedConfig struct {
242242
// ── Defaults ───────────────────────────────────────────────────────────
243243

244244
const (
245-
DefaultSandboxNetwork = "bridge"
245+
DefaultSandboxNetwork = "none"
246246
)
247247

248248
// ── Paths ──────────────────────────────────────────────────────────────
@@ -561,6 +561,12 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
561561
resolved.APIKey = os.Getenv("OPENAI_API_KEY")
562562
}
563563

564+
// Clear API key env vars to prevent exposure via /proc/pid/environ.
565+
// The key is now in the Config struct; the environment shouldn't keep a copy.
566+
os.Unsetenv("ODEK_API_KEY")
567+
os.Unsetenv("DEEPSEEK_API_KEY")
568+
os.Unsetenv("OPENAI_API_KEY")
569+
564570
return resolved
565571
}
566572

internal/config/loader_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,3 +691,29 @@ func TestLoadConfig_MemoryNotSetReturnsDefaults(t *testing.T) {
691691
t.Error("Enabled should match default")
692692
}
693693
}
694+
695+
func TestLoadConfig_ClearsAPIKeyFromEnviron(t *testing.T) {
696+
os.Setenv("ODEK_API_KEY", "sk-odek-test")
697+
os.Setenv("DEEPSEEK_API_KEY", "sk-deepseek-test")
698+
os.Setenv("OPENAI_API_KEY", "sk-openai-test")
699+
700+
dir := t.TempDir()
701+
prevHome := os.Getenv("HOME")
702+
os.Setenv("HOME", dir)
703+
defer os.Setenv("HOME", prevHome)
704+
705+
cfg := LoadConfig(CLIFlags{})
706+
707+
if cfg.APIKey != "sk-odek-test" {
708+
t.Errorf("APIKey = %q, want 'sk-odek-test'", cfg.APIKey)
709+
}
710+
if v := os.Getenv("ODEK_API_KEY"); v != "" {
711+
t.Errorf("ODEK_API_KEY should be cleared after LoadConfig, got %q", v)
712+
}
713+
if v := os.Getenv("DEEPSEEK_API_KEY"); v != "" {
714+
t.Errorf("DEEPSEEK_API_KEY should be cleared after LoadConfig, got %q", v)
715+
}
716+
if v := os.Getenv("OPENAI_API_KEY"); v != "" {
717+
t.Errorf("OPENAI_API_KEY should be cleared after LoadConfig, got %q", v)
718+
}
719+
}

0 commit comments

Comments
 (0)