Skip to content

Commit d384e26

Browse files
committed
fix: sanitise MCP server subprocess environment
MCP server children no longer inherit the full odek process environment. They receive a minimal allowlist of safe variables plus explicit env overrides, and keys matching secret patterns are always stripped. - Replace inherited os.Environ() with an allowlist (PATH, HOME, LANG, etc.) in internal/mcpclient/client.go buildEnv. - Block *_API_KEY, *_TOKEN, *_SECRET, *_PASSWORD, *_CREDENTIAL, *_PRIVATE_KEY, and *_ACCESS_KEY from both inherited env and overrides. - Always set cmd.Env so servers with no overrides still get sanitised env. - Add regression tests for allowlist/blocklist behaviour. - Update docs/MCP.md, docs/SECURITY.md, and AGENTS.md.
1 parent 06842e1 commit d384e26

5 files changed

Lines changed: 144 additions & 16 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Layered prompt-injection / approval-fatigue defenses. Full reference: [docs/SECU
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.
135135
- **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+
- **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.
136137
- **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.
137138
- **Secret redaction** (`internal/redact/redact.go`) — 20+ patterns: OpenAI, Anthropic, GitHub PAT, AWS, PEM, JWT, Vault, Google OAuth, SendGrid, Discord, DB URLs, etc.
138139

docs/MCP.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,11 +108,11 @@ Add `mcp_servers` to `~/.odek/config.json` (global, operator-trusted) or `odek.j
108108
"command": "uvx",
109109
"args": ["mcp-server-fetch"]
110110
},
111-
"github": {
112-
"command": "node",
113-
"args": ["/path/to/github-mcp-server/index.js"],
111+
"fetch": {
112+
"command": "uvx",
113+
"args": ["mcp-server-fetch"],
114114
"env": {
115-
"GITHUB_TOKEN": "${GITHUB_TOKEN}"
115+
"LOG_LEVEL": "debug"
116116
}
117117
}
118118
}
@@ -124,6 +124,14 @@ Each server is defined by:
124124
- `args` — optional command-line arguments
125125
- `env` — optional environment variable overrides (empty string removes the variable)
126126

127+
> **Environment sanitisation.** MCP server children receive only a minimal
128+
> allowlist of safe variables (e.g. `PATH`, `HOME`, `LANG`) plus the overrides
129+
> from `env`. Keys matching secret patterns (`*_API_KEY`, `*_TOKEN`,
130+
> `*_SECRET`, `*_PASSWORD`, etc.) are stripped even when listed in `env`, so a
131+
> compromised server cannot exfiltrate parent secrets. Pass authentication
132+
> material via server-specific config files or command-line arguments instead
133+
> of environment variables.
134+
127135
The format matches Claude Code's `mcpServers` config — any MCP server you use
128136
with Claude Code can be added to odek's config.
129137

docs/SECURITY.md

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

309309
These fields can only be set from operator-controlled sources: `~/.odek/config.json`, `ODEK_*` environment variables, or CLI flags.
310310

311+
### 19. MCP server environment sanitisation
312+
313+
MCP server subprocesses no longer inherit the full odek process environment. They receive only a minimal allowlist of safe variables (e.g. `PATH`, `HOME`, `LANG`, `TMPDIR`) plus any explicit `env` overrides from the server config. Keys matching secret patterns — `*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `*_CREDENTIAL`, `*_PRIVATE_KEY`, etc. — are stripped even when listed in `env`. This prevents a compromised or malicious MCP server from reading secrets loaded from `~/.odek/secrets.env` or other provider keys that were present in the parent environment.
314+
311315
### YOLO mode
312316

313317
```json

internal/mcpclient/client.go

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -167,10 +167,9 @@ type Client struct {
167167
func New(name string, cfg ServerConfig) (*Client, error) {
168168
cmd := exec.Command(cfg.Command, cfg.Args...)
169169

170-
// Apply env overrides
171-
if len(cfg.Env) > 0 {
172-
cmd.Env = buildEnv(cfg.Env)
173-
}
170+
// Apply env overrides. Always build a sanitized environment so MCP children
171+
// do not inherit the full parent environment (API keys, tokens, secrets).
172+
cmd.Env = buildEnv(cfg.Env)
174173

175174
stdin, err := cmd.StdinPipe()
176175
if err != nil {
@@ -213,25 +212,79 @@ func New(name string, cfg ServerConfig) (*Client, error) {
213212
return c, nil
214213
}
215214

215+
// allowedEnvVars is the allowlist of parent environment variables that may be
216+
// forwarded to MCP server subprocesses. It contains only non-sensitive,
217+
// commonly-required variables (e.g. PATH so the server can find binaries).
218+
var allowedEnvVars = map[string]bool{
219+
"PATH": true,
220+
"HOME": true,
221+
"USER": true,
222+
"LOGNAME": true,
223+
"SHELL": true,
224+
"TMPDIR": true,
225+
"LANG": true,
226+
"LC_ALL": true,
227+
"LC_CTYPE": true,
228+
"LC_MESSAGES": true,
229+
"LC_NUMERIC": true,
230+
"LC_TIME": true,
231+
"LC_COLLATE": true,
232+
"LC_MONETARY": true,
233+
"LC_PAPER": true,
234+
"LC_NAME": true,
235+
"LC_ADDRESS": true,
236+
"LC_TELEPHONE": true,
237+
"LC_MEASUREMENT": true,
238+
"LC_IDENTIFICATION": true,
239+
"TZ": true,
240+
"TERM": true,
241+
}
242+
243+
// isSensitiveEnvVar reports whether a key looks like a secret. These patterns
244+
// are blocked from being forwarded to MCP children even if they are present in
245+
// the parent environment or explicitly supplied as overrides.
246+
func isSensitiveEnvVar(key string) bool {
247+
upper := strings.ToUpper(key)
248+
for _, pat := range []string{
249+
"API_KEY", "TOKEN", "SECRET", "PASSWORD", "CREDENTIAL", "CREDS",
250+
"PRIVATE_KEY", "ACCESS_KEY",
251+
} {
252+
if strings.Contains(upper, pat) {
253+
return true
254+
}
255+
}
256+
return false
257+
}
258+
216259
// buildEnv constructs the environment for the subprocess.
217-
// Overrides ODEK_ prefixed env vars cannot be removed.
260+
//
261+
// Only a small allowlist of parent environment variables is forwarded, plus any
262+
// overrides from the MCP server config. Keys that look like secrets (e.g.
263+
// *_API_KEY, *_TOKEN, *_SECRET) are always stripped, even when provided as
264+
// overrides, so a compromised or malicious MCP server cannot exfiltrate tokens.
218265
func buildEnv(overrides map[string]string) []string {
219266
// Start with current env
220267
env := osEnviron()
221268
if env == nil {
222269
env = environ() // fallback for testing
223270
}
224271

225-
// Build a map for efficient update
226-
envMap := make(map[string]string, len(env))
272+
// Build a map from the allowlist only.
273+
envMap := make(map[string]string)
227274
for _, e := range env {
228275
if k, v, ok := strings.Cut(e, "="); ok {
229-
envMap[k] = v
276+
if allowedEnvVars[k] && !isSensitiveEnvVar(k) {
277+
envMap[k] = v
278+
}
230279
}
231280
}
232281

233-
// Apply overrides
282+
// Apply overrides. Sensitive overrides are dropped; empty values remove the
283+
// variable.
234284
for k, v := range overrides {
285+
if isSensitiveEnvVar(k) {
286+
continue
287+
}
235288
if v == "" {
236289
delete(envMap, k)
237290
} else {

internal/mcpclient/client_test.go

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -282,16 +282,78 @@ func TestBuildEnv_Overrides(t *testing.T) {
282282

283283
func TestBuildEnv_RemovesEmptyValue(t *testing.T) {
284284
result := buildEnv(map[string]string{
285-
"REMOVE_ME": "", // empty = remove from env
285+
"PATH": "", // empty = remove from env
286286
})
287287

288288
for _, e := range result {
289-
if len(e) > 8 && e[:9] == "REMOVE_ME" {
290-
t.Errorf("expected REMOVE_ME to be removed, but found: %s", e)
289+
if strings.HasPrefix(e, "PATH=") {
290+
t.Errorf("expected PATH to be removed, but found: %s", e)
291291
}
292292
}
293293
}
294294

295+
func TestBuildEnv_AllowlistBlocksSecrets(t *testing.T) {
296+
orig := osEnviron
297+
osEnviron = func() []string {
298+
return []string{
299+
"PATH=/usr/bin",
300+
"HOME=/home/user",
301+
"ODEK_API_KEY=sk-odek",
302+
"GITHUB_TOKEN=ghp-secret",
303+
"SOME_SECRET=shh",
304+
"MY_PASSWORD=hunter2",
305+
}
306+
}
307+
defer func() { osEnviron = orig }()
308+
309+
result := buildEnv(nil)
310+
m := envToMap(result)
311+
312+
if m["PATH"] != "/usr/bin" {
313+
t.Errorf("PATH = %q, want /usr/bin", m["PATH"])
314+
}
315+
if m["HOME"] != "/home/user" {
316+
t.Errorf("HOME = %q, want /home/user", m["HOME"])
317+
}
318+
for _, k := range []string{"ODEK_API_KEY", "GITHUB_TOKEN", "SOME_SECRET", "MY_PASSWORD"} {
319+
if _, ok := m[k]; ok {
320+
t.Errorf("sensitive key %q should not be forwarded", k)
321+
}
322+
}
323+
}
324+
325+
func TestBuildEnv_OverridesCannotInjectSecrets(t *testing.T) {
326+
orig := osEnviron
327+
osEnviron = func() []string { return []string{"PATH=/usr/bin"} }
328+
defer func() { osEnviron = orig }()
329+
330+
result := buildEnv(map[string]string{
331+
"LEGIT_VAR": "ok",
332+
"EVIL_API_KEY": "sk-stolen",
333+
"BOT_TOKEN": "tok-stolen",
334+
})
335+
m := envToMap(result)
336+
337+
if m["LEGIT_VAR"] != "ok" {
338+
t.Errorf("LEGIT_VAR = %q, want ok", m["LEGIT_VAR"])
339+
}
340+
for _, k := range []string{"EVIL_API_KEY", "BOT_TOKEN"} {
341+
if _, ok := m[k]; ok {
342+
t.Errorf("sensitive override %q should be dropped", k)
343+
}
344+
}
345+
}
346+
347+
func envToMap(env []string) map[string]string {
348+
m := make(map[string]string, len(env))
349+
for _, e := range env {
350+
if k, v, ok := strings.Cut(e, "="); ok {
351+
m[k] = v
352+
}
353+
}
354+
return m
355+
}
356+
295357
func TestDiscover_FailsOnDeadProcess(t *testing.T) {
296358
client, err := New("dead", ServerConfig{
297359
Command: fakeServerPath(t),

0 commit comments

Comments
 (0)