diff --git a/AGENTS.md b/AGENTS.md index dc1782b..7fb0982 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -182,6 +182,14 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU - **Secrets.env permission gate** (`internal/config/loader.go`) — refuses to load `~/.odek/secrets.env` when it is group/world-readable, preventing local users from reading API keys injected into the environment. - **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc. +### Security findings (`sec_findings.md`) + +`sec_findings.md` at the repository root is the running security audit log. It is +intentionally listed in `.gitignore` so that audit output and in-progress +findings are not committed to the repository by default. Do not commit this +file in pull requests unless you explicitly intend to publish a finalized +audit snapshot. + ### Platform Support CLI, REPL, Web UI, Telegram bot — all in a single binary. diff --git a/cmd/odek/main.go b/cmd/odek/main.go index f553212..2dbaf72 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -1179,6 +1179,9 @@ func run(args []string) error { GuardScanToolOutputs: f.GuardScanToolOutputs, GuardScanTelegram: f.GuardScanTelegram, }) + if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil { + return err + } // Resolve @references and --ctx file attachments in the task cwd, _ := os.Getwd() diff --git a/cmd/odek/mcp.go b/cmd/odek/mcp.go index 5f50749..74ca0a8 100644 --- a/cmd/odek/mcp.go +++ b/cmd/odek/mcp.go @@ -50,6 +50,9 @@ Flags: // Load config resolved := config.LoadConfig(cliFlags) + if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil { + return err + } // Start agent loop (mcp) sbCfg := sandboxConfig{ diff --git a/cmd/odek/project_sandbox_approval.go b/cmd/odek/project_sandbox_approval.go new file mode 100644 index 0000000..71bef59 --- /dev/null +++ b/cmd/odek/project_sandbox_approval.go @@ -0,0 +1,175 @@ +package main + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/BackendStack21/odek/internal/config" + "golang.org/x/term" +) + +// projectSandboxApprovalsFile is the persistent store for user-approved +// project-level sandbox configurations. It lives under ~/.odek and is created +// 0600. +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. +// +// 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. +// +// If approval is required and cannot be obtained, approveProjectSandbox +// returns an error and the command should abort before creating the sandbox. +func approveProjectSandbox(resolved config.ResolvedConfig, stdin io.Reader, stdout io.Writer) error { + isTTY := stdin == os.Stdin && term.IsTerminal(int(os.Stdin.Fd())) + return approveProjectSandboxWithTTY(resolved, stdin, stdout, isTTY) +} + +// 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 { + return nil + } + + if os.Getenv("ODEK_APPROVE_PROJECT_SANDBOX") == "1" { + return nil + } + + projectDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("project sandbox approval: get working directory: %w", err) + } + projectDir, err = filepath.Abs(projectDir) + if err != nil { + return fmt.Errorf("project sandbox approval: abs working directory: %w", err) + } + + approved, err := loadProjectSandboxApprovals() + if err != nil { + return fmt.Errorf("project sandbox approval: load approvals: %w", err) + } + + key := projectSandboxApprovalKey(projectDir, o) + if approved[key] { + return nil + } + + if !tty { + return fmt.Errorf( + "project-level sandbox config in %s requires explicit approval\n"+ + "set ODEK_APPROVE_PROJECT_SANDBOX=1 to approve, or run interactively", + config.ProjectConfigPath(), + ) + } + + 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 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.") + fmt.Fprintln(stdout) + fmt.Fprint(stdout, "Approve? [y = once / t = trust this project / N] ") + + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("project sandbox approval: read prompt: %w", err) + } + line = strings.ToLower(strings.TrimSpace(line)) + + switch line { + case "y", "yes": + return nil + case "t", "trust": + approved[key] = true + if err := saveProjectSandboxApprovals(approved); err != nil { + return fmt.Errorf("project sandbox approval: save approvals: %w", err) + } + return nil + default: + return fmt.Errorf("project sandbox config was not approved") + } +} + +// 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. +func projectSandboxApprovalKey(projectDir string, o config.ProjectSandboxOverride) string { + h := sha256.New() + fmt.Fprintf(h, "%s\x00%s\x00%s", projectDir, o.Image, o.Network) + for _, k := range o.EnvKeys { + fmt.Fprintf(h, "\x00env:%s", k) + } + for _, v := range o.Volumes { + fmt.Fprintf(h, "\x00vol:%s", v) + } + 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) { + path := filepath.Join(expandHome("~/.odek"), projectSandboxApprovalsFile) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return make(map[string]bool), nil + } + return nil, err + } + + var approvals map[string]bool + if err := json.Unmarshal(data, &approvals); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + if approvals == nil { + approvals = make(map[string]bool) + } + return approvals, nil +} + +// saveProjectSandboxApprovals writes the approval map to disk with 0600 +// permissions. +func saveProjectSandboxApprovals(approvals map[string]bool) error { + dir := expandHome("~/.odek") + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + path := filepath.Join(dir, projectSandboxApprovalsFile) + data, err := json.MarshalIndent(approvals, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0600) +} diff --git a/cmd/odek/project_sandbox_approval_test.go b/cmd/odek/project_sandbox_approval_test.go new file mode 100644 index 0000000..038677d --- /dev/null +++ b/cmd/odek/project_sandbox_approval_test.go @@ -0,0 +1,153 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/BackendStack21/odek/internal/config" +) + +func TestApproveProjectSandbox_NoOverride(t *testing.T) { + resolved := config.ResolvedConfig{} + if err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false); err != nil { + t.Fatalf("expected no approval needed when no override, got: %v", err) + } +} + +func TestApproveProjectSandbox_EnvBypass(t *testing.T) { + resolved := config.ResolvedConfig{ + ProjectSandboxOverride: config.ProjectSandboxOverride{ + HasEnv: true, + EnvKeys: []string{"X"}, + }, + } + t.Setenv("ODEK_APPROVE_PROJECT_SANDBOX", "1") + if err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false); err != nil { + t.Fatalf("expected env approval, got: %v", err) + } +} + +func TestApproveProjectSandbox_NonTTYRequiresEnv(t *testing.T) { + resolved := config.ResolvedConfig{ + ProjectSandboxOverride: config.ProjectSandboxOverride{ + HasEnv: true, + EnvKeys: []string{"X"}, + }, + } + os.Unsetenv("ODEK_APPROVE_PROJECT_SANDBOX") + err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false) + if err == nil { + t.Fatal("expected error for non-interactive unapproved project sandbox") + } + if !strings.Contains(err.Error(), "ODEK_APPROVE_PROJECT_SANDBOX") { + t.Errorf("error = %q, want ODEK_APPROVE_PROJECT_SANDBOX hint", err.Error()) + } +} + +func TestApproveProjectSandbox_TTYDeny(t *testing.T) { + resolved := config.ResolvedConfig{ + ProjectSandboxOverride: config.ProjectSandboxOverride{ + HasEnv: true, + EnvKeys: []string{"X"}, + }, + } + var out bytes.Buffer + err := approveProjectSandboxWithTTY(resolved, strings.NewReader("\n"), &out, true) + if err == nil { + t.Fatal("expected error when user denies approval") + } + if !strings.Contains(err.Error(), "not approved") { + t.Errorf("error = %q, want 'not approved'", err.Error()) + } + if !strings.Contains(out.String(), "WARNING") { + t.Errorf("prompt = %q, want WARNING header", out.String()) + } +} + +func TestApproveProjectSandbox_TTYApproveOnce(t *testing.T) { + resolved := config.ResolvedConfig{ + ProjectSandboxOverride: config.ProjectSandboxOverride{ + HasEnv: true, + EnvKeys: []string{"X"}, + }, + } + var out bytes.Buffer + err := approveProjectSandboxWithTTY(resolved, strings.NewReader("y\n"), &out, true) + if err != nil { + t.Fatalf("expected approval, got: %v", err) + } +} + +func TestApproveProjectSandbox_TTYTrustPersists(t *testing.T) { + homeDir := setupTestHome(t) + resolved := config.ResolvedConfig{ + ProjectSandboxOverride: config.ProjectSandboxOverride{ + HasEnv: true, + EnvKeys: []string{"X"}, + }, + } + + var out bytes.Buffer + err := approveProjectSandboxWithTTY(resolved, strings.NewReader("t\n"), &out, true) + if err != nil { + t.Fatalf("expected trust approval, got: %v", err) + } + + // Second call with same key and no input should succeed because of persisted trust. + err = approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false) + if err != nil { + t.Fatalf("expected persisted approval, got: %v", err) + } + + approvalPath := filepath.Join(homeDir, ".odek", projectSandboxApprovalsFile) + if _, err := os.Stat(approvalPath); err != nil { + t.Fatalf("approval file not created: %v", err) + } +} + +func TestApproveProjectSandbox_KeyChanges(t *testing.T) { + setupTestHome(t) + resolved := config.ResolvedConfig{ + ProjectSandboxOverride: config.ProjectSandboxOverride{ + HasEnv: true, + EnvKeys: []string{"X"}, + }, + } + + var out bytes.Buffer + if err := approveProjectSandboxWithTTY(resolved, strings.NewReader("t\n"), &out, true); err != nil { + t.Fatalf("expected trust approval, got: %v", err) + } + + // Add a new env key: previous trust should be invalidated. + resolved.ProjectSandboxOverride.EnvKeys = []string{"X", "Y"} + err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &bytes.Buffer{}, false) + if err == nil { + t.Fatal("expected error after key change invalidated trust") + } +} + +func TestApproveProjectSandbox_PromptHidesValues(t *testing.T) { + resolved := config.ResolvedConfig{ + ProjectSandboxOverride: config.ProjectSandboxOverride{ + HasEnv: true, + EnvKeys: []string{"X"}, + EnvHasInterpolation: true, + }, + } + var out bytes.Buffer + _ = approveProjectSandboxWithTTY(resolved, strings.NewReader("\n"), &out, true) + prompt := out.String() + if !strings.Contains(prompt, "X") { + t.Errorf("prompt = %q, want env key X", prompt) + } + if strings.Contains(prompt, "${HOME}") || strings.Contains(prompt, "secret-value") { + t.Errorf("prompt should not contain env values; got %q", prompt) + } + if !strings.Contains(prompt, "${...}") { + t.Errorf("prompt = %q, want interpolation warning", prompt) + } +} diff --git a/cmd/odek/repl.go b/cmd/odek/repl.go index 06905cc..d8577e0 100644 --- a/cmd/odek/repl.go +++ b/cmd/odek/repl.go @@ -62,6 +62,9 @@ func replCmd(args []string) error { SandboxCPUs: f.SandboxCPUs, SandboxUser: f.SandboxUser, }) + if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil { + return err + } systemMessage := buildSystemPrompt(resolved) // session resume diff --git a/cmd/odek/schedule.go b/cmd/odek/schedule.go index b4d6e17..665fd76 100644 --- a/cmd/odek/schedule.go +++ b/cmd/odek/schedule.go @@ -266,6 +266,9 @@ func scheduleRunNow(args []string) error { } resolved := config.LoadConfig(config.CLIFlags{}) + if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil { + return err + } system := buildSystemPrompt(resolved) ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer cancel() @@ -297,6 +300,9 @@ func scheduleDaemon(_ []string) error { defer unlock() resolved := config.LoadConfig(config.CLIFlags{}) + if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil { + return err + } system := buildSystemPrompt(resolved) st, err := schedule.NewStore() if err != nil { diff --git a/cmd/odek/security_report_validation_test.go b/cmd/odek/security_report_validation_test.go index 1499e5e..52b7ab1 100644 --- a/cmd/odek/security_report_validation_test.go +++ b/cmd/odek/security_report_validation_test.go @@ -265,3 +265,57 @@ func TestReport_SubagentSchemaHasTrustGates(t *testing.T) { } } } + +// ── Claim 7 (post-fix) ───────────────────────────────────────────────── +// +// Original report claim (sec_findings.md C-1): "Project ./odek.json can +// exfiltrate host secrets via sandbox_env ${VAR} expansion + attacker +// image/network." After the fix, project-level sandbox knobs require +// explicit operator approval before they are applied. This test pins the +// approval gate so a regression that silently applies project sandbox +// config is caught. +func TestReport_ProjectSandboxRequiresApproval(t *testing.T) { + dir := t.TempDir() + prevHome := os.Getenv("HOME") + os.Setenv("HOME", dir) + defer os.Setenv("HOME", prevHome) + + prevWd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } + defer os.Chdir(prevWd) + + if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{ + "sandbox": true, + "sandbox_image": "alpine:latest", + "sandbox_network": "bridge", + "sandbox_env": {"X": "${HOME}"} + }`), 0644); err != nil { + t.Fatalf("setup: %v", err) + } + + resolved := config.LoadConfig(config.CLIFlags{}) + if !resolved.ProjectSandboxOverride.HasEnv { + t.Fatal("LoadConfig did not record project sandbox_env override") + } + + // Non-interactive, no env bypass: approval must fail. + os.Unsetenv("ODEK_APPROVE_PROJECT_SANDBOX") + err = approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &strings.Builder{}, false) + if err == nil { + t.Fatal("project sandbox config was applied without approval in non-interactive mode") + } + if !strings.Contains(err.Error(), "ODEK_APPROVE_PROJECT_SANDBOX") { + t.Errorf("error = %q, want ODEK_APPROVE_PROJECT_SANDBOX hint", err.Error()) + } + + // Env bypass must succeed. + t.Setenv("ODEK_APPROVE_PROJECT_SANDBOX", "1") + if err := approveProjectSandboxWithTTY(resolved, strings.NewReader(""), &strings.Builder{}, false); err != nil { + t.Fatalf("env bypass should approve, got: %v", err) + } +} diff --git a/cmd/odek/serve.go b/cmd/odek/serve.go index 9a5fdd7..14bcac2 100644 --- a/cmd/odek/serve.go +++ b/cmd/odek/serve.go @@ -263,6 +263,9 @@ func serveCmd(args []string) error { ToolsEnabled: toolsEnabled, ToolsDisabled: toolsDisabled, }) + if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil { + return err + } // Serve mode default-on for sandbox: the Web UI surface is the // largest blast radius (browser-driven tool calls, untrusted-page // fetches), and the user opted into a long-running process. If no diff --git a/cmd/odek/subagent.go b/cmd/odek/subagent.go index 4b0b761..4f3d684 100644 --- a/cmd/odek/subagent.go +++ b/cmd/odek/subagent.go @@ -316,6 +316,9 @@ func subagentCmd(args []string) error { // Resolve config (inherits everything from normal chain) resolved := config.LoadConfig(config.CLIFlags{}) + if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil { + return err + } // If the parent handed us an API key via FD 3, prefer it over any // env-resolved value. This keeps the key out of the child's process diff --git a/cmd/odek/telegram.go b/cmd/odek/telegram.go index aa53445..329fbcb 100644 --- a/cmd/odek/telegram.go +++ b/cmd/odek/telegram.go @@ -166,6 +166,9 @@ func telegramCmd(args []string) error { // 1. Load config from all sources (file → env). resolved := config.LoadConfig(config.CLIFlags{}) + if err := approveProjectSandbox(resolved, os.Stdin, os.Stdout); err != nil { + return err + } // 1b. If the parent handed us an API key via an inherited file descriptor // (FD-based handoff used on Telegram restart), use it. This keeps the key diff --git a/docs/CHEATSHEET.md b/docs/CHEATSHEET.md index f74dd73..3e556c7 100644 --- a/docs/CHEATSHEET.md +++ b/docs/CHEATSHEET.md @@ -229,6 +229,8 @@ Flags: `--sandbox`, `--sandbox-image`, `--sandbox-network`, `--sandbox-readonly` Env vars: `ODEK_SANDBOX=true`, `ODEK_SANDBOX_IMAGE`, `ODEK_SANDBOX_NETWORK`, etc. +> **Project config approval:** sandbox knobs set in `./odek.json` (`sandbox_env`, `sandbox_image`, `sandbox_network`, `sandbox_volumes`) require an interactive approval prompt. Use `ODEK_APPROVE_PROJECT_SANDBOX=1` in CI/scripts, or set sandbox config via `~/.odek/config.json` / env vars / CLI flags instead. + Default network: `bridge` (internet access). Set `none` for air-gapped execution. ## Telegram Bot @@ -289,6 +291,7 @@ odek mcp # stdio transport | `ODEK_SANDBOX_MEMORY` | sandbox_memory | | `ODEK_SANDBOX_CPUS` | sandbox_cpus | | `ODEK_SANDBOX_USER` | sandbox_user | +| `ODEK_APPROVE_PROJECT_SANDBOX` | auto-approve project-level sandbox config (CI) | | `ODEK_SYSTEM` | system | | `ODEK_NO_COLOR` | no_color | | `ODEK_NO_AGENTS` | no_agents | diff --git a/docs/CLI.md b/docs/CLI.md index 266004b..9aa73a1 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -296,6 +296,8 @@ Use `odek skill reset-skips` to clear the skip list and re-enable suppressed sug `odek serve` enables `--sandbox` by default. `odek run` and `odek repl` keep sandbox opt-in but print a startup warning when running unsandboxed. Set `ODEK_SUPPRESS_SANDBOX_WARNING=1` to silence the warning if you've made an informed decision. +**Project-level sandbox approval:** if `./odek.json` sets `sandbox_env`, `sandbox_image`, `sandbox_network`, or `sandbox_volumes`, odek prompts for approval before applying them. In CI or scripted invocations, set `ODEK_APPROVE_PROJECT_SANDBOX=1` to auto-approve, or place sandbox config in `~/.odek/config.json` / `ODEK_*` env vars / CLI flags instead, which do not require approval. + ## Audit log `odek audit` reads the per-session prompt-injection audit log written under `/audit/.json`. Every time the agent ingests externally-sourced content (browser fetch, file read, MCP tool response, audio transcript) the log records: diff --git a/docs/CONFIG.md b/docs/CONFIG.md index e7f5565..9ad5278 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -122,6 +122,7 @@ 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_MAX_TOOL_PARALLEL` | `max_tool_parallel` | int | | `ODEK_MEMORY_EXTENDED_ENABLED` | `--memory-extended-enabled` | bool | | `ODEK_MEMORY_EXTENDED_MAX_SIZE_MB` | `--memory-extended-max-size-mb` | int | @@ -828,6 +829,11 @@ odek run "list files" echo '{"max_iterations": 30}' > ./odek.json odek run "quick status" +# Project-level sandbox knobs require explicit approval (or the CI bypass) +# because they can read host env vars and pick arbitrary images/networks. +echo '{"sandbox": true, "sandbox_env": {"X": "${HOME}"}}' > ./odek.json +ODEK_APPROVE_PROJECT_SANDBOX=1 odek run "run untrusted script" + # 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 fef1595..244512c 100644 --- a/docs/SANDBOXING.md +++ b/docs/SANDBOXING.md @@ -68,6 +68,46 @@ All sandbox settings are available in `~/.odek/config.json`, `./odek.json`, `ODE > or symlinks are rejected. Relative paths are resolved relative to the working > directory and must stay inside it. +## Project-level sandbox approval + +`./odek.json` can be shipped by any repository the agent runs in. Because +project-level sandbox knobs (`sandbox_env`, `sandbox_image`, `sandbox_network`, +`sandbox_volumes`) can exfiltrate host secrets via `${VAR}` interpolation, pull +an attacker-controlled image, or widen network access, odek requires explicit +operator approval before applying them. + +When a project requests any sandbox override, odek prints a warning and prompts: + +```text +WARNING: project config (./odek.json) requests sandbox overrides: + image: alpine:latest + network: bridge + env: X + ⚠️ sandbox_env values contain ${...} interpolation against host environment variables + +Allowing this means code in the sandbox can read workspace files and, +depending on network mode, contact external hosts. + +Approve? [y = once / t = trust this project / N] +``` + +Approval options: + +- `y` / `yes` — approve once for this run. +- `t` / `trust` — persist approval for this project/sandbox fingerprint in + `~/.odek/project_sandbox_approvals.json`. +- `N` / empty — abort. + +For non-interactive use (CI, cron, scheduled tasks), set: + +```bash +export ODEK_APPROVE_PROJECT_SANDBOX=1 +``` + +Operator-controlled sources (`~/.odek/config.json`, `ODEK_*` env vars, and CLI +flags) do not require approval — only values coming from the project-level +`./odek.json` are gated. + ### Env var examples ```bash diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e5bcbe8..467b41a 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -351,6 +351,17 @@ exceed the cap are rejected before they are written. These fields can only be set from operator-controlled sources: `~/.odek/config.json` (and `ODEK_TELEGRAM_*` env vars for `telegram`, `ODEK_GUARD_*` env vars for `guard`). +### 18a. Project-level sandbox config approval + +`./odek.json` can also set sandbox knobs (`sandbox_env`, `sandbox_image`, `sandbox_network`, `sandbox_volumes`). Rather than silently rejecting them, odek gates them behind explicit operator approval, mirroring the MCP-server approval flow: + +- Interactive TTY prompt (`y` = once, `t` = trust this project, `N` = deny). +- Persistent per-project approvals stored in `~/.odek/project_sandbox_approvals.json`. +- `ODEK_APPROVE_PROJECT_SANDBOX=1` bypass for CI/non-interactive use. +- Non-TTY runs without the bypass fail closed. + +This prevents a malicious repo from exfiltrating host secrets via `${VAR}` interpolation in `sandbox_env`, pulling an attacker-controlled image, or widening the container's network access without the operator's consent. + ### 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/config/loader.go b/internal/config/loader.go index a3b86d7..6412afb 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -315,6 +315,23 @@ type FileConfig struct { ToolProgressCleanup *bool `json:"tool_progress_cleanup,omitempty"` } +// ProjectSandboxOverride records which sandbox knobs were supplied by the +// project-level ./odek.json config. These require explicit operator approval +// before they are applied, because a malicious repo could otherwise +// exfiltrate host secrets (via ${VAR} interpolation in sandbox_env), pull an +// attacker-controlled image, or widen the container's network access. +type ProjectSandboxOverride struct { + HasEnv bool + EnvKeys []string + EnvHasInterpolation bool + HasImage bool + Image string + HasNetwork bool + Network string + HasVolumes bool + Volumes []string +} + // ResolvedConfig is the fully merged result. Every field has a concrete // value — callers can read directly without checking for "not set". type ResolvedConfig struct { @@ -401,6 +418,12 @@ type ResolvedConfig struct { // otherwise execute arbitrary code via the mcp_servers section. ProjectMCPServerNames []string + // ProjectSandboxOverride records sandbox knobs supplied by the project-level + // ./odek.json config. These require explicit operator approval before they + // are applied, because a malicious repo could otherwise exfiltrate host + // secrets or pull an attacker-controlled sandbox image. + ProjectSandboxOverride ProjectSandboxOverride + // MaxConcurrency limits how many sub-agent tasks run in parallel. // Config: max_concurrency, ODEK_MAX_CONCURRENCY. // Default: 3. @@ -867,6 +890,34 @@ func LoadConfig(cli CLIFlags) ResolvedConfig { project.SandboxReadonly = nil } + // Capture which sandbox knobs the project requested, before the overlay + // hides them behind CLI/env values. This drives the approval gate in cmd/odek. + var projectSandboxOverride ProjectSandboxOverride + if len(project.SandboxEnv) > 0 { + projectSandboxOverride.HasEnv = true + projectSandboxOverride.EnvKeys = make([]string, 0, len(project.SandboxEnv)) + for k, v := range project.SandboxEnv { + projectSandboxOverride.EnvKeys = append(projectSandboxOverride.EnvKeys, k) + if strings.Contains(v, "${") { + projectSandboxOverride.EnvHasInterpolation = true + } + } + sort.Strings(projectSandboxOverride.EnvKeys) + } + if project.SandboxImage != "" { + projectSandboxOverride.HasImage = true + projectSandboxOverride.Image = project.SandboxImage + } + if project.SandboxNetwork != "" { + projectSandboxOverride.HasNetwork = true + projectSandboxOverride.Network = project.SandboxNetwork + } + if len(project.SandboxVolumes) > 0 { + projectSandboxOverride.HasVolumes = true + projectSandboxOverride.Volumes = append([]string(nil), project.SandboxVolumes...) + sort.Strings(projectSandboxOverride.Volumes) + } + // Start with global, overlay project cfg := overlayFile(FileConfig{}, global) cfg = overlayFile(cfg, project) @@ -1380,9 +1431,10 @@ func LoadConfig(cli CLIFlags) ResolvedConfig { Memory: resolveMemory(cfg.Memory), Guard: resolveGuard(cfg.Guard), Embedding: cfg.Embedding, - MCPServers: cfg.MCPServers, - ProjectMCPServerNames: projectMCPNames, - Telegram: resolveTelegram(cfg.Telegram), + MCPServers: cfg.MCPServers, + ProjectMCPServerNames: projectMCPNames, + ProjectSandboxOverride: projectSandboxOverride, + Telegram: resolveTelegram(cfg.Telegram), Transcription: resolveTranscription(cfg.Transcription), Vision: resolveVision(cfg.Vision), WebSearch: resolveWebSearch(cfg.WebSearch), diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 06ff533..0c68511 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -438,6 +438,82 @@ func TestLoadConfig_ProjectCanEnableSandbox(t *testing.T) { } } +func TestLoadConfig_ProjectSandboxOverride(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Chdir(dir) + + if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{ + "sandbox": true, + "sandbox_image": "alpine:latest", + "sandbox_network": "bridge", + "sandbox_env": {"X": "${HOME}", "Y": "literal"}, + "sandbox_volumes": ["/tmp:/host-tmp"] + }`), 0644); err != nil { + t.Fatal(err) + } + + cfg := LoadConfig(CLIFlags{}) + if !cfg.ProjectSandboxOverride.HasImage { + t.Error("ProjectSandboxOverride.HasImage = false, want true") + } + if cfg.ProjectSandboxOverride.Image != "alpine:latest" { + t.Errorf("ProjectSandboxOverride.Image = %q, want alpine:latest", cfg.ProjectSandboxOverride.Image) + } + if !cfg.ProjectSandboxOverride.HasNetwork { + t.Error("ProjectSandboxOverride.HasNetwork = false, want true") + } + if cfg.ProjectSandboxOverride.Network != "bridge" { + t.Errorf("ProjectSandboxOverride.Network = %q, want bridge", cfg.ProjectSandboxOverride.Network) + } + if !cfg.ProjectSandboxOverride.HasEnv { + t.Error("ProjectSandboxOverride.HasEnv = false, want true") + } + wantKeys := []string{"X", "Y"} + if !sliceEqual(cfg.ProjectSandboxOverride.EnvKeys, wantKeys) { + t.Errorf("ProjectSandboxOverride.EnvKeys = %v, want %v", cfg.ProjectSandboxOverride.EnvKeys, wantKeys) + } + if !cfg.ProjectSandboxOverride.EnvHasInterpolation { + t.Error("ProjectSandboxOverride.EnvHasInterpolation = false, want true") + } + if !cfg.ProjectSandboxOverride.HasVolumes { + t.Error("ProjectSandboxOverride.HasVolumes = false, want true") + } + if len(cfg.ProjectSandboxOverride.Volumes) != 1 || cfg.ProjectSandboxOverride.Volumes[0] != "/tmp:/host-tmp" { + t.Errorf("ProjectSandboxOverride.Volumes = %v, want [/tmp:/host-tmp]", cfg.ProjectSandboxOverride.Volumes) + } +} + +func TestLoadConfig_NoProjectSandboxOverride(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + t.Chdir(dir) + + if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{ + "model": "project-model" + }`), 0644); err != nil { + t.Fatal(err) + } + + cfg := LoadConfig(CLIFlags{}) + if cfg.ProjectSandboxOverride.HasEnv || cfg.ProjectSandboxOverride.HasImage || + cfg.ProjectSandboxOverride.HasNetwork || cfg.ProjectSandboxOverride.HasVolumes { + t.Errorf("ProjectSandboxOverride = %+v, want empty", cfg.ProjectSandboxOverride) + } +} + +func sliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + func TestLoadConfig_ProjectDangerousIgnored(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir)