Skip to content

Commit 489f1f2

Browse files
authored
fix(sandbox): gate implicit Dockerfile.odek builds behind operator approval (#104)
A Dockerfile.odek in the working directory was built automatically with no approval gate, while the analogous sandbox_image/sandbox_env project-config knobs correctly require explicit operator approval. docker build executes the repo-controlled Dockerfile's RUN instructions outside the sandbox threat model (default capabilities, entire working directory readable as build context), so merely running odek inside a malicious repository - including odek serve, which sandboxes by default - granted host-adjacent code execution and workspace exfiltration. Changes: - approveProjectSandbox now also requires approval for an implicit Dockerfile.odek build (sandbox active, no explicit image, file present). The approval is keyed on the Dockerfile content hash, so editing the file invalidates a prior "trust this project" approval. The prompt warns that building runs repo-controlled code with the whole working directory as build context. - setupSandbox enforces the approval non-interactively at build time (env bypass, persisted trust, or in-process session approval), closing the gap where a Dockerfile appears or changes after startup - e.g. a serve-mode sandbox created per WebSocket connection, or a session continuation that re-enables the sandbox. - continueCmd now calls approveProjectSandbox like every other command; previously odek continue applied project sandbox overrides without any approval at all. - docker build runs with --network=none by default so RUN steps cannot fetch attacker payloads or exfiltrate build-context data over the network; ODEK_SANDBOX_BUILD_NETWORK=1 (operator-only) opts back in for legitimate networked builds such as RUN apk add. Regression tests cover the requirement detection, non-TTY fail-closed behavior, env bypass, once-vs-trust semantics, content-change invalidation at both gates, build-time enforcement, and the --network=none default. Docs updated: SANDBOXING.md, SECURITY.md (new section 18b), CONFIG.md, AGENTS.md.
1 parent 6265f39 commit 489f1f2

9 files changed

Lines changed: 408 additions & 35 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
159159
- **REPL history file permissions** (`cmd/odek/repl_editor.go`) — `~/.odek/repl_history` is now created/hardened with `0600` permissions (and any existing world-readable file is `chmod`d on the next persist), preventing local users from reading pasted API keys, tokens, and URLs from the REPL history.
160160
- **Serve sandbox default-on**`odek serve` enables `--sandbox` automatically unless `--no-sandbox` is passed. Project-requested sandbox overrides from `./odek.json` still require explicit approval via the project sandbox gate.
161161
- **Project-level sandbox approval** (`cmd/odek/project_sandbox_approval.go`) — `./odek.json` can set `sandbox_env`, `sandbox_image`, `sandbox_network`, and `sandbox_volumes`, but these knobs are not applied until the operator explicitly approves them (`y` = once, `t` = trust this project), or `ODEK_APPROVE_PROJECT_SANDBOX=1` is set for CI/non-interactive use. Persisted approvals live in `~/.odek/project_sandbox_approvals.json` (0600). This closes the C-1 vector where a malicious repo could exfiltrate host secrets via `${VAR}` interpolation in `sandbox_env`, pull an attacker-controlled image, or widen the container's network access.
162+
- **Implicit Dockerfile.odek build approval** (`cmd/odek/project_sandbox_approval.go` + `internal/sandbox/sandbox.go`) — a `Dockerfile.odek` in the working directory is gated behind the same approval mechanism as project sandbox overrides, because `docker build` runs its repo-controlled `RUN` instructions outside the sandbox threat model with the entire working directory as build context. The approval key includes the Dockerfile content hash (edits force re-approval), `setupSandbox` re-verifies approval non-interactively at build time (closing post-startup introduction/modification, e.g. serve-mode per-connection containers), and builds run with `--network=none` by default (`ODEK_SANDBOX_BUILD_NETWORK=1` opts back in).
162163
- **Sandbox volume confinement** (`internal/sandbox/sandbox.go`) — extra `--sandbox-volume` host paths must resolve to a location under the working directory, cannot contain `..` or symlink escapes, and cannot match sensitive prefixes such as `/etc`, `/proc`, `/sys`, `/dev`, `/root`, `/home`, `/var`, `/run`, or `/var/run/docker.sock`.
163164
- **Sandbox read-only enforcement** (`cmd/odek/sandbox_file.go` + `cmd/odek/file_tool.go` + `cmd/odek/perf_tools.go`) — when a sandbox container is active, `write_file`, `patch`, and `batch_patch` translate host paths to `/workspace/...` and copy data into the container with `docker cp`, so a read-only workspace mount (`--sandbox-readonly`) is enforced for the agent's own file tools.
164165
- **Project config sensitive-field rejection** (`internal/config/loader.go`) — `./odek.json` is untrusted, so `base_url`, `api_key`, `system`, the `dangerous` section, `embedding`, `memory`, `sessions`, `skills.dirs`/`skills.embedding`, `telegram`, and `web_search` set there are ignored (with stderr warnings). Project-level sandbox knobs (`sandbox_env`, `sandbox_image`, `sandbox_network`, `sandbox_volumes`) are not silently ignored either; they are gated by the project-level sandbox approval flow before application. These can only be configured from operator-controlled sources: `~/.odek/config.json`, `ODEK_*` env vars, or CLI flags.

cmd/odek/main.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1597,6 +1597,16 @@ func deliverToTelegram(text string, resolved config.ResolvedConfig) error {
15971597
// The returned cleanup function destroys the container; always invoke it
15981598
// via Agent.Close().
15991599
func setupSandbox(tools []odek.Tool, cfg sandboxConfig) (containerName string, cleanup func() error, err error) {
1600+
// An implicit Dockerfile.odek build executes repo-controlled code on the
1601+
// host; refuse to proceed unless it was approved (startup prompt, trusted
1602+
// project, or ODEK_APPROVE_PROJECT_SANDBOX=1). Skipped when an explicit
1603+
// image is configured because ResolveImage ignores the Dockerfile then.
1604+
if cfg.Image == "" {
1605+
if err := requireDockerfileBuildApproval(); err != nil {
1606+
return "", nil, err
1607+
}
1608+
}
1609+
16001610
image, err := sandbox.ResolveImage(cfg)
16011611
if err != nil {
16021612
return "", nil, err
@@ -2305,6 +2315,13 @@ func continueCmd(args []string) error {
23052315
fmt.Fprintf(os.Stderr, "odek: session was sandboxed — enabling sandbox for this continuation\n")
23062316
}
23072317

2318+
// Gate project-level sandbox knobs and any implicit Dockerfile.odek build
2319+
// on explicit operator approval, same as `odek run` — a continued session
2320+
// must not bypass the approval flow.
2321+
if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil {
2322+
return err
2323+
}
2324+
23082325
// Build tools
23092326
var sm *skills.SkillManager
23102327
if resolved.Skills.Learn {

cmd/odek/project_sandbox_approval.go

Lines changed: 164 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ import (
1010
"os"
1111
"path/filepath"
1212
"strings"
13+
"sync"
1314

1415
"github.com/BackendStack21/odek/internal/config"
16+
"github.com/BackendStack21/odek/internal/sandbox"
1517
"golang.org/x/term"
1618
)
1719

@@ -21,16 +23,26 @@ import (
2123
const projectSandboxApprovalsFile = "project_sandbox_approvals.json"
2224

2325
// approveProjectSandbox requires explicit operator approval before any
24-
// project-level ./odek.json sandbox knobs are applied. This closes the C-1
25-
// vector where a malicious repo exfiltrates host secrets via ${VAR}
26-
// interpolation in sandbox_env, pulls an attacker-controlled image, or widens
27-
// the container's network access.
26+
// project-controlled sandbox inputs are applied. This covers two surfaces:
27+
//
28+
// 1. Project-level ./odek.json sandbox knobs (sandbox_env, sandbox_image,
29+
// sandbox_network, sandbox_volumes) — the C-1 vector where a malicious
30+
// repo exfiltrates host secrets via ${VAR} interpolation in sandbox_env,
31+
// pulls an attacker-controlled image, or widens the container's network
32+
// access.
33+
// 2. An implicit Dockerfile.odek build — docker build executes the
34+
// repo-controlled Dockerfile's RUN instructions outside the sandbox
35+
// threat model (default capabilities, full read access to the entire
36+
// working directory as build context), so merely running odek inside a
37+
// malicious repository would otherwise grant host-adjacent code
38+
// execution. The approval is keyed on the Dockerfile content hash, so
39+
// changing the file invalidates a prior approval.
2840
//
2941
// Approval can be granted in three ways:
30-
// 1. Set ODEK_APPROVE_PROJECT_SANDBOX=1 (useful for CI/non-interactive use).
31-
// 2. Answer the interactive prompt when running on a TTY.
32-
// 3. A prior approval for the same project/sandbox fingerprint is persisted
33-
// in ~/.odek/project_sandbox_approvals.json.
42+
// 1. Set ODEK_APPROVE_PROJECT_SANDBOX=1 (useful for CI/non-interactive use).
43+
// 2. Answer the interactive prompt when running on a TTY.
44+
// 3. A prior approval for the same project/sandbox fingerprint is persisted
45+
// in ~/.odek/project_sandbox_approvals.json.
3446
//
3547
// If approval is required and cannot be obtained, approveProjectSandbox
3648
// returns an error and the command should abort before creating the sandbox.
@@ -42,7 +54,9 @@ func approveProjectSandbox(resolved config.ResolvedConfig, stdin io.Reader, stdo
4254
// approveProjectSandboxWithTTY is the testable core of approveProjectSandbox.
4355
func approveProjectSandboxWithTTY(resolved config.ResolvedConfig, stdin io.Reader, stdout io.Writer, tty bool) error {
4456
o := resolved.ProjectSandboxOverride
45-
if !o.HasEnv && !o.HasImage && !o.HasNetwork && !o.HasVolumes {
57+
hasOverride := o.HasEnv || o.HasImage || o.HasNetwork || o.HasVolumes
58+
dfHash, dfRequired := dockerfileBuildRequirement(resolved)
59+
if !hasOverride && !dfRequired {
4660
return nil
4761
}
4862

@@ -64,42 +78,61 @@ func approveProjectSandboxWithTTY(resolved config.ResolvedConfig, stdin io.Reade
6478
return fmt.Errorf("project sandbox approval: load approvals: %w", err)
6579
}
6680

67-
key := projectSandboxApprovalKey(projectDir, o)
68-
if approved[key] {
81+
overrideKey := projectSandboxApprovalKey(projectDir, o)
82+
dfKey := dockerfileApprovalKey(projectDir, dfHash)
83+
overrideOK := !hasOverride || approved[overrideKey]
84+
dfOK := !dfRequired || approved[dfKey] || sessionDockerfileApproved(dfKey)
85+
if overrideOK && dfOK {
6986
return nil
7087
}
7188

7289
if !tty {
90+
what := fmt.Sprintf("project-level sandbox config in %s", config.ProjectConfigPath())
91+
switch {
92+
case !hasOverride:
93+
what = fmt.Sprintf("%s in the working directory (implicit docker build)", sandbox.DockerfileName)
94+
case dfRequired:
95+
what += fmt.Sprintf(" and %s (implicit docker build)", sandbox.DockerfileName)
96+
}
7397
return fmt.Errorf(
74-
"project-level sandbox config in %s requires explicit approval\n"+
98+
"%s requires explicit approval\n"+
7599
"set ODEK_APPROVE_PROJECT_SANDBOX=1 to approve, or run interactively",
76-
config.ProjectConfigPath(),
100+
what,
77101
)
78102
}
79103

80104
reader := bufio.NewReader(stdin)
81105

82106
fmt.Fprintln(stdout)
83-
fmt.Fprintf(stdout, "WARNING: project config (%s) requests sandbox overrides:\n", config.ProjectConfigPath())
84-
if o.HasImage {
85-
fmt.Fprintf(stdout, " image: %s\n", o.Image)
86-
}
87-
if o.HasNetwork {
88-
fmt.Fprintf(stdout, " network: %s\n", o.Network)
89-
}
90-
if o.HasEnv {
91-
fmt.Fprintf(stdout, " env: %s\n", strings.Join(o.EnvKeys, ", "))
92-
if o.EnvHasInterpolation {
93-
fmt.Fprintln(stdout, " ⚠️ sandbox_env values contain ${...} interpolation against host environment variables")
107+
if hasOverride {
108+
fmt.Fprintf(stdout, "WARNING: project config (%s) requests sandbox overrides:\n", config.ProjectConfigPath())
109+
if o.HasImage {
110+
fmt.Fprintf(stdout, " image: %s\n", o.Image)
111+
}
112+
if o.HasNetwork {
113+
fmt.Fprintf(stdout, " network: %s\n", o.Network)
114+
}
115+
if o.HasEnv {
116+
fmt.Fprintf(stdout, " env: %s\n", strings.Join(o.EnvKeys, ", "))
117+
if o.EnvHasInterpolation {
118+
fmt.Fprintln(stdout, " ⚠️ sandbox_env values contain ${...} interpolation against host environment variables")
119+
}
94120
}
121+
if o.HasVolumes {
122+
fmt.Fprintf(stdout, " volumes: %s\n", strings.Join(o.Volumes, ", "))
123+
}
124+
fmt.Fprintln(stdout)
125+
fmt.Fprintln(stdout, "Allowing this means code in the sandbox can read workspace files and,")
126+
fmt.Fprintln(stdout, "depending on network mode, contact external hosts.")
95127
}
96-
if o.HasVolumes {
97-
fmt.Fprintf(stdout, " volumes: %s\n", strings.Join(o.Volumes, ", "))
128+
if dfRequired {
129+
fmt.Fprintf(stdout, "WARNING: %s found in the working directory — odek will build a sandbox image from it.\n", sandbox.DockerfileName)
130+
fmt.Fprintln(stdout, " docker build executes the Dockerfile's RUN instructions as repo-controlled code")
131+
fmt.Fprintln(stdout, " on this host, with the ENTIRE working directory readable as build context.")
132+
fmt.Fprintln(stdout, " (Build network is disabled by default; set ODEK_SANDBOX_BUILD_NETWORK=1 to")
133+
fmt.Fprintln(stdout, " allow networked builds such as `RUN apk add …`.)")
98134
}
99135
fmt.Fprintln(stdout)
100-
fmt.Fprintln(stdout, "Allowing this means code in the sandbox can read workspace files and,")
101-
fmt.Fprintln(stdout, "depending on network mode, contact external hosts.")
102-
fmt.Fprintln(stdout)
103136
fmt.Fprint(stdout, "Approve? [y = once / t = trust this project / N] ")
104137

105138
line, err := reader.ReadString('\n')
@@ -110,9 +143,18 @@ func approveProjectSandboxWithTTY(resolved config.ResolvedConfig, stdin io.Reade
110143

111144
switch line {
112145
case "y", "yes":
146+
if dfRequired {
147+
recordSessionDockerfileApproval(dfKey)
148+
}
113149
return nil
114150
case "t", "trust":
115-
approved[key] = true
151+
if hasOverride {
152+
approved[overrideKey] = true
153+
}
154+
if dfRequired {
155+
approved[dfKey] = true
156+
recordSessionDockerfileApproval(dfKey)
157+
}
116158
if err := saveProjectSandboxApprovals(approved); err != nil {
117159
return fmt.Errorf("project sandbox approval: save approvals: %w", err)
118160
}
@@ -122,6 +164,88 @@ func approveProjectSandboxWithTTY(resolved config.ResolvedConfig, stdin io.Reade
122164
}
123165
}
124166

167+
// dockerfileBuildRequirement reports whether the resolved config will trigger
168+
// an implicit Dockerfile.odek build: sandbox is active, no explicit image is
169+
// configured (an explicit image makes ResolveImage ignore the Dockerfile),
170+
// and Dockerfile.odek exists in the working directory. The returned hash is
171+
// the SHA-256 of the file content, used to key approvals so that changing
172+
// the Dockerfile invalidates a prior approval.
173+
func dockerfileBuildRequirement(resolved config.ResolvedConfig) (hash string, required bool) {
174+
if !resolved.Sandbox || resolved.SandboxImage != "" {
175+
return "", false
176+
}
177+
data, err := os.ReadFile(sandbox.DockerfileName)
178+
if err != nil {
179+
return "", false
180+
}
181+
sum := sha256.Sum256(data)
182+
return hex.EncodeToString(sum[:]), true
183+
}
184+
185+
// requireDockerfileBuildApproval enforces the Dockerfile.odek build gate at
186+
// the point of container creation. It is intentionally non-interactive:
187+
// approval must already have been granted — interactively at startup
188+
// (recorded as a session approval), persisted via "trust this project", or
189+
// given via ODEK_APPROVE_PROJECT_SANDBOX=1. Enforcing here (in addition to
190+
// the startup prompt) closes the gap where a Dockerfile appears or changes
191+
// AFTER startup — e.g. a serve-mode sandbox created per WebSocket
192+
// connection, or a resumed session that flips sandbox on — would otherwise
193+
// build unapproved repo-controlled code.
194+
func requireDockerfileBuildApproval() error {
195+
data, err := os.ReadFile(sandbox.DockerfileName)
196+
if err != nil {
197+
return nil // no Dockerfile → ResolveImage falls back to alpine:latest
198+
}
199+
if os.Getenv("ODEK_APPROVE_PROJECT_SANDBOX") == "1" {
200+
return nil
201+
}
202+
203+
projectDir, err := os.Getwd()
204+
if err != nil {
205+
return fmt.Errorf("dockerfile build approval: get working directory: %w", err)
206+
}
207+
projectDir, err = filepath.Abs(projectDir)
208+
if err != nil {
209+
return fmt.Errorf("dockerfile build approval: abs working directory: %w", err)
210+
}
211+
212+
sum := sha256.Sum256(data)
213+
key := dockerfileApprovalKey(projectDir, hex.EncodeToString(sum[:]))
214+
if sessionDockerfileApproved(key) {
215+
return nil
216+
}
217+
if approved, err := loadProjectSandboxApprovals(); err == nil && approved[key] {
218+
return nil
219+
}
220+
return fmt.Errorf(
221+
"%s requires explicit approval before odek builds it — docker build executes\n"+
222+
"repo-controlled RUN instructions with the entire working directory as build context.\n"+
223+
"Approve interactively at startup, or set ODEK_APPROVE_PROJECT_SANDBOX=1",
224+
sandbox.DockerfileName,
225+
)
226+
}
227+
228+
// sessionDockerfileApprovals records Dockerfile content approvals granted
229+
// interactively in this process ("y" = once), so the build-time enforcement
230+
// in setupSandbox can honour an approval that was given seconds earlier
231+
// without persisting it. Keyed the same as the persisted store.
232+
var (
233+
sessionDockerfileApprovalsMu sync.Mutex
234+
sessionDockerfileApprovals = map[string]bool{}
235+
)
236+
237+
func recordSessionDockerfileApproval(key string) {
238+
sessionDockerfileApprovalsMu.Lock()
239+
defer sessionDockerfileApprovalsMu.Unlock()
240+
sessionDockerfileApprovals[key] = true
241+
}
242+
243+
func sessionDockerfileApproved(key string) bool {
244+
sessionDockerfileApprovalsMu.Lock()
245+
defer sessionDockerfileApprovalsMu.Unlock()
246+
return sessionDockerfileApprovals[key]
247+
}
248+
125249
// projectSandboxApprovalKey returns a stable key for the persisted approval
126250
// store. A change to the project directory, image, network, env keys, or
127251
// volumes invalidates the prior approval.
@@ -137,6 +261,16 @@ func projectSandboxApprovalKey(projectDir string, o config.ProjectSandboxOverrid
137261
return hex.EncodeToString(h.Sum(nil))
138262
}
139263

264+
// dockerfileApprovalKey returns the persisted-approval key for an implicit
265+
// Dockerfile.odek build. It is keyed on the project directory and the
266+
// Dockerfile content hash, so editing the Dockerfile invalidates the prior
267+
// approval and forces re-review.
268+
func dockerfileApprovalKey(projectDir, contentHash string) string {
269+
h := sha256.New()
270+
fmt.Fprintf(h, "dockerfile\x00%s\x00%s", projectDir, contentHash)
271+
return hex.EncodeToString(h.Sum(nil))
272+
}
273+
140274
// loadProjectSandboxApprovals reads the persisted approval map. A missing file
141275
// is treated as an empty approval set.
142276
func loadProjectSandboxApprovals() (map[string]bool, error) {

0 commit comments

Comments
 (0)