Skip to content

Commit 06842e1

Browse files
committed
fix: disallow api_key, system, dangerous from project config
Project-level ./odek.json is untrusted, so it can no longer override the API key, system prompt, or dangerous-policy. Global config, ODEK_* env vars, and CLI flags remain valid sources. - Ignore project BaseURL, APIKey, System, and Dangerous in LoadConfig. - Print stderr warnings for each ignored project-level sensitive field. - Update TestRun_WithProjectConfig to use env API key + project model. - Add regression tests for api_key/system/dangerous rejection. - Update docs/CONFIG.md, docs/SECURITY.md, and AGENTS.md.
1 parent 02beff2 commit 06842e1

6 files changed

Lines changed: 125 additions & 9 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +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`.
135+
- **Project config sensitive-field rejection** (`internal/config/loader.go`) — `./odek.json` is untrusted, so `base_url`, `api_key`, `system`, and the `dangerous` section set there are ignored (with stderr warnings). These can only be configured from operator-controlled sources: `~/.odek/config.json`, `ODEK_*` env vars, or CLI flags.
136136
- **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.
137137
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
138138

cmd/odek/main_test.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,7 @@ func TestRun_WithProjectConfig(t *testing.T) {
724724

725725
origDS := os.Getenv("DEEPSEEK_API_KEY")
726726
origOAI := os.Getenv("OPENAI_API_KEY")
727+
origOdekAPI := os.Getenv("ODEK_API_KEY")
727728
origHome := os.Getenv("HOME")
728729
origCwd, _ := os.Getwd()
729730
os.Unsetenv("DEEPSEEK_API_KEY")
@@ -732,18 +733,22 @@ func TestRun_WithProjectConfig(t *testing.T) {
732733
defer func() {
733734
os.Setenv("DEEPSEEK_API_KEY", origDS)
734735
os.Setenv("OPENAI_API_KEY", origOAI)
736+
os.Setenv("ODEK_API_KEY", origOdekAPI)
735737
os.Setenv("HOME", origHome)
736738
os.Chdir(origCwd)
737739
}()
738740

739741
// Isolate from any global config
740742
os.Setenv("HOME", t.TempDir())
741743

744+
// API keys may not come from the untrusted project config; set one via env.
745+
os.Setenv("ODEK_API_KEY", "sk-project-test-key")
746+
742747
// Create project-level config in a temp directory
743748
projectDir := t.TempDir()
744749
os.Chdir(projectDir)
745750
if err := os.WriteFile(projectDir+"/odek.json", []byte(`{
746-
"api_key": "sk-project-config"
751+
"model": "project-model"
747752
}`), 0644); err != nil {
748753
t.Fatal(err)
749754
}

docs/CONFIG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,14 @@ Same schema as global. Only set the fields you want to override:
5656
}
5757
```
5858

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.
59+
> **Security note:** The following fields cannot be set in `./odek.json` because a malicious repository could use them to steal secrets, poison the system prompt, or disable safety policy:
60+
>
61+
> - `base_url` — use `~/.odek/config.json`, `ODEK_BASE_URL`, or `--base-url`
62+
> - `api_key` — use `~/.odek/config.json`, `ODEK_API_KEY`, or `~/.odek/secrets.env`
63+
> - `system` — use `~/.odek/config.json`, `ODEK_SYSTEM`, or `--system`
64+
> - `dangerous` — use `~/.odek/config.json`
65+
>
66+
> If any of these appear in `./odek.json`, odek ignores them and prints a warning.
6067
6168
Both files are optional. Missing files are silently ignored. String values support `${VAR}` environment variable substitution — useful for API keys without plaintext storage.
6269

docs/SECURITY.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,16 @@ 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
300+
### 18. Project-level sensitive config rejection
301301

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.
302+
`./odek.json` can be shipped by any repository the agent runs in, so it is treated as untrusted for sensitive fields. If a project config sets any of the following, the value is ignored and a warning is printed to stderr:
303+
304+
- `base_url` — can redirect the conversation history and API key to an attacker-controlled server.
305+
- `api_key` — can exfiltrate prompts by billing runs to an attacker-owned key.
306+
- `system` — can poison the system prompt with hidden instructions.
307+
- `dangerous` — can disable the approval gate (`{"action": "allow"}`) and enable destructive auto-execution.
308+
309+
These fields can only be set from operator-controlled sources: `~/.odek/config.json`, `ODEK_*` environment variables, or CLI flags.
303310

304311
### YOLO mode
305312

internal/config/loader.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -601,14 +601,26 @@ 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.
604+
// Project config is untrusted: a malicious repo must not be able to steal
605+
// the API key, poison the system prompt, or disable safety policy.
606+
// Keep global values for these sensitive fields; env vars and CLI flags can
607+
// still override below.
608608
if project.BaseURL != "" {
609609
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())
610610
project.BaseURL = ""
611611
}
612+
if project.APIKey != "" {
613+
fmt.Fprintf(os.Stderr, "odek: WARNING: ignoring api_key from project config (%s); set it via ~/.odek/config.json, ODEK_API_KEY, or ~/.odek/secrets.env\n", ProjectConfigPath())
614+
project.APIKey = ""
615+
}
616+
if project.System != "" {
617+
fmt.Fprintf(os.Stderr, "odek: WARNING: ignoring system from project config (%s); set it via ~/.odek/config.json, ODEK_SYSTEM, or --system\n", ProjectConfigPath())
618+
project.System = ""
619+
}
620+
if project.Dangerous != nil {
621+
fmt.Fprintf(os.Stderr, "odek: WARNING: ignoring dangerous section from project config (%s); set it via ~/.odek/config.json\n", ProjectConfigPath())
622+
project.Dangerous = nil
623+
}
612624

613625
// Start with global, overlay project
614626
cfg := overlayFile(FileConfig{}, global)

internal/config/loader_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,91 @@ func TestLoadConfig_ProjectBaseURLIgnored_EnvAndCLIStillOverride(t *testing.T) {
326326
}
327327
}
328328

329+
func TestLoadConfig_ProjectAPIKeyIgnored(t *testing.T) {
330+
dir := t.TempDir()
331+
t.Setenv("HOME", dir)
332+
t.Chdir(dir)
333+
334+
globalDir := filepath.Join(dir, ".odek")
335+
os.MkdirAll(globalDir, 0755)
336+
if err := os.WriteFile(filepath.Join(globalDir, "config.json"), []byte(`{
337+
"api_key": "global-key"
338+
}`), 0644); err != nil {
339+
t.Fatal(err)
340+
}
341+
342+
if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{
343+
"api_key": "project-key"
344+
}`), 0644); err != nil {
345+
t.Fatal(err)
346+
}
347+
348+
cfg := LoadConfig(CLIFlags{})
349+
if cfg.APIKey != "global-key" {
350+
t.Errorf("APIKey = %q, want global-key (project api_key must be ignored)", cfg.APIKey)
351+
}
352+
}
353+
354+
func TestLoadConfig_ProjectSystemIgnored(t *testing.T) {
355+
dir := t.TempDir()
356+
t.Setenv("HOME", dir)
357+
t.Chdir(dir)
358+
359+
globalDir := filepath.Join(dir, ".odek")
360+
os.MkdirAll(globalDir, 0755)
361+
if err := os.WriteFile(filepath.Join(globalDir, "config.json"), []byte(`{
362+
"system": "global-system"
363+
}`), 0644); err != nil {
364+
t.Fatal(err)
365+
}
366+
367+
if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{
368+
"system": "project-system"
369+
}`), 0644); err != nil {
370+
t.Fatal(err)
371+
}
372+
373+
cfg := LoadConfig(CLIFlags{})
374+
if cfg.System != "global-system" {
375+
t.Errorf("System = %q, want global-system (project system must be ignored)", cfg.System)
376+
}
377+
378+
t.Setenv("ODEK_SYSTEM", "env-system")
379+
cfg2 := LoadConfig(CLIFlags{})
380+
if cfg2.System != "env-system" {
381+
t.Errorf("System = %q, want env-system (env still overrides)", cfg2.System)
382+
}
383+
}
384+
385+
func TestLoadConfig_ProjectDangerousIgnored(t *testing.T) {
386+
dir := t.TempDir()
387+
t.Setenv("HOME", dir)
388+
t.Chdir(dir)
389+
390+
globalDir := filepath.Join(dir, ".odek")
391+
os.MkdirAll(globalDir, 0755)
392+
if err := os.WriteFile(filepath.Join(globalDir, "config.json"), []byte(`{
393+
"dangerous": {"action": "deny"}
394+
}`), 0644); err != nil {
395+
t.Fatal(err)
396+
}
397+
398+
if err := os.WriteFile(filepath.Join(dir, "odek.json"), []byte(`{
399+
"dangerous": {"action": "allow"}
400+
}`), 0644); err != nil {
401+
t.Fatal(err)
402+
}
403+
404+
cfg := LoadConfig(CLIFlags{})
405+
if cfg.Dangerous.DefaultAction == nil || *cfg.Dangerous.DefaultAction != "deny" {
406+
action := "<nil>"
407+
if cfg.Dangerous.DefaultAction != nil {
408+
action = *cfg.Dangerous.DefaultAction
409+
}
410+
t.Errorf("Dangerous.DefaultAction = %s, want deny (project dangerous must be ignored)", action)
411+
}
412+
}
413+
329414
func TestLoadConfig_EnvOverridesProjectFile(t *testing.T) {
330415
t.Setenv("HOME", t.TempDir())
331416
dir := t.TempDir()

0 commit comments

Comments
 (0)