provide-uterm-client 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- provide_uterm_client-0.5.0/PKG-INFO +28 -0
- provide_uterm_client-0.5.0/README.md +60 -0
- provide_uterm_client-0.5.0/VERSION +1 -0
- provide_uterm_client-0.5.0/pyproject.toml +102 -0
- provide_uterm_client-0.5.0/setup.cfg +4 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/__init__.py +7 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/auth.py +252 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/cli.py +69 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/constants.py +29 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/patterns.py +113 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/policy.py +125 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/py.typed +0 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/server.py +28 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/server_impl.py +153 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/server_tools_gui.py +143 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/server_tools_hijack.py +244 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/server_tools_session.py +340 -0
- provide_uterm_client-0.5.0/src/provide/uterm/ai/server_validators.py +310 -0
- provide_uterm_client-0.5.0/src/provide/uterm/client/__init__.py +44 -0
- provide_uterm_client-0.5.0/src/provide/uterm/client/control_ws.py +186 -0
- provide_uterm_client-0.5.0/src/provide/uterm/client/hijack.py +494 -0
- provide_uterm_client-0.5.0/src/provide/uterm/client/mcp_tools.py +140 -0
- provide_uterm_client-0.5.0/src/provide/uterm/client/sanitizer.py +15 -0
- provide_uterm_client-0.5.0/src/provide/uterm/py.typed +0 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/__init__.py +83 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/_telnet_const.py +43 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/base.py +73 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/chaos.py +78 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/reconnect.py +201 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/ssh.py +367 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/telnet.py +77 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/telnet_client.py +104 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/telnet_server.py +137 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/telnet_transport.py +475 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/websocket.py +115 -0
- provide_uterm_client-0.5.0/src/provide/uterm/transports/ws_transport.py +136 -0
- provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/PKG-INFO +28 -0
- provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/SOURCES.txt +43 -0
- provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/dependency_links.txt +1 -0
- provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/entry_points.txt +2 -0
- provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/requires.txt +27 -0
- provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/top_level.txt +1 -0
- provide_uterm_client-0.5.0/tests/test_go_server_interop.py +410 -0
- provide_uterm_client-0.5.0/tests/test_lazy_imports.py +47 -0
- provide_uterm_client-0.5.0/tests/test_sanitizer.py +25 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: provide-uterm-client
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: Client libraries, transports, and AI/MCP integration for provide-uterm
|
|
5
|
+
License-Expression: AGPL-3.0-or-later
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: provide-uterm>=0.5.0
|
|
8
|
+
Requires-Dist: mcp>=2.0.0
|
|
9
|
+
Provides-Extra: http
|
|
10
|
+
Requires-Dist: httpx2>=2.10; extra == "http"
|
|
11
|
+
Provides-Extra: websocket
|
|
12
|
+
Requires-Dist: websockets>=16.0; extra == "websocket"
|
|
13
|
+
Provides-Extra: ssh
|
|
14
|
+
Requires-Dist: asyncssh>=2.22.0; extra == "ssh"
|
|
15
|
+
Provides-Extra: all
|
|
16
|
+
Requires-Dist: httpx2>=2.10; extra == "all"
|
|
17
|
+
Requires-Dist: websockets>=16.0; extra == "all"
|
|
18
|
+
Requires-Dist: asyncssh>=2.22.0; extra == "all"
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=9.0; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-asyncio>=1.3; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-cov>=7.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest-timeout>=2.4.0; extra == "dev"
|
|
24
|
+
Requires-Dist: mypy>=1.19; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.15; extra == "dev"
|
|
26
|
+
Requires-Dist: httpx2>=2.10; extra == "dev"
|
|
27
|
+
Requires-Dist: websockets>=16.0; extra == "dev"
|
|
28
|
+
Requires-Dist: asyncssh>=2.22.0; extra == "dev"
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
# provide-uterm-client
|
|
5
|
+
|
|
6
|
+
Async HTTP and WebSocket client library for the provide-uterm control plane.
|
|
7
|
+
Provides `HijackClient` for REST-based session and hijack management, plus
|
|
8
|
+
inline WebSocket clients (`AsyncInlineWebSocketClient`,
|
|
9
|
+
`SyncInlineWebSocketClient`) for real-time terminal data streams. Every method
|
|
10
|
+
returns `tuple[bool, dict]` for straightforward success/error handling.
|
|
11
|
+
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install provide-uterm-client[http] # HijackClient (httpx)
|
|
16
|
+
pip install provide-uterm-client[websocket] # WS clients (websockets)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Python 3.11+.
|
|
20
|
+
|
|
21
|
+
## HijackClient API
|
|
22
|
+
|
|
23
|
+
| Method | Description |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `acquire(worker_id, owner, lease_s)` | Acquire a lease-based hijack session |
|
|
26
|
+
| `heartbeat(worker_id, hijack_id, lease_s)` | Extend a hijack lease |
|
|
27
|
+
| `send(worker_id, hijack_id, keys, ...)` | Send input with optional prompt guard |
|
|
28
|
+
| `snapshot(worker_id, hijack_id, wait_ms)` | Read terminal snapshot |
|
|
29
|
+
| `events(worker_id, hijack_id, after_seq)` | Read event log |
|
|
30
|
+
| `step(worker_id, hijack_id)` | Single-step the worker loop |
|
|
31
|
+
| `release(worker_id, hijack_id)` | Release hijack, resume automation |
|
|
32
|
+
| `list_sessions()` | List all sessions |
|
|
33
|
+
| `get_session(session_id)` | Get session details |
|
|
34
|
+
| `session_snapshot(session_id)` | Get session terminal snapshot |
|
|
35
|
+
| `watch_session_events(session_id, ...)` | Long-poll session event stream |
|
|
36
|
+
| `quick_connect(connector_type, ...)` | Create an ephemeral session |
|
|
37
|
+
| `health()` | Server health check |
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from provide.uterm.client import HijackClient
|
|
43
|
+
|
|
44
|
+
async with HijackClient("http://localhost:8780") as c:
|
|
45
|
+
ok, data = await c.acquire("worker-1", owner="agent")
|
|
46
|
+
if ok:
|
|
47
|
+
ok, snap = await c.snapshot("worker-1", data["hijack_id"])
|
|
48
|
+
print(snap["snapshot"]["screen"])
|
|
49
|
+
await c.release("worker-1", data["hijack_id"])
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Key modules
|
|
53
|
+
|
|
54
|
+
- `provide.uterm.client.hijack` -- `HijackClient` async REST client
|
|
55
|
+
- `provide.uterm.client.control_ws` -- `AsyncInlineWebSocketClient`, `SyncInlineWebSocketClient`, `LogicalFrameDecoder`
|
|
56
|
+
- `provide.uterm.client.mcp_tools` -- shared helpers used by the AI package
|
|
57
|
+
|
|
58
|
+
## Links
|
|
59
|
+
|
|
60
|
+
- [Main repository README](../../README.md)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.5.0
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
[build-system]
|
|
5
|
+
requires = ["setuptools>=61.0", "wheel"]
|
|
6
|
+
build-backend = "setuptools.build_meta"
|
|
7
|
+
|
|
8
|
+
[project]
|
|
9
|
+
name = "provide-uterm-client"
|
|
10
|
+
dynamic = ["version"]
|
|
11
|
+
description = "Client libraries, transports, and AI/MCP integration for provide-uterm"
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
license = "AGPL-3.0-or-later"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"provide-uterm>=0.5.0",
|
|
16
|
+
# Base dependency, not an extra: `uterm-mcp` (below, [project.scripts]) is
|
|
17
|
+
# an unconditional console script pointing at provide.uterm.ai, which
|
|
18
|
+
# imports mcp at module scope. Gating mcp behind an extra would let
|
|
19
|
+
# `pip install provide-uterm-client` install a console script whose own
|
|
20
|
+
# import fails until the caller separately opts into an extra it has no
|
|
21
|
+
# way to discover from the entry point alone -- a latent packaging bug
|
|
22
|
+
# that predates this port (the old fastmcp-extra arrangement had the same
|
|
23
|
+
# shape). Declaring mcp unconditionally is the fix, not an oversight.
|
|
24
|
+
"mcp>=2.0.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
http = ["httpx2>=2.10"]
|
|
29
|
+
websocket = ["websockets>=16.0"]
|
|
30
|
+
ssh = ["asyncssh>=2.22.0"]
|
|
31
|
+
all = ["httpx2>=2.10", "websockets>=16.0", "asyncssh>=2.22.0"]
|
|
32
|
+
dev = [
|
|
33
|
+
"pytest>=9.0",
|
|
34
|
+
"pytest-asyncio>=1.3",
|
|
35
|
+
"pytest-cov>=7.0",
|
|
36
|
+
"pytest-timeout>=2.4.0",
|
|
37
|
+
"mypy>=1.19",
|
|
38
|
+
"ruff>=0.15",
|
|
39
|
+
"httpx2>=2.10",
|
|
40
|
+
"websockets>=16.0",
|
|
41
|
+
"asyncssh>=2.22.0",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
uterm-mcp = "provide.uterm.ai.cli:main"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.dynamic]
|
|
48
|
+
version = {file = "VERSION"}
|
|
49
|
+
|
|
50
|
+
[tool.setuptools.packages.find]
|
|
51
|
+
where = ["src"]
|
|
52
|
+
|
|
53
|
+
[tool.setuptools.package-data]
|
|
54
|
+
"provide.uterm" = ["py.typed"]
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
consider_namespace_packages = true
|
|
58
|
+
asyncio_mode = "auto"
|
|
59
|
+
testpaths = ["tests"]
|
|
60
|
+
markers = [
|
|
61
|
+
"go_interop: tests requiring a built Go uterm binary",
|
|
62
|
+
]
|
|
63
|
+
addopts = [
|
|
64
|
+
"--import-mode=importlib",
|
|
65
|
+
"-v",
|
|
66
|
+
"--strict-markers",
|
|
67
|
+
"--tb=short",
|
|
68
|
+
"-ra",
|
|
69
|
+
"--cov=provide.uterm.client",
|
|
70
|
+
"--cov=provide.uterm.transports",
|
|
71
|
+
"--cov=provide.uterm.ai",
|
|
72
|
+
"--cov-branch",
|
|
73
|
+
"--cov-report=term-missing",
|
|
74
|
+
"--cov-fail-under=100",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[tool.ruff]
|
|
78
|
+
target-version = "py311"
|
|
79
|
+
line-length = 120
|
|
80
|
+
|
|
81
|
+
[tool.ruff.lint]
|
|
82
|
+
select = [
|
|
83
|
+
"E", "W", "F", "I", "N", "UP", "B", "C4", "SIM", "TCH", "PTH",
|
|
84
|
+
"DTZ", "ICN", "ISC", "PERF", "PIE", "RET", "RSE", "TID", "S", "ARG", "RUF",
|
|
85
|
+
]
|
|
86
|
+
# Aligned with the workspace-root ruff config in ../../pyproject.toml.
|
|
87
|
+
ignore = [
|
|
88
|
+
"E501", "S101", "S104", "S108", "S110", "S112", "S311", "S603", "S607",
|
|
89
|
+
"S604", "F811", "N806", "N814", "B017", "SIM105", "SIM117", "ARG001",
|
|
90
|
+
"ARG002", "PTH108", "SIM108", "PERF401",
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
[tool.ruff.lint.per-file-ignores]
|
|
94
|
+
"__init__.py" = ["F401", "RUF022"]
|
|
95
|
+
"tests/**/*.py" = ["E402", "B905", "S101", "S105", "S106", "S110", "ARG", "TCH", "RUF003", "RUF012", "RUF015", "RUF043", "RUF059"]
|
|
96
|
+
|
|
97
|
+
[tool.mypy]
|
|
98
|
+
python_version = "3.11"
|
|
99
|
+
strict = true
|
|
100
|
+
namespace_packages = true
|
|
101
|
+
explicit_package_bases = true
|
|
102
|
+
mypy_path = ["src"]
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
"""Authorization chokepoint for MCP tool dispatch.
|
|
6
|
+
|
|
7
|
+
All MCP tool handlers go through :func:`authorize` (applied via
|
|
8
|
+
:func:`authorized` decorator) before their bodies execute. The chokepoint:
|
|
9
|
+
|
|
10
|
+
1. Resolves the calling :class:`McpPrincipal` via the transport's
|
|
11
|
+
authenticated identity (when available, from :func:`mcp.server.mcpserver.authenticated_principal`),
|
|
12
|
+
or falls back to the server's configured default principal.
|
|
13
|
+
2. Looks up the tool's required role in
|
|
14
|
+
:mod:`provide.uterm.ai.policy`.
|
|
15
|
+
3. Rejects with a typed :class:`AuthorizationDenied` (returned as a
|
|
16
|
+
structured error dict — never raised across the wire) before the tool
|
|
17
|
+
body runs.
|
|
18
|
+
|
|
19
|
+
Adding a new tool therefore requires an explicit
|
|
20
|
+
:data:`~provide.uterm.ai.policy.TOOL_REQUIRED_ROLES` entry; otherwise
|
|
21
|
+
the chokepoint refuses the call.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Awaitable, Callable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from functools import wraps
|
|
29
|
+
from typing import Any, Protocol, TypeVar, cast
|
|
30
|
+
|
|
31
|
+
from mcp.server.mcpserver import authenticated_principal
|
|
32
|
+
|
|
33
|
+
from provide.uterm.ai.policy import Role, required_role, role_at_least
|
|
34
|
+
|
|
35
|
+
F = TypeVar("F", bound=Callable[..., Awaitable[Any]])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True, frozen=True)
|
|
39
|
+
class McpPrincipal:
|
|
40
|
+
"""Principal calling an MCP tool.
|
|
41
|
+
|
|
42
|
+
Mirrors the fields used by ``provide.uterm.server.auth.Principal``
|
|
43
|
+
that matter at the MCP boundary. Kept lightweight: the MCP layer does
|
|
44
|
+
not need OIDC claims, just identity + role(s).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
subject_id: str = "anonymous"
|
|
48
|
+
roles: frozenset[str] = field(default_factory=lambda: frozenset({"viewer"}))
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def primary_role(self) -> str:
|
|
52
|
+
"""Return the highest-privilege role the principal holds."""
|
|
53
|
+
from provide.uterm.ai.policy import role_rank
|
|
54
|
+
|
|
55
|
+
if not self.roles:
|
|
56
|
+
return "viewer"
|
|
57
|
+
return max(self.roles, key=role_rank)
|
|
58
|
+
|
|
59
|
+
def has_at_least(self, minimum: Role) -> bool:
|
|
60
|
+
"""Return True if the principal has a role at least *minimum*."""
|
|
61
|
+
return any(role_at_least(r, minimum) for r in self.roles)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _ContextLike(Protocol):
|
|
65
|
+
"""Subset of the MCP ``Context`` the chokepoint uses (kept narrow for typing)."""
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def request_context(self) -> Any: ...
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class AuthorizationDenied(Exception): # noqa: N818 — domain-modeling name; not all exceptions end in Error.
|
|
72
|
+
"""Raised when a principal is not permitted to invoke a tool.
|
|
73
|
+
|
|
74
|
+
The chokepoint catches this and converts it into a structured error
|
|
75
|
+
dict so MCP clients receive a deterministic shape rather than an MCP
|
|
76
|
+
transport-level exception.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, *, tool: str, principal: McpPrincipal, required: Role) -> None:
|
|
80
|
+
self.tool = tool
|
|
81
|
+
self.principal = principal
|
|
82
|
+
self.required = required
|
|
83
|
+
super().__init__(
|
|
84
|
+
f"principal {principal.subject_id!r} (roles={sorted(principal.roles)}) "
|
|
85
|
+
f"is not authorized to call tool {tool!r} (requires {required!r})"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Principal resolution.
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
# MCP 2.0 removed fastmcp's per-request state bag (``ctx.get_state``), so the
|
|
94
|
+
# principal is no longer looked up by key. The SDK's own binding —
|
|
95
|
+
# ``authenticated_principal`` — supplies the authenticated (client, issuer,
|
|
96
|
+
# subject) identity directly, and returns None on unauthenticated transports
|
|
97
|
+
# such as stdio.
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def principal_from_headers(headers: dict[str, str] | None) -> McpPrincipal | None:
|
|
101
|
+
"""Build a principal from ``X-Uterm-Principal`` / ``X-Uterm-Role`` headers.
|
|
102
|
+
|
|
103
|
+
Returns ``None`` when neither header is present. Header lookup is
|
|
104
|
+
case-insensitive.
|
|
105
|
+
|
|
106
|
+
Security boundary: these headers are trusted only because they are
|
|
107
|
+
supplied locally by the operator launching the stdio server, via
|
|
108
|
+
``client_kwargs["headers"]``. They are NOT a remote caller's assertion.
|
|
109
|
+
If an HTTP transport is ever enabled for this server, this path must be
|
|
110
|
+
removed or gated behind a verified token first — MCP 2.0's
|
|
111
|
+
``Context.headers`` is explicit that client-supplied headers are never an
|
|
112
|
+
identity assertion.
|
|
113
|
+
"""
|
|
114
|
+
if not headers:
|
|
115
|
+
return None
|
|
116
|
+
lowered = {k.lower(): v for k, v in headers.items()}
|
|
117
|
+
subject = lowered.get("x-uterm-principal")
|
|
118
|
+
role = lowered.get("x-uterm-role")
|
|
119
|
+
if subject is None and role is None:
|
|
120
|
+
return None
|
|
121
|
+
return McpPrincipal(
|
|
122
|
+
subject_id=subject or "anonymous",
|
|
123
|
+
roles=frozenset({role}) if role else frozenset({"viewer"}),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _authenticated_identity(ctx: _ContextLike) -> str | None:
|
|
128
|
+
"""Return the transport-authenticated identity, or None.
|
|
129
|
+
|
|
130
|
+
Not a bare subject id: :func:`mcp.server.mcpserver.authenticated_principal`
|
|
131
|
+
returns a JSON-serialized ``(client_id, issuer, subject)`` triple (see
|
|
132
|
+
``mcp/server/request_state.py``), which becomes ``McpPrincipal.subject_id``
|
|
133
|
+
verbatim. That value is operator-visible — it surfaces in
|
|
134
|
+
:func:`deny_payload`'s ``"principal"`` field and in
|
|
135
|
+
:class:`AuthorizationDenied`'s exception message — so it must read as an
|
|
136
|
+
identity string, not be mistaken for a plain username.
|
|
137
|
+
|
|
138
|
+
``Context.request_context`` raises when no request is bound rather than
|
|
139
|
+
returning None, so the access is guarded: an unbound context is a
|
|
140
|
+
"no authenticated identity" answer, not an error worth propagating out of
|
|
141
|
+
an authorization check.
|
|
142
|
+
|
|
143
|
+
The subsequent call to ``authenticated_principal(request_context)`` is
|
|
144
|
+
deliberately left unguarded: it only reads a contextvar via
|
|
145
|
+
``get_access_token()`` (default ``None``) and never touches its ``ctx``
|
|
146
|
+
argument, so it cannot raise on a garbage or mock ``request_context`` —
|
|
147
|
+
unlike the property access above, there is nothing here for a bare
|
|
148
|
+
``except`` to usefully catch.
|
|
149
|
+
"""
|
|
150
|
+
try:
|
|
151
|
+
request_context = ctx.request_context
|
|
152
|
+
except Exception:
|
|
153
|
+
return None
|
|
154
|
+
return authenticated_principal(request_context)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def resolve_principal(
|
|
158
|
+
ctx: _ContextLike | None,
|
|
159
|
+
*,
|
|
160
|
+
default: McpPrincipal,
|
|
161
|
+
) -> McpPrincipal:
|
|
162
|
+
"""Resolve the principal for the current MCP request.
|
|
163
|
+
|
|
164
|
+
Lookup order:
|
|
165
|
+
|
|
166
|
+
1. The transport-authenticated identity, when the transport binds one
|
|
167
|
+
(:func:`mcp.server.mcpserver.authenticated_principal`). Roles come from
|
|
168
|
+
the configured *default*, because that binding carries identity, not
|
|
169
|
+
authorisation.
|
|
170
|
+
2. Configured server *default* (passed in by ``create_mcp_app``).
|
|
171
|
+
|
|
172
|
+
Kept ``async`` despite no longer awaiting anything, so that
|
|
173
|
+
:func:`authorized` and its tests need no change.
|
|
174
|
+
"""
|
|
175
|
+
if ctx is not None:
|
|
176
|
+
identity = _authenticated_identity(ctx)
|
|
177
|
+
if identity is not None:
|
|
178
|
+
return McpPrincipal(subject_id=identity, roles=default.roles)
|
|
179
|
+
return default
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Chokepoint dispatch.
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def deny_payload(err: AuthorizationDenied) -> dict[str, Any]:
|
|
188
|
+
"""Render an :class:`AuthorizationDenied` as a tool-result dict.
|
|
189
|
+
|
|
190
|
+
Shape matches the rest of the MCP tool surface (``success`` is False
|
|
191
|
+
and an ``error`` discriminator is included) so that callers can branch
|
|
192
|
+
on it without having to special-case authorization failures.
|
|
193
|
+
"""
|
|
194
|
+
return {
|
|
195
|
+
"success": False,
|
|
196
|
+
"error": "authorization_denied",
|
|
197
|
+
"tool": err.tool,
|
|
198
|
+
"required_role": err.required,
|
|
199
|
+
"principal": err.principal.subject_id,
|
|
200
|
+
"principal_roles": sorted(err.principal.roles),
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(slots=True)
|
|
205
|
+
class AuthorizationContext:
|
|
206
|
+
"""Bundle of state the chokepoint needs at every call.
|
|
207
|
+
|
|
208
|
+
A single instance is created by :func:`create_mcp_app` and closed over
|
|
209
|
+
by the per-tool wrappers. Holding it in one place lets tests inject a
|
|
210
|
+
different default principal without monkey-patching.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
default_principal: McpPrincipal
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def authorized(tool_name: str, auth_ctx: AuthorizationContext) -> Callable[[F], F]:
|
|
217
|
+
"""Return a decorator that gates *tool_name* on its required role.
|
|
218
|
+
|
|
219
|
+
Principal resolution uses the transport-authenticated identity when
|
|
220
|
+
available (via :func:`mcp.server.mcpserver.authenticated_principal`), and
|
|
221
|
+
falls back to ``auth_ctx.default_principal``. The decorator preserves the
|
|
222
|
+
wrapped function's signature via :func:`functools.wraps` so that the MCP
|
|
223
|
+
server can introspect parameter types as if no decoration were applied.
|
|
224
|
+
"""
|
|
225
|
+
minimum = required_role(tool_name)
|
|
226
|
+
|
|
227
|
+
def _decorator(fn: F) -> F:
|
|
228
|
+
@wraps(fn)
|
|
229
|
+
async def _wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
230
|
+
ctx = kwargs.get("ctx")
|
|
231
|
+
principal = await resolve_principal(
|
|
232
|
+
cast("_ContextLike | None", ctx),
|
|
233
|
+
default=auth_ctx.default_principal,
|
|
234
|
+
)
|
|
235
|
+
if not principal.has_at_least(minimum):
|
|
236
|
+
err = AuthorizationDenied(tool=tool_name, principal=principal, required=minimum)
|
|
237
|
+
return deny_payload(err)
|
|
238
|
+
return await fn(*args, **kwargs)
|
|
239
|
+
|
|
240
|
+
# Stash metadata for tests / introspection. Using ``setattr`` makes
|
|
241
|
+
# the dynamic attribute attachment explicit to both mypy and ty
|
|
242
|
+
# (both type checkers treat direct ``obj.attr = …`` on a typed
|
|
243
|
+
# callable as an unresolved-attribute error; ``setattr`` is the
|
|
244
|
+
# canonical opt-out).
|
|
245
|
+
setattr(_wrapper, "__uterm_tool_name__", tool_name) # noqa: B010
|
|
246
|
+
setattr(_wrapper, "__uterm_required_role__", minimum) # noqa: B010
|
|
247
|
+
# ``functools.wraps`` returns ``_Wrapped[…]`` rather than the
|
|
248
|
+
# original ``F``; the cast tells both type checkers the wrapper
|
|
249
|
+
# honours the same callable shape as the wrapped function.
|
|
250
|
+
return cast("F", _wrapper)
|
|
251
|
+
|
|
252
|
+
return _decorator
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
"""CLI entry point for the uterm-mcp server.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
uterm-mcp --url http://localhost:8780
|
|
10
|
+
uterm-mcp --url http://localhost:8780 --entity-prefix /agent
|
|
11
|
+
uterm-mcp --url http://localhost:8780 --header Authorization:"Bearer tok"
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="uterm-mcp",
|
|
23
|
+
description="MCP server for provide-uterm session and hijack control.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("--url", required=True, help="Base URL of the provide-uterm server.")
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--entity-prefix",
|
|
28
|
+
default="/worker",
|
|
29
|
+
help="Path prefix for worker endpoints (default: /worker).",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument(
|
|
32
|
+
"--header",
|
|
33
|
+
dest="headers",
|
|
34
|
+
action="append",
|
|
35
|
+
default=[],
|
|
36
|
+
help="Extra header as key:value (repeatable).",
|
|
37
|
+
)
|
|
38
|
+
# Default role granted to the stdio caller (the LLM) when no explicit
|
|
39
|
+
# identity headers are supplied. Operators must opt in to ``admin`` —
|
|
40
|
+
# see Finding #2 in the security review notes.
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--role",
|
|
43
|
+
dest="role",
|
|
44
|
+
choices=("admin", "operator", "viewer"),
|
|
45
|
+
default="operator",
|
|
46
|
+
help="Default role for the stdio caller (default: operator).",
|
|
47
|
+
)
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv: list[str] | None = None) -> None:
|
|
52
|
+
"""Parse args and run the MCP server on stdio."""
|
|
53
|
+
parser = _build_parser()
|
|
54
|
+
args = parser.parse_args(argv if argv is not None else sys.argv[1:])
|
|
55
|
+
|
|
56
|
+
headers: dict[str, str] = {}
|
|
57
|
+
for h in args.headers:
|
|
58
|
+
key, _, value = h.partition(":")
|
|
59
|
+
headers[key.strip()] = value.strip()
|
|
60
|
+
|
|
61
|
+
from provide.uterm.ai.server import create_mcp_app
|
|
62
|
+
|
|
63
|
+
app = create_mcp_app(
|
|
64
|
+
args.url,
|
|
65
|
+
entity_prefix=args.entity_prefix,
|
|
66
|
+
headers=headers if headers else None,
|
|
67
|
+
default_role=args.role,
|
|
68
|
+
)
|
|
69
|
+
app.run(transport="stdio")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
"""Input-hardening limits and policy toggles for the MCP tool surface.
|
|
6
|
+
|
|
7
|
+
Centralises the security tunables so no policy is hardcoded inline at the call
|
|
8
|
+
sites.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
# Keystroke byte cap for hijack_send (matches the sanitizer default so the two
|
|
14
|
+
# code paths cannot drift).
|
|
15
|
+
MAX_KEYSTROKE_BYTES: int = 4096
|
|
16
|
+
|
|
17
|
+
# Maximum length of a user/LLM-supplied regex pattern. The length cap removes
|
|
18
|
+
# the cheap amplification path; it is paired with a structural denylist (see
|
|
19
|
+
# ``provide.uterm.ai.patterns.has_catastrophic_construct``) that rejects nested
|
|
20
|
+
# quantifiers and quantified backreferences — the classic catastrophic-
|
|
21
|
+
# backtracking shapes a short, under-the-cap pattern can still trigger. True
|
|
22
|
+
# backtracking-time bounds would still require a ``regex``/``re2`` engine, so a
|
|
23
|
+
# residual risk for exotic shapes (e.g. overlapping alternations) remains.
|
|
24
|
+
MAX_USER_PATTERN_LEN: int = 512
|
|
25
|
+
|
|
26
|
+
# Whether MCP-driven session_create may target private/internal hosts. Defaults
|
|
27
|
+
# to deny: an LLM should not be able to pivot to 169.254.169.254, RFC1918, or
|
|
28
|
+
# loopback. Operators that genuinely need internal targets must opt in.
|
|
29
|
+
ALLOW_PRIVATE_HOSTS: bool = False
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#
|
|
2
|
+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
3
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
4
|
+
#
|
|
5
|
+
"""Structural ReDoS guard for untrusted (LLM/user-supplied) regex patterns.
|
|
6
|
+
|
|
7
|
+
The MCP tool surface lets a caller — including a low-privilege ``viewer`` role
|
|
8
|
+
via ``session_watch`` / ``session_subscribe`` — hand us a regex that we compile
|
|
9
|
+
with stdlib :mod:`re`. A pure length cap does not bound catastrophic
|
|
10
|
+
backtracking for *short* pathological patterns such as ``(a+)+$``, so before any
|
|
11
|
+
``re.compile`` we reject the cheap, well-known catastrophic constructs:
|
|
12
|
+
|
|
13
|
+
* **nested quantifiers** — a quantified group whose body is itself quantified
|
|
14
|
+
(``(a+)+``, ``(a*)*``, ``(a+)*``, ``(a*)+``, ``(\\w+)+`` …), the classic
|
|
15
|
+
exponential-backtracking shape; and
|
|
16
|
+
* **quantified backreferences** — ``\\1+``, ``(\\1)+`` …, which also drive
|
|
17
|
+
pathological backtracking.
|
|
18
|
+
|
|
19
|
+
Neither stdlib :mod:`re` (no time bound) nor a new third-party engine
|
|
20
|
+
(``re2``/``regex`` are not project dependencies) is used: this is a structural
|
|
21
|
+
denylist applied *in addition to* the length cap.
|
|
22
|
+
|
|
23
|
+
Residual risk (documented honestly): this does NOT catch every ReDoS shape —
|
|
24
|
+
notably overlapping alternations like ``(a|a)*`` or quantifier interactions
|
|
25
|
+
across adjacent tokens — because detecting those structurally without large
|
|
26
|
+
false-positive collateral is hard. It removes the cheap, classic amplification
|
|
27
|
+
paths an LLM is most likely to emit; egress/time bounds at the server remain the
|
|
28
|
+
defence-in-depth backstop.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
|
|
35
|
+
# A backreference token: ``\1`` .. ``\99`` (stdlib ``re`` allows up to 99 groups).
|
|
36
|
+
_BACKREF = re.compile(r"\\[1-9][0-9]?$")
|
|
37
|
+
|
|
38
|
+
# A quantified backreference *anywhere* in the pattern: ``\1+``, ``\2*``,
|
|
39
|
+
# ``\3{2,5}`` — the ``\`` is not itself escaped because the scan below skips
|
|
40
|
+
# escaped pairs when collecting groups, but a top-level backref+quantifier needs
|
|
41
|
+
# its own check (it is not wrapped in a group).
|
|
42
|
+
_QUANTIFIED_BACKREF = re.compile(r"\\[1-9][0-9]?[+*{]")
|
|
43
|
+
|
|
44
|
+
# Quantifier characters that, when they immediately follow a group close paren,
|
|
45
|
+
# make that group "repeated".
|
|
46
|
+
_QUANTIFIER_OPENERS = frozenset("+*{")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _group_bodies_with_following_char(pattern: str) -> list[tuple[str, str]]:
|
|
50
|
+
"""Return ``(body, char_after_close)`` for every balanced ``(...)`` group.
|
|
51
|
+
|
|
52
|
+
Escaped parens (``\\(`` / ``\\)``) are treated as literals, not group
|
|
53
|
+
delimiters. Unbalanced closes are ignored (the downstream stdlib compiler
|
|
54
|
+
rejects the pattern as invalid), so this never raises on malformed input.
|
|
55
|
+
"""
|
|
56
|
+
stack: list[int] = []
|
|
57
|
+
results: list[tuple[str, str]] = []
|
|
58
|
+
i = 0
|
|
59
|
+
n = len(pattern)
|
|
60
|
+
while i < n:
|
|
61
|
+
char = pattern[i]
|
|
62
|
+
if char == "\\":
|
|
63
|
+
# Skip the escaped character so ``\(`` / ``\)`` are literals.
|
|
64
|
+
i += 2
|
|
65
|
+
continue
|
|
66
|
+
if char == "(":
|
|
67
|
+
stack.append(i)
|
|
68
|
+
elif char == ")" and stack:
|
|
69
|
+
start = stack.pop()
|
|
70
|
+
body = pattern[start + 1 : i]
|
|
71
|
+
after = pattern[i + 1] if i + 1 < n else ""
|
|
72
|
+
results.append((body, after))
|
|
73
|
+
i += 1
|
|
74
|
+
return results
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _body_is_repeated_unit(body: str) -> bool:
|
|
78
|
+
"""Return ``True`` when a group *body* is itself a repeated/backref unit.
|
|
79
|
+
|
|
80
|
+
Such a body, when the enclosing group is also quantified, forms the classic
|
|
81
|
+
nested-quantifier (``(a+)+``) or quantified-backref-in-group (``(\\1)+``)
|
|
82
|
+
catastrophic shape.
|
|
83
|
+
"""
|
|
84
|
+
if not body:
|
|
85
|
+
return False
|
|
86
|
+
# The whole body is a backreference, e.g. ``(\1)+``.
|
|
87
|
+
if _BACKREF.fullmatch(body):
|
|
88
|
+
return True
|
|
89
|
+
last = body[-1]
|
|
90
|
+
# A lazy quantifier (``a+?``) ends in ``?``; the real quantifier is the
|
|
91
|
+
# char before it.
|
|
92
|
+
if last == "?":
|
|
93
|
+
return len(body) >= 2 and body[-2] in "+*}"
|
|
94
|
+
if last not in "+*}":
|
|
95
|
+
return False
|
|
96
|
+
# Guard against an escaped quantifier (``a\*``) being read as a quantifier.
|
|
97
|
+
return not (len(body) >= 2 and body[-2] == "\\")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def has_catastrophic_construct(pattern: str) -> bool:
|
|
101
|
+
"""Return ``True`` when *pattern* contains a known catastrophic construct.
|
|
102
|
+
|
|
103
|
+
Detects nested quantifiers (a quantified group whose body is itself
|
|
104
|
+
quantified) and quantified backreferences. See the module docstring for the
|
|
105
|
+
residual-risk caveats — this is a structural denylist, not a proof of
|
|
106
|
+
linear-time matching.
|
|
107
|
+
"""
|
|
108
|
+
if _QUANTIFIED_BACKREF.search(pattern):
|
|
109
|
+
return True
|
|
110
|
+
for body, after in _group_bodies_with_following_char(pattern):
|
|
111
|
+
if after in _QUANTIFIER_OPENERS and _body_is_repeated_unit(body):
|
|
112
|
+
return True
|
|
113
|
+
return False
|