@supernova123/docker-mcp-server 0.2.4 → 0.2.5

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.
package/README.md CHANGED
@@ -141,11 +141,21 @@ claude mcp add docker -- npx -y @supernova123/docker-mcp-server
141
141
 
142
142
  ## Security
143
143
 
144
+ This server has **full Docker daemon access** via the Docker socket. It is designed for local development and trusted environments.
145
+
144
146
  - **Read-only by default**: all container and image tools read state; write operations (start/stop/remove) require explicit tool calls
145
- - **No API keys needed**: connects to local Docker daemon via socket
147
+ - **No API keys needed**: connects to local Docker socket (`/var/run/docker.sock`), not remote API tokens
146
148
  - **No network access**: all operations are local Docker API calls
149
+ - **Input validation**: Zod schemas on every tool parameter — command injection, path traversal, and env injection are rejected at the schema level
150
+ - **Output sanitization**: ANSI escape codes, invisible Unicode, and Docker stream headers are stripped from all tool output
151
+ - **Output size caps**: log output capped at 100KB, general output at 1MB, to prevent LLM context overflow
152
+ - **Parameter bounds**: command arrays limited to 50 args, env to 50 vars, log tail to 10K lines, timeouts enforced (600s health, 300s events)
147
153
  - **MIT License**: fully auditable
148
154
 
155
+ **Threat model**: any tool that calls Docker through this server can start any container with any flags, including privileged. The threat model is the same as giving a user shell access to the Docker socket. Do not expose this server to untrusted users.
156
+
157
+ For vulnerability reports, see [SECURITY.md](SECURITY.md).
158
+
149
159
  ## License
150
160
 
151
161
  MIT
package/SECURITY.md ADDED
@@ -0,0 +1,52 @@
1
+ # Security
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ If you discover a security vulnerability in this project, please report it responsibly.
6
+
7
+ **Email:** security@friendlygeorge.org
8
+
9
+ Please do not file public GitHub issues for security bugs.
10
+
11
+ ## Supported Versions
12
+
13
+ The latest release on npm (`@supernova123/docker-mcp-server`) is the only supported version. Security fixes are applied to the latest release only.
14
+
15
+ ## Threat Model
16
+
17
+ This server has **full Docker daemon access** via the Docker socket (`/var/run/docker.sock`). It is designed for **local development and trusted environments**.
18
+
19
+ **In scope:**
20
+ - Input validation on all tool parameters (command injection, path traversal, env injection)
21
+ - Output sanitization (ANSI escape stripping, invisible Unicode removal, output size caps)
22
+ - Schema-level bounds on all numeric and array parameters
23
+
24
+ **Out of scope:**
25
+ - Exposing this server to untrusted users or the public internet
26
+ - Running in multi-tenant environments without additional isolation
27
+ - Docker socket access control (by design, the server needs full daemon access)
28
+
29
+ **Key security properties:**
30
+ - No embedded credentials — the server uses the Docker socket, not API tokens
31
+ - No network egress — all communication is local Docker API calls
32
+ - Zod input validation on every tool parameter
33
+ - Output sanitization prevents prompt injection via command output
34
+ - Read-only tools marked with `readOnlyHint: true` in MCP metadata
35
+
36
+ ## Security Hardening (v0.2.5+)
37
+
38
+ Starting with v0.2.5, the server includes:
39
+ - **Command validation:** `exec_in_container` rejects shell metacharacters and enforces POSIX path rules
40
+ - **Path validation:** `build_image` restricts build context to local absolute paths (no URLs)
41
+ - **Output sanitization:** ANSI escapes, invisible Unicode, and Docker stream headers are stripped
42
+ - **Output size caps:** Log output capped at 100KB, general output at 1MB
43
+ - **Parameter bounds:** Command arrays limited to 50 args, env to 50 vars, log tail to 10K lines
44
+ - **Timeout enforcement:** Health watch (600s max), event listening (300s max)
45
+
46
+ ## Dependencies
47
+
48
+ Run `npm audit` regularly. The CI pipeline includes `npm audit --audit-level=high` on every push.
49
+
50
+ ## License
51
+
52
+ MIT — see [LICENSE](LICENSE) for details.
package/dist/docker.d.ts CHANGED
@@ -8,5 +8,10 @@ export declare function createDockerClient(options?: DockerClientOptions): Docke
8
8
  export declare function formatError(error: unknown): string;
9
9
  export declare function formatContainer(container: Dockerode.ContainerInfo): Record<string, unknown>;
10
10
  export declare function formatImage(image: Dockerode.ImageInfo): Record<string, unknown>;
11
+ /**
12
+ * Sanitize tool output: strip ANSI escapes, invisible Unicode, and truncate.
13
+ * Prevents prompt injection via output and caps LLM context cost.
14
+ */
15
+ export declare function sanitizeOutput(text: string, maxLength?: number): string;
11
16
  export declare function formatBytes(bytes: number): string;
12
17
  //# sourceMappingURL=docker.d.ts.map
package/dist/docker.js CHANGED
@@ -47,6 +47,25 @@ export function formatImage(image) {
47
47
  created: new Date(image.Created).toISOString(),
48
48
  };
49
49
  }
50
+ /**
51
+ * Sanitize tool output: strip ANSI escapes, invisible Unicode, and truncate.
52
+ * Prevents prompt injection via output and caps LLM context cost.
53
+ */
54
+ export function sanitizeOutput(text, maxLength = 1_000_000) {
55
+ // Strip ANSI escape codes (CSI, OSC, simple sequences)
56
+ text = text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
57
+ text = text.replace(/\x1b\][^\x07]*\x07/g, ""); // OSC terminated by BEL
58
+ text = text.replace(/\x1b[@-Z\\-_]/g, ""); // Single-char escapes
59
+ // Strip invisible Unicode (Tag chars, bidi overrides, zero-width)
60
+ text = text.replace(/[\uE0001-\uE007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g, "");
61
+ // Strip Docker stream-frame headers (8-byte prefix per frame)
62
+ text = text.replace(/^[\x00-\x0f]{8}/gm, "");
63
+ // Truncate to cap memory and LLM context cost
64
+ if (text.length > maxLength) {
65
+ return text.slice(0, maxLength) + `\n... [output truncated at ${maxLength} chars]`;
66
+ }
67
+ return text;
68
+ }
50
69
  export function formatBytes(bytes) {
51
70
  if (bytes === 0)
52
71
  return "0 B";
@@ -3,7 +3,7 @@ import { existsSync, statSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { promisify } from "util";
5
5
  import { ComposeUpSchema, ComposeDownSchema, ComposePsSchema, ComposeLogsSchema, ComposeRestartSchema, } from "../types.js";
6
- import { formatError } from "../docker.js";
6
+ import { formatError, sanitizeOutput } from "../docker.js";
7
7
  const execAsync = promisify(execCb);
8
8
  const COMPOSE_FILE_NAMES = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"];
9
9
  function resolveComposePath(inputPath) {
@@ -92,7 +92,8 @@ export function registerComposeTools(server) {
92
92
  if (params.services?.length)
93
93
  args.push(...params.services);
94
94
  const output = runCompose(params.path, args);
95
- return { content: [{ type: "text", text: output || "No logs found." }] };
95
+ // Use 100KB cap for log output to keep LLM context small
96
+ return { content: [{ type: "text", text: sanitizeOutput(output, 100_000) || "No logs found." }] };
96
97
  }
97
98
  catch (error) {
98
99
  return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
@@ -1,5 +1,5 @@
1
1
  import { ExecInContainerSchema } from "../types.js";
2
- import { formatError } from "../docker.js";
2
+ import { formatError, sanitizeOutput } from "../docker.js";
3
3
  export function registerExecTools(server, docker) {
4
4
  server.tool("exec_in_container", "Execute a command inside a running Docker container. Returns stdout, stderr, and exit code.", ExecInContainerSchema.shape, { openWorldHint: false }, async (params) => {
5
5
  try {
@@ -18,7 +18,7 @@ export function registerExecTools(server, docker) {
18
18
  stream.on("end", () => resolve(data));
19
19
  });
20
20
  const inspect = await exec.inspect();
21
- const cleanOutput = output.replace(/^[\x00-\x0f]{8}/gm, "");
21
+ const cleanOutput = sanitizeOutput(output);
22
22
  return {
23
23
  content: [{
24
24
  type: "text",
@@ -1,5 +1,5 @@
1
1
  import { StreamLogsSchema, ContainerStatsSchema } from "../types.js";
2
- import { formatError, formatBytes } from "../docker.js";
2
+ import { formatError, formatBytes, sanitizeOutput } from "../docker.js";
3
3
  export function registerLogsTools(server, docker) {
4
4
  server.tool("stream_logs", "Get logs from a single Docker container by ID or name. Use stream_logs for one container; use compose_logs for multi-service Compose stacks. Supports tail count (default 100 lines), since timestamp for filtering, and follow mode. Returns UTF-8 log text with multiplexed stream headers stripped, or 'No logs found.' when the container has no output. Read-only and safe to call repeatedly. Returns an error string if the container does not exist.", StreamLogsSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
5
5
  try {
@@ -12,7 +12,8 @@ export function registerLogsTools(server, docker) {
12
12
  follow: false,
13
13
  });
14
14
  // Dockerode returns a Buffer with multiplexed stream headers
15
- const output = logs.toString("utf-8").replace(/^[\x00-\x0f]{8}/gm, "");
15
+ // Use 100KB cap for logs to keep LLM context small
16
+ const output = sanitizeOutput(logs.toString("utf-8"), 100_000);
16
17
  return { content: [{ type: "text", text: output || "No logs found." }] };
17
18
  }
18
19
  catch (error) {
@@ -1,4 +1,5 @@
1
1
  import { ContainerHealthStatusSchema, ContainerResourceUsageSchema, WatchEventsSchema, SearchLogsSchema, ResourceAlertCheckSchema, MonitorDashboardSchema, } from "../types.js";
2
+ import { sanitizeOutput } from "../docker.js";
2
3
  export function registerMonitoringTools(server, docker) {
3
4
  // 1. fleet_status — health status of all running containers
4
5
  server.tool("container_health_status", "Check health status, uptime, and restart count for all running Docker containers. Returns JSON with container name, state, health probe status (healthy/unhealthy/no-healthcheck), and restart count. Use this for a quick fleet health overview; for resource metrics use container_resource_usage instead. Returns an array of objects with name, id, state, status, health, uptime, restartCount, and image fields. Read-only and safe to call repeatedly.", ContainerHealthStatusSchema.shape, { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, async (params) => {
@@ -146,7 +147,7 @@ export function registerMonitoringTools(server, docker) {
146
147
  tail: params.tail || 500,
147
148
  since: params.since ? Math.floor(new Date(params.since).getTime() / 1000) : undefined,
148
149
  });
149
- const output = logStream.toString("utf-8").replace(/^[\x00-\x0f]{8}/gm, "");
150
+ const output = sanitizeOutput(logStream.toString("utf-8"), 100_000);
150
151
  const lines = output.split("\n");
151
152
  for (const line of lines) {
152
153
  if (regex.test(line)) {
package/dist/types.d.ts CHANGED
@@ -320,15 +320,15 @@ export declare const WatchEventsSchema: z.ZodObject<{
320
320
  since: z.ZodOptional<z.ZodString>;
321
321
  duration: z.ZodOptional<z.ZodNumber>;
322
322
  }, "strip", z.ZodTypeAny, {
323
+ duration?: number | undefined;
323
324
  since?: string | undefined;
324
325
  container?: string | undefined;
325
326
  event_type?: "all" | "start" | "stop" | "die" | "restart" | "health_status" | "oom" | undefined;
326
- duration?: number | undefined;
327
327
  }, {
328
+ duration?: number | undefined;
328
329
  since?: string | undefined;
329
330
  container?: string | undefined;
330
331
  event_type?: "all" | "start" | "stop" | "die" | "restart" | "health_status" | "oom" | undefined;
331
- duration?: number | undefined;
332
332
  }>;
333
333
  export declare const SearchLogsSchema: z.ZodObject<{
334
334
  pattern: z.ZodString;
package/dist/types.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { z } from "zod";
2
+ // Validation regexes (shared across schemas)
3
+ const SAFE_CMD_ARG = /^[A-Za-z0-9_./:@%+=,\-]+$/;
4
+ const SAFE_PATH = /^\/[A-Za-z0-9_./\-]+$/;
5
+ const SAFE_ENV_KEY = /^[A-Z_][A-Z0-9_]*$/;
6
+ const SAFE_BUILD_CONTEXT = /^\/[A-Za-z0-9_./\-]+$/;
2
7
  // Container lifecycle schemas
3
8
  export const ListContainersSchema = z.object({
4
9
  all: z.boolean().optional().describe("Include stopped containers (default: false)"),
@@ -48,11 +53,12 @@ export const PullImageSchema = z.object({
48
53
  tag: z.string().optional().describe("Tag to pull (default: 'latest')"),
49
54
  });
50
55
  export const BuildImageSchema = z.object({
51
- context: z.string().describe("Build context path or Dockerfile content"),
56
+ context: z.string().regex(SAFE_BUILD_CONTEXT, "Build context must be a local absolute path (no URLs, no '..')")
57
+ .max(4096).describe("Build context path (local absolute path only)"),
52
58
  tag: z.string().describe("Tag for the built image (e.g., 'myapp:v1')"),
53
- dockerfile: z.string().optional().describe("Dockerfile name relative to context (default: 'Dockerfile')"),
54
- build_args: z.record(z.string()).optional().describe("Build arguments"),
55
- target: z.string().optional().describe("Target build stage"),
59
+ dockerfile: z.string().max(256).optional().describe("Dockerfile name relative to context (default: 'Dockerfile')"),
60
+ build_args: z.record(z.string().regex(SAFE_ENV_KEY, "Build arg key must be POSIX-style").max(256), z.string().max(4096)).optional().describe("Build arguments (keys must be POSIX-style)"),
61
+ target: z.string().max(256).optional().describe("Target build stage"),
56
62
  });
57
63
  export const RemoveImageSchema = z.object({
58
64
  image: z.string().describe("Image name or ID"),
@@ -93,8 +99,8 @@ export const CheckHealthSchema = z.object({
93
99
  });
94
100
  export const WatchHealthSchema = z.object({
95
101
  container_id: z.string().describe("Container ID or name"),
96
- timeout: z.number().optional().describe("Max seconds to wait (default: 60)"),
97
- interval: z.number().optional().describe("Seconds between polls (default: 5)"),
102
+ timeout: z.number().max(600).optional().describe("Max seconds to wait (default: 60, max: 600)"),
103
+ interval: z.number().max(60).optional().describe("Seconds between polls (default: 5, max: 60)"),
98
104
  });
99
105
  export const SetRestartPolicySchema = z.object({
100
106
  container_id: z.string().describe("Container ID or name"),
@@ -104,19 +110,21 @@ export const SetRestartPolicySchema = z.object({
104
110
  // Logs schemas
105
111
  export const StreamLogsSchema = z.object({
106
112
  container_id: z.string().describe("Container ID or name"),
107
- tail: z.number().optional().describe("Number of lines to show (default: 100)"),
113
+ tail: z.number().max(10000).optional().describe("Number of lines to show (default: 100, max: 10000)"),
108
114
  since: z.string().optional().describe("Show logs since timestamp (e.g., '2026-01-01T00:00:00Z')"),
109
115
  follow: z.boolean().optional().describe("Follow log output (default: false)"),
110
116
  });
111
117
  export const ContainerStatsSchema = z.object({
112
118
  container_id: z.string().describe("Container ID or name"),
113
119
  });
114
- // Exec schema
120
+ // Exec schema (validated per Finding 8.1 — command/working_dir/env constraints)
115
121
  export const ExecInContainerSchema = z.object({
116
122
  container_id: z.string().describe("Container ID or name"),
117
- command: z.array(z.string()).describe("Command to execute"),
118
- working_dir: z.string().optional().describe("Working directory inside container"),
119
- env: z.record(z.string()).optional().describe("Environment variables"),
123
+ command: z.array(z.string().max(500).regex(SAFE_CMD_ARG, "Command arg contains disallowed characters"))
124
+ .min(1).max(50).describe("Command to execute (max 50 args, alphanumeric + safe chars only)"),
125
+ working_dir: z.string().regex(SAFE_PATH, "Working directory must be an absolute path without '..'")
126
+ .max(1000).optional().describe("Working directory inside container (absolute path)"),
127
+ env: z.record(z.string().regex(SAFE_ENV_KEY, "Env key must be POSIX-style (A-Z, 0-9, _)").max(100), z.string().max(1000)).optional().describe("Environment variables (keys must be POSIX-style)"),
120
128
  });
121
129
  // Network/Volume schemas
122
130
  export const ListNetworksSchema = z.object({
@@ -134,12 +142,12 @@ export const WatchEventsSchema = z.object({
134
142
  container: z.string().optional().describe("Filter by container name or ID"),
135
143
  event_type: z.enum(["start", "stop", "die", "restart", "health_status", "oom", "all"]).optional().describe("Filter by event type (default: all)"),
136
144
  since: z.string().optional().describe("Show events since timestamp (e.g., '2026-01-01T00:00:00Z')"),
137
- duration: z.number().optional().describe("Max seconds to listen (default: 30)"),
145
+ duration: z.number().max(300).optional().describe("Max seconds to listen (default: 30, max: 300)"),
138
146
  });
139
147
  export const SearchLogsSchema = z.object({
140
- pattern: z.string().describe("Regex or grep pattern to search for"),
141
- containers: z.array(z.string()).optional().describe("Specific containers to search (default: all running)"),
142
- tail: z.number().optional().describe("Max lines to scan per container (default: 500)"),
148
+ pattern: z.string().max(1000).describe("Regex or grep pattern to search for"),
149
+ containers: z.array(z.string()).max(50).optional().describe("Specific containers to search (default: all running, max: 50)"),
150
+ tail: z.number().max(10000).optional().describe("Max lines to scan per container (default: 500, max: 10000)"),
143
151
  since: z.string().optional().describe("Only search logs since timestamp"),
144
152
  ignore_case: z.boolean().optional().describe("Case-insensitive search (default: false)"),
145
153
  });
package/glama.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "$schema": "https://glama.ai/schemas/glama.json",
3
+ "maintainers": ["friendlygeorge"],
4
+ "title": "Docker MCP Server",
5
+ "description": "31 tools for AI agent Docker management — container lifecycle, Compose stack operations, health checks, log streaming, and fleet monitoring through the Model Context Protocol.",
6
+ "tags": ["docker", "containers", "mcp", "compose", "health-checks", "monitoring", "devops", "ai-agents", "typescript", "self-healing"],
7
+ "repository": "https://github.com/friendlygeorge/docker-mcp-server",
8
+ "license": "MIT",
9
+ "categories": ["virtualization", "developer-tools"],
10
+ "language": "TypeScript",
11
+ "runtime": "node"
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supernova123/docker-mcp-server",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "mcpName": "io.github.friendlygeorge/docker-mcp-server",
5
5
  "description": "MCP server for Docker — container management, health checks, auto-restart, Compose lifecycle, and log streaming for Claude, Cursor, and AI agents",
6
6
  "type": "module",
package/src/docker.ts CHANGED
@@ -56,6 +56,26 @@ export function formatImage(image: Dockerode.ImageInfo): Record<string, unknown>
56
56
  };
57
57
  }
58
58
 
59
+ /**
60
+ * Sanitize tool output: strip ANSI escapes, invisible Unicode, and truncate.
61
+ * Prevents prompt injection via output and caps LLM context cost.
62
+ */
63
+ export function sanitizeOutput(text: string, maxLength = 1_000_000): string {
64
+ // Strip ANSI escape codes (CSI, OSC, simple sequences)
65
+ text = text.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
66
+ text = text.replace(/\x1b\][^\x07]*\x07/g, ""); // OSC terminated by BEL
67
+ text = text.replace(/\x1b[@-Z\\-_]/g, ""); // Single-char escapes
68
+ // Strip invisible Unicode (Tag chars, bidi overrides, zero-width)
69
+ text = text.replace(/[\uE0001-\uE007F\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/g, "");
70
+ // Strip Docker stream-frame headers (8-byte prefix per frame)
71
+ text = text.replace(/^[\x00-\x0f]{8}/gm, "");
72
+ // Truncate to cap memory and LLM context cost
73
+ if (text.length > maxLength) {
74
+ return text.slice(0, maxLength) + `\n... [output truncated at ${maxLength} chars]`;
75
+ }
76
+ return text;
77
+ }
78
+
59
79
  export function formatBytes(bytes: number): string {
60
80
  if (bytes === 0) return "0 B";
61
81
  const k = 1024;
@@ -10,7 +10,7 @@ import {
10
10
  ComposeLogsSchema,
11
11
  ComposeRestartSchema,
12
12
  } from "../types.js";
13
- import { formatError } from "../docker.js";
13
+ import { formatError, sanitizeOutput } from "../docker.js";
14
14
 
15
15
  const execAsync = promisify(execCb);
16
16
 
@@ -114,7 +114,8 @@ export function registerComposeTools(server: McpServer): void {
114
114
  if (params.follow) args.push("-f");
115
115
  if (params.services?.length) args.push(...params.services);
116
116
  const output = runCompose(params.path, args);
117
- return { content: [{ type: "text", text: output || "No logs found." }] };
117
+ // Use 100KB cap for log output to keep LLM context small
118
+ return { content: [{ type: "text", text: sanitizeOutput(output, 100_000) || "No logs found." }] };
118
119
  } catch (error) {
119
120
  return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
120
121
  }
package/src/tools/exec.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import Dockerode from "dockerode";
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { ExecInContainerSchema } from "../types.js";
4
- import { formatError } from "../docker.js";
4
+ import { formatError, sanitizeOutput } from "../docker.js";
5
5
 
6
6
  export function registerExecTools(server: McpServer, docker: Dockerode): void {
7
7
  server.tool(
@@ -28,7 +28,7 @@ export function registerExecTools(server: McpServer, docker: Dockerode): void {
28
28
  });
29
29
 
30
30
  const inspect = await exec.inspect();
31
- const cleanOutput = output.replace(/^[\x00-\x0f]{8}/gm, "");
31
+ const cleanOutput = sanitizeOutput(output);
32
32
 
33
33
  return {
34
34
  content: [{
package/src/tools/logs.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import Dockerode from "dockerode";
2
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
3
  import { StreamLogsSchema, ContainerStatsSchema } from "../types.js";
4
- import { formatError, formatBytes } from "../docker.js";
4
+ import { formatError, formatBytes, sanitizeOutput } from "../docker.js";
5
5
 
6
6
  export function registerLogsTools(server: McpServer, docker: Dockerode): void {
7
7
  server.tool(
@@ -20,7 +20,8 @@ export function registerLogsTools(server: McpServer, docker: Dockerode): void {
20
20
  follow: false as const,
21
21
  });
22
22
  // Dockerode returns a Buffer with multiplexed stream headers
23
- const output = logs.toString("utf-8").replace(/^[\x00-\x0f]{8}/gm, "");
23
+ // Use 100KB cap for logs to keep LLM context small
24
+ const output = sanitizeOutput(logs.toString("utf-8"), 100_000);
24
25
  return { content: [{ type: "text", text: output || "No logs found." }] };
25
26
  } catch (error) {
26
27
  return { content: [{ type: "text", text: `Error: ${formatError(error)}` }], isError: true };
@@ -8,6 +8,7 @@ import {
8
8
  ResourceAlertCheckSchema,
9
9
  MonitorDashboardSchema,
10
10
  } from "../types.js";
11
+ import { sanitizeOutput } from "../docker.js";
11
12
 
12
13
  export function registerMonitoringTools(server: McpServer, docker: Dockerode): void {
13
14
  // 1. fleet_status — health status of all running containers
@@ -190,7 +191,7 @@ export function registerMonitoringTools(server: McpServer, docker: Dockerode): v
190
191
  tail: params.tail || 500,
191
192
  since: params.since ? Math.floor(new Date(params.since).getTime() / 1000) : undefined,
192
193
  });
193
- const output = logStream.toString("utf-8").replace(/^[\x00-\x0f]{8}/gm, "");
194
+ const output = sanitizeOutput(logStream.toString("utf-8"), 100_000);
194
195
  const lines = output.split("\n");
195
196
  for (const line of lines) {
196
197
  if (regex.test(line)) {
package/src/types.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import { z } from "zod";
2
2
 
3
+ // Validation regexes (shared across schemas)
4
+ const SAFE_CMD_ARG = /^[A-Za-z0-9_./:@%+=,\-]+$/;
5
+ const SAFE_PATH = /^\/[A-Za-z0-9_./\-]+$/;
6
+ const SAFE_ENV_KEY = /^[A-Z_][A-Z0-9_]*$/;
7
+ const SAFE_BUILD_CONTEXT = /^\/[A-Za-z0-9_./\-]+$/;
8
+
3
9
  // Container lifecycle schemas
4
10
  export const ListContainersSchema = z.object({
5
11
  all: z.boolean().optional().describe("Include stopped containers (default: false)"),
@@ -59,11 +65,15 @@ export const PullImageSchema = z.object({
59
65
  });
60
66
 
61
67
  export const BuildImageSchema = z.object({
62
- context: z.string().describe("Build context path or Dockerfile content"),
68
+ context: z.string().regex(SAFE_BUILD_CONTEXT, "Build context must be a local absolute path (no URLs, no '..')")
69
+ .max(4096).describe("Build context path (local absolute path only)"),
63
70
  tag: z.string().describe("Tag for the built image (e.g., 'myapp:v1')"),
64
- dockerfile: z.string().optional().describe("Dockerfile name relative to context (default: 'Dockerfile')"),
65
- build_args: z.record(z.string()).optional().describe("Build arguments"),
66
- target: z.string().optional().describe("Target build stage"),
71
+ dockerfile: z.string().max(256).optional().describe("Dockerfile name relative to context (default: 'Dockerfile')"),
72
+ build_args: z.record(
73
+ z.string().regex(SAFE_ENV_KEY, "Build arg key must be POSIX-style").max(256),
74
+ z.string().max(4096)
75
+ ).optional().describe("Build arguments (keys must be POSIX-style)"),
76
+ target: z.string().max(256).optional().describe("Target build stage"),
67
77
  });
68
78
 
69
79
  export const RemoveImageSchema = z.object({
@@ -112,8 +122,8 @@ export const CheckHealthSchema = z.object({
112
122
 
113
123
  export const WatchHealthSchema = z.object({
114
124
  container_id: z.string().describe("Container ID or name"),
115
- timeout: z.number().optional().describe("Max seconds to wait (default: 60)"),
116
- interval: z.number().optional().describe("Seconds between polls (default: 5)"),
125
+ timeout: z.number().max(600).optional().describe("Max seconds to wait (default: 60, max: 600)"),
126
+ interval: z.number().max(60).optional().describe("Seconds between polls (default: 5, max: 60)"),
117
127
  });
118
128
 
119
129
  export const SetRestartPolicySchema = z.object({
@@ -125,7 +135,7 @@ export const SetRestartPolicySchema = z.object({
125
135
  // Logs schemas
126
136
  export const StreamLogsSchema = z.object({
127
137
  container_id: z.string().describe("Container ID or name"),
128
- tail: z.number().optional().describe("Number of lines to show (default: 100)"),
138
+ tail: z.number().max(10000).optional().describe("Number of lines to show (default: 100, max: 10000)"),
129
139
  since: z.string().optional().describe("Show logs since timestamp (e.g., '2026-01-01T00:00:00Z')"),
130
140
  follow: z.boolean().optional().describe("Follow log output (default: false)"),
131
141
  });
@@ -134,12 +144,17 @@ export const ContainerStatsSchema = z.object({
134
144
  container_id: z.string().describe("Container ID or name"),
135
145
  });
136
146
 
137
- // Exec schema
147
+ // Exec schema (validated per Finding 8.1 — command/working_dir/env constraints)
138
148
  export const ExecInContainerSchema = z.object({
139
149
  container_id: z.string().describe("Container ID or name"),
140
- command: z.array(z.string()).describe("Command to execute"),
141
- working_dir: z.string().optional().describe("Working directory inside container"),
142
- env: z.record(z.string()).optional().describe("Environment variables"),
150
+ command: z.array(z.string().max(500).regex(SAFE_CMD_ARG, "Command arg contains disallowed characters"))
151
+ .min(1).max(50).describe("Command to execute (max 50 args, alphanumeric + safe chars only)"),
152
+ working_dir: z.string().regex(SAFE_PATH, "Working directory must be an absolute path without '..'")
153
+ .max(1000).optional().describe("Working directory inside container (absolute path)"),
154
+ env: z.record(
155
+ z.string().regex(SAFE_ENV_KEY, "Env key must be POSIX-style (A-Z, 0-9, _)").max(100),
156
+ z.string().max(1000)
157
+ ).optional().describe("Environment variables (keys must be POSIX-style)"),
143
158
  });
144
159
 
145
160
  // Network/Volume schemas
@@ -163,13 +178,13 @@ export const WatchEventsSchema = z.object({
163
178
  container: z.string().optional().describe("Filter by container name or ID"),
164
179
  event_type: z.enum(["start", "stop", "die", "restart", "health_status", "oom", "all"]).optional().describe("Filter by event type (default: all)"),
165
180
  since: z.string().optional().describe("Show events since timestamp (e.g., '2026-01-01T00:00:00Z')"),
166
- duration: z.number().optional().describe("Max seconds to listen (default: 30)"),
181
+ duration: z.number().max(300).optional().describe("Max seconds to listen (default: 30, max: 300)"),
167
182
  });
168
183
 
169
184
  export const SearchLogsSchema = z.object({
170
- pattern: z.string().describe("Regex or grep pattern to search for"),
171
- containers: z.array(z.string()).optional().describe("Specific containers to search (default: all running)"),
172
- tail: z.number().optional().describe("Max lines to scan per container (default: 500)"),
185
+ pattern: z.string().max(1000).describe("Regex or grep pattern to search for"),
186
+ containers: z.array(z.string()).max(50).optional().describe("Specific containers to search (default: all running, max: 50)"),
187
+ tail: z.number().max(10000).optional().describe("Max lines to scan per container (default: 500, max: 10000)"),
173
188
  since: z.string().optional().describe("Only search logs since timestamp"),
174
189
  ignore_case: z.boolean().optional().describe("Case-insensitive search (default: false)"),
175
190
  });