mcp-shipyard 0.1.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.
- mcp_shipyard-0.1.0/PKG-INFO +78 -0
- mcp_shipyard-0.1.0/README.md +70 -0
- mcp_shipyard-0.1.0/__init__.py +31 -0
- mcp_shipyard-0.1.0/__main__.py +6 -0
- mcp_shipyard-0.1.0/cli.py +85 -0
- mcp_shipyard-0.1.0/environments.py +327 -0
- mcp_shipyard-0.1.0/features.py +98 -0
- mcp_shipyard-0.1.0/lease_store.py +176 -0
- mcp_shipyard-0.1.0/leases.py +406 -0
- mcp_shipyard-0.1.0/mcp_shipyard.egg-info/PKG-INFO +78 -0
- mcp_shipyard-0.1.0/mcp_shipyard.egg-info/SOURCES.txt +35 -0
- mcp_shipyard-0.1.0/mcp_shipyard.egg-info/dependency_links.txt +1 -0
- mcp_shipyard-0.1.0/mcp_shipyard.egg-info/entry_points.txt +2 -0
- mcp_shipyard-0.1.0/mcp_shipyard.egg-info/top_level.txt +1 -0
- mcp_shipyard-0.1.0/models.py +69 -0
- mcp_shipyard-0.1.0/payloads.py +38 -0
- mcp_shipyard-0.1.0/pipelines.py +89 -0
- mcp_shipyard-0.1.0/pyproject.toml +18 -0
- mcp_shipyard-0.1.0/responses.py +53 -0
- mcp_shipyard-0.1.0/server.py +242 -0
- mcp_shipyard-0.1.0/setup.cfg +4 -0
- mcp_shipyard-0.1.0/stdio.py +65 -0
- mcp_shipyard-0.1.0/tools.py +181 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mcp-shipyard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Application-independent MCP hosting, payloads, and responses.
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# MCP Shipyard
|
|
10
|
+
|
|
11
|
+
Install the `mcp-shipyard` distribution and import `mcp_shipyard`:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
python -m pip install mcp-shipyard
|
|
15
|
+
shipyard --help
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`mcp_shipyard` manages MCP applications independently of their tool set.
|
|
19
|
+
It contains no Exactus, District, Conjure, FastAPI, or Docker imports.
|
|
20
|
+
|
|
21
|
+
- `models.py`: JSON types, server identity, protocol failures, and outcomes.
|
|
22
|
+
- `payloads.py`: request envelope decoding and validation.
|
|
23
|
+
- `responses.py`: protocol and tool result encoding.
|
|
24
|
+
- `server.py`: negotiation, discovery, authenticated dispatch, and observers.
|
|
25
|
+
- `tools.py`: tool registration and explicit application authorization.
|
|
26
|
+
- `pipelines.py`: pipeline request and executor contracts, and tool adapter.
|
|
27
|
+
- `stdio.py`: bounded newline-delimited transport with caller-owned streams.
|
|
28
|
+
- `environments.py`: approved profiles, enforceable permissions, and disposable
|
|
29
|
+
worker lifecycle shared by providers.
|
|
30
|
+
- `lease_store.py` and `leases.py`: durable ownership, expiration, serialization,
|
|
31
|
+
restart recovery contracts and confirmed worker cleanup.
|
|
32
|
+
- `cli.py` and `__main__.py`: explicit application factory and local principal
|
|
33
|
+
for `python -m mcp_shipyard serve`.
|
|
34
|
+
- `__init__.py`: public API.
|
|
35
|
+
- `features.py`: optional terminal, package-install and resource-relay contracts.
|
|
36
|
+
- `pyproject.toml`: the independently installable `mcp-shipyard` distribution.
|
|
37
|
+
|
|
38
|
+
Applications supply identity, schemas, handlers, and authorization. Monitoring
|
|
39
|
+
is an injected observer; District can consume those events without becoming
|
|
40
|
+
the MCP application's protocol core. Pipeline adapters own worker selection,
|
|
41
|
+
queues, leases, cancellation, and cleanup. The host never interprets arbitrary
|
|
42
|
+
payload values as executable code. Unexpected application errors propagate;
|
|
43
|
+
only explicitly configured safe error types become MCP tool-error content.
|
|
44
|
+
|
|
45
|
+
The Exactus adapter in `mcp/exactus_mcp/mcp_server.py` now uses this shared
|
|
46
|
+
host and registry while retaining its existing tool names and permissions.
|
|
47
|
+
Its environment and REST services remain application adapters; importing this
|
|
48
|
+
library never imports or starts them. Observer parameters are transient and
|
|
49
|
+
must be redacted by a monitor before persistence. Stdio adapters do not close
|
|
50
|
+
streams owned by their callers.
|
|
51
|
+
|
|
52
|
+
Run the independent example from the repository root:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
python -m mcp_shipyard serve \
|
|
56
|
+
--application examples.tools.mcp_shipyard.echo:create --principal example-user
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The application factory and stdio principal are trusted operator configuration.
|
|
60
|
+
They must never be populated directly from remote request arguments.
|
|
61
|
+
|
|
62
|
+
Environment callers choose a registered profile and an argv vector. They
|
|
63
|
+
cannot override that profile's network, privilege, package, or exposure policy.
|
|
64
|
+
Providers must enforce both grants and denials before starting a worker.
|
|
65
|
+
The Conjure Docker adapter currently enforces Linux containers, non-root
|
|
66
|
+
execution, dropped capabilities, no privilege escalation, bounded CPU/memory,
|
|
67
|
+
read-only root, private scratch, bounded output and destruction on every exit.
|
|
68
|
+
Network `none` disables guest networking; `internet` enables Docker bridge
|
|
69
|
+
networking, including reachable private destinations. It is not an Internet-only
|
|
70
|
+
firewall. Allow-list egress, privilege grants, managed package installs, resource
|
|
71
|
+
publishing and interactive shell installation are refused by this adapter.
|
|
72
|
+
|
|
73
|
+
VM providers use the same interface, but no audited Windows or macOS template
|
|
74
|
+
is shipped here. Contract tests for those platform names are not native guest
|
|
75
|
+
execution evidence. A future VM adapter must clone immutable prepared images
|
|
76
|
+
and enforce host-side egress plus guest account policy before advertising those
|
|
77
|
+
capabilities. A shell for a network-isolated guest needs staged artifacts and an
|
|
78
|
+
explicit control channel; enabling arbitrary networking for bootstrap is unsafe.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# MCP Shipyard
|
|
2
|
+
|
|
3
|
+
Install the `mcp-shipyard` distribution and import `mcp_shipyard`:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
python -m pip install mcp-shipyard
|
|
7
|
+
shipyard --help
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
`mcp_shipyard` manages MCP applications independently of their tool set.
|
|
11
|
+
It contains no Exactus, District, Conjure, FastAPI, or Docker imports.
|
|
12
|
+
|
|
13
|
+
- `models.py`: JSON types, server identity, protocol failures, and outcomes.
|
|
14
|
+
- `payloads.py`: request envelope decoding and validation.
|
|
15
|
+
- `responses.py`: protocol and tool result encoding.
|
|
16
|
+
- `server.py`: negotiation, discovery, authenticated dispatch, and observers.
|
|
17
|
+
- `tools.py`: tool registration and explicit application authorization.
|
|
18
|
+
- `pipelines.py`: pipeline request and executor contracts, and tool adapter.
|
|
19
|
+
- `stdio.py`: bounded newline-delimited transport with caller-owned streams.
|
|
20
|
+
- `environments.py`: approved profiles, enforceable permissions, and disposable
|
|
21
|
+
worker lifecycle shared by providers.
|
|
22
|
+
- `lease_store.py` and `leases.py`: durable ownership, expiration, serialization,
|
|
23
|
+
restart recovery contracts and confirmed worker cleanup.
|
|
24
|
+
- `cli.py` and `__main__.py`: explicit application factory and local principal
|
|
25
|
+
for `python -m mcp_shipyard serve`.
|
|
26
|
+
- `__init__.py`: public API.
|
|
27
|
+
- `features.py`: optional terminal, package-install and resource-relay contracts.
|
|
28
|
+
- `pyproject.toml`: the independently installable `mcp-shipyard` distribution.
|
|
29
|
+
|
|
30
|
+
Applications supply identity, schemas, handlers, and authorization. Monitoring
|
|
31
|
+
is an injected observer; District can consume those events without becoming
|
|
32
|
+
the MCP application's protocol core. Pipeline adapters own worker selection,
|
|
33
|
+
queues, leases, cancellation, and cleanup. The host never interprets arbitrary
|
|
34
|
+
payload values as executable code. Unexpected application errors propagate;
|
|
35
|
+
only explicitly configured safe error types become MCP tool-error content.
|
|
36
|
+
|
|
37
|
+
The Exactus adapter in `mcp/exactus_mcp/mcp_server.py` now uses this shared
|
|
38
|
+
host and registry while retaining its existing tool names and permissions.
|
|
39
|
+
Its environment and REST services remain application adapters; importing this
|
|
40
|
+
library never imports or starts them. Observer parameters are transient and
|
|
41
|
+
must be redacted by a monitor before persistence. Stdio adapters do not close
|
|
42
|
+
streams owned by their callers.
|
|
43
|
+
|
|
44
|
+
Run the independent example from the repository root:
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
python -m mcp_shipyard serve \
|
|
48
|
+
--application examples.tools.mcp_shipyard.echo:create --principal example-user
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The application factory and stdio principal are trusted operator configuration.
|
|
52
|
+
They must never be populated directly from remote request arguments.
|
|
53
|
+
|
|
54
|
+
Environment callers choose a registered profile and an argv vector. They
|
|
55
|
+
cannot override that profile's network, privilege, package, or exposure policy.
|
|
56
|
+
Providers must enforce both grants and denials before starting a worker.
|
|
57
|
+
The Conjure Docker adapter currently enforces Linux containers, non-root
|
|
58
|
+
execution, dropped capabilities, no privilege escalation, bounded CPU/memory,
|
|
59
|
+
read-only root, private scratch, bounded output and destruction on every exit.
|
|
60
|
+
Network `none` disables guest networking; `internet` enables Docker bridge
|
|
61
|
+
networking, including reachable private destinations. It is not an Internet-only
|
|
62
|
+
firewall. Allow-list egress, privilege grants, managed package installs, resource
|
|
63
|
+
publishing and interactive shell installation are refused by this adapter.
|
|
64
|
+
|
|
65
|
+
VM providers use the same interface, but no audited Windows or macOS template
|
|
66
|
+
is shipped here. Contract tests for those platform names are not native guest
|
|
67
|
+
execution evidence. A future VM adapter must clone immutable prepared images
|
|
68
|
+
and enforce host-side egress plus guest account policy before advertising those
|
|
69
|
+
capabilities. A shell for a network-isolated guest needs staged artifacts and an
|
|
70
|
+
explicit control channel; enabling arbitrary networking for bootstrap is unsafe.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Application-independent Model Context Protocol server infrastructure."""
|
|
2
|
+
|
|
3
|
+
from .environments import (
|
|
4
|
+
EnvironmentPipeline,
|
|
5
|
+
EnvironmentProfile,
|
|
6
|
+
ResourcePolicy,
|
|
7
|
+
)
|
|
8
|
+
from .features import ExtendedEnvironment, TerminalChannel
|
|
9
|
+
from .lease_store import Lease, LeaseStore
|
|
10
|
+
from .leases import EnvironmentLeases, ManagedProvider
|
|
11
|
+
from .models import JsonObject, JsonValue, Outcome, RpcError, ServerIdentity
|
|
12
|
+
from .server import McpServer, RequestEvent
|
|
13
|
+
|
|
14
|
+
__all__: tuple[str, ...] = (
|
|
15
|
+
"EnvironmentLeases",
|
|
16
|
+
"EnvironmentPipeline",
|
|
17
|
+
"EnvironmentProfile",
|
|
18
|
+
"ExtendedEnvironment",
|
|
19
|
+
"JsonObject",
|
|
20
|
+
"JsonValue",
|
|
21
|
+
"Lease",
|
|
22
|
+
"LeaseStore",
|
|
23
|
+
"ManagedProvider",
|
|
24
|
+
"McpServer",
|
|
25
|
+
"Outcome",
|
|
26
|
+
"RequestEvent",
|
|
27
|
+
"ResourcePolicy",
|
|
28
|
+
"RpcError",
|
|
29
|
+
"ServerIdentity",
|
|
30
|
+
"TerminalChannel",
|
|
31
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Launch an operator-selected MCP application over the shared stdio host."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from argparse import ArgumentParser, Namespace
|
|
6
|
+
from importlib import import_module
|
|
7
|
+
from re import fullmatch
|
|
8
|
+
from sys import stdin, stdout
|
|
9
|
+
from typing import TYPE_CHECKING, Protocol, cast
|
|
10
|
+
|
|
11
|
+
from .stdio import serve
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from types import ModuleType
|
|
15
|
+
|
|
16
|
+
from .server import McpServer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ApplicationFactory(Protocol):
|
|
20
|
+
"""Construct an application from trusted launcher configuration."""
|
|
21
|
+
|
|
22
|
+
def __call__(self) -> McpServer[str]:
|
|
23
|
+
"""Create a fully configured MCP application.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Host, registered tools, authorization, and optional observers.
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(arguments: list[str] | None = None) -> int:
|
|
33
|
+
"""Launch a configured application without embedding its tool set.
|
|
34
|
+
|
|
35
|
+
The module is operator configuration, never an agent payload. Stdio
|
|
36
|
+
authority belongs to the launching parent process; HTTP authentication
|
|
37
|
+
must be supplied by that transport's adapter.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
arguments: Optional command-line argument list.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Zero after clean stdin EOF.
|
|
44
|
+
|
|
45
|
+
"""
|
|
46
|
+
parser: ArgumentParser = ArgumentParser(description=__doc__)
|
|
47
|
+
launcher: ArgumentParser = parser.add_subparsers(
|
|
48
|
+
dest="command",
|
|
49
|
+
required=True,
|
|
50
|
+
).add_parser(name="serve")
|
|
51
|
+
launcher.add_argument(
|
|
52
|
+
"--application",
|
|
53
|
+
required=True,
|
|
54
|
+
metavar="MODULE:FACTORY",
|
|
55
|
+
help="Trusted local application factory; never accepted from MCP.",
|
|
56
|
+
)
|
|
57
|
+
launcher.add_argument(
|
|
58
|
+
"--principal",
|
|
59
|
+
required=True,
|
|
60
|
+
help="Parent-established identity interpreted by the application.",
|
|
61
|
+
)
|
|
62
|
+
parsed: Namespace = parser.parse_args(args=arguments)
|
|
63
|
+
target: str = parsed.application
|
|
64
|
+
if (
|
|
65
|
+
fullmatch(
|
|
66
|
+
pattern=r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*:[A-Za-z_]\w*",
|
|
67
|
+
string=target,
|
|
68
|
+
)
|
|
69
|
+
is None
|
|
70
|
+
):
|
|
71
|
+
parser.error(message="--application must be a local MODULE:FACTORY")
|
|
72
|
+
module_name: str
|
|
73
|
+
factory_name: str
|
|
74
|
+
module_name, _, factory_name = target.partition(":")
|
|
75
|
+
module: ModuleType = import_module(name=module_name)
|
|
76
|
+
factory: ApplicationFactory = cast(
|
|
77
|
+
typ="ApplicationFactory",
|
|
78
|
+
val=getattr(module, factory_name),
|
|
79
|
+
)
|
|
80
|
+
return serve(
|
|
81
|
+
server=factory(),
|
|
82
|
+
principal=parsed.principal,
|
|
83
|
+
reader=stdin.buffer,
|
|
84
|
+
writer=stdout.buffer,
|
|
85
|
+
)
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Define one fail-closed disposable-environment workflow for every backend.
|
|
2
|
+
|
|
3
|
+
Profiles and providers are registered by the operator, never constructed from
|
|
4
|
+
agent-supplied capability claims. A provider must implement every requested
|
|
5
|
+
grant and denial. Merely running a command on a host is not sandboxing.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from math import isfinite
|
|
12
|
+
from secrets import token_hex
|
|
13
|
+
from typing import TYPE_CHECKING, Final, Literal, Protocol, final
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
|
|
18
|
+
from .models import JsonObject, JsonValue
|
|
19
|
+
from .pipelines import PipelineRequest
|
|
20
|
+
|
|
21
|
+
type NetworkMode = Literal["none", "internet", "allowlist"]
|
|
22
|
+
type PackageMode = Literal["disabled", "environment", "system"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@final
|
|
26
|
+
class Constants:
|
|
27
|
+
"""Bound agent-controlled command vectors.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
max_arguments: Maximum command argument count.
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
max_arguments: Final[int] = 256
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EnvironmentPolicyError(ValueError):
|
|
38
|
+
"""Reject an unenforceable policy before starting any worker."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
42
|
+
class ResourcePolicy:
|
|
43
|
+
"""Express grants and denials independently of host and guest OS.
|
|
44
|
+
|
|
45
|
+
Package policy applies to the broker's managed installation operation.
|
|
46
|
+
It does not claim that arbitrary writable code cannot import local files.
|
|
47
|
+
|
|
48
|
+
Attributes:
|
|
49
|
+
network: Egress mode enforced outside agent control.
|
|
50
|
+
allowed_hosts: Egress destinations when an allow-list is selected.
|
|
51
|
+
elevation: Whether guest elevation may be offered, never host sudo.
|
|
52
|
+
packages: Managed package installation scope.
|
|
53
|
+
expose_resources: Whether authenticated resources may be published.
|
|
54
|
+
timeout_seconds: Maximum pipeline command lifetime.
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
network: NetworkMode = "none"
|
|
59
|
+
allowed_hosts: tuple[str, ...] = ()
|
|
60
|
+
elevation: bool = False
|
|
61
|
+
packages: PackageMode = "disabled"
|
|
62
|
+
expose_resources: bool = False
|
|
63
|
+
timeout_seconds: float = 300.0
|
|
64
|
+
|
|
65
|
+
def __post_init__(self) -> None:
|
|
66
|
+
"""Reject ambiguous and unbounded security requests.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
EnvironmentPolicyError: When policy dimensions are inconsistent.
|
|
70
|
+
|
|
71
|
+
"""
|
|
72
|
+
if (
|
|
73
|
+
type(self.elevation) is not bool
|
|
74
|
+
or type(self.expose_resources) is not bool
|
|
75
|
+
or isinstance(self.timeout_seconds, bool)
|
|
76
|
+
or self.network not in ("none", "internet", "allowlist")
|
|
77
|
+
or self.packages not in ("disabled", "environment", "system")
|
|
78
|
+
or not isfinite(self.timeout_seconds)
|
|
79
|
+
or self.timeout_seconds <= 0
|
|
80
|
+
or bool(self.allowed_hosts) != (self.network == "allowlist")
|
|
81
|
+
or any(not host or "\x00" in host for host in self.allowed_hosts)
|
|
82
|
+
):
|
|
83
|
+
msg: str = "Invalid disposable environment policy"
|
|
84
|
+
raise EnvironmentPolicyError(msg)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
88
|
+
class RuntimeCapabilities:
|
|
89
|
+
"""Declare policy combinations a trusted provider can enforce.
|
|
90
|
+
|
|
91
|
+
These declarations belong to audited provider implementations, not to
|
|
92
|
+
clients or unverified image metadata. Denying a capability is itself a
|
|
93
|
+
capability: a VM with default administrator credentials cannot claim
|
|
94
|
+
``elevation_modes={False}``.
|
|
95
|
+
|
|
96
|
+
Attributes:
|
|
97
|
+
isolated: Whether this is a real disposable isolation boundary.
|
|
98
|
+
platforms: Supported guest OS identifiers.
|
|
99
|
+
network_modes: Egress modes this provider enforces.
|
|
100
|
+
elevation_modes: Guest privilege modes this provider enforces.
|
|
101
|
+
package_modes: Managed package installation modes.
|
|
102
|
+
exposure_modes: Whether resources may be published.
|
|
103
|
+
supports_district_shell: Whether a scoped shell can be installed.
|
|
104
|
+
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
isolated: bool
|
|
108
|
+
platforms: frozenset[str]
|
|
109
|
+
network_modes: frozenset[NetworkMode]
|
|
110
|
+
elevation_modes: frozenset[bool]
|
|
111
|
+
package_modes: frozenset[PackageMode]
|
|
112
|
+
exposure_modes: frozenset[bool]
|
|
113
|
+
supports_district_shell: bool = False
|
|
114
|
+
|
|
115
|
+
def require(self, *, profile: EnvironmentProfile) -> None:
|
|
116
|
+
"""Refuse unsupported grants and denials before worker creation.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
profile: Operator-approved environment configuration.
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
EnvironmentPolicyError: When any requested dimension is missing.
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
policy: ResourcePolicy = profile.policy
|
|
126
|
+
if not (
|
|
127
|
+
self.isolated
|
|
128
|
+
and profile.platform in self.platforms
|
|
129
|
+
and policy.network in self.network_modes
|
|
130
|
+
and policy.elevation in self.elevation_modes
|
|
131
|
+
and policy.packages in self.package_modes
|
|
132
|
+
and policy.expose_resources in self.exposure_modes
|
|
133
|
+
and (not profile.district_shell or self.supports_district_shell)
|
|
134
|
+
):
|
|
135
|
+
msg: str = "Provider cannot enforce the complete requested policy"
|
|
136
|
+
raise EnvironmentPolicyError(msg)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
140
|
+
class EnvironmentProfile:
|
|
141
|
+
"""Select one operator-approved template and security policy.
|
|
142
|
+
|
|
143
|
+
Attributes:
|
|
144
|
+
provider: Registered driver identifier.
|
|
145
|
+
template: Immutable prepared image or snapshot identifier.
|
|
146
|
+
platform: Guest OS identifier understood by the driver.
|
|
147
|
+
policy: Explicit resource policy.
|
|
148
|
+
district_shell: Whether to install an environment-scoped shell.
|
|
149
|
+
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
provider: str
|
|
153
|
+
template: str
|
|
154
|
+
platform: str
|
|
155
|
+
policy: ResourcePolicy = ResourcePolicy()
|
|
156
|
+
district_shell: bool = False
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
160
|
+
class EnvironmentCommandResult:
|
|
161
|
+
"""Return a command result without prescribing a transport.
|
|
162
|
+
|
|
163
|
+
Attributes:
|
|
164
|
+
exit_code: Actual guest command exit status.
|
|
165
|
+
stdout: Bounded command output.
|
|
166
|
+
stderr: Bounded diagnostic output.
|
|
167
|
+
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
exit_code: int
|
|
171
|
+
stdout: str
|
|
172
|
+
stderr: str
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class DisposableEnvironment(Protocol):
|
|
176
|
+
"""Own one disposable worker and its full cleanup lifecycle."""
|
|
177
|
+
|
|
178
|
+
def run(
|
|
179
|
+
self,
|
|
180
|
+
*,
|
|
181
|
+
argv: tuple[str, ...],
|
|
182
|
+
timeout_seconds: float,
|
|
183
|
+
) -> EnvironmentCommandResult:
|
|
184
|
+
"""Execute argv without implicit shell interpolation.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
argv: Guest command vector.
|
|
188
|
+
timeout_seconds: Command deadline enforced by the provider.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Real command status and bounded output.
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
...
|
|
195
|
+
|
|
196
|
+
def install_shell(self) -> None:
|
|
197
|
+
"""Install the approved shell using scoped bootstrap credentials."""
|
|
198
|
+
...
|
|
199
|
+
|
|
200
|
+
def close(self) -> None:
|
|
201
|
+
"""Destroy only this disposable worker and its owned scratch data.
|
|
202
|
+
|
|
203
|
+
Providers must make this idempotent and preserve immutable templates.
|
|
204
|
+
"""
|
|
205
|
+
...
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class EnvironmentProvider(Protocol):
|
|
209
|
+
"""Provide OS-specific isolation behind the same lifecycle contract."""
|
|
210
|
+
|
|
211
|
+
@property
|
|
212
|
+
def capabilities(self) -> RuntimeCapabilities:
|
|
213
|
+
"""Describe policy combinations this driver actually enforces.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
Trusted runtime capabilities.
|
|
217
|
+
|
|
218
|
+
"""
|
|
219
|
+
...
|
|
220
|
+
|
|
221
|
+
def create(
|
|
222
|
+
self,
|
|
223
|
+
*,
|
|
224
|
+
name: str,
|
|
225
|
+
profile: EnvironmentProfile,
|
|
226
|
+
) -> DisposableEnvironment:
|
|
227
|
+
"""Create a disposable clone, cleaning up partial failures itself.
|
|
228
|
+
|
|
229
|
+
Args:
|
|
230
|
+
name: Unique name generated by the host, never supplied by agents.
|
|
231
|
+
profile: Validated operator-approved template and policy.
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
Started worker whose lifecycle belongs to the caller.
|
|
235
|
+
|
|
236
|
+
"""
|
|
237
|
+
...
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class EnvironmentPipeline[Identity]:
|
|
241
|
+
"""Use one workflow for Docker and every registered VM guest OS."""
|
|
242
|
+
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
*,
|
|
246
|
+
profiles: Mapping[str, EnvironmentProfile],
|
|
247
|
+
providers: Mapping[str, EnvironmentProvider],
|
|
248
|
+
) -> None:
|
|
249
|
+
"""Freeze the operator's approved environment catalog.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
profiles: Agent-selectable names mapped to approved policies.
|
|
253
|
+
providers: Trusted implementations, such as Conjure adapters.
|
|
254
|
+
|
|
255
|
+
"""
|
|
256
|
+
self._profiles: dict[str, EnvironmentProfile] = dict(profiles)
|
|
257
|
+
self._providers: dict[str, EnvironmentProvider] = dict(providers)
|
|
258
|
+
|
|
259
|
+
def execute(self, *, request: PipelineRequest[Identity]) -> JsonObject:
|
|
260
|
+
"""Validate, clone, optionally install a shell, execute, and destroy.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
request: Authorized tool payload with ``profile`` and ``argv``.
|
|
264
|
+
Client-supplied policy overrides are rejected.
|
|
265
|
+
|
|
266
|
+
Returns:
|
|
267
|
+
Command outcome after required cleanup has completed.
|
|
268
|
+
|
|
269
|
+
Raises:
|
|
270
|
+
EnvironmentPolicyError: When the request or policy is unsafe.
|
|
271
|
+
BaseExceptionGroup: When both execution and cleanup fail.
|
|
272
|
+
|
|
273
|
+
"""
|
|
274
|
+
name: JsonValue = request.payload.get("profile")
|
|
275
|
+
raw: JsonValue = request.payload.get("argv")
|
|
276
|
+
if (
|
|
277
|
+
not isinstance(name, str)
|
|
278
|
+
or name not in self._profiles
|
|
279
|
+
or not isinstance(raw, list)
|
|
280
|
+
or not raw
|
|
281
|
+
or len(raw) > Constants.max_arguments
|
|
282
|
+
or any(not isinstance(arg, str) or "\x00" in arg for arg in raw)
|
|
283
|
+
or set(request.payload) - {"profile", "argv"}
|
|
284
|
+
):
|
|
285
|
+
msg: str = "Expected an approved profile and a safe argv vector"
|
|
286
|
+
raise EnvironmentPolicyError(msg)
|
|
287
|
+
argv: tuple[str, ...] = tuple(
|
|
288
|
+
arg for arg in raw if isinstance(arg, str)
|
|
289
|
+
)
|
|
290
|
+
profile: EnvironmentProfile = self._profiles[name]
|
|
291
|
+
provider: EnvironmentProvider | None = self._providers.get(
|
|
292
|
+
profile.provider,
|
|
293
|
+
)
|
|
294
|
+
if provider is None:
|
|
295
|
+
msg = "Environment provider is not registered"
|
|
296
|
+
raise EnvironmentPolicyError(msg)
|
|
297
|
+
provider.capabilities.require(profile=profile)
|
|
298
|
+
environment: DisposableEnvironment = provider.create(
|
|
299
|
+
name=f"mcp-{token_hex(nbytes=12)}",
|
|
300
|
+
profile=profile,
|
|
301
|
+
)
|
|
302
|
+
result: EnvironmentCommandResult
|
|
303
|
+
try:
|
|
304
|
+
if profile.district_shell:
|
|
305
|
+
environment.install_shell()
|
|
306
|
+
result = environment.run(
|
|
307
|
+
argv=argv,
|
|
308
|
+
timeout_seconds=profile.policy.timeout_seconds,
|
|
309
|
+
)
|
|
310
|
+
except BaseException as primary:
|
|
311
|
+
try:
|
|
312
|
+
environment.close()
|
|
313
|
+
except (OSError, RuntimeError, ValueError) as cleanup:
|
|
314
|
+
msg = "Environment execution and cleanup failed"
|
|
315
|
+
raise BaseExceptionGroup(
|
|
316
|
+
msg,
|
|
317
|
+
[primary, cleanup],
|
|
318
|
+
) from None
|
|
319
|
+
raise
|
|
320
|
+
environment.close()
|
|
321
|
+
return {
|
|
322
|
+
"exit_code": result.exit_code,
|
|
323
|
+
"stdout": result.stdout,
|
|
324
|
+
"stderr": result.stderr,
|
|
325
|
+
"provider": profile.provider,
|
|
326
|
+
"platform": profile.platform,
|
|
327
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Describe optional guest operations without depending on a VM backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from .environments import DisposableEnvironment
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .environments import EnvironmentCommandResult
|
|
13
|
+
from .models import JsonObject
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TerminalChannel(Protocol):
|
|
17
|
+
"""Own a bounded terminal stream until the environment is destroyed."""
|
|
18
|
+
|
|
19
|
+
def read(self, *, maximum: int = 65536) -> bytes:
|
|
20
|
+
"""Read one bounded chunk, returning empty bytes at EOF.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
maximum: Maximum bytes returned by one read.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Terminal output bytes.
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
def write(self, *, data: bytes) -> None:
|
|
32
|
+
"""Write bounded terminal input without interpreting it on the host.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
data: Raw input for the guest terminal.
|
|
36
|
+
|
|
37
|
+
"""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def close(self) -> None:
|
|
41
|
+
"""Stop and reap the owned control process."""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@runtime_checkable
|
|
46
|
+
class ExtendedEnvironment(DisposableEnvironment, Protocol):
|
|
47
|
+
"""Provide optional operations within the same isolation boundary."""
|
|
48
|
+
|
|
49
|
+
def open_terminal(self) -> TerminalChannel:
|
|
50
|
+
"""Open a guest terminal without enabling guest Internet access.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Owned byte stream; closing its lease destroys all guest children.
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
def install_packages(
|
|
59
|
+
self,
|
|
60
|
+
*,
|
|
61
|
+
wheels: tuple[Path, ...],
|
|
62
|
+
system: bool,
|
|
63
|
+
) -> EnvironmentCommandResult:
|
|
64
|
+
"""Install only staged, approved wheel artifacts without index access.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
wheels: Verified local wheel copies selected by the broker.
|
|
68
|
+
system: Whether an explicit guest system-package grant applies.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Actual offline pip outcome.
|
|
72
|
+
|
|
73
|
+
"""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
def request_resource(
|
|
77
|
+
self,
|
|
78
|
+
*,
|
|
79
|
+
port: int,
|
|
80
|
+
method: str,
|
|
81
|
+
path: str,
|
|
82
|
+
body: bytes,
|
|
83
|
+
content_type: str,
|
|
84
|
+
) -> JsonObject:
|
|
85
|
+
"""Relay bounded HTTP to guest loopback without opening a host port.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
port: Operator-approved guest service port.
|
|
89
|
+
method: Allowed HTTP method.
|
|
90
|
+
path: Relative HTTP request target.
|
|
91
|
+
body: Bounded request body.
|
|
92
|
+
content_type: Validated request media type.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Bounded response status, headers and base64 content.
|
|
96
|
+
|
|
97
|
+
"""
|
|
98
|
+
...
|