Skip to content

Commit 3fdd0fa

Browse files
committed
feat: TASKFLOW_ENV_DENYLIST to filter env vars from MCP subprocesses
1 parent 167b495 commit 3fdd0fa

3 files changed

Lines changed: 75 additions & 1 deletion

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,19 @@ Error: [BadRequestError] model 'foo' not found
115115
python -m seclab_taskflow_agent --debug -t examples.taskflows.echo
116116
```
117117

118+
### MCP Environment Denylist
119+
120+
By default, MCP server subprocesses inherit the parent environment. To prevent
121+
specific variables from leaking to MCP servers, set `TASKFLOW_ENV_DENYLIST` to
122+
a comma-separated list of variable names:
123+
124+
```bash
125+
export TASKFLOW_ENV_DENYLIST="MY_SECRET_TOKEN,PRIVATE_KEY,OTHER_CREDENTIAL"
126+
```
127+
128+
Toolbox-level `env:` declarations in YAML still inject exactly what each server
129+
needs, so explicitly configured variables are unaffected.
130+
118131
## Use Cases and Examples
119132

120133
The Seclab Taskflow Agent framework was primarily designed to fit the iterative feedback loop driven work involved in Agentic security research workflows and vulnerability triage tasks.

src/seclab_taskflow_agent/mcp_transport.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"AsyncDebugMCPServerStdio",
1919
"ReconnectingMCPServerStdio",
2020
"StreamableMCPThread",
21+
"_filtered_env",
2122
]
2223

2324
import asyncio
@@ -38,6 +39,21 @@
3839
_EXPECTED_EXIT_CODES: frozenset[int] = frozenset({0, -signal.SIGTERM})
3940

4041

42+
def _filtered_env() -> dict[str, str]:
43+
"""Return a copy of ``os.environ`` with denied variables removed.
44+
45+
Set ``TASKFLOW_ENV_DENYLIST`` to a comma-separated list of variable
46+
names that should not be forwarded to MCP server subprocesses.
47+
Toolbox-level ``env:`` declarations in YAML still inject what each
48+
server explicitly needs.
49+
"""
50+
denylist_raw = os.environ.get("TASKFLOW_ENV_DENYLIST", "")
51+
if not denylist_raw:
52+
return os.environ.copy()
53+
denied = {k.strip() for k in denylist_raw.split(",") if k.strip()}
54+
return {k: v for k, v in os.environ.items() if k not in denied}
55+
56+
4157
class StreamableMCPThread(Thread):
4258
"""Thread that manages a local streamable MCP server subprocess.
4359
@@ -68,7 +84,7 @@ def __init__(
6884
self.on_output: Callable[[str], None] | None = on_output
6985
self.on_error: Callable[[str], None] | None = on_error
7086
self.poll_interval: float = poll_interval
71-
self.env: dict[str, str] = os.environ.copy() # XXX: potential for environment leak to MCP
87+
self.env: dict[str, str] = _filtered_env()
7288
if env:
7389
self.env.update(env)
7490
self._stop_event: Event = Event()

tests/test_mcp_transport.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# SPDX-FileCopyrightText: GitHub, Inc.
2+
# SPDX-License-Identifier: MIT
3+
4+
"""Tests for MCP transport utilities."""
5+
6+
import os
7+
8+
from seclab_taskflow_agent.mcp_transport import _filtered_env
9+
10+
11+
class TestFilteredEnv:
12+
"""Tests for _filtered_env environment denylist."""
13+
14+
def test_no_denylist_copies_env(self, monkeypatch):
15+
"""Without TASKFLOW_ENV_DENYLIST, returns full env copy."""
16+
monkeypatch.delenv("TASKFLOW_ENV_DENYLIST", raising=False)
17+
monkeypatch.setenv("TEST_VAR_A", "hello")
18+
result = _filtered_env()
19+
assert result["TEST_VAR_A"] == "hello"
20+
assert result is not os.environ
21+
22+
def test_denylist_strips_variables(self, monkeypatch):
23+
"""Comma-separated denylist removes matching variables."""
24+
monkeypatch.setenv("SECRET_TOKEN", "s3cret")
25+
monkeypatch.setenv("MY_API_KEY", "key123")
26+
monkeypatch.setenv("SAFE_VAR", "keep")
27+
monkeypatch.setenv("TASKFLOW_ENV_DENYLIST", "SECRET_TOKEN,MY_API_KEY")
28+
result = _filtered_env()
29+
assert "SECRET_TOKEN" not in result
30+
assert "MY_API_KEY" not in result
31+
assert result["SAFE_VAR"] == "keep"
32+
33+
def test_denylist_handles_whitespace(self, monkeypatch):
34+
"""Whitespace around denylist entries is trimmed."""
35+
monkeypatch.setenv("FOO", "bar")
36+
monkeypatch.setenv("TASKFLOW_ENV_DENYLIST", " FOO , ")
37+
result = _filtered_env()
38+
assert "FOO" not in result
39+
40+
def test_empty_denylist_copies_env(self, monkeypatch):
41+
"""Empty TASKFLOW_ENV_DENYLIST behaves like unset."""
42+
monkeypatch.setenv("TASKFLOW_ENV_DENYLIST", "")
43+
monkeypatch.setenv("KEEP_ME", "yes")
44+
result = _filtered_env()
45+
assert result["KEEP_ME"] == "yes"

0 commit comments

Comments
 (0)