Skip to content

Commit 03124be

Browse files
committed
Always clear env in tool sandboxes, pass vars via env capability
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent abf2768 commit 03124be

File tree

3 files changed

+59
-21
lines changed

3 files changed

+59
-21
lines changed

examples/mcp_agent.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -106,19 +106,17 @@ def web_fetch(url: str) -> str:
106106
async def run_agent(user_prompt: str, workspace: str):
107107
"""Run the agent loop: OpenAI reasoning + sandboxed local tool execution."""
108108

109-
# Set workspace as env var so tool functions can find it inside the sandbox
110-
os.environ["SANDLOCK_WORKSPACE"] = workspace
111-
112109
# -- Set up McpSandbox with local tools --
113110
mcp = McpSandbox(workspace=workspace)
114111

115-
# Deny by default — each tool explicitly declares what it needs.
116-
# No capabilities = read-only system paths + workspace, no network.
112+
# Deny by default: clean env, no writes, no network.
113+
# Each tool gets only the env vars and permissions it needs.
114+
ws_env = {"SANDLOCK_WORKSPACE": workspace}
117115

118116
mcp.add_tool(
119117
"read_file", read_file,
120118
description="Read a file from the workspace. Path is relative to workspace root.",
121-
# No capabilities needed — default read-only is sufficient
119+
capabilities={"env": ws_env},
122120
input_schema={
123121
"type": "object",
124122
"properties": {"path": {"type": "string", "description": "Relative file path"}},
@@ -128,7 +126,7 @@ async def run_agent(user_prompt: str, workspace: str):
128126
mcp.add_tool(
129127
"write_file", write_file,
130128
description="Write content to a file in the workspace. Creates parent directories.",
131-
capabilities={"fs_writable": [workspace]}, # only grant: write to workspace
129+
capabilities={"fs_writable": [workspace], "env": ws_env},
132130
input_schema={
133131
"type": "object",
134132
"properties": {
@@ -141,7 +139,7 @@ async def run_agent(user_prompt: str, workspace: str):
141139
mcp.add_tool(
142140
"run_python", run_python,
143141
description="Run Python code and return stdout. No filesystem or network access.",
144-
capabilities={"max_memory": "128M"}, # only grant: memory limit
142+
capabilities={"max_memory": "128M"},
145143
input_schema={
146144
"type": "object",
147145
"properties": {"code": {"type": "string", "description": "Python code to execute"}},
@@ -151,7 +149,7 @@ async def run_agent(user_prompt: str, workspace: str):
151149
mcp.add_tool(
152150
"list_files", list_files,
153151
description="List files in the workspace directory.",
154-
# No capabilities needed — default read-only is sufficient
152+
capabilities={"env": ws_env},
155153
input_schema={"type": "object", "properties": {}},
156154
)
157155
mcp.add_tool(

src/sandlock/mcp/_policy.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,30 +36,39 @@ def policy_for_tool(
3636
**Deny by default**: no capabilities = read-only access to system
3737
paths and the workspace. Every permission must be granted.
3838
39+
Environment is always cleared. Use ``env`` capability to pass
40+
specific variables::
41+
42+
capabilities={"env": {"API_KEY": "..."}}
43+
3944
Args:
4045
workspace: Filesystem path the sandbox can read.
4146
capabilities: Grants keyed by Policy field name. Common keys:
4247
4348
- ``fs_writable: ["/tmp/workspace"]``
44-
- ``net_connect: [443]``
4549
- ``net_allow_hosts: ["api.example.com"]``
50+
- ``env: {"KEY": "value"}``
4651
- ``max_memory: "256M"``
4752
4853
Returns:
4954
A frozen :class:`Policy` instance.
5055
"""
56+
# Fields that users cannot override — always enforced.
57+
_ENFORCED = {"clean_env"}
58+
5159
kwargs: dict[str, Any] = {
5260
"fs_writable": [],
5361
"fs_readable": [workspace, "/usr", "/lib", "/etc", "/bin", "/sbin"],
5462
"net_connect": [],
5563
"isolate_pids": True,
5664
"isolate_ipc": True,
5765
"no_raw_sockets": True,
66+
"clean_env": True,
5867
}
5968

6069
if capabilities:
6170
for key, value in capabilities.items():
62-
if key in _POLICY_FIELDS:
71+
if key in _POLICY_FIELDS and key not in _ENFORCED:
6372
kwargs[key] = value
6473

6574
# net_allow_hosts implies net_connect: [80, 443] unless explicit

tests/test_mcp_integration.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -133,20 +133,47 @@ def _run(self, coro):
133133

134134
def test_read_only_by_default(self, tmp_path):
135135
workspace = str(tmp_path)
136-
os.environ["SANDLOCK_WORKSPACE"] = workspace
137136

138137
mcp = McpSandbox(workspace=workspace)
139138
mcp.add_tool("run_python", _run_python_tool)
140139

141140
result = self._run(mcp.call_tool("run_python", {"code": "print(42)"}))
142141
assert "42" in result
143142

143+
def test_clean_env_by_default(self, tmp_path):
144+
"""Tools get clean env — can't see agent's env vars."""
145+
workspace = str(tmp_path)
146+
os.environ["SECRET_API_KEY"] = "should-not-leak"
147+
148+
mcp = McpSandbox(workspace=workspace)
149+
mcp.add_tool("run_python", _run_python_tool)
150+
151+
result = self._run(mcp.call_tool("run_python", {
152+
"code": "import os; print(os.environ.get('SECRET_API_KEY', 'HIDDEN'))",
153+
}))
154+
assert "HIDDEN" in result
155+
assert "should-not-leak" not in result
156+
157+
def test_env_capability_passes_vars(self, tmp_path):
158+
"""Only explicitly granted env vars are visible."""
159+
workspace = str(tmp_path)
160+
ws_env = {"SANDLOCK_WORKSPACE": workspace}
161+
162+
mcp = McpSandbox(workspace=workspace)
163+
mcp.add_tool("read_file", _read_file_tool,
164+
capabilities={"env": ws_env})
165+
166+
(tmp_path / "test.txt").write_text("hello")
167+
result = self._run(mcp.call_tool("read_file", {"path": "test.txt"}))
168+
assert "hello" in result
169+
144170
def test_write_requires_capability(self, tmp_path):
145171
workspace = str(tmp_path)
146-
os.environ["SANDLOCK_WORKSPACE"] = workspace
172+
ws_env = {"SANDLOCK_WORKSPACE": workspace}
147173

148174
mcp = McpSandbox(workspace=workspace)
149-
mcp.add_tool("write_file", _write_file_tool) # no capabilities
175+
mcp.add_tool("write_file", _write_file_tool,
176+
capabilities={"env": ws_env}) # env but no fs_writable
150177

151178
with pytest.raises(RuntimeError, match="failed"):
152179
self._run(mcp.call_tool(
@@ -155,12 +182,13 @@ def test_write_requires_capability(self, tmp_path):
155182

156183
def test_write_with_capability(self, tmp_path):
157184
workspace = str(tmp_path)
158-
os.environ["SANDLOCK_WORKSPACE"] = workspace
185+
ws_env = {"SANDLOCK_WORKSPACE": workspace}
159186

160187
mcp = McpSandbox(workspace=workspace)
161188
mcp.add_tool("write_file", _write_file_tool,
162-
capabilities={"fs_writable": [workspace]})
163-
mcp.add_tool("read_file", _read_file_tool)
189+
capabilities={"fs_writable": [workspace], "env": ws_env})
190+
mcp.add_tool("read_file", _read_file_tool,
191+
capabilities={"env": ws_env})
164192

165193
self._run(mcp.call_tool(
166194
"write_file", {"path": "test.txt", "content": "hello"},
@@ -176,6 +204,7 @@ def test_get_policy(self, tmp_path):
176204
capabilities={"fs_writable": [workspace]})
177205

178206
assert mcp.get_policy("reader").fs_writable == []
207+
assert mcp.get_policy("reader").clean_env is True
179208
assert workspace in mcp.get_policy("writer").fs_writable
180209

181210
def test_unknown_tool_raises(self, tmp_path):
@@ -194,14 +223,16 @@ def test_openai_format(self, tmp_path):
194223

195224
def test_full_workflow(self, tmp_path):
196225
workspace = str(tmp_path)
197-
os.environ["SANDLOCK_WORKSPACE"] = workspace
226+
ws_env = {"SANDLOCK_WORKSPACE": workspace}
198227

199228
mcp = McpSandbox(workspace=workspace)
200229
mcp.add_tool("write_file", _write_file_tool,
201-
capabilities={"fs_writable": [workspace]})
202-
mcp.add_tool("read_file", _read_file_tool)
230+
capabilities={"fs_writable": [workspace], "env": ws_env})
231+
mcp.add_tool("read_file", _read_file_tool,
232+
capabilities={"env": ws_env})
203233
mcp.add_tool("run_python", _run_python_tool)
204-
mcp.add_tool("list_files", _list_files_tool)
234+
mcp.add_tool("list_files", _list_files_tool,
235+
capabilities={"env": ws_env})
205236

206237
async def workflow():
207238
await mcp.call_tool("write_file",

0 commit comments

Comments
 (0)