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.
Files changed (45) hide show
  1. provide_uterm_client-0.5.0/PKG-INFO +28 -0
  2. provide_uterm_client-0.5.0/README.md +60 -0
  3. provide_uterm_client-0.5.0/VERSION +1 -0
  4. provide_uterm_client-0.5.0/pyproject.toml +102 -0
  5. provide_uterm_client-0.5.0/setup.cfg +4 -0
  6. provide_uterm_client-0.5.0/src/provide/uterm/ai/__init__.py +7 -0
  7. provide_uterm_client-0.5.0/src/provide/uterm/ai/auth.py +252 -0
  8. provide_uterm_client-0.5.0/src/provide/uterm/ai/cli.py +69 -0
  9. provide_uterm_client-0.5.0/src/provide/uterm/ai/constants.py +29 -0
  10. provide_uterm_client-0.5.0/src/provide/uterm/ai/patterns.py +113 -0
  11. provide_uterm_client-0.5.0/src/provide/uterm/ai/policy.py +125 -0
  12. provide_uterm_client-0.5.0/src/provide/uterm/ai/py.typed +0 -0
  13. provide_uterm_client-0.5.0/src/provide/uterm/ai/server.py +28 -0
  14. provide_uterm_client-0.5.0/src/provide/uterm/ai/server_impl.py +153 -0
  15. provide_uterm_client-0.5.0/src/provide/uterm/ai/server_tools_gui.py +143 -0
  16. provide_uterm_client-0.5.0/src/provide/uterm/ai/server_tools_hijack.py +244 -0
  17. provide_uterm_client-0.5.0/src/provide/uterm/ai/server_tools_session.py +340 -0
  18. provide_uterm_client-0.5.0/src/provide/uterm/ai/server_validators.py +310 -0
  19. provide_uterm_client-0.5.0/src/provide/uterm/client/__init__.py +44 -0
  20. provide_uterm_client-0.5.0/src/provide/uterm/client/control_ws.py +186 -0
  21. provide_uterm_client-0.5.0/src/provide/uterm/client/hijack.py +494 -0
  22. provide_uterm_client-0.5.0/src/provide/uterm/client/mcp_tools.py +140 -0
  23. provide_uterm_client-0.5.0/src/provide/uterm/client/sanitizer.py +15 -0
  24. provide_uterm_client-0.5.0/src/provide/uterm/py.typed +0 -0
  25. provide_uterm_client-0.5.0/src/provide/uterm/transports/__init__.py +83 -0
  26. provide_uterm_client-0.5.0/src/provide/uterm/transports/_telnet_const.py +43 -0
  27. provide_uterm_client-0.5.0/src/provide/uterm/transports/base.py +73 -0
  28. provide_uterm_client-0.5.0/src/provide/uterm/transports/chaos.py +78 -0
  29. provide_uterm_client-0.5.0/src/provide/uterm/transports/reconnect.py +201 -0
  30. provide_uterm_client-0.5.0/src/provide/uterm/transports/ssh.py +367 -0
  31. provide_uterm_client-0.5.0/src/provide/uterm/transports/telnet.py +77 -0
  32. provide_uterm_client-0.5.0/src/provide/uterm/transports/telnet_client.py +104 -0
  33. provide_uterm_client-0.5.0/src/provide/uterm/transports/telnet_server.py +137 -0
  34. provide_uterm_client-0.5.0/src/provide/uterm/transports/telnet_transport.py +475 -0
  35. provide_uterm_client-0.5.0/src/provide/uterm/transports/websocket.py +115 -0
  36. provide_uterm_client-0.5.0/src/provide/uterm/transports/ws_transport.py +136 -0
  37. provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/PKG-INFO +28 -0
  38. provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/SOURCES.txt +43 -0
  39. provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/dependency_links.txt +1 -0
  40. provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/entry_points.txt +2 -0
  41. provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/requires.txt +27 -0
  42. provide_uterm_client-0.5.0/src/provide_uterm_client.egg-info/top_level.txt +1 -0
  43. provide_uterm_client-0.5.0/tests/test_go_server_interop.py +410 -0
  44. provide_uterm_client-0.5.0/tests/test_lazy_imports.py +47 -0
  45. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
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
+ from provide.uterm.ai.server import create_mcp_app
6
+
7
+ __all__ = ["create_mcp_app"]
@@ -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