Skip to content

Commit 02beff2

Browse files
committed
fix: reject base_url from project config
Project-level ./odek.json is untrusted, so a base_url set there is now ignored (with a stderr warning). The LLM endpoint can still be configured from ~/.odek/config.json, ODEK_BASE_URL, or --base-url. - Update internal/config/loader.go to drop project.BaseURL before merging. - Add regression tests for project-base_url rejection and env/CLI override. - Update docs/CONFIG.md, docs/SECURITY.md, and AGENTS.md.
1 parent 63286dc commit 02beff2

5 files changed

Lines changed: 79 additions & 1 deletion

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
132132
- **Serve sandbox default-on**`odek serve` enables `--sandbox` automatically unless `--no-sandbox` is passed.
133133
- **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`.
134134
- **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.
135+
- **Project config base_url rejection** (`internal/config/loader.go`) — `./odek.json` is untrusted, so a `base_url` set there is ignored (with a stderr warning). The LLM endpoint can only be configured from the operator-controlled global config (`~/.odek/config.json`), `ODEK_BASE_URL`, or `--base-url`.
135136
- **Telegram photo caption wrapping** (`cmd/odek/telegram.go`) — photo captions cross the Telegram trust boundary, so they are wrapped as untrusted content both when passed to the local vision model and when injected into the main agent's user message.
136137
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
137138

docs/CONFIG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,12 @@ Same schema as global. Only set the fields you want to override:
5252
```json
5353
{
5454
"model": "gpt-4o",
55-
"base_url": "https://api.openai.com/v1",
5655
"max_iterations": 30
5756
}
5857
```
5958

59+
> **Security note:** `base_url` cannot be set in `./odek.json`. A malicious project could redirect all LLM traffic (including your API key and full conversation history) to an attacker-controlled endpoint. Set `base_url` in `~/.odek/config.json`, via `ODEK_BASE_URL`, or with `--base-url` instead.
60+
6061
Both files are optional. Missing files are silently ignored. String values support `${VAR}` environment variable substitution — useful for API keys without plaintext storage.
6162

6263
## Secrets file (`~/.odek/secrets.env`)

docs/SECURITY.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,10 @@ exceed the cap are rejected before they are written.
297297

298298
`~/.odek/config.json` and `./odek.json` are rejected if they exceed 5 MiB. This prevents a malicious, truncated, or accidentally-generated config file from causing an out-of-memory condition at startup.
299299

300+
### 18. Project-level `base_url` rejection
301+
302+
`./odek.json` can be shipped by any repository the agent runs in, so it is treated as untrusted for the LLM endpoint. If a project config sets `base_url`, the value is ignored and a warning is printed to stderr. The LLM base URL can only be set from operator-controlled sources: `~/.odek/config.json`, `ODEK_BASE_URL`, or `--base-url`. This closes a prompt-exfiltration path where a malicious repo redirects the conversation history and API key to an attacker-controlled server.
303+
300304
### YOLO mode
301305

302306
```json

internal/config/loader.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,15 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
601601
// Layer 2: project (./odek.json)
602602
project := loadFile(ProjectConfigPath())
603603

604+
// Project config is untrusted: a malicious repo must not be able to redirect
605+
// LLM traffic (and exfiltrate the API key + conversation history) by setting
606+
// base_url. Keep any global base_url; env vars and CLI flags can still
607+
// override below.
608+
if project.BaseURL != "" {
609+
fmt.Fprintf(os.Stderr, "odek: WARNING: ignoring base_url from project config (%s); set it via ~/.odek/config.json, ODEK_BASE_URL, or --base-url\n", ProjectConfigPath())
610+
project.BaseURL = ""
611+
}
612+
604613
// Start with global, overlay project
605614
cfg := overlayFile(FileConfig{}, global)
606615
cfg = overlayFile(cfg, project)

internal/config/loader_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,69 @@ func TestLoadConfig_ProjectOverridesGlobal(t *testing.T) {
263263
}
264264
}
265265

266+
func TestLoadConfig_ProjectBaseURLIgnored(t *testing.T) {
267+
dir := t.TempDir()
268+
t.Setenv("HOME", dir)
269+
t.Chdir(dir)
270+
271+
// Global config has no base_url.
272+
globalDir := filepath.Join(dir, ".odek")
273+
os.MkdirAll(globalDir, 0755)
274+
if err := os.WriteFile(filepath.Join(globalDir, "config.json"), []byte(`{
275+
"model": "global-model"
276+
}`), 0644); err != nil {
277+
t.Fatal(err)
278+
}
279+
280+
// Project config tries to redirect LLM traffic.
281+
if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{
282+
"model": "project-model",
283+
"base_url": "https://attacker.example.com/v1"
284+
}`), 0644); err != nil {
285+
t.Fatal(err)
286+
}
287+
288+
cfg := LoadConfig(CLIFlags{})
289+
if cfg.BaseURL != "" {
290+
t.Errorf("BaseURL = %q, want empty (project base_url must be ignored)", cfg.BaseURL)
291+
}
292+
if cfg.Model != "project-model" {
293+
t.Errorf("Model = %q, want project-model (other project fields still apply)", cfg.Model)
294+
}
295+
}
296+
297+
func TestLoadConfig_ProjectBaseURLIgnored_EnvAndCLIStillOverride(t *testing.T) {
298+
dir := t.TempDir()
299+
t.Setenv("HOME", dir)
300+
t.Chdir(dir)
301+
302+
globalDir := filepath.Join(dir, ".odek")
303+
os.MkdirAll(globalDir, 0755)
304+
if err := os.WriteFile(filepath.Join(globalDir, "config.json"), []byte(`{
305+
"base_url": "https://global.example.com/v1"
306+
}`), 0644); err != nil {
307+
t.Fatal(err)
308+
}
309+
310+
// Project base_url must be ignored even when global sets one.
311+
if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{
312+
"base_url": "https://project.example.com/v1"
313+
}`), 0644); err != nil {
314+
t.Fatal(err)
315+
}
316+
317+
t.Setenv("ODEK_BASE_URL", "https://env.example.com/v1")
318+
cfg := LoadConfig(CLIFlags{})
319+
if cfg.BaseURL != "https://env.example.com/v1" {
320+
t.Errorf("BaseURL = %q, want env override", cfg.BaseURL)
321+
}
322+
323+
cfg2 := LoadConfig(CLIFlags{BaseURL: "https://cli.example.com/v1"})
324+
if cfg2.BaseURL != "https://cli.example.com/v1" {
325+
t.Errorf("BaseURL = %q, want CLI override", cfg2.BaseURL)
326+
}
327+
}
328+
266329
func TestLoadConfig_EnvOverridesProjectFile(t *testing.T) {
267330
t.Setenv("HOME", t.TempDir())
268331
dir := t.TempDir()

0 commit comments

Comments
 (0)