Skip to content

Commit 2246fdd

Browse files
committed
docs+security: sync docs/docker configs and harden subagent/config boundaries
- Reject trusted_proxies from project-level ./odek.json (operator-only config). - Deny MCP server tools in untrusted sub-agents; pass DangerousConfig into sub-agent runtime so trust caps are enforced. - Sync SECURITY.md, MCP.md, TELEGRAM.md, CLI.md, CONFIG.md, AGENTS.md with v1.13.0+ hardening (MCP schema scan, env-hash approvals, Telegram outbound media approval, class-trust friction, clarify callback binding, etc.). - Update docker README/.env.example and DOCKER_COMPOSE_USER_GUIDE for the per-instance WebSocket token URL and telegram.lock. - Add/extend tests for subagent trust, MCP approval, SSRF guard, Telegram commands, and config loader trusted_proxies rejection.
1 parent 696e556 commit 2246fdd

17 files changed

Lines changed: 660 additions & 63 deletions

AGENTS.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ cmd/odek/
3434
perf_tools.go Performance/parallelism tools (batch_patch, parallel_shell, http_batch, math_eval, diff, count_lines, multi_grep, json_query, tree, checksum, sort, head_tail, base64, tr, word_count)
3535
mcp.go MCP server implementation (stdio + SSE transport)
3636
mcp_approval.go Per-tool MCP server approval UI and persistence
37+
project_sandbox_approval.go Project-level sandbox config approval gate (C-1)
3738
transcribe_tool.go Whisper.cpp audio transcription
3839
vision_tool.go Vision / image-input tool
3940
web_search_tool.go Web search tool
@@ -92,7 +93,7 @@ ReAct cycle: observe → think → act → repeat.
9293
- **Post-response async processing** — skill learning and episode extraction run in background goroutines, eliminating the hang after every `odek run`.
9394
- **Artifact-aware file search**`search_files` and `multi_grep` skip build/artifact directories (`node_modules`, `vendor`, `.git`, `__pycache__`, `.venv`, etc.) automatically, reducing noise and speeding scans.
9495
- **Semantic session search** — the `session_search` tool uses go-vector RandomProjections + k-NN for semantic similarity search through session content, with a two-tier pipeline: vector index (fast, ~1ms) → deepSearch fallback (exhaustive, slower).
95-
- **Security-first defaults** — the latest hardening closes the high/medium/low findings tracked in `sec_findings.md`: default `non_interactive` is `deny`, project-level `odek.json` cannot redirect backends or hijack delivery, `~/.odek` trust anchors are protected, WebSocket upgrades require a per-instance CSRF token, and all untrusted content is wrapped before reaching the model. See Security Architecture below for the full list.
96+
- **Security-first defaults** — the latest hardening closes the high/medium/low findings tracked in `sec_findings.md`: default `non_interactive` is `deny`, project-level `odek.json` cannot redirect backends or hijack delivery, project-level sandbox knobs require explicit operator approval, `~/.odek` trust anchors are protected, WebSocket upgrades require a per-instance CSRF token, and all untrusted content is wrapped before reaching the model. See Security Architecture below for the full list.
9697

9798
### Tools
9899
All built-in tools with zero subprocess forks: batch_read, batch_patch, parallel_shell, http_batch, math_eval, diff, count_lines, multi_grep, json_query, tree, checksum, sort, head_tail, base64, tr, word_count, transcribe, browser, read_file, write_file, search_files, patch, shell, delegate_tasks, session_search.
@@ -110,7 +111,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
110111
- **Audit log** (`cmd/odek/audit.go` + `internal/session/audit.go`) — every `wrapUntrusted` call records source + content-hash + turn into `<sessions>/audit/<id>.json`. After each turn a divergence heuristic flags `suspicious_divergence=true` when the agent ingested untrusted content AND its actions or final response reference resources that either did not appear in the user's message or were introduced by the untrusted content itself (closing response-only exfiltration and reused-resource injection bypasses). Inspect with `odek audit <session-id>` / `odek audit --list`.
111112
- **Memory taint** (`internal/memory/provenance.go`) — `EpisodeProvenance` tracks Untrusted/Sources/UserApproved. Tainted episodes are stored but `Search()` filters them out, so a one-shot injection cannot persist via the episode pipeline. User must explicitly promote.
112113
- **Skill provenance gate** (`internal/skills/loader.go` + `cache.go` + `tools.go`) — `Skill.Provenance{Untrusted, Sources, NeedsReview}`. NeedsReview skills pin to Lazy regardless of `auto_load`. The auto-save path declines tainted suggestions by default. Agent-created skills via `skill_save` and patched skills via `skill_patch` are forced to `Untrusted` + `NeedsReview`, and `skill_patch` refuses edits that touch the YAML frontmatter, blocking an injected agent from flipping `auto_load` or clearing `needs_review`. `odek skill promote <name> --force` clears the flag after explicit user review.
113-
- **Sub-agent damage cap** (`cmd/odek/subagent.go::applySubagentTrust`) — `delegate_tasks` carries `trust_level` + `max_risk`. Untrusted ⇒ NonInteractive=deny, Destructive/CodeExec/Install/SystemWrite/NetworkEgress all forced to Deny. `max_risk` ⇒ everything above cap forced to Deny.
114+
- **Sub-agent damage cap** (`cmd/odek/subagent.go::applySubagentTrust`) — `delegate_tasks` carries `trust_level` + `max_risk`. The mutated `DangerousConfig` is passed into the sub-agent's `odek.Config` so the engine enforces the cap. Untrusted ⇒ NonInteractive=deny, Destructive/CodeExec/Install/SystemWrite/NetworkEgress/Unknown/Blocked all forced to Deny. `max_risk` ⇒ everything above cap forced to Deny. MCP tools are not loaded into untrusted sub-agents, because the MCP `ToolAdapter` does not perform its own danger check and would otherwise bypass the cap.
114115
- **FD-based API key handoff** (`cmd/odek/subagent_key.go`) — parent writes key to a 0600 tempfile, immediately `unlink()`s, passes the FD via `cmd.ExtraFiles`. Sub-agent reads from `$ODEK_API_KEY_FD` and closes. Key never in `/proc/<pid>/environ`.
115116
- **Approver friction** (`internal/danger/approver.go`, `cmd/odek/wsapprover.go`, `cmd/odek/shell.go`, `cmd/odek/perf_tools.go`) — both TTYApprover and WSApprover engage friction mode after 3 approvals of the same class in 60s: require typing literal `approve`, 1.5s pause. Trust-class shortcut disabled for `destructive` + `blocked` regardless. CLI `shell`/`parallel_shell` reuse a single TTYApprover instance per process so the counter and trust cache persist across prompts.
116117
- **Danger classifier bypass resistance** (`internal/danger/classifier.go`) — `normalize()` pre-processes: expand `$IFS` / `${IFS}`, extract `$(...)` / `` `...` `` substitutions, strip `command` / `exec` / `builtin` wrappers, collapse unquoted backslashes, basename absolute paths. `awk`/`gawk`, `sed` (`e` command / `-f`), and editors (`vi`/`vim`/`nvim`/`emacs`/`ed`/`ex`) are classified as `code_execution` when given a script or file operand, closing `awk 'BEGIN{system(...)}'`, `sed 's///e'`, and editor `!` shell escapes. `rm -rf ./` / `rm -rf ./..` are normalised to `.` / `..` for wipe-target matching, and `${VAR:--rf}` default-value flag substitutions are treated as fail-closed. Regression suite in `classifier_bypass_test.go`.
@@ -152,10 +153,11 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
152153
- **Session ID write-path validation** (`internal/session/session.go`) — `saveLocked` validates `sess.ID` with `ValidateSessionID` before computing the destination path, and `Load` checks that the embedded ID matches the filename it was loaded from. A planted session file with an attacker-controlled `"id"` field can no longer redirect `Append`/`Save` to write outside the session directory.
153154
- **Skill file size cap** (`internal/skills/loader.go`) — `SKILL.md` files larger than 1 MiB are skipped so a malicious project cannot OOM the process at startup or bloat the system prompt.
154155
- **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.
155-
- **Serve sandbox default-on**`odek serve` enables `--sandbox` automatically unless `--no-sandbox` is passed.
156+
- **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.
157+
- **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.
156158
- **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`.
157159
- **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.
158-
- **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.
160+
- **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.
159161
- **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.
160162
- **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. Approval keys hash the server's `env` map (sorted key/value pairs), and the interactive prompt prints each env variable with its value, so a later `env` change cannot silently reuse an old approval.
161163
- **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.
@@ -164,7 +166,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
164166
- **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.
165167
- **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.
166168
- **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. Invalid values such as `"prompt"` are rejected at load time with a warning and floored to `"deny"`, because a non-interactive environment cannot prompt.
167-
- **`~/.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. Matching is case-insensitive so variants such as `CONFIG.JSON` or `SECRETS.ENV` are also blocked on case-insensitive filesystems (e.g. macOS APFS). Shell reads of these trust anchors are also escalated to `system_write`.
169+
- **`~/.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`, `project_sandbox_approvals.json`, `restart.json`, `telegram.lock`, and related state files. These paths classify as `system_write` and must be modified through their dedicated subsystems. Matching is case-insensitive so variants such as `CONFIG.JSON` or `SECRETS.ENV` are also blocked on case-insensitive filesystems (e.g. macOS APFS). Shell reads of these trust anchors are also escalated to `system_write`.
168170
- **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.
169171
- **`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.
170172
- **`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/mcp_approval_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"bytes"
55
"os"
6+
"path/filepath"
67
"strings"
78
"testing"
89

@@ -316,3 +317,84 @@ func TestSanitizeTerminal_StripsANSIAndControlChars(t *testing.T) {
316317
t.Errorf("normal text should be preserved, got: %q", got)
317318
}
318319
}
320+
321+
// TestLoadSaveMCPApprovalsRoundTrip verifies that server approvals can be
322+
// persisted and reloaded.
323+
func TestLoadSaveMCPApprovalsRoundTrip(t *testing.T) {
324+
dir := t.TempDir()
325+
origHome := os.Getenv("HOME")
326+
os.Setenv("HOME", dir)
327+
defer os.Setenv("HOME", origHome)
328+
329+
approvals := map[string]bool{"project/server": true}
330+
if err := saveMCPApprovals(approvals); err != nil {
331+
t.Fatalf("saveMCPApprovals: %v", err)
332+
}
333+
334+
loaded, err := loadMCPApprovals()
335+
if err != nil {
336+
t.Fatalf("loadMCPApprovals: %v", err)
337+
}
338+
if !loaded["project/server"] {
339+
t.Errorf("loaded approval missing: %v", loaded)
340+
}
341+
}
342+
343+
// TestLoadMCPApprovals_MissingFile returns an empty map when no approvals file
344+
// exists yet.
345+
func TestLoadMCPApprovals_MissingFile(t *testing.T) {
346+
dir := t.TempDir()
347+
origHome := os.Getenv("HOME")
348+
os.Setenv("HOME", dir)
349+
defer os.Setenv("HOME", origHome)
350+
351+
loaded, err := loadMCPApprovals()
352+
if err != nil {
353+
t.Fatalf("loadMCPApprovals: %v", err)
354+
}
355+
if len(loaded) != 0 {
356+
t.Errorf("expected empty map, got %v", loaded)
357+
}
358+
}
359+
360+
// TestLoadSaveMCPToolApprovalsRoundTrip verifies that per-tool approvals can
361+
// be persisted and reloaded.
362+
func TestLoadSaveMCPToolApprovalsRoundTrip(t *testing.T) {
363+
dir := t.TempDir()
364+
origHome := os.Getenv("HOME")
365+
os.Setenv("HOME", dir)
366+
defer os.Setenv("HOME", origHome)
367+
368+
approvals := map[string]bool{"dir/server/tool": true}
369+
if err := saveMCPToolApprovals(approvals); err != nil {
370+
t.Fatalf("saveMCPToolApprovals: %v", err)
371+
}
372+
373+
loaded, err := loadMCPToolApprovals()
374+
if err != nil {
375+
t.Fatalf("loadMCPToolApprovals: %v", err)
376+
}
377+
if !loaded["dir/server/tool"] {
378+
t.Errorf("loaded tool approval missing: %v", loaded)
379+
}
380+
}
381+
382+
// TestLoadMCPToolApprovals_CorruptFile returns an error for invalid JSON.
383+
func TestLoadMCPToolApprovals_CorruptFile(t *testing.T) {
384+
dir := t.TempDir()
385+
origHome := os.Getenv("HOME")
386+
os.Setenv("HOME", dir)
387+
defer os.Setenv("HOME", origHome)
388+
389+
path := filepath.Join(dir, ".odek", mcpToolApprovalsFile)
390+
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
391+
t.Fatal(err)
392+
}
393+
if err := os.WriteFile(path, []byte("not-json"), 0600); err != nil {
394+
t.Fatal(err)
395+
}
396+
397+
if _, err := loadMCPToolApprovals(); err == nil {
398+
t.Error("expected error for corrupt approvals file")
399+
}
400+
}

cmd/odek/ssrf_guard_test.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"net"
66
"net/http"
77
"net/url"
8+
"os"
89
"strings"
910
"testing"
1011

@@ -290,3 +291,22 @@ func TestSSRFGuardedTransport_Installed(t *testing.T) {
290291
t.Error("web_search tool Transport is missing the guarded DialContext")
291292
}
292293
}
294+
295+
func TestProxyEnvSet(t *testing.T) {
296+
// Ensure no proxy env vars are set at start.
297+
for _, k := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "NO_PROXY", "no_proxy"} {
298+
os.Unsetenv(k)
299+
}
300+
if proxyEnvSet() {
301+
t.Error("proxyEnvSet = true with no proxy env vars set")
302+
}
303+
304+
for _, k := range []string{"HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "NO_PROXY", "no_proxy"} {
305+
t.Run(k, func(t *testing.T) {
306+
t.Setenv(k, "http://proxy.example:8080")
307+
if !proxyEnvSet() {
308+
t.Errorf("proxyEnvSet = false with %s set", k)
309+
}
310+
})
311+
}
312+
}

0 commit comments

Comments
 (0)