Skip to content

Commit 333ff33

Browse files
fix: conservative trust-boundary hardening (#540)
call_method dunder denylist, instantiate non-estimator rejection, run_command doc fix + output cap. Addresses #540 (BUG-09 sandboxing left for maintainers). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent f8f377c commit 333ff33

5 files changed

Lines changed: 176 additions & 10 deletions

File tree

src/sktime_mcp/runtime/executor.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,29 @@ def _to_period_index_if_possible(obj: Any) -> Any:
8787
# flood the client with ~30KB+ of inline JSON.
8888
_MAX_PREDICTION_ROWS = 500
8989

90+
# Dunder methods that are safe and useful to call via call_method (e.g. __call__
91+
# for callable metrics/aligners). Everything else starting with "_" is blocked
92+
# (BUG-11) — notably __reduce__/__class__/__getattribute__ and private methods.
93+
_ALLOWED_DUNDERS = frozenset({"__call__", "__len__", "__repr__", "__str__"})
94+
95+
96+
def _is_sktime_object(obj: Any) -> bool:
97+
"""True if *obj* is a genuine sktime estimator/object, not a bare value.
98+
99+
craft evaluates arbitrary specs, so a spec like "42" returns an int. Such
100+
non-objects should not receive an estimator handle (BUG-10). We accept
101+
anything deriving from skbase's BaseObject, falling back to a duck-typed
102+
check for get_params + a scitype tag.
103+
"""
104+
try:
105+
from skbase.base import BaseObject
106+
107+
if isinstance(obj, BaseObject):
108+
return True
109+
except Exception: # pragma: no cover - skbase always present with sktime
110+
pass
111+
return hasattr(obj, "get_params") and hasattr(obj, "get_class_tag")
112+
90113

91114
def _cap_prediction_rows(result: dict) -> tuple[dict, dict | None]:
92115
"""Cap an index-keyed prediction dict, returning (capped, truncation_note)."""
@@ -326,6 +349,19 @@ def mock_all_estimators(*args, **kwargs):
326349
finally:
327350
_craft_module.all_estimators = original_all
328351

352+
# Reject specs that don't produce an sktime object — e.g. "42",
353+
# "[1,2,3]", "None" otherwise got est_ handles that failed
354+
# confusingly downstream (BUG-10).
355+
if not _is_sktime_object(instance):
356+
return {
357+
"success": False,
358+
"error": (
359+
f"Spec did not produce an sktime estimator, got "
360+
f"{type(instance).__name__}. Provide a craft spec such as "
361+
"'NaiveForecaster(sp=12)' or 'Detrender() * ARIMA()'."
362+
),
363+
}
364+
329365
estimator_name = type(instance).__name__
330366
handle_id = self._handle_manager.create_handle(
331367
estimator_name=estimator_name,
@@ -732,6 +768,18 @@ def call_method(
732768
except KeyError:
733769
return {"success": False, "error": self._handle_manager.describe_missing(handle_id)}
734770

771+
# Block private/dunder methods: they are not part of the estimator API
772+
# and expose internals — e.g. __reduce__ dumps __dict__ including the
773+
# fitted _y/_X training data to any caller (BUG-11).
774+
if method_name.startswith("_") and method_name not in _ALLOWED_DUNDERS:
775+
return {
776+
"success": False,
777+
"error": (
778+
f"Method '{method_name}' is private and not callable via call_method. "
779+
"Only public estimator methods are exposed."
780+
),
781+
}
782+
735783
if not hasattr(instance, method_name):
736784
obj_type = getattr(instance, "get_class_tag", lambda x, y: "")("object_type", "")
737785
return {

src/sktime_mcp/server.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -958,16 +958,18 @@ async def list_tools() -> list[Tool]:
958958
Tool(
959959
name="run_command",
960960
description=(
961-
"Run an arbitrary CLI/bash command inside the sktime container. "
962-
"Use this to install missing python packages (e.g., 'pip install mlflow') "
963-
"or inspect the file system."
961+
"Run an arbitrary CLI/bash command on the HOST machine, in the server's "
962+
"working directory, as the user that launched the server (not a container "
963+
"or sandbox). Use it to install packages into the server's environment "
964+
"(e.g. the server's python -m pip install mlflow) or inspect the filesystem. "
965+
"Output is capped; a 'truncated' flag indicates when it was shortened."
964966
),
965967
inputSchema={
966968
"type": "object",
967969
"properties": {
968970
"command": {
969971
"type": "string",
970-
"description": "The bash command to run",
972+
"description": "The shell command to run (executed via /bin/sh -c).",
971973
},
972974
},
973975
"required": ["command"],

src/sktime_mcp/tools/run_command.py

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,51 @@
11
import subprocess
22
from typing import Any
33

4+
# Cap on returned output so a command like `seq 1 100000` (688k chars observed)
5+
# can't overflow the client (BUG-23).
6+
_MAX_OUTPUT_CHARS = 20_000
7+
8+
9+
def _truncate(text: str) -> tuple[str, bool]:
10+
if len(text) <= _MAX_OUTPUT_CHARS:
11+
return text, False
12+
head = _MAX_OUTPUT_CHARS // 2
13+
tail = _MAX_OUTPUT_CHARS - head
14+
omitted = len(text) - _MAX_OUTPUT_CHARS
15+
return (
16+
f"{text[:head]}\n...[{omitted} characters truncated]...\n{text[-tail:]}",
17+
True,
18+
)
19+
420

521
def run_command_tool(command: str) -> dict[str, Any]:
622
"""
7-
Run an arbitrary CLI/bash command inside the sktime-mcp container.
23+
Run an arbitrary CLI/bash command on the host, in the server's working
24+
directory, as the user that launched the server.
25+
26+
Note: this executes on the host machine (not a container or sandbox) with
27+
the server's own permissions. Output is capped; see ``truncated``.
828
"""
929
try:
1030
result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=120)
1131
output = result.stdout
1232
if result.stderr:
1333
output += f"\nErrors:\n{result.stderr}"
1434

15-
return {
35+
output, truncated = _truncate(output.strip())
36+
response = {
1637
"success": result.returncode == 0,
17-
"output": output.strip(),
38+
"output": output,
1839
"returncode": result.returncode,
40+
"truncated": truncated,
1941
}
42+
# Every other tool reports failures under "error"; do the same on a
43+
# non-zero exit so callers can branch on it uniformly.
44+
if result.returncode != 0:
45+
response["error"] = (
46+
result.stderr.strip() or f"Command exited with code {result.returncode}"
47+
)
48+
return response
2049
except subprocess.TimeoutExpired:
2150
return {"success": False, "error": "Command execution timed out after 120 seconds."}
2251
except Exception as e:

tests/test_evaluate_scitype.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ def test_rejects_transformer():
3232

3333

3434
def test_rejects_non_estimator():
35-
res = instantiate_tool(spec="42")
36-
handle = res["handle"]
35+
# instantiate now blocks non-estimators (BUG-10), so inject an int handle
36+
# directly to exercise evaluate's own object_type guard.
37+
handle = get_handle_manager().create_handle("int", 42, {})
3738
try:
3839
out = evaluate_tool(estimator_handle=handle, y="airline", cv_folds=3)
3940
assert not out["success"]
40-
# int handle has no forecaster object_type -> rejected (here or by scitype)
4141
assert "forecaster" in out["error"].lower() or "series" in out["error"].lower()
4242
finally:
4343
_release(handle)

tests/test_security_hardening.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Conservative trust-boundary hardening (#540).
2+
3+
Covers the non-controversial parts: block private/dunder methods in
4+
call_method (BUG-11), reject non-estimator instantiate results (BUG-10), and
5+
cap run_command output (BUG-23). instantiate sandboxing (BUG-09) is
6+
intentionally out of scope — the server already exposes run_command.
7+
"""
8+
9+
import contextlib
10+
11+
import pytest
12+
13+
from sktime_mcp.runtime.executor import get_executor
14+
from sktime_mcp.runtime.handles import get_handle_manager
15+
from sktime_mcp.tools.instantiate import instantiate_tool
16+
from sktime_mcp.tools.run_command import run_command_tool
17+
18+
19+
def _release(handle):
20+
with contextlib.suppress(KeyError):
21+
get_handle_manager().release_handle(handle)
22+
23+
24+
class TestCallMethodDenylist:
25+
def test_reduce_blocked(self):
26+
h = instantiate_tool(spec="NaiveForecaster()")["handle"]
27+
try:
28+
res = get_executor().call_method(handle_id=h, method_name="__reduce__", kwargs={})
29+
assert not res["success"]
30+
assert "private" in res["error"].lower()
31+
finally:
32+
_release(h)
33+
34+
@pytest.mark.parametrize("m", ["__class__", "__getattribute__", "_get_class_flags", "__init__"])
35+
def test_private_methods_blocked(self, m):
36+
h = instantiate_tool(spec="NaiveForecaster()")["handle"]
37+
try:
38+
res = get_executor().call_method(handle_id=h, method_name=m, kwargs={})
39+
assert not res["success"]
40+
assert "private" in res["error"].lower()
41+
finally:
42+
_release(h)
43+
44+
def test_public_method_still_works(self):
45+
h = instantiate_tool(spec="NaiveForecaster(sp=12)")["handle"]
46+
try:
47+
res = get_executor().call_method(handle_id=h, method_name="get_params", kwargs={})
48+
assert res["success"]
49+
assert res["result"]["sp"] == 12
50+
finally:
51+
_release(h)
52+
53+
54+
class TestInstantiateTypeCheck:
55+
@pytest.mark.parametrize("spec", ["42", "[1, 2, 3]", "'just a string'", "None"])
56+
def test_non_estimator_rejected(self, spec):
57+
res = instantiate_tool(spec=spec)
58+
assert not res["success"]
59+
assert "did not produce an sktime estimator" in res["error"]
60+
61+
def test_real_estimator_still_instantiates(self):
62+
res = instantiate_tool(spec="NaiveForecaster(sp=12)")
63+
try:
64+
assert res["success"]
65+
finally:
66+
_release(res.get("handle"))
67+
68+
69+
class TestRunCommandCap:
70+
def test_large_output_truncated(self):
71+
res = run_command_tool("seq 1 100000")
72+
assert res["success"]
73+
assert res["truncated"] is True
74+
assert len(res["output"]) < 25_000
75+
assert "truncated" in res["output"]
76+
77+
def test_small_output_not_truncated(self):
78+
res = run_command_tool("echo hello")
79+
assert res["success"]
80+
assert res["truncated"] is False
81+
assert res["output"] == "hello"
82+
83+
def test_nonzero_exit_has_error_key(self):
84+
res = run_command_tool("exit 3")
85+
assert not res["success"]
86+
assert res["returncode"] == 3
87+
assert "error" in res

0 commit comments

Comments
 (0)