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.
Files changed (78) hide show
  1. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/PKG-INFO +8 -3
  2. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/README.md +7 -2
  3. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/configuration.md +3 -1
  4. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/security.md +14 -7
  5. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/pyproject.toml +1 -1
  6. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/config/loader.py +3 -1
  7. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/config/settings.py +3 -1
  8. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/argv.py +20 -0
  9. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/runner.py +2 -0
  10. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/mcp/tool_adapter.py +2 -1
  11. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/version.py +1 -1
  12. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_config.py +4 -0
  13. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_executor.py +29 -2
  14. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_mcp.py +14 -0
  15. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/.gitignore +0 -0
  16. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/LICENSE +0 -0
  17. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/architecture.md +0 -0
  18. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/clients.md +0 -0
  19. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/discovery.md +0 -0
  20. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/plugins.md +0 -0
  21. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/docs/transports.md +0 -0
  22. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/cli-mcp.yaml +0 -0
  23. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/clicksample.py +0 -0
  24. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/nodesample.js +0 -0
  25. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/reducer.py +0 -0
  26. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/typersample.py +0 -0
  27. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/examples/sample-clis/yargssample.js +0 -0
  28. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/__init__.py +0 -0
  29. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/cli/__init__.py +0 -0
  30. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/cli/main.py +0 -0
  31. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/config/__init__.py +0 -0
  32. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/core.py +0 -0
  33. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/detect.py +0 -0
  34. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/__init__.py +0 -0
  35. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/base.py +0 -0
  36. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/engine.py +0 -0
  37. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/grammars.py +0 -0
  38. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/help_parser.py +0 -0
  39. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/introspect.py +0 -0
  40. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/manifest.py +0 -0
  41. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/node_reflection.py +0 -0
  42. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/pipeline.py +0 -0
  43. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/python_reflection.py +0 -0
  44. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/reflection_payload.py +0 -0
  45. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/discovery/registry.py +0 -0
  46. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/errors.py +0 -0
  47. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/__init__.py +0 -0
  48. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/approval.py +0 -0
  49. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/executor/sandbox.py +0 -0
  50. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/mcp/__init__.py +0 -0
  51. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/mcp/server.py +0 -0
  52. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/models.py +0 -0
  53. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/plugins/__init__.py +0 -0
  54. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/__init__.py +0 -0
  55. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/docgen.py +0 -0
  56. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/jsonschema.py +0 -0
  57. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/openapi.py +0 -0
  58. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/schema/validate.py +0 -0
  59. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/security/__init__.py +0 -0
  60. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/security/policy.py +0 -0
  61. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/security/readonly.py +0 -0
  62. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/src/cli_mcp/transport/__init__.py +0 -0
  63. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/conftest.py +0 -0
  64. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_cli.py +0 -0
  65. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_core.py +0 -0
  66. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_detect.py +0 -0
  67. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_e2e.py +0 -0
  68. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_engine.py +0 -0
  69. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_grammars.py +0 -0
  70. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_help_parser.py +0 -0
  71. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_manifest.py +0 -0
  72. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_models.py +0 -0
  73. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_registry.py +0 -0
  74. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_runner_safe.py +0 -0
  75. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_safety.py +0 -0
  76. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_schema.py +0 -0
  77. {universal_cli_mcp_server-0.1.0 → universal_cli_mcp_server-0.2.0}/tests/test_security.py +0 -0
  78. {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.1.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 default working-directory
268
- mount is writable. Discovery still runs on the host. Firecracker support planned.
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 default working-directory
232
- mount is writable. Discovery still runs on the host. Firecracker support planned.
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` | `true` | Mount the working directory at `/work`. |
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 default bind mount is writable, so a sandboxed command can still change host
102
- files in that directory. Use `workdir_mount: false` for no working-directory host access, or
103
- configure read-only mounts in trusted runtime arguments. Do not expose runtime sockets or
104
- privileged mounts. Set runtime memory/CPU limits as appropriate; sandboxing is opt-in and
105
- does not sandbox discovery. Firecracker microVMs are planned for a later release.
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. These limits do not bound the target program's own RAM
113
- or CPU usage. Discovery also uses bounded output capture.
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "universal-cli-mcp-server"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Swagger for command line tools — expose any CLI as a fully compliant MCP server"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13"
@@ -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: true",
110
+ " workdir_mount: false",
109
111
  ]
110
112
  lines = [
111
113
  "# cli-mcp configuration",
@@ -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(True, description="Mount the current directory into the container")
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")
@@ -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):
@@ -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:
@@ -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")
@@ -1,3 +1,3 @@
1
1
  """Package version, kept in one place so tests and the CLI agree on it."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -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")