Skip to content

Commit 23e9ed8

Browse files
committed
security: fix high-severity findings #50, #52, #53, #63, #64
- Default non-interactive policy is now deny (was allow). - Strip project-level backend redirects: embedding, memory, sessions, skills.dirs/skills.embedding, telegram, web_search. - Protect all ~/.odek trust anchors from generic file-tool writes: sessions/, audit/, plans/, schedules.json, schedule-state.json, mcp_approvals.json, mcp_tool_approvals.json, restart.json, telegram.lock, telegram.pid, schedule.pid, schedule.log. - Add field-by-field skills overlay so project skills settings do not clobber global dirs/embedding. - Update docs (SECURITY.md, CONFIG.md, CLI.md, AGENTS.md) and add regression tests.
1 parent d9906d9 commit 23e9ed8

18 files changed

Lines changed: 483 additions & 72 deletions

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,16 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
133133
- **Serve sandbox default-on**`odek serve` enables `--sandbox` automatically unless `--no-sandbox` is passed.
134134
- **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`.
135135
- **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.
136-
- **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.
136+
- **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). These can only be configured from operator-controlled sources: `~/.odek/config.json`, `ODEK_*` env vars, or CLI flags.
137137
- **MCP subprocess environment sanitisation** (`internal/mcpclient/client.go`) — MCP server children receive only a minimal allowlist of safe environment variables plus explicit `env` overrides. Keys matching secret patterns (`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, etc.) are stripped, preventing a compromised or malicious MCP server from reading parent secrets.
138138
- **MCP `tools/list` metadata hardening** (`internal/mcpclient/client.go`, `cmd/odek/mcp_approval.go`, `cmd/odek/main.go`) — tool names from MCP servers are validated (ASCII letters/digits/`-`/`_`, ≤ 64 chars) and descriptions are scanned for injection patterns. Tools whose raw names collide with odek built-ins are rejected. Each tool from a project-level server requires per-tool approval (interactive, `ODEK_APPROVE_MCP=1`, or persisted in `~/.odek/mcp_tool_approvals.json`); global servers from `~/.odek/config.json` are operator-trusted.
139139
- **Read-only perf-tool file-size cap** (`cmd/odek/perf_tools.go`) — `count_lines`, `checksum`, `head_tail`, and `word_count` reject files larger than 10 MiB before scanning/hashing, consistent with other perf tools.
140140
- **Inline content size cap** (`cmd/odek/perf_tools.go`) — `base64` and `tr` reject inline `string`/`content` arguments larger than 10 MiB, preventing prompt-injected multi-hundred-megabyte payloads from OOMing the process.
141141
- **Schedule atomic-write hardening** (`internal/schedule/store.go` + `internal/fsatomic`) — schedule file writes now use `fsatomic.WriteFile`, which creates a random temp file with `O_EXCL`, fsyncs data and directory, and renames over the target. A swapped-in symlink is replaced rather than followed, closing the symlink-override attack on `schedules.json` / `schedule-state.json`.
142142
- **Schedule cross-process lock hard error** (`internal/schedule/store.go`) — `fileLock` now returns an error instead of silently falling back to a no-op releaser when `~/.odek/schedules.lock` cannot be opened or locked. Mutating schedule operations abort rather than risk two concurrent processes loading the same baseline and overwriting each other.
143143
- **Schedule JSON file-size cap** (`internal/schedule/store.go`) — `schedules.json` and `schedule-state.json` are rejected if larger than 10 MiB before being read into memory, preventing a tampered multi-gigabyte file from OOMing the scheduler.
144+
- **Default non-interactive policy is deny** (`internal/danger/classifier.go`, `cmd/odek/main.go`) — headless/CI/piped runs no longer auto-approve dangerous operations. Operators must explicitly set `non_interactive: "allow"` for unattended execution.
145+
- **`~/.odek` trust-anchor protection** (`internal/danger/classifier.go`, `cmd/odek/file_tool.go`) — generic file tools reject writes to `~/.odek/config.json`, `secrets.env`, `IDENTITY.md`, `skills/`, `sessions/`, `audit/`, `plans/`, `schedules.json`, `schedule-state.json`, `mcp_approvals.json`, `mcp_tool_approvals.json`, `restart.json`, `telegram.lock`, and related state files. These paths classify as `system_write` and must be modified through their dedicated subsystems.
144146
- **Nonce'd tool-result delimiter** (`internal/loop/loop.go`) — the static `┌── TOOL RESULT: ... └── END TOOL RESULT: ...` delimiter is now unique per tool call via a random hex nonce embedded in both the opening and closing lines. A tool or MCP server can no longer forge the closing delimiter to break out of the data framing and inject instructions.
145147
- **`parallel_shell` context + process-group kill** (`cmd/odek/perf_tools.go`) — commands now run via `exec.CommandContext` bound to the agent context, in their own process group. Cancellation or timeout kills the whole group (negative PID), so `sh -c 'sleep 3600 &'` cannot leave orphaned children. Per-command timeouts are also capped at 30 minutes.
146148
- **`batch_patch` trusted-class propagation** (`cmd/odek/perf_tools.go`) — `batch_patch` now passes its cached `trustedClasses` to `CheckOperation`, matching `write_file` and `patch`. A trusted `local_write` class is honored across all patches in the batch instead of re-prompting per patch.

cmd/odek/browser_tool_test.go

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,18 @@ import (
66
"net/http/httptest"
77
"strings"
88
"testing"
9+
10+
"github.com/BackendStack21/odek/internal/danger"
911
)
1012

13+
// newTestBrowserTool returns a browserTool configured with non_interactive=allow
14+
// so unit tests that hit local httptest servers are not blocked by the default
15+
// deny policy.
16+
func newTestBrowserTool() *browserTool {
17+
allow := "allow"
18+
return newBrowserTool(danger.DangerousConfig{NonInteractive: &allow})
19+
}
20+
1121
// ── Browser Navigate ──────────────────────────────────────────────────
1222

1323
func TestBrowser_Navigate(t *testing.T) {
@@ -21,7 +31,7 @@ func TestBrowser_Navigate(t *testing.T) {
2131
}))
2232
defer ts.Close()
2333

24-
b := &browserTool{}
34+
b := newTestBrowserTool()
2535
result := callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
2636
var r struct {
2737
Title string `json:"title"`
@@ -49,7 +59,7 @@ func TestBrowser_Navigate(t *testing.T) {
4959
}
5060

5161
func TestBrowser_Navigate_InvalidURL(t *testing.T) {
52-
b := &browserTool{}
62+
b := newTestBrowserTool()
5363
result := callJSON(t, b, `{"action":"navigate","url":"not-a-valid-url"}`)
5464
var r struct {
5565
Error string `json:"error"`
@@ -61,7 +71,7 @@ func TestBrowser_Navigate_InvalidURL(t *testing.T) {
6171
}
6272

6373
func TestBrowser_Navigate_MissingURL(t *testing.T) {
64-
b := &browserTool{}
74+
b := newTestBrowserTool()
6575
result := callJSON(t, b, `{"action":"navigate"}`)
6676
var r struct {
6777
Error string `json:"error"`
@@ -79,7 +89,7 @@ func TestBrowser_Navigate_HTTPError(t *testing.T) {
7989
}))
8090
defer ts.Close()
8191

82-
b := &browserTool{}
92+
b := newTestBrowserTool()
8393
result := callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
8494
var r struct {
8595
Content string `json:"content"`
@@ -105,7 +115,7 @@ func TestBrowser_Snapshot(t *testing.T) {
105115
}))
106116
defer ts.Close()
107117

108-
b := &browserTool{}
118+
b := newTestBrowserTool()
109119
callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
110120

111121
result := callJSON(t, b, `{"action":"snapshot"}`)
@@ -127,7 +137,7 @@ func TestBrowser_Snapshot(t *testing.T) {
127137
}
128138

129139
func TestBrowser_Snapshot_NoPage(t *testing.T) {
130-
b := &browserTool{}
140+
b := newTestBrowserTool()
131141
result := callJSON(t, b, `{"action":"snapshot"}`)
132142
var r struct {
133143
Error string `json:"error"`
@@ -156,7 +166,7 @@ func TestBrowser_Click(t *testing.T) {
156166
"/page2": `<html><body><h1>Page 2 Content</h1></body></html>`,
157167
}
158168

159-
b := &browserTool{}
169+
b := newTestBrowserTool()
160170
callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
161171

162172
// Click on the link
@@ -188,7 +198,7 @@ func TestBrowser_Click_InvalidRef(t *testing.T) {
188198
}))
189199
defer ts.Close()
190200

191-
b := &browserTool{}
201+
b := newTestBrowserTool()
192202
callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
193203

194204
result := callJSON(t, b, `{"action":"click","ref":"nonexistent"}`)
@@ -202,7 +212,7 @@ func TestBrowser_Click_InvalidRef(t *testing.T) {
202212
}
203213

204214
func TestBrowser_Click_MissingRef(t *testing.T) {
205-
b := &browserTool{}
215+
b := newTestBrowserTool()
206216
result := callJSON(t, b, `{"action":"click"}`)
207217
var r struct {
208218
Error string `json:"error"`
@@ -219,7 +229,7 @@ func TestBrowser_Click_ButtonAcknowledges(t *testing.T) {
219229
}))
220230
defer ts.Close()
221231

222-
b := &browserTool{}
232+
b := newTestBrowserTool()
223233
callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
224234

225235
result := callJSON(t, b, `{"action":"click","ref":"e1"}`)
@@ -242,7 +252,7 @@ func TestBrowser_Click_InputSubmitAcknowledges(t *testing.T) {
242252
}))
243253
defer ts.Close()
244254

245-
b := &browserTool{}
255+
b := newTestBrowserTool()
246256
callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
247257

248258
result := callJSON(t, b, `{"action":"click","ref":"e1"}`)
@@ -275,7 +285,7 @@ func TestBrowser_Back(t *testing.T) {
275285
"/page2": `<html><body><h1>Page 2</h1></body></html>`,
276286
}
277287

278-
b := &browserTool{}
288+
b := newTestBrowserTool()
279289
callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
280290
callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`/page2"}`)
281291

@@ -306,7 +316,7 @@ func TestBrowser_Back_NoHistory(t *testing.T) {
306316
}))
307317
defer ts.Close()
308318

309-
b := &browserTool{}
319+
b := newTestBrowserTool()
310320
callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
311321

312322
result := callJSON(t, b, `{"action":"back"}`)
@@ -322,7 +332,7 @@ func TestBrowser_Back_NoHistory(t *testing.T) {
322332
// ── Browser Unknown Action ────────────────────────────────────────────
323333

324334
func TestBrowser_UnknownAction(t *testing.T) {
325-
b := &browserTool{}
335+
b := newTestBrowserTool()
326336
result := callJSON(t, b, `{"action":"fly"}`)
327337
var r struct {
328338
Error string `json:"error"`
@@ -336,7 +346,7 @@ func TestBrowser_UnknownAction(t *testing.T) {
336346
// ── Browser Schema ───────────────────────────────────────────────────
337347

338348
func TestBrowser_Schema(t *testing.T) {
339-
b := &browserTool{}
349+
b := newTestBrowserTool()
340350
schema := b.Schema().(map[string]any)
341351
props := schema["properties"].(map[string]any)
342352

@@ -364,7 +374,7 @@ func TestBrowser_ExtractsInteractiveElements(t *testing.T) {
364374
}))
365375
defer ts.Close()
366376

367-
b := &browserTool{}
377+
b := newTestBrowserTool()
368378
callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
369379

370380
result := callJSON(t, b, `{"action":"snapshot"}`)
@@ -447,7 +457,7 @@ func TestResolveURL_SameDirRelative(t *testing.T) {
447457
// ── Browser Bad Action Parameters ─────────────────────────────────────
448458

449459
func TestBrowser_Navigate_BadJSON(t *testing.T) {
450-
b := &browserTool{}
460+
b := newTestBrowserTool()
451461
result, err := b.Call(`{"action":"navigate","url":123}`)
452462
if err != nil {
453463
t.Fatalf("Call() error: %v", err)
@@ -467,7 +477,7 @@ func TestBrowser_WrapsLinkURL(t *testing.T) {
467477
}))
468478
defer ts.Close()
469479

470-
b := &browserTool{}
480+
b := newTestBrowserTool()
471481
result := callJSON(t, b, `{"action":"navigate","url":"`+ts.URL+`"}`)
472482

473483
var r struct {

cmd/odek/file_tool.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,9 +1091,44 @@ func confineToCWD(path string) (string, error) {
10911091
// must not be writable through the generic file tools. Keep in sync with
10921092
// the SystemWrite escalation in danger.ClassifyPath.
10931093
func isProtectedOdekPath(rel string) bool {
1094-
return rel == "config.json" || rel == "secrets.env" ||
1095-
rel == "IDENTITY.md" ||
1096-
rel == "skills" || strings.HasPrefix(rel, "skills"+string(filepath.Separator))
1094+
rel = filepath.Clean(rel)
1095+
1096+
// Exact-file trust anchors. Rewriting any of these can disable safety
1097+
// policy, exfiltrate secrets, or persist attacker control.
1098+
protectedExact := []string{
1099+
"config.json",
1100+
"secrets.env",
1101+
"IDENTITY.md",
1102+
"schedules.json",
1103+
"schedule-state.json",
1104+
"schedules.lock",
1105+
"mcp_approvals.json",
1106+
"mcp_tool_approvals.json",
1107+
"restart.json",
1108+
"telegram.lock",
1109+
"telegram.pid",
1110+
"schedule.pid",
1111+
"schedule.log",
1112+
}
1113+
for _, p := range protectedExact {
1114+
if rel == p {
1115+
return true
1116+
}
1117+
}
1118+
1119+
// Directory trust anchors. Anything inside these directories is protected.
1120+
protectedDirs := []string{
1121+
"skills", // auto-loaded into future prompts
1122+
"sessions", // conversation history & auth tokens
1123+
"audit", // forensic audit log
1124+
"plans", // persisted task plans
1125+
}
1126+
for _, d := range protectedDirs {
1127+
if rel == d || strings.HasPrefix(rel, d+string(filepath.Separator)) {
1128+
return true
1129+
}
1130+
}
1131+
return false
10971132
}
10981133

10991134
func truncateDiff(s string, maxLen int) string {

cmd/odek/file_tool_test.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -786,8 +786,8 @@ func TestConfineToCWD_AllowsOdekDir(t *testing.T) {
786786
}
787787
for _, p := range []string{
788788
home + "/.odek/memory/episodes.json",
789-
home + "/.odek/sessions/abc.json",
790789
home + "/.odek/notes.md",
790+
home + "/.odek/media/photo.jpg",
791791
} {
792792
if _, err := confineToCWD(p); err != nil {
793793
t.Errorf("non-sensitive ~/.odek/ path %q should be allowed, got: %v", p, err)
@@ -812,9 +812,27 @@ func TestConfineToCWD_RejectsProtectedOdekPaths(t *testing.T) {
812812
home + "/.odek/IDENTITY.md",
813813
home + "/.odek/skills/evil/SKILL.md",
814814
home + "/.odek/skills",
815+
home + "/.odek/sessions/abc.json",
816+
home + "/.odek/sessions",
817+
home + "/.odek/audit/turn-1.json",
818+
home + "/.odek/audit",
819+
home + "/.odek/plans/evil.md",
820+
home + "/.odek/plans",
821+
home + "/.odek/schedules.json",
822+
home + "/.odek/schedule-state.json",
823+
home + "/.odek/schedules.lock",
824+
home + "/.odek/mcp_approvals.json",
825+
home + "/.odek/mcp_tool_approvals.json",
826+
home + "/.odek/restart.json",
827+
home + "/.odek/telegram.lock",
828+
home + "/.odek/telegram.pid",
829+
home + "/.odek/schedule.pid",
830+
home + "/.odek/schedule.log",
815831
// ".." traversal inside the carve-out must not reach the anchors
816832
home + "/.odek/memory/../config.json",
817833
home + "/.odek/memory/../IDENTITY.md",
834+
home + "/.odek/memory/../schedules.json",
835+
home + "/.odek/memory/../sessions/abc.json",
818836
} {
819837
if _, err := confineToCWD(p); err == nil {
820838
t.Errorf("protected odek path %q should be rejected by confineToCWD", p)

cmd/odek/injection_hardening_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ func TestHTTPBatch_Redirect_BlockedTargetIsNotFetched(t *testing.T) {
186186
func TestBrowserClients_HaveCheckRedirectInstalled(t *testing.T) {
187187
// Guards against a future refactor dropping the redirect guard from a
188188
// client constructor (the original gap that motivated this fix).
189-
bt := newBrowserTool(danger.DangerousConfig{})
189+
bt := newTestBrowserTool()
190190
if bt.client.CheckRedirect == nil {
191191
t.Error("browser client is missing CheckRedirect — redirects would not be re-classified")
192192
}
@@ -197,7 +197,7 @@ func TestBrowserClients_HaveCheckRedirectInstalled(t *testing.T) {
197197
}
198198

199199
func TestCheckRedirect_EnforcesHopLimit(t *testing.T) {
200-
bt := newBrowserTool(danger.DangerousConfig{}) // allow-all (no class overrides)
200+
bt := newTestBrowserTool() // policy not exercised by this test
201201
req, _ := http.NewRequest("GET", "http://example.com/", nil)
202202
via := make([]*http.Request, 10)
203203
if err := bt.checkRedirect(req, via); err == nil {

cmd/odek/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -656,7 +656,7 @@ const defaultConfigTemplate = `{
656656
"sandbox_volumes": [],
657657
"dangerous": {
658658
"action": "prompt",
659-
"non_interactive": "allow",
659+
"non_interactive": "deny",
660660
"classes": {
661661
"destructive": "deny",
662662
"network_egress": "prompt",

cmd/odek/next_security_vulnerabilities_test.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func TestBrowser_HistoryCap(t *testing.T) {
2727
}))
2828
defer srv.Close()
2929

30-
tool := newBrowserTool(danger.DangerousConfig{})
30+
tool := newTestBrowserTool()
3131
for i := 0; i < 55; i++ {
3232
callJSON(t, tool, fmt.Sprintf(`{"action":"navigate","url":%q}`, srv.URL))
3333
}
@@ -331,7 +331,8 @@ func TestShell_CapsOutputSize(t *testing.T) {
331331
path := filepath.Join(dir, "huge.txt")
332332
os.WriteFile(path, []byte(strings.Repeat("x", 15*1024*1024)), 0644)
333333

334-
tool := &shellTool{}
334+
allow := "allow"
335+
tool := &shellTool{dangerousConfig: danger.DangerousConfig{NonInteractive: &allow}}
335336
tool.SetContext(context.Background())
336337
result, err := tool.Call(fmt.Sprintf(`{"command":"cat %s","description":"read huge file"}`, path))
337338
if err != nil {
@@ -349,7 +350,8 @@ func TestParallelShell_CapsOutputSize(t *testing.T) {
349350
path := filepath.Join(dir, "huge.txt")
350351
os.WriteFile(path, []byte(strings.Repeat("x", 15*1024*1024)), 0644)
351352

352-
tool := &parallelShellTool{}
353+
allow := "allow"
354+
tool := &parallelShellTool{dangerousConfig: danger.DangerousConfig{NonInteractive: &allow}}
353355
result := callJSON(t, tool, fmt.Sprintf(`{"commands":[{"command":"cat %s"}]}`, path))
354356
var r struct {
355357
Results []struct {
@@ -381,7 +383,7 @@ func TestBrowser_NavigateTimeout(t *testing.T) {
381383
}))
382384
defer srv.Close()
383385

384-
tool := newBrowserTool(danger.DangerousConfig{})
386+
tool := newTestBrowserTool()
385387
result := callJSON(t, tool, fmt.Sprintf(`{"action":"navigate","url":%q}`, srv.URL))
386388
var r struct {
387389
Error string `json:"error,omitempty"`
@@ -928,7 +930,7 @@ func TestBrowser_WrapsTitleAndElementText(t *testing.T) {
928930
}))
929931
defer srv.Close()
930932

931-
tool := newBrowserTool(danger.DangerousConfig{})
933+
tool := newTestBrowserTool()
932934
result := callJSON(t, tool, fmt.Sprintf(`{"action":"navigate","url":%q}`, srv.URL))
933935
var r struct {
934936
Title string `json:"title"`
@@ -961,7 +963,7 @@ func TestBrowser_CapsElementCount(t *testing.T) {
961963
}))
962964
defer srv.Close()
963965

964-
tool := newBrowserTool(danger.DangerousConfig{})
966+
tool := newTestBrowserTool()
965967
result := callJSON(t, tool, fmt.Sprintf(`{"action":"navigate","url":%q}`, srv.URL))
966968
var r struct {
967969
Elements []any `json:"elements"`

cmd/odek/security_report_validation_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ func TestReport_BrowserWrapsUntrustedContent(t *testing.T) {
9292
}))
9393
defer ts.Close()
9494

95-
bt := newBrowserTool(danger.DangerousConfig{})
95+
allow := "allow"
96+
bt := newBrowserTool(danger.DangerousConfig{NonInteractive: &allow})
9697
resJSON, err := bt.doNavigate(ts.URL)
9798
if err != nil {
9899
t.Fatalf("doNavigate: %v", err)

0 commit comments

Comments
 (0)