Commit 60d46a5ab for llama.cpp
commit 60d46a5abe4f13fa54af61c488047549c5840bbe
Author: Xuan-Son Nguyen <son@huggingface.co>
Date: Thu Nov 12 09:34:16 2026 +0100
server: when --tools-runtime is specified, ignore x-tool-runtime (#32119)
diff --git a/tools/server/README-dev.md b/tools/server/README-dev.md
index 17b39f0aa..01d1a65cf 100644
--- a/tools/server/README-dev.md
+++ b/tools/server/README-dev.md
@@ -201,7 +201,7 @@ Invoke a tool call, request body is a JSON object with:
Headers:
- `x-tool-cwd`: optional; if set, use as the CWD for tool; this is not part of tool's params because it's meant to be set by the runtime, not the LLM itself
-- `x-tool-runtime`: optional; if set, run the tool inside this isolate instead of on the host. Either `docker-container:<id>` or `podman-container:<id>`, using an already-running container, or `ssh:<target>`, running the tool on a remote host
+- `x-tool-runtime`: optional; if set, run the tool inside this isolate instead of on the host. Either `docker-container:<id>` or `podman-container:<id>`, using an already-running container, or `ssh:<target>`, running the tool on a remote host. Refused with 403 when the server is started with `--tools-runtime`, so clients cannot move tools out of that sandbox
Returns JSON object. There are two response formats (MCP tools use the same two formats: their result content is concatenated into `plain_text_response`, and RPC or tool errors are surfaced as the `error` string):
diff --git a/tools/server/server-tools.cpp b/tools/server/server-tools.cpp
index bde774b37..8070e14b4 100644
--- a/tools/server/server-tools.cpp
+++ b/tools/server/server-tools.cpp
@@ -2093,15 +2093,20 @@ void server_tools::setup(const std::vector<std::string> & enabled_tools,
}
// accept x-tool-runtime header to route tool I/O through an isolate, e.g. "docker-container:<id>";
- // falls back to the --tools-runtime isolate, if configured
+ // --tools-runtime wins: it is the sandbox, the client must not move tools out of it
if (params.contains("runtime")) {
params.erase("runtime");
}
auto runtime_header = get_header(req.headers, "x-tool-runtime");
- if (!runtime_header.empty()) {
- params["runtime"] = runtime_header;
- } else if (runtime) {
+ if (runtime) {
+ if (!runtime_header.empty()) {
+ res->status = 403;
+ res->data = safe_json_to_str(format_error_response("x-tool-runtime is not allowed when --tools-runtime is set", ERROR_TYPE_PERMISSION));
+ return res;
+ }
params["runtime"] = runtime->spec();
+ } else if (!runtime_header.empty()) {
+ params["runtime"] = runtime_header;
}
// x-resp-type header is only used by read_file for now
diff --git a/tools/server/tests/unit/test_tools_builtin.py b/tools/server/tests/unit/test_tools_builtin.py
index d4ebd28d1..b3af73604 100755
--- a/tools/server/tests/unit/test_tools_builtin.py
+++ b/tools/server/tests/unit/test_tools_builtin.py
@@ -206,6 +206,21 @@ def test_tools_builtin_runtime_header(container_engine: str, container_id: str):
assert "hello container" in exec_res["plain_text_response"]
+def test_tools_builtin_runtime_header_refused_with_configured_runtime(container_engine: str, container_id: str):
+ global server
+ server.server_tools_runtime = f"{container_engine}-container:{container_id}"
+ server.start()
+
+ res = call_tool("exec_shell_command", {"command": "echo sandboxed"})
+ assert "sandboxed" in res["plain_text_response"]
+
+ for runtime in ["ssh:user@internal-host", f"{container_engine}-container:other", server.server_tools_runtime]:
+ res = server.make_request("POST", "/tools",
+ data={"tool": "exec_shell_command", "params": {"command": "echo hi"}},
+ headers={"x-tool-runtime": runtime})
+ assert res.status_code == 403, res.body
+
+
def test_tools_builtin_runtime_header_unknown_scheme():
global server
server.start()