Quote names put into SDK client URL paths; share the helper with MCP

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013GAeg4Jo6xjZzjwy9QDWMQ
This commit is contained in:
2026-10-06 17:23:49 +02:00
co-authored by Claude Opus 5.5
parent 847abd67d6
commit 1090bcabfd
3 changed files with 49 additions and 24 deletions
+2 -12
View File
@@ -15,12 +15,13 @@ from __future__ import annotations
import logging import logging
from contextvars import ContextVar from contextvars import ContextVar
from typing import Any from typing import Any
from urllib.parse import quote
import httpx import httpx
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.server.lowlevel.server import request_ctx from mcp.server.lowlevel.server import request_ctx
from fluksio.sdk.client import seg as _seg
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
mcp = FastMCP("fluksio") mcp = FastMCP("fluksio")
@@ -86,17 +87,6 @@ async def forward_caller_auth(request: httpx.Request) -> None:
request.headers["authorization"] = authorization request.headers["authorization"] = authorization
def _seg(value: str) -> str:
"""*value* as one path segment, and nothing more.
httpx resolves ``..`` before sending, so a name put into a path as written
could climb out of the route it was meant for: ``save_flow("../ai/config")``
was ``PUT /ai/config``. ``quote`` covers the slashes but leaves dots alone,
hence encoding those too; the server decodes both.
"""
return quote(value, safe="").replace(".", "%2E")
async def _call(method: str, path: str, **kwargs: Any) -> Any: async def _call(method: str, path: str, **kwargs: Any) -> Any:
"""One REST call, with failures handed back as data rather than raised. """One REST call, with failures handed back as data rather than raised.
+27 -12
View File
@@ -15,6 +15,7 @@ import uuid
from collections.abc import Iterable from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote
from fluksio.sdk import MARKER, Flow, SyncError from fluksio.sdk import MARKER, Flow, SyncError
@@ -27,6 +28,7 @@ __all__ = [
"find_data_dir", "find_data_dir",
"ignore_self", "ignore_self",
"login", "login",
"seg",
"sync", "sync",
"write_config", "write_config",
] ]
@@ -126,6 +128,17 @@ def _stored() -> dict[str, str]:
return {} return {}
def seg(value: str) -> str:
"""*value* as one path segment, and nothing more.
httpx resolves ``..`` before sending, so a name put into a path as written
could climb out of the route it was meant for: ``get_flow("../ai/config")``
was ``GET /ai/config``. ``quote`` covers the slashes but leaves dots alone,
hence encoding those too; the server decodes both.
"""
return quote(value, safe="").replace(".", "%2E")
class ApiError(Exception): class ApiError(Exception):
"""The engine refused, with what it said.""" """The engine refused, with what it said."""
@@ -226,7 +239,7 @@ class Client:
def get_flow(self, name: str) -> dict[str, Any] | None: def get_flow(self, name: str) -> dict[str, Any] | None:
"""The stored flow, draft included, or ``None`` if there is none.""" """The stored flow, draft included, or ``None`` if there is none."""
try: try:
result: dict[str, Any] = self._call("GET", f"/flows/{name}") result: dict[str, Any] = self._call("GET", f"/flows/{seg(name)}")
except ApiError as exc: except ApiError as exc:
if exc.status == 404: if exc.status == 404:
return None return None
@@ -235,7 +248,7 @@ class Client:
def put_flow(self, document: dict[str, Any]) -> dict[str, Any]: def put_flow(self, document: dict[str, Any]) -> dict[str, Any]:
result: dict[str, Any] = self._call( result: dict[str, Any] = self._call(
"PUT", f"/flows/{document['name']}", json=document "PUT", f"/flows/{seg(document['name'])}", json=document
) )
return result return result
@@ -249,18 +262,20 @@ class Client:
somebody edited: the engine answers a new-node template for both, and somebody edited: the engine answers a new-node template for both, and
only one of them is work worth refusing to overwrite. only one of them is work worth refusing to overwrite.
""" """
result: dict[str, Any] = self._call("GET", f"/flows/{flow}/nodes/{node}/source") result: dict[str, Any] = self._call(
"GET", f"/flows/{seg(flow)}/nodes/{seg(node)}/source"
)
return result or {} return result or {}
def put_source(self, flow: str, node: str, code: str) -> dict[str, Any]: def put_source(self, flow: str, node: str, code: str) -> dict[str, Any]:
result: dict[str, Any] = self._call( result: dict[str, Any] = self._call(
"PUT", f"/flows/{flow}/nodes/{node}/source", json={"code": code} "PUT", f"/flows/{seg(flow)}/nodes/{seg(node)}/source", json={"code": code}
) )
return result return result
def publish(self, name: str, version: int) -> dict[str, Any]: def publish(self, name: str, version: int) -> dict[str, Any]:
result: dict[str, Any] = self._call( result: dict[str, Any] = self._call(
"POST", f"/flows/{name}/publish", json={"version": version} "POST", f"/flows/{seg(name)}/publish", json={"version": version}
) )
return result return result
@@ -345,7 +360,7 @@ class Client:
""" """
row = self._call( row = self._call(
"POST", "POST",
f"/runs/flows/{flow}", f"/runs/flows/{seg(flow)}",
idempotent=True, idempotent=True,
json={ json={
"params": params or {}, "params": params or {},
@@ -371,7 +386,7 @@ class Client:
keyed = [{**entry, "idempotency_key": uuid.uuid4().hex} for entry in entries] keyed = [{**entry, "idempotency_key": uuid.uuid4().hex} for entry in entries]
rows = self._call( rows = self._call(
"POST", "POST",
f"/runs/flows/{flow}/sweep", f"/runs/flows/{seg(flow)}/sweep",
idempotent=True, idempotent=True,
json={"runs": keyed, "no_cache": no_cache}, json={"runs": keyed, "no_cache": no_cache},
) )
@@ -397,7 +412,7 @@ class Client:
return answers return answers
def run(self, run_id: str) -> dict[str, Any]: def run(self, run_id: str) -> dict[str, Any]:
result: dict[str, Any] = self._call("GET", f"/runs/{run_id}") result: dict[str, Any] = self._call("GET", f"/runs/{seg(run_id)}")
return result return result
def runs(self, flow: str = "", limit: int = 20, **filters: Any) -> Any: def runs(self, flow: str = "", limit: int = 20, **filters: Any) -> Any:
@@ -410,7 +425,7 @@ class Client:
query: dict[str, Any] = {"stride": stride} query: dict[str, Any] = {"stride": stride}
if name: if name:
query["name"] = name query["name"] = name
return self._call("GET", f"/runs/{run_id}/metrics", params=query) return self._call("GET", f"/runs/{seg(run_id)}/metrics", params=query)
def metric_names( def metric_names(
self, ids: Iterable[str] = (), flow: str = "", **filters: Any self, ids: Iterable[str] = (), flow: str = "", **filters: Any
@@ -512,14 +527,14 @@ class Client:
def cancel(self, run_id: str) -> Any: def cancel(self, run_id: str) -> Any:
# Cancelling a cancelled run is cancelled. # Cancelling a cancelled run is cancelled.
return self._call("POST", f"/runs/{run_id}/cancel", idempotent=True) return self._call("POST", f"/runs/{seg(run_id)}/cancel", idempotent=True)
def retry(self, run_id: str) -> Any: def retry(self, run_id: str) -> Any:
"""Submit the same run again. Not idempotent: it makes a run.""" """Submit the same run again. Not idempotent: it makes a run."""
return self._call("POST", f"/runs/{run_id}/retry") return self._call("POST", f"/runs/{seg(run_id)}/retry")
def download(self, digest: str) -> bytes: def download(self, digest: str) -> bytes:
response = self._request("GET", f"/artifacts/{digest}", idempotent=True) response = self._request("GET", f"/artifacts/{seg(digest)}", idempotent=True)
if response.status_code >= 400: if response.status_code >= 400:
raise ApiError(response.status_code, _detail(response)) raise ApiError(response.status_code, _detail(response))
return bytes(response.content) return bytes(response.content)
+20
View File
@@ -181,3 +181,23 @@ def test_a_history_is_imported_a_page_at_a_time(monkeypatch):
assert [one["external_id"] for one in answers] == [ assert [one["external_id"] for one in answers] == [
e["external_id"] for e in entries e["external_id"] for e in entries
] ]
def test_a_name_cannot_climb_out_of_its_route(monkeypatch):
"""httpx resolves ``..``, so ``get_flow("../x")`` was ``GET /api/v1/x``."""
seen = []
def handler(request):
seen.append(request.url.raw_path.decode())
return httpx.Response(200, json={})
client = a_client(handler, monkeypatch)
client.get_flow("../x")
client.put_source("..", "../x", "code")
client.run("..")
assert seen == [
"/api/v1/flows/%2E%2E%2Fx",
"/api/v1/flows/%2E%2E/nodes/%2E%2E%2Fx/source",
"/api/v1/runs/%2E%2E",
]