Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ jobs:
working-directory: python
run: |
pip install pytest
pip install -e .
pip install -e '.[mcp]'

- name: Run Python tests
working-directory: python
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ jobs:
working-directory: python
run: |
pip install pytest
pip install -e .
pip install -e '.[mcp]'
pytest tests/ -v

# Publish Rust crates to crates.io
Expand Down
25 changes: 18 additions & 7 deletions python/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -619,19 +619,30 @@ mcp = McpSandbox(workspace="/tmp/agent", timeout=30.0)

#### `mcp.add_tool(name, func, *, description="", capabilities=None, input_schema=None)`

Register a local tool. The function must be self-contained (imports inside
the body) -- it is serialized and executed in a fresh sandbox process.
Register a local tool. `func` must be a top-level function in an import-safe
module: the worker imports that module by name and calls the function in a
fresh per-call sandbox. Module-level imports, helpers, constants, and state
are all fine; lambdas, methods, and nested functions are rejected. Guard any
module startup logic under `if __name__ == "__main__":`.

A tool that declares a parameter named `workspace` receives the sandbox's
workspace path automatically (injected at call time, hidden from the LLM
schema, and not overridable by the model). No env wiring needed.

```python
def read_file(path: str) -> str:
import os
workspace = os.environ["SANDLOCK_WORKSPACE"]
# tools.py (an importable module)
import os

def read_file(path: str, *, workspace: str) -> str:
with open(os.path.join(workspace, path)) as f:
return f.read()
```

```python
import tools

mcp.add_tool("read_file", read_file,
mcp.add_tool("read_file", tools.read_file,
description="Read a file from the workspace",
capabilities={"env": {"SANDLOCK_WORKSPACE": "/tmp/agent"}},
)
```

Expand Down
53 changes: 53 additions & 0 deletions python/examples/agent_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# SPDX-License-Identifier: Apache-2.0
"""Tool implementations for the mcp_agent example.

Plain stdlib-only functions in their own importable module. McpSandbox
imports this module by name inside each per-call jail, so it must not pull
in sandlock, openai, or any other heavy dependency. Module-level imports
are fine (and shown here): the worker imports the module normally.
"""
import contextlib
import io
import os
from urllib.request import urlopen


def read_file(path: str, *, workspace: str) -> str:
"""Read a file from the workspace."""
with open(os.path.join(workspace, path)) as f:
return f.read()


def write_file(path: str, content: str, *, workspace: str) -> str:
"""Write content to a file in the workspace."""
full = os.path.join(workspace, path)
parent = os.path.dirname(full)
if parent:
os.makedirs(parent, exist_ok=True)
with open(full, "w") as f:
f.write(content)
return f"Wrote {len(content)} bytes to {path}"


def run_python(code: str) -> str:
"""Execute Python code and return stdout."""
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
exec(code)
return buf.getvalue()


def list_files(*, workspace: str) -> str:
"""List files in the workspace."""
entries = sorted(os.listdir(workspace))
lines = []
for e in entries:
kind = "dir" if os.path.isdir(os.path.join(workspace, e)) else "file"
lines.append(f"{kind} {e}")
return "\n".join(lines) if lines else "(empty)"


def web_fetch(url: str) -> str:
"""Fetch a URL and return the response body (first 4KB)."""
resp = urlopen(url, timeout=10)
return resp.read(4096).decode("utf-8", errors="replace")
66 changes: 6 additions & 60 deletions python/examples/mcp_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,60 +43,9 @@
from openai import OpenAI
from sandlock.mcp import McpSandbox


# ---------------------------------------------------------------------------
# Tool implementations — plain Python functions
# ---------------------------------------------------------------------------

def read_file(path: str) -> str:
"""Read a file from the workspace."""
import os
workspace = os.environ["SANDLOCK_WORKSPACE"]
full = os.path.join(workspace, path)
with open(full) as f:
return f.read()


def write_file(path: str, content: str) -> str:
"""Write content to a file in the workspace."""
import os
workspace = os.environ["SANDLOCK_WORKSPACE"]
full = os.path.join(workspace, path)
parent = os.path.dirname(full)
if parent:
os.makedirs(parent, exist_ok=True)
with open(full, "w") as f:
f.write(content)
return f"Wrote {len(content)} bytes to {path}"


def run_python(code: str) -> str:
"""Execute Python code and return stdout."""
import io
import contextlib
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
exec(code)
return buf.getvalue()


def list_files() -> str:
"""List files in the workspace."""
import os
workspace = os.environ["SANDLOCK_WORKSPACE"]
entries = sorted(os.listdir(workspace))
lines = []
for e in entries:
kind = "dir" if os.path.isdir(os.path.join(workspace, e)) else "file"
lines.append(f"{kind} {e}")
return "\n".join(lines) if lines else "(empty)"


def web_fetch(url: str) -> str:
"""Fetch a URL and return the response body (first 4KB)."""
from urllib.request import urlopen
resp = urlopen(url, timeout=10)
return resp.read(4096).decode("utf-8", errors="replace")
# Tools live in a stdlib-only module so McpSandbox can import them inside
# each per-call jail without pulling in openai or sandlock. See agent_tools.py.
from agent_tools import read_file, write_file, run_python, list_files, web_fetch


# ---------------------------------------------------------------------------
Expand All @@ -110,13 +59,11 @@ async def run_agent(user_prompt: str, workspace: str):
mcp = McpSandbox(workspace=workspace)

# Deny by default: clean env, no writes, no network.
# Each tool gets only the env vars and permissions it needs.
ws_env = {"SANDLOCK_WORKSPACE": workspace}

# Each tool gets only the permissions it needs; the workspace path is
# injected automatically into any tool that declares a `workspace` param.
mcp.add_tool(
"read_file", read_file,
description="Read a file from the workspace. Path is relative to workspace root.",
capabilities={"env": ws_env},
input_schema={
"type": "object",
"properties": {"path": {"type": "string", "description": "Relative file path"}},
Expand All @@ -126,7 +73,7 @@ async def run_agent(user_prompt: str, workspace: str):
mcp.add_tool(
"write_file", write_file,
description="Write content to a file in the workspace. Creates parent directories.",
capabilities={"fs_writable": [workspace], "env": ws_env},
capabilities={"fs_writable": [workspace]},
input_schema={
"type": "object",
"properties": {
Expand All @@ -149,7 +96,6 @@ async def run_agent(user_prompt: str, workspace: str):
mcp.add_tool(
"list_files", list_files,
description="List files in the workspace directory.",
capabilities={"env": ws_env},
input_schema={"type": "object", "properties": {}},
)
mcp.add_tool(
Expand Down
14 changes: 10 additions & 4 deletions python/src/sandlock/mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@

from ._policy import policy_for_tool, capabilities_from_mcp_tool
from ._sandbox import McpSandbox
try:
from .server import create_server
except ImportError:
pass # mcp not installed

__all__ = [
"McpSandbox",
"create_server",
"policy_for_tool",
"capabilities_from_mcp_tool",
]


def __getattr__(name):
# Import the server (and the heavy ``mcp`` framework) lazily, so that
# importing a built-in tool module in the per-call worker does not pull
# the whole MCP stack into the jail. Requires the ``mcp`` extra.
if name == "create_server":
from .server import create_server
return create_server
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
76 changes: 76 additions & 0 deletions python/src/sandlock/mcp/_builtins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# SPDX-License-Identifier: Apache-2.0
"""Built-in tool functions for the sandlock MCP server.

Kept in a stdlib-only module (no ``mcp``, no sandlock imports) so the
per-call worker can import them in the jail cheaply: importing ``server``
would pull in the whole MCP framework on every tool call.

Tools that declare a ``workspace`` parameter receive the sandbox's
workspace path automatically (injected by McpSandbox).
"""
from __future__ import annotations


def shell(command: str) -> str:
"""Run a shell command and return stdout+stderr."""
import subprocess

result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=30,
)
output = result.stdout
if result.stderr:
output += result.stderr
if result.returncode != 0:
output += f"\n[exit code: {result.returncode}]"
return output


def python(code: str) -> str:
"""Execute Python code and return stdout."""
import io
import contextlib

buf = io.StringIO()
with contextlib.redirect_stdout(buf):
exec(code)
return buf.getvalue()


def read_file(path: str, *, workspace: str) -> str:
"""Read a file from the workspace."""
import os

full = os.path.join(workspace, path)
with open(full) as f:
return f.read()


def write_file(path: str, content: str, *, workspace: str) -> str:
"""Write content to a file in the workspace."""
import os

full = os.path.join(workspace, path)
parent = os.path.dirname(full)
if parent:
os.makedirs(parent, exist_ok=True)
with open(full, "w") as f:
f.write(content)
return f"Wrote {len(content)} bytes to {path}"


def list_files(subdir: str = "", *, workspace: str) -> str:
"""List files in the workspace directory."""
import os

target = os.path.join(workspace, subdir) if subdir else workspace
entries = sorted(os.listdir(target))
lines = []
for e in entries:
kind = "dir" if os.path.isdir(os.path.join(target, e)) else "file"
lines.append(f"{kind} {e}")
return "\n".join(lines) if lines else "(empty)"
31 changes: 30 additions & 1 deletion python/src/sandlock/mcp/_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from __future__ import annotations

import os
import site
import sys
from dataclasses import fields
from typing import Any, Mapping, Sequence
Expand All @@ -28,6 +29,33 @@
_PYTHON_PREFIX = sys.prefix


def _interpreter_readable() -> list[str]:
"""Paths the sandboxed worker must read to launch and import a tool.

Covers the interpreter prefixes, the site-packages directories, and the
sandlock package root (the last so the worker script is readable even in
an editable install).
"""
paths = [sys.prefix, sys.base_prefix]
try:
paths.extend(site.getsitepackages())
except Exception:
pass
try:
paths.append(site.getusersitepackages())
except Exception:
pass
# Parent of the 'sandlock' package dir, e.g. .../site-packages or the
# editable source root .../python/src.
paths.append(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
)
return [p for p in dict.fromkeys(paths) if p and os.path.isdir(p)]


_INTERP_READABLE = _interpreter_readable()


_POLICY_FIELDS = frozenset(f.name for f in fields(Sandbox))
_SANDLOCK_PREFIX = "sandlock:"

Expand All @@ -36,6 +64,7 @@ def policy_for_tool(
*,
workspace: str = "/tmp/sandlock",
capabilities: Mapping[str, Any] | None = None,
extra_readable: Sequence[str] = (),
) -> Sandbox:
"""Build a :class:`Sandbox` from explicit capabilities.

Expand Down Expand Up @@ -66,7 +95,7 @@ def policy_for_tool(
"fs_writable": [],
"fs_readable": list(dict.fromkeys([
workspace, "/usr", "/lib", "/lib64", "/etc", "/bin", "/sbin",
_PYTHON_PREFIX,
_PYTHON_PREFIX, *_INTERP_READABLE, *extra_readable,
])),
"net_bind": [],
"net_allow": [],
Expand Down
Loading
Loading