universal-cli-mcp-server 0.1.0__tar.gz → 0.2.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.
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/PKG-INFO +8 -3
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/README.md +7 -2
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/configuration.md +3 -1
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/security.md +14 -7
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/pyproject.toml +1 -1
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/config/loader.py +3 -1
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/config/settings.py +3 -1
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/argv.py +20 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/runner.py +2 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/mcp/tool_adapter.py +2 -1
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/version.py +1 -1
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_config.py +4 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_executor.py +29 -2
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_mcp.py +14 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/.gitignore +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/LICENSE +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/architecture.md +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/clients.md +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/discovery.md +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/plugins.md +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/transports.md +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/cli-mcp.yaml +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/clicksample.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/nodesample.js +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/reducer.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/typersample.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/yargssample.js +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/__init__.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/cli/__init__.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/cli/main.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/config/__init__.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/core.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/detect.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/__init__.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/base.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/engine.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/grammars.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/help_parser.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/introspect.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/manifest.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/node_reflection.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/pipeline.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/python_reflection.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/reflection_payload.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/registry.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/errors.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/__init__.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/approval.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/sandbox.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/mcp/__init__.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/mcp/server.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/models.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/plugins/__init__.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/__init__.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/docgen.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/jsonschema.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/openapi.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/validate.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/security/__init__.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/security/policy.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/security/readonly.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/transport/__init__.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/conftest.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_cli.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_core.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_detect.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_e2e.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_engine.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_grammars.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_help_parser.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_manifest.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_models.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_registry.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_runner_safe.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_safety.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_schema.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_security.py +0 -0
- {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_transport.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: universal-cli-mcp-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Swagger for command line tools — expose any CLI as a fully compliant MCP server
|
|
5
5
|
Project-URL: Homepage, https://github.com/AndreasHeine/cli-mcp
|
|
6
6
|
Project-URL: Repository, https://github.com/AndreasHeine/cli-mcp
|
|
@@ -250,10 +250,13 @@ security:
|
|
|
250
250
|
approval: true # human confirmation required
|
|
251
251
|
timeout_seconds: 30
|
|
252
252
|
max_output_bytes: 200000
|
|
253
|
+
max_argv_tokens: 256
|
|
254
|
+
max_argv_bytes: 32768
|
|
253
255
|
sandbox:
|
|
254
256
|
backend: docker
|
|
255
257
|
image: my-cli-image:latest
|
|
256
258
|
network: false
|
|
259
|
+
workdir_mount: false
|
|
257
260
|
```
|
|
258
261
|
|
|
259
262
|
- **Allow list** — a non-empty list restricts configured binaries by basename;
|
|
@@ -264,8 +267,10 @@ security:
|
|
|
264
267
|
- **Approval mode** — `auto`/`prompt` require a local human at a terminal; without a TTY
|
|
265
268
|
approval-required execution is refused. Clients cannot approve their own calls. Legacy
|
|
266
269
|
`explicit` refuses approval-required calls. Normal stdio clients do not provide a local TTY.
|
|
267
|
-
- **Sandboxing** — run the CLI inside Docker or Podman; the
|
|
268
|
-
|
|
270
|
+
- **Sandboxing** — run the CLI inside Docker or Podman; the working directory is not
|
|
271
|
+
mounted by default. Discovery still runs on the host. Firecracker support planned.
|
|
272
|
+
- **Execution limits** — each invocation is limited to 256 argv tokens and 32 KiB,
|
|
273
|
+
including the executable and separators, before approval or process creation.
|
|
269
274
|
- **Argument validation** — arguments are validated against the generated JSON schema
|
|
270
275
|
before the requested tool process is spawned. Arguments are not interpolated into
|
|
271
276
|
a shell string.
|
|
@@ -214,10 +214,13 @@ security:
|
|
|
214
214
|
approval: true # human confirmation required
|
|
215
215
|
timeout_seconds: 30
|
|
216
216
|
max_output_bytes: 200000
|
|
217
|
+
max_argv_tokens: 256
|
|
218
|
+
max_argv_bytes: 32768
|
|
217
219
|
sandbox:
|
|
218
220
|
backend: docker
|
|
219
221
|
image: my-cli-image:latest
|
|
220
222
|
network: false
|
|
223
|
+
workdir_mount: false
|
|
221
224
|
```
|
|
222
225
|
|
|
223
226
|
- **Allow list** — a non-empty list restricts configured binaries by basename;
|
|
@@ -228,8 +231,10 @@ security:
|
|
|
228
231
|
- **Approval mode** — `auto`/`prompt` require a local human at a terminal; without a TTY
|
|
229
232
|
approval-required execution is refused. Clients cannot approve their own calls. Legacy
|
|
230
233
|
`explicit` refuses approval-required calls. Normal stdio clients do not provide a local TTY.
|
|
231
|
-
- **Sandboxing** — run the CLI inside Docker or Podman; the
|
|
232
|
-
|
|
234
|
+
- **Sandboxing** — run the CLI inside Docker or Podman; the working directory is not
|
|
235
|
+
mounted by default. Discovery still runs on the host. Firecracker support planned.
|
|
236
|
+
- **Execution limits** — each invocation is limited to 256 argv tokens and 32 KiB,
|
|
237
|
+
including the executable and separators, before approval or process creation.
|
|
233
238
|
- **Argument validation** — arguments are validated against the generated JSON schema
|
|
234
239
|
before the requested tool process is spawned. Arguments are not interpolated into
|
|
235
240
|
a shell string.
|
|
@@ -36,10 +36,12 @@ cli-mcp init git --readonly --transport streamable-http --sandbox-image alpine:3
|
|
|
36
36
|
| `security.allow_patterns` | `[]` | Regex overrides for custom block patterns outside read-only mode only. |
|
|
37
37
|
| `security.timeout_seconds` | `60` | Hard limit per executed command. |
|
|
38
38
|
| `security.max_output_bytes` | `200000` | Maximum retained raw bytes per output stream, including timeouts. |
|
|
39
|
+
| `security.max_argv_tokens` | `256` | Maximum tokens in one complete CLI invocation, including the executable. |
|
|
40
|
+
| `security.max_argv_bytes` | `32768` | Maximum encoded bytes in one complete CLI invocation, including token separators. |
|
|
39
41
|
| `security.sandbox.backend` | `docker` | `docker` or `podman`. |
|
|
40
42
|
| `security.sandbox.image` | `alpine:3.20` | Container image the command runs in. |
|
|
41
43
|
| `security.sandbox.network` | `false` | Attach the container network. |
|
|
42
|
-
| `security.sandbox.workdir_mount` | `
|
|
44
|
+
| `security.sandbox.workdir_mount` | `false` | Mount the working directory at `/work`; opt in only when required. |
|
|
43
45
|
| `security.sandbox.container_workdir` | `/work` | Working directory inside the container. |
|
|
44
46
|
| `security.sandbox.extra_args` | `[]` | Extra arguments for the container runtime. |
|
|
45
47
|
| `discovery.strategies` | `[reflection, help, manifest]` | Ordered strategies to try. |
|
|
@@ -98,19 +98,26 @@ security:
|
|
|
98
98
|
```
|
|
99
99
|
|
|
100
100
|
cli-mcp checks the runtime is available (`docker`/`podman` on `PATH`) and prefixes the command with
|
|
101
|
-
`run --rm`. The
|
|
102
|
-
files
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
`run --rm`. The working directory is not mounted by default. Set `workdir_mount: true` only when
|
|
102
|
+
the command needs host files; that bind mount is writable, so the command can change files in the
|
|
103
|
+
directory. Do not expose runtime sockets or privileged mounts. Set runtime memory/CPU limits as
|
|
104
|
+
appropriate; sandboxing is opt-in and does not sandbox discovery. Firecracker microVMs are planned
|
|
105
|
+
for a later release.
|
|
106
106
|
|
|
107
107
|
## Limits
|
|
108
108
|
|
|
109
109
|
`timeout_seconds` limits execution and reports `exitCode: -1` with a
|
|
110
110
|
`[cli-mcp] timed out after Ns` note. `max_output_bytes` bounds retained raw bytes separately
|
|
111
111
|
for stdout and stderr, including partial timeout output; excess output is drained and discarded.
|
|
112
|
-
Truncation is reported in the result.
|
|
113
|
-
|
|
112
|
+
Truncation is reported in the result. `max_argv_tokens` and `max_argv_bytes` bound each complete
|
|
113
|
+
invocation, counting the executable and NUL separators; oversized or NUL-containing invocations
|
|
114
|
+
are rejected before approval or process creation. These limits do not bound the target program's
|
|
115
|
+
own RAM or CPU usage. Discovery also uses bounded output capture.
|
|
116
|
+
|
|
117
|
+
`execution.cwd` sets the process working directory but does not confine file access. Paths passed
|
|
118
|
+
as CLI arguments are interpreted by the target program and are not generally normalized or
|
|
119
|
+
restricted by cli-mcp; their behavior differs between CLIs. Use a container without host directory
|
|
120
|
+
mounts, or define and enforce CLI-specific path rules when filesystem confinement is required.
|
|
114
121
|
|
|
115
122
|
`discovery.max_commands` additionally limits uncached help invocations, checked before
|
|
116
123
|
process creation. The root query, failed attempts and alternate help flags consume this
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/config/loader.py
RENAMED
|
@@ -96,6 +96,8 @@ def template(
|
|
|
96
96
|
" approval_mode: auto # auto/prompt require a local TTY; explicit always refuses",
|
|
97
97
|
" timeout_seconds: 60",
|
|
98
98
|
" max_output_bytes: 200000",
|
|
99
|
+
" max_argv_tokens: 256",
|
|
100
|
+
" max_argv_bytes: 32768",
|
|
99
101
|
" allowlist:",
|
|
100
102
|
f" - {binary}",
|
|
101
103
|
]
|
|
@@ -105,7 +107,7 @@ def template(
|
|
|
105
107
|
" backend: docker",
|
|
106
108
|
f" image: {sandbox_image}",
|
|
107
109
|
" network: false",
|
|
108
|
-
" workdir_mount:
|
|
110
|
+
" workdir_mount: false",
|
|
109
111
|
]
|
|
110
112
|
lines = [
|
|
111
113
|
"# cli-mcp configuration",
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/config/settings.py
RENAMED
|
@@ -23,7 +23,7 @@ class SandboxSettings(BaseModel):
|
|
|
23
23
|
|
|
24
24
|
backend: Literal["docker", "podman"] = "docker"
|
|
25
25
|
image: str = "alpine:3.20"
|
|
26
|
-
workdir_mount: bool = Field(
|
|
26
|
+
workdir_mount: bool = Field(False, description="Mount the current directory into the container")
|
|
27
27
|
container_workdir: str = "/work"
|
|
28
28
|
network: bool = False
|
|
29
29
|
extra_args: list[str] = Field(default_factory=list)
|
|
@@ -53,6 +53,8 @@ class SecuritySettings(BaseModel):
|
|
|
53
53
|
allow_patterns: list[str] = Field(default_factory=list, description="Regexes that re-admit blocked verbs")
|
|
54
54
|
timeout_seconds: float = Field(60.0, gt=0)
|
|
55
55
|
max_output_bytes: int = Field(200_000, gt=0)
|
|
56
|
+
max_argv_tokens: int = Field(256, ge=1)
|
|
57
|
+
max_argv_bytes: int = Field(32_768, ge=1)
|
|
56
58
|
sandbox: SandboxSettings | None = None
|
|
57
59
|
|
|
58
60
|
@field_validator("readonly_argv")
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/argv.py
RENAMED
|
@@ -2,11 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
8
|
+
from cli_mcp.config.settings import SecuritySettings
|
|
9
|
+
from cli_mcp.errors import SecurityViolation
|
|
7
10
|
from cli_mcp.models import CommandSpec, Parameter, ParamKind
|
|
8
11
|
|
|
9
12
|
|
|
13
|
+
def enforce_argv_limits(argv: list[str], security: SecuritySettings) -> None:
|
|
14
|
+
"""Reject empty, NUL-containing or oversized CLI invocations."""
|
|
15
|
+
if not argv:
|
|
16
|
+
raise SecurityViolation("command argv must not be empty")
|
|
17
|
+
if any("\0" in token for token in argv):
|
|
18
|
+
raise SecurityViolation("command argv must not contain NUL bytes")
|
|
19
|
+
if len(argv) > security.max_argv_tokens:
|
|
20
|
+
raise SecurityViolation(
|
|
21
|
+
f"command argv has {len(argv)} tokens; limit is {security.max_argv_tokens}"
|
|
22
|
+
)
|
|
23
|
+
size = sum(len(os.fsencode(token)) + 1 for token in argv)
|
|
24
|
+
if size > security.max_argv_bytes:
|
|
25
|
+
raise SecurityViolation(
|
|
26
|
+
f"command argv is {size} bytes; limit is {security.max_argv_bytes}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
10
30
|
def format_value(param: Parameter, value: Any) -> str:
|
|
11
31
|
"""Render one argument value as a CLI token."""
|
|
12
32
|
if isinstance(value, bool):
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/runner.py
RENAMED
|
@@ -19,6 +19,7 @@ from typing import IO, Any
|
|
|
19
19
|
from cli_mcp.config.settings import SecuritySettings
|
|
20
20
|
from cli_mcp.detect import reject_batch_target
|
|
21
21
|
from cli_mcp.errors import ExecutionError
|
|
22
|
+
from cli_mcp.executor.argv import enforce_argv_limits
|
|
22
23
|
from cli_mcp.executor.sandbox import sandbox_argv, sandbox_available
|
|
23
24
|
|
|
24
25
|
TRUNCATION_NOTE = "\n[cli-mcp: output truncated]"
|
|
@@ -332,6 +333,7 @@ def run(
|
|
|
332
333
|
job/session members are terminated on timeout. Windows job members are also
|
|
333
334
|
terminated after successful exit; already-reaped POSIX IDs are not signaled.
|
|
334
335
|
"""
|
|
336
|
+
enforce_argv_limits(argv, security)
|
|
335
337
|
reject_batch_target(argv[0])
|
|
336
338
|
sandboxed = security.sandbox is not None
|
|
337
339
|
if sandboxed and security.sandbox is not None:
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/mcp/tool_adapter.py
RENAMED
|
@@ -16,7 +16,7 @@ from cli_mcp.config.settings import Settings
|
|
|
16
16
|
from cli_mcp.detect import executable_stem
|
|
17
17
|
from cli_mcp.errors import SecurityViolation
|
|
18
18
|
from cli_mcp.executor.approval import ApprovalGate
|
|
19
|
-
from cli_mcp.executor.argv import build_argv
|
|
19
|
+
from cli_mcp.executor.argv import build_argv, enforce_argv_limits
|
|
20
20
|
from cli_mcp.executor.runner import run
|
|
21
21
|
from cli_mcp.models import CommandSpec
|
|
22
22
|
from cli_mcp.schema.jsonschema import input_schema
|
|
@@ -67,6 +67,7 @@ def build_tool(
|
|
|
67
67
|
"""Validate, gate and run the command, returning the execution payload as JSON."""
|
|
68
68
|
validated = validate_arguments(spec, arguments)
|
|
69
69
|
argv = build_argv(launch, spec, validated)
|
|
70
|
+
enforce_argv_limits(argv, settings.security)
|
|
70
71
|
decision = policy.evaluate(settings.binary, spec, argv)
|
|
71
72
|
if not decision.allowed:
|
|
72
73
|
raise SecurityViolation(decision.reason or "command rejected by security policy")
|
|
@@ -91,6 +91,9 @@ def test_load_settings_defaults_without_file(tmp_path: Path) -> None:
|
|
|
91
91
|
settings = load_settings(cwd=tmp_path, binary="git")
|
|
92
92
|
assert settings.binary == "git"
|
|
93
93
|
assert settings.mcp.transport == "stdio"
|
|
94
|
+
assert settings.security.max_argv_tokens == 256
|
|
95
|
+
assert settings.security.max_argv_bytes == 32768
|
|
96
|
+
assert settings.security.sandbox is None
|
|
94
97
|
|
|
95
98
|
|
|
96
99
|
def test_load_settings_without_anything_raises(tmp_path: Path) -> None:
|
|
@@ -114,6 +117,7 @@ def test_template_round_trips_through_the_loader(tmp_path: Path) -> None:
|
|
|
114
117
|
assert settings.security.approval is True
|
|
115
118
|
assert settings.security.sandbox is not None
|
|
116
119
|
assert settings.security.sandbox.image == "alpine:3.20"
|
|
120
|
+
assert settings.security.sandbox.workdir_mount is False
|
|
117
121
|
assert "strategies: [reflection, help, manifest]" in text
|
|
118
122
|
|
|
119
123
|
|
|
@@ -9,7 +9,7 @@ import pytest
|
|
|
9
9
|
from cli_mcp.config.settings import SandboxSettings, SecuritySettings
|
|
10
10
|
from cli_mcp.errors import ApprovalRequired, ExecutionError, SecurityViolation
|
|
11
11
|
from cli_mcp.executor.approval import EXPLICIT_HINT, ApprovalGate
|
|
12
|
-
from cli_mcp.executor.argv import build_argv, format_value
|
|
12
|
+
from cli_mcp.executor.argv import build_argv, enforce_argv_limits, format_value
|
|
13
13
|
from cli_mcp.executor.runner import TRUNCATION_NOTE, run
|
|
14
14
|
from cli_mcp.executor.sandbox import sandbox_argv, sandbox_available
|
|
15
15
|
from cli_mcp.models import CommandSpec, Parameter, ParamKind
|
|
@@ -68,8 +68,22 @@ def test_build_argv_expands_positional_lists() -> None:
|
|
|
68
68
|
assert build_argv(["tool"], spec, {"left": "a.xml", "right": "b.xml"}) == ["tool", "diff", "a.xml", "b.xml"]
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def test_enforce_argv_limits_counts_tokens_bytes_and_nul_separators() -> None:
|
|
72
|
+
security = SecuritySettings(max_argv_tokens=2, max_argv_bytes=5)
|
|
73
|
+
enforce_argv_limits(["x", "é"], security)
|
|
74
|
+
|
|
75
|
+
with pytest.raises(SecurityViolation, match="2 tokens; limit is 1"):
|
|
76
|
+
enforce_argv_limits(["x", "y"], SecuritySettings(max_argv_tokens=1))
|
|
77
|
+
with pytest.raises(SecurityViolation, match="5 bytes; limit is 4"):
|
|
78
|
+
enforce_argv_limits(["x", "é"], SecuritySettings(max_argv_bytes=4))
|
|
79
|
+
with pytest.raises(SecurityViolation, match="NUL"):
|
|
80
|
+
enforce_argv_limits(["x", "bad\0arg"], SecuritySettings())
|
|
81
|
+
with pytest.raises(SecurityViolation, match="must not be empty"):
|
|
82
|
+
enforce_argv_limits([], SecuritySettings())
|
|
83
|
+
|
|
84
|
+
|
|
71
85
|
def test_sandbox_argv_wraps_the_command_in_a_container() -> None:
|
|
72
|
-
argv = sandbox_argv(["git", "status"], SandboxSettings(), Path("/work"))
|
|
86
|
+
argv = sandbox_argv(["git", "status"], SandboxSettings(workdir_mount=True), Path("/work"))
|
|
73
87
|
assert argv[:3] == ["docker", "run", "--rm"]
|
|
74
88
|
assert "--network=none" in argv
|
|
75
89
|
assert "--workdir" in argv and "/work" in argv
|
|
@@ -77,6 +91,14 @@ def test_sandbox_argv_wraps_the_command_in_a_container() -> None:
|
|
|
77
91
|
assert argv[-3:] == ["alpine:3.20", "git", "status"]
|
|
78
92
|
|
|
79
93
|
|
|
94
|
+
def test_sandbox_does_not_mount_the_working_directory_by_default() -> None:
|
|
95
|
+
settings = SandboxSettings()
|
|
96
|
+
assert settings.workdir_mount is False
|
|
97
|
+
argv = sandbox_argv(["tool"], settings, Path("/work"))
|
|
98
|
+
assert "-v" not in argv
|
|
99
|
+
assert "--workdir" not in argv
|
|
100
|
+
|
|
101
|
+
|
|
80
102
|
def test_sandbox_argv_keeps_network_and_extra_args() -> None:
|
|
81
103
|
sandbox = SandboxSettings(network=True, extra_args=["--user", "nobody"], image="alpine:3.20")
|
|
82
104
|
argv = sandbox_argv(["tool"], sandbox, None)
|
|
@@ -122,6 +144,11 @@ def test_run_raises_when_the_binary_is_missing() -> None:
|
|
|
122
144
|
run(["definitely-not-a-binary"], SecuritySettings())
|
|
123
145
|
|
|
124
146
|
|
|
147
|
+
def test_run_rejects_oversized_argv_before_process_creation() -> None:
|
|
148
|
+
with pytest.raises(SecurityViolation, match="tokens; limit is 1"):
|
|
149
|
+
run(["tool", "arg"], SecuritySettings(max_argv_tokens=1))
|
|
150
|
+
|
|
151
|
+
|
|
125
152
|
def test_run_requires_an_available_sandbox_backend(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
126
153
|
monkeypatch.setattr("cli_mcp.executor.runner.sandbox_available", lambda backend: False)
|
|
127
154
|
security = SecuritySettings(sandbox=SandboxSettings(backend="docker"))
|
|
@@ -125,6 +125,20 @@ async def test_handler_rejects_missing_required_arguments(tmp_path: Path) -> Non
|
|
|
125
125
|
await tool.handler(input=None, output="out.xml")
|
|
126
126
|
|
|
127
127
|
|
|
128
|
+
async def test_handler_enforces_argv_limits_before_approval() -> None:
|
|
129
|
+
security = SecuritySettings(approval=True, max_argv_bytes=32)
|
|
130
|
+
settings = Settings(binary="reducer.py", security=security)
|
|
131
|
+
tool = build_tool(
|
|
132
|
+
SPEC,
|
|
133
|
+
["reducer.py"],
|
|
134
|
+
settings,
|
|
135
|
+
AccessPolicy(security),
|
|
136
|
+
ApprovalGate(security, stdin=io.StringIO(), stdout=io.StringIO()),
|
|
137
|
+
)
|
|
138
|
+
with pytest.raises(SecurityViolation, match="bytes; limit is 32"):
|
|
139
|
+
await tool.handler(input="x" * 40, output="out.xml")
|
|
140
|
+
|
|
141
|
+
|
|
128
142
|
async def test_read_only_mode_blocks_destructive_tools(tmp_path: Path) -> None:
|
|
129
143
|
script = tmp_path / "git.py"
|
|
130
144
|
script.write_text("pass\n", encoding="utf-8")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/nodesample.js
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/reducer.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/cli/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/config/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/__init__.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/base.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/engine.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/grammars.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/manifest.py
RENAMED
|
File without changes
|
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/pipeline.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/registry.py
RENAMED
|
File without changes
|
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/__init__.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/approval.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/sandbox.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/mcp/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/plugins/__init__.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/__init__.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/docgen.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/jsonschema.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/openapi.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/validate.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/security/__init__.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/security/policy.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/security/readonly.py
RENAMED
|
File without changes
|
{universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/transport/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|