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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@

- 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
Expand Down Expand Up @@ -42,3 +44,19 @@
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 ./...

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium test

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +49 to +62
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions cmd/odek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
194 changes: 164 additions & 30 deletions cmd/odek/project_sandbox_approval.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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.
Expand All @@ -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
}

Expand All @@ -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')
Expand All @@ -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)
}
Expand All @@ -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.
Expand All @@ -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) {
Expand Down
Loading
Loading