Harden Docker and process safety guardrails.
Confine container ops to tracked IDs, tighten image/compose/volume checks, enforce binary allow-lists on background processes, and fix scoped npm docs lookup.
This commit is contained in:
@@ -73,7 +73,7 @@ Post-v1 roadmap (see `PLAN.md` §9): **v1.4** Vue/Nuxt adapters, **v1.5** Sail/p
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Node.js >= 18.17 (developed/tested on Node 24)
|
- Node.js >= 22 (developed/tested on Node 24)
|
||||||
- For Laravel/CodeIgniter tools: PHP + Composer on `PATH`
|
- For Laravel/CodeIgniter tools: PHP + Composer on `PATH`
|
||||||
- For scaffolding Laravel/CodeIgniter projects: Composer on `PATH`
|
- For scaffolding Laravel/CodeIgniter projects: Composer on `PATH`
|
||||||
- For browser tools: `npx playwright install chromium` (run once after `npm install`)
|
- For browser tools: `npx playwright install chromium` (run once after `npm install`)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, afterEach } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { processManager } from '../processManager.js';
|
import { processManager } from '../processManager.js';
|
||||||
|
import { SandboxViolationError } from '../sandbox.js';
|
||||||
|
|
||||||
describe('processManager', () => {
|
describe('processManager', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -22,6 +23,17 @@ describe('processManager', () => {
|
|||||||
expect(info.args).toEqual(['-e', 'console.log("ok")']);
|
expect(info.args).toEqual(['-e', 'console.log("ok")']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects binaries that are not on the allow-list', () => {
|
||||||
|
expect(() =>
|
||||||
|
processManager.start({
|
||||||
|
label: 'evil',
|
||||||
|
command: 'curl',
|
||||||
|
args: ['https://example.com'],
|
||||||
|
cwd: process.cwd(),
|
||||||
|
}),
|
||||||
|
).toThrow(SandboxViolationError);
|
||||||
|
});
|
||||||
|
|
||||||
it('collects logs from a process', async () => {
|
it('collects logs from a process', async () => {
|
||||||
const info = processManager.start({
|
const info = processManager.start({
|
||||||
label: 'echo',
|
label: 'echo',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
|
|||||||
import { execa, type ResultPromise } from 'execa';
|
import { execa, type ResultPromise } from 'execa';
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
|
import { assertAllowedBinary } from './sandbox.js';
|
||||||
|
|
||||||
export type ProcessStatus = 'running' | 'exited' | 'error' | 'stopped';
|
export type ProcessStatus = 'running' | 'exited' | 'error' | 'stopped';
|
||||||
|
|
||||||
@@ -37,6 +38,8 @@ class ProcessManager {
|
|||||||
private readonly processes = new Map<string, ManagedProcess>();
|
private readonly processes = new Map<string, ManagedProcess>();
|
||||||
|
|
||||||
start(options: StartProcessOptions): ManagedProcessInfo {
|
start(options: StartProcessOptions): ManagedProcessInfo {
|
||||||
|
assertAllowedBinary(options.command);
|
||||||
|
|
||||||
if (this.runningCount() >= config.maxConcurrentProcesses) {
|
if (this.runningCount() >= config.maxConcurrentProcesses) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Refusing to start another process: already at the max of ${config.maxConcurrentProcesses} concurrent managed processes. Stop one first with stop_process.`,
|
`Refusing to start another process: already at the max of ${config.maxConcurrentProcesses} concurrent managed processes. Stop one first with stop_process.`,
|
||||||
|
|||||||
+3
-1
@@ -1,3 +1,4 @@
|
|||||||
|
import { createRequire } from 'node:module';
|
||||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { detectProject, summarizeDetection } from './lib/frameworks/detect.js';
|
import { detectProject, summarizeDetection } from './lib/frameworks/detect.js';
|
||||||
@@ -15,7 +16,8 @@ import { registerGitTools } from './tools/git/index.js';
|
|||||||
import { registerAuditTools } from './tools/audit/index.js';
|
import { registerAuditTools } from './tools/audit/index.js';
|
||||||
import { registerBackendResourceTools } from './tools/backends/index.js';
|
import { registerBackendResourceTools } from './tools/backends/index.js';
|
||||||
|
|
||||||
const SERVER_VERSION = '0.6.0';
|
const require = createRequire(import.meta.url);
|
||||||
|
const SERVER_VERSION = (require('../package.json') as { version: string }).version;
|
||||||
|
|
||||||
export function createServer(): McpServer {
|
export function createServer(): McpServer {
|
||||||
const server = new McpServer(
|
const server = new McpServer(
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
DBlJZGFwdGl2ZSBDdXN0b21lciBBQUE0ODIzMB4XDTE5MTIyNTIyMTE0NFoXDTM5MDEwMTAwMDAw
|
|
||||||
MFowJDEiMCAGA1UEAwwZSWRhcHRpdmUgQ3VzdG9tZXIgQUFBNDgyMzCCASIwDQYJKoZIhvcNAQEB
|
|
||||||
BQADggEPADCCAQoCggEBAIVjH4iQ1cHbAKWw8LrN+v4B0Tpq7aNUL4S3+z2mjSJ5ixZOgR9CSFv+
|
|
||||||
a4NopGKu5wqUMzfSP6VnTLNR71oFimhwkOKwjTbwj358LMIs5ogkPj1ReqOcCNCDr6BKpqVlbt+5
|
|
||||||
PfAFqYFK0y+n/AiKzvflWYJ4xfQqvPCMNwwGIbyM4yetExXrkS7lGx90fAfT0nx/wj4e5n6uOcX9
|
|
||||||
vCApVjVKzJrT71KH6H77jPA2cz2xfNUZ+isgb5FbVc/7YyrPbF/OooXbTKT3sD7rvrZ/WmdMbuJy
|
|
||||||
zESDTmIt4pTtHiV5gxIBOtoHXAdUbIoB+6TihrvyoyH2NGjhk1dl9UmoRD8CAwEAAaMTMBEwDwYD
|
|
||||||
VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEACT7WaElM/DHPdnRhRp2fCwQiSDMAZ46T
|
|
||||||
4dPzF12SmFJla/kn02eidSFpSCb+eM+FHnM7mP6W2bHOmMbuOkXPet/XBKesD/vZHMIfxrNSiHb/
|
|
||||||
R62YEIALxcudGRyDG+XPdbI54c3+uJgM1p2O/msp4Qj141tdcE26eFO+WEqH+sY8uLWOL4MWuSTH
|
|
||||||
GLToYP9WXQqJ66shRr5Cs648A+6LNsitUG55XSIq29qXeW/2PFK68RxpJl8HERTqVSUSthin8ryj
|
|
||||||
uv9l5YDJSZyEOIdgXtzE9V2ftM4gx3ZPtMRGnNGoYXeYcXXm3VyVOA5TnMJC85z6Zeb8J4j/8D5q
|
|
||||||
LZaF2A==
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
-----BEGIN CERTIFICATE-----
|
|
||||||
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
|
|
||||||
fg==
|
|
||||||
-----END CERTIFICATE-----
|
|
||||||
@@ -1,8 +1,22 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
import { validateVolumeMount, isAllowListedImage, isLocallyBuiltImage } from '../dockerManager.js';
|
import {
|
||||||
|
validateVolumeMount,
|
||||||
|
isAllowListedImage,
|
||||||
|
isLocallyBuiltImage,
|
||||||
|
resolveComposeFile,
|
||||||
|
dockerManager,
|
||||||
|
} from '../dockerManager.js';
|
||||||
import { SandboxViolationError } from '../../../lib/sandbox.js';
|
import { SandboxViolationError } from '../../../lib/sandbox.js';
|
||||||
|
import { config } from '../../../config.js';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
describe('Docker guardrails', () => {
|
describe('Docker guardrails', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
for (const c of dockerManager.listTracked()) {
|
||||||
|
dockerManager.untrack(c.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('validates workspace-confined volume mounts', () => {
|
it('validates workspace-confined volume mounts', () => {
|
||||||
const mount = validateVolumeMount('src:/app/src');
|
const mount = validateVolumeMount('src:/app/src');
|
||||||
expect(mount.target).toBe('/app/src');
|
expect(mount.target).toBe('/app/src');
|
||||||
@@ -19,6 +33,23 @@ describe('Docker guardrails', () => {
|
|||||||
expect(() => validateVolumeMount(':/app')).toThrow();
|
expect(() => validateVolumeMount(':/app')).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses Windows drive-letter volume sources', () => {
|
||||||
|
const winSource = path.join(config.workspaceRoot, 'src');
|
||||||
|
const drive = winSource.slice(0, 2); // e.g. "C:"
|
||||||
|
if (!/^[A-Za-z]:$/.test(drive)) {
|
||||||
|
// Non-Windows CI: still exercise the parser via a synthetic path under workspace.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mount = validateVolumeMount(`${winSource}:/app:ro`);
|
||||||
|
expect(mount.source.toLowerCase()).toBe(path.resolve(winSource).toLowerCase());
|
||||||
|
expect(mount.target).toBe('/app');
|
||||||
|
expect(mount.mode).toBe('ro');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects Windows volume sources outside the workspace', () => {
|
||||||
|
expect(() => validateVolumeMount('C:\\Windows\\System32:/app')).toThrow(SandboxViolationError);
|
||||||
|
});
|
||||||
|
|
||||||
it('allows listed base images', () => {
|
it('allows listed base images', () => {
|
||||||
expect(isAllowListedImage('node:20-alpine')).toBe(true);
|
expect(isAllowListedImage('node:20-alpine')).toBe(true);
|
||||||
expect(isAllowListedImage('nginx:latest')).toBe(true);
|
expect(isAllowListedImage('nginx:latest')).toBe(true);
|
||||||
@@ -26,9 +57,11 @@ describe('Docker guardrails', () => {
|
|||||||
expect(isAllowListedImage('php:8.2')).toBe(true);
|
expect(isAllowListedImage('php:8.2')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unlisted images', () => {
|
it('rejects unlisted images and prefix lookalikes', () => {
|
||||||
expect(isAllowListedImage('evil/image')).toBe(false);
|
expect(isAllowListedImage('evil/image')).toBe(false);
|
||||||
expect(isAllowListedImage('ubuntu:latest')).toBe(false);
|
expect(isAllowListedImage('ubuntu:latest')).toBe(false);
|
||||||
|
expect(isAllowListedImage('phpmyadmin:latest')).toBe(false);
|
||||||
|
expect(isAllowListedImage('nodejs:20')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats registry-prefixed images as not locally built', () => {
|
it('treats registry-prefixed images as not locally built', () => {
|
||||||
@@ -37,4 +70,20 @@ describe('Docker guardrails', () => {
|
|||||||
expect(isLocallyBuiltImage('registry.io/my-app')).toBe(false);
|
expect(isLocallyBuiltImage('registry.io/my-app')).toBe(false);
|
||||||
expect(isLocallyBuiltImage('user/repo')).toBe(false);
|
expect(isLocallyBuiltImage('user/repo')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('confines compose file paths to the workspace', () => {
|
||||||
|
const resolved = resolveComposeFile('docker-compose.yml');
|
||||||
|
expect(resolved.toLowerCase()).toBe(path.resolve(config.workspaceRoot, 'docker-compose.yml').toLowerCase());
|
||||||
|
expect(() => resolveComposeFile('../outside.yml')).toThrow(SandboxViolationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requireTrackedContainer rejects untracked ids', () => {
|
||||||
|
expect(() => dockerManager.requireTrackedContainer('deadbeefcafe')).toThrow(/not tracked/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requireTrackedContainer accepts tracked ids (short and long)', () => {
|
||||||
|
const tracked = dockerManager.track('abcdef1234567890deadbeef', 'node:20', 'test', { 3000: 3000 });
|
||||||
|
expect(dockerManager.requireTrackedContainer(tracked.id).name).toBe('test');
|
||||||
|
expect(dockerManager.requireTrackedContainer('abcdef1234567890deadbeef').id).toBe('abcdef123456');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { runCommand } from '../../lib/runCommand.js';
|
import { runCommand } from '../../lib/runCommand.js';
|
||||||
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||||
|
import { resolveComposeFile } from './dockerManager.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Docker Compose is invoked via the local `docker compose` or `docker-compose`
|
* Docker Compose is invoked via the local `docker compose` or `docker-compose`
|
||||||
@@ -35,14 +36,15 @@ export function registerDockerComposeTools(server: McpServer): void {
|
|||||||
},
|
},
|
||||||
async ({ file, services, build }) => {
|
async ({ file, services, build }) => {
|
||||||
try {
|
try {
|
||||||
const args = ['compose', '-f', file, 'up', '-d'];
|
const composeFile = resolveComposeFile(file);
|
||||||
|
const args = ['compose', '-f', composeFile, 'up', '-d'];
|
||||||
if (build) args.push('--build');
|
if (build) args.push('--build');
|
||||||
if (services && services.length > 0) args.push(...services);
|
if (services && services.length > 0) args.push(...services);
|
||||||
const result = await runCommand('docker', args, {
|
const result = await runCommand('docker', args, {
|
||||||
cwd: config.workspaceRoot,
|
cwd: config.workspaceRoot,
|
||||||
timeoutMs: config.scaffoldCommandTimeoutMs,
|
timeoutMs: config.scaffoldCommandTimeoutMs,
|
||||||
});
|
});
|
||||||
return jsonResult({ file, ...result });
|
return jsonResult({ file: composeFile, ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResult(toErrorMessage(error));
|
return errorResult(toErrorMessage(error));
|
||||||
}
|
}
|
||||||
@@ -70,13 +72,14 @@ export function registerDockerComposeTools(server: McpServer): void {
|
|||||||
},
|
},
|
||||||
async ({ file, volumes }) => {
|
async ({ file, volumes }) => {
|
||||||
try {
|
try {
|
||||||
const args = ['compose', '-f', file, 'down'];
|
const composeFile = resolveComposeFile(file);
|
||||||
|
const args = ['compose', '-f', composeFile, 'down'];
|
||||||
if (volumes) args.push('-v');
|
if (volumes) args.push('-v');
|
||||||
const result = await runCommand('docker', args, {
|
const result = await runCommand('docker', args, {
|
||||||
cwd: config.workspaceRoot,
|
cwd: config.workspaceRoot,
|
||||||
timeoutMs: config.defaultCommandTimeoutMs,
|
timeoutMs: config.defaultCommandTimeoutMs,
|
||||||
});
|
});
|
||||||
return jsonResult({ file, ...result });
|
return jsonResult({ file: composeFile, ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResult(toErrorMessage(error));
|
return errorResult(toErrorMessage(error));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,23 +30,24 @@ export function registerDockerContainerLogsTool(server: McpServer): void {
|
|||||||
async ({ containerId, tail, follow }) => {
|
async ({ containerId, tail, follow }) => {
|
||||||
try {
|
try {
|
||||||
await dockerManager.ensureReachable();
|
await dockerManager.ensureReachable();
|
||||||
|
const tracked = dockerManager.requireTrackedContainer(containerId);
|
||||||
if (follow) {
|
if (follow) {
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
containerId,
|
containerId: tracked.id,
|
||||||
note: 'Live log streaming is not supported via this tool; use docker_get_container_logs with follow:false to poll.',
|
note: 'Live log streaming is not supported via this tool; use docker_get_container_logs with follow:false to poll.',
|
||||||
logs: '',
|
logs: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const docker = dockerManager.getDocker();
|
const docker = dockerManager.getDocker();
|
||||||
const buffer = await docker.getContainer(containerId).logs({
|
const buffer = await docker.getContainer(tracked.id).logs({
|
||||||
stdout: true,
|
stdout: true,
|
||||||
stderr: true,
|
stderr: true,
|
||||||
tail,
|
tail,
|
||||||
});
|
});
|
||||||
const raw = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer);
|
const raw = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer);
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
containerId,
|
containerId: tracked.id,
|
||||||
logs: raw.slice(-config.maxToolOutputChars),
|
logs: raw.slice(-config.maxToolOutputChars),
|
||||||
bytes: raw.length,
|
bytes: raw.length,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ class DockerManager {
|
|||||||
return this.containers.get(id) ?? this.containers.get(id.substring(0, 12));
|
return this.containers.get(id) ?? this.containers.get(id.substring(0, 12));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the tracked container or throws. Use before stop/remove/exec/logs
|
||||||
|
* so tools cannot act on arbitrary host containers.
|
||||||
|
*/
|
||||||
|
requireTrackedContainer(id: string): ManagedContainer {
|
||||||
|
const tracked = this.getTracked(id);
|
||||||
|
if (!tracked) {
|
||||||
|
throw new Error(
|
||||||
|
`Container "${id}" is not tracked by this server. Only containers started via docker_run_container can be managed.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return tracked;
|
||||||
|
}
|
||||||
|
|
||||||
listTracked(): ManagedContainer[] {
|
listTracked(): ManagedContainer[] {
|
||||||
return Array.from(this.containers.values());
|
return Array.from(this.containers.values());
|
||||||
}
|
}
|
||||||
@@ -87,21 +101,65 @@ class DockerManager {
|
|||||||
|
|
||||||
export const dockerManager = new DockerManager();
|
export const dockerManager = new DockerManager();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a Docker volume mount spec into source, target, and optional mode.
|
||||||
|
* Handles Windows drive-letter sources (e.g. `C:\\proj\\src:/app:ro`).
|
||||||
|
*/
|
||||||
|
export function parseVolumeMountSpec(spec: string): { source: string; target: string; mode: string } {
|
||||||
|
let source: string;
|
||||||
|
let remainder: string;
|
||||||
|
|
||||||
|
if (/^[A-Za-z]:[\\/]/.test(spec)) {
|
||||||
|
const sep = spec.indexOf(':', 2);
|
||||||
|
if (sep === -1) {
|
||||||
|
throw new Error(`Invalid volume mount "${spec}": expected format "source:target[:mode]".`);
|
||||||
|
}
|
||||||
|
source = spec.slice(0, sep);
|
||||||
|
remainder = spec.slice(sep + 1);
|
||||||
|
} else if (spec.startsWith('/')) {
|
||||||
|
const targetSep = spec.indexOf(':/');
|
||||||
|
if (targetSep === -1) {
|
||||||
|
throw new Error(`Invalid volume mount "${spec}": expected format "source:target[:mode]".`);
|
||||||
|
}
|
||||||
|
source = spec.slice(0, targetSep);
|
||||||
|
remainder = spec.slice(targetSep + 1);
|
||||||
|
} else {
|
||||||
|
const firstColon = spec.indexOf(':');
|
||||||
|
if (firstColon === -1) {
|
||||||
|
throw new Error(`Invalid volume mount "${spec}": expected format "source:target[:mode]".`);
|
||||||
|
}
|
||||||
|
source = spec.slice(0, firstColon);
|
||||||
|
remainder = spec.slice(firstColon + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let target: string;
|
||||||
|
let mode = 'rw';
|
||||||
|
const modeSep = remainder.lastIndexOf(':');
|
||||||
|
if (modeSep > 0) {
|
||||||
|
const maybeMode = remainder.slice(modeSep + 1);
|
||||||
|
if (/^[a-zA-Z,]+$/.test(maybeMode) && !maybeMode.includes('/') && !maybeMode.includes('\\')) {
|
||||||
|
target = remainder.slice(0, modeSep);
|
||||||
|
mode = maybeMode;
|
||||||
|
} else {
|
||||||
|
target = remainder;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
target = remainder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!source || !target) {
|
||||||
|
throw new Error(`Invalid volume mount "${spec}": source and target are required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { source, target, mode };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates that a volume mount specification keeps the source path inside the
|
* Validates that a volume mount specification keeps the source path inside the
|
||||||
* workspace root. Rejects Docker socket mounts, host-relative escapes, etc.
|
* workspace root. Rejects Docker socket mounts, host-relative escapes, etc.
|
||||||
*/
|
*/
|
||||||
export function validateVolumeMount(spec: string): { source: string; target: string; mode: string } {
|
export function validateVolumeMount(spec: string): { source: string; target: string; mode: string } {
|
||||||
const parts = spec.split(':');
|
const { source, target, mode } = parseVolumeMountSpec(spec);
|
||||||
if (parts.length < 2) {
|
|
||||||
throw new Error(`Invalid volume mount "${spec}": expected format "source:target[:mode]".`) ;
|
|
||||||
}
|
|
||||||
const source = parts[0] ?? '';
|
|
||||||
const target = parts[1] ?? '';
|
|
||||||
const mode = parts[2] ?? 'rw';
|
|
||||||
if (!source || !target) {
|
|
||||||
throw new Error(`Invalid volume mount "${spec}": source and target are required.`);
|
|
||||||
}
|
|
||||||
const normalizedSource = source.toLowerCase().replaceAll('/', '\\');
|
const normalizedSource = source.toLowerCase().replaceAll('/', '\\');
|
||||||
if (
|
if (
|
||||||
normalizedSource.startsWith('\\\\.\\pipe\\docker_engine') ||
|
normalizedSource.startsWith('\\\\.\\pipe\\docker_engine') ||
|
||||||
@@ -116,7 +174,7 @@ export function validateVolumeMount(spec: string): { source: string; target: str
|
|||||||
|
|
||||||
export function isAllowListedImage(image: string): boolean {
|
export function isAllowListedImage(image: string): boolean {
|
||||||
const base = (image.split(':')[0] ?? '').split('/').pop()?.toLowerCase() ?? '';
|
const base = (image.split(':')[0] ?? '').split('/').pop()?.toLowerCase() ?? '';
|
||||||
return (config.dockerAllowedBaseImages as readonly string[]).some((allowed) => base === allowed || base.startsWith(allowed));
|
return (config.dockerAllowedBaseImages as readonly string[]).some((allowed) => base === allowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLocallyBuiltImage(image: string): boolean {
|
export function isLocallyBuiltImage(image: string): boolean {
|
||||||
@@ -125,3 +183,8 @@ export function isLocallyBuiltImage(image: string): boolean {
|
|||||||
// with a registry host (contains '.' or '/') is not.
|
// with a registry host (contains '.' or '/') is not.
|
||||||
return !/[/.]/.test(image);
|
return !/[/.]/.test(image);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolve a compose file path and ensure it stays inside the workspace. */
|
||||||
|
export function resolveComposeFile(file: string): string {
|
||||||
|
return resolveWorkspacePath(config.workspaceRoot, file);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ export function registerDockerExecTool(server: McpServer): void {
|
|||||||
async ({ containerId, command, workingDir, env }) => {
|
async ({ containerId, command, workingDir, env }) => {
|
||||||
try {
|
try {
|
||||||
await dockerManager.ensureReachable();
|
await dockerManager.ensureReachable();
|
||||||
|
const tracked = dockerManager.requireTrackedContainer(containerId);
|
||||||
const docker = dockerManager.getDocker();
|
const docker = dockerManager.getDocker();
|
||||||
const exec = await docker.getContainer(containerId).exec({
|
const exec = await docker.getContainer(tracked.id).exec({
|
||||||
Cmd: command,
|
Cmd: command,
|
||||||
WorkingDir: workingDir,
|
WorkingDir: workingDir,
|
||||||
Env: env ? Object.entries(env).map(([k, v]) => `${k}=${v}`) : undefined,
|
Env: env ? Object.entries(env).map(([k, v]) => `${k}=${v}`) : undefined,
|
||||||
@@ -40,7 +41,7 @@ export function registerDockerExecTool(server: McpServer): void {
|
|||||||
const raw = Buffer.concat(chunks).toString('utf8');
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
const result = await exec.inspect();
|
const result = await exec.inspect();
|
||||||
return jsonResult({
|
return jsonResult({
|
||||||
containerId,
|
containerId: tracked.id,
|
||||||
command,
|
command,
|
||||||
exitCode: result.ExitCode ?? null,
|
exitCode: result.ExitCode ?? null,
|
||||||
output: raw.slice(-config.maxToolOutputChars),
|
output: raw.slice(-config.maxToolOutputChars),
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ export function registerDockerStopRemoveTools(server: McpServer): void {
|
|||||||
async ({ containerId, timeout }) => {
|
async ({ containerId, timeout }) => {
|
||||||
try {
|
try {
|
||||||
await dockerManager.ensureReachable();
|
await dockerManager.ensureReachable();
|
||||||
await dockerManager.getDocker().getContainer(containerId).stop({ t: timeout });
|
const tracked = dockerManager.requireTrackedContainer(containerId);
|
||||||
return jsonResult({ stopped: true, containerId });
|
await dockerManager.getDocker().getContainer(tracked.id).stop({ t: timeout });
|
||||||
|
return jsonResult({ stopped: true, containerId: tracked.id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResult(toErrorMessage(error));
|
return errorResult(toErrorMessage(error));
|
||||||
}
|
}
|
||||||
@@ -38,9 +39,10 @@ export function registerDockerStopRemoveTools(server: McpServer): void {
|
|||||||
async ({ containerId, force }) => {
|
async ({ containerId, force }) => {
|
||||||
try {
|
try {
|
||||||
await dockerManager.ensureReachable();
|
await dockerManager.ensureReachable();
|
||||||
await dockerManager.getDocker().getContainer(containerId).remove({ force });
|
const tracked = dockerManager.requireTrackedContainer(containerId);
|
||||||
dockerManager.untrack(containerId);
|
await dockerManager.getDocker().getContainer(tracked.id).remove({ force });
|
||||||
return jsonResult({ removed: true, containerId });
|
dockerManager.untrack(tracked.id);
|
||||||
|
return jsonResult({ removed: true, containerId: tracked.id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return errorResult(toErrorMessage(error));
|
return errorResult(toErrorMessage(error));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { encodeNpmPackageName, resolvePackageEcosystem } from '../searchPackageDocs.js';
|
||||||
|
|
||||||
|
describe('searchPackageDocs helpers', () => {
|
||||||
|
it('resolves scoped npm packages as npm in auto mode', () => {
|
||||||
|
expect(resolvePackageEcosystem('@vue/reactivity', 'auto')).toBe('npm');
|
||||||
|
expect(resolvePackageEcosystem('@scope/pkg', 'auto')).toBe('npm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves vendor/package as composer in auto mode', () => {
|
||||||
|
expect(resolvePackageEcosystem('laravel/framework', 'auto')).toBe('composer');
|
||||||
|
expect(resolvePackageEcosystem('axios', 'auto')).toBe('npm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors an explicit ecosystem override', () => {
|
||||||
|
expect(resolvePackageEcosystem('@vue/reactivity', 'composer')).toBe('composer');
|
||||||
|
expect(resolvePackageEcosystem('laravel/framework', 'npm')).toBe('npm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes scoped npm names for the registry API', () => {
|
||||||
|
expect(encodeNpmPackageName('@vue/reactivity')).toBe('@vue%2Freactivity');
|
||||||
|
expect(encodeNpmPackageName('axios')).toBe('axios');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||||
import { errorResult, jsonResult, textResult, toErrorMessage } from '../shared.js';
|
import { errorResult, jsonResult, toErrorMessage } from '../shared.js';
|
||||||
|
|
||||||
async function fetchJson(url: string): Promise<unknown> {
|
async function fetchJson(url: string): Promise<unknown> {
|
||||||
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
const response = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
@@ -18,6 +18,27 @@ async function fetchText(url: string): Promise<string> {
|
|||||||
return response.text();
|
return response.text();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Encode an npm package name for the registry API (`@scope/name` → `@scope%2Fname`). */
|
||||||
|
export function encodeNpmPackageName(name: string): string {
|
||||||
|
if (name.startsWith('@')) {
|
||||||
|
const slash = name.indexOf('/');
|
||||||
|
if (slash === -1) return encodeURIComponent(name);
|
||||||
|
return `${name.slice(0, slash)}%2F${encodeURIComponent(name.slice(slash + 1))}`;
|
||||||
|
}
|
||||||
|
return encodeURIComponent(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve auto ecosystem: scoped npm (`@scope/pkg`) stays npm; `vendor/pkg` → composer. */
|
||||||
|
export function resolvePackageEcosystem(
|
||||||
|
name: string,
|
||||||
|
ecosystem: 'npm' | 'composer' | 'auto',
|
||||||
|
): 'npm' | 'composer' {
|
||||||
|
if (ecosystem !== 'auto') return ecosystem;
|
||||||
|
if (name.startsWith('@')) return 'npm';
|
||||||
|
if (name.includes('/')) return 'composer';
|
||||||
|
return 'npm';
|
||||||
|
}
|
||||||
|
|
||||||
interface NpmPackageInfo {
|
interface NpmPackageInfo {
|
||||||
'dist-tags': { latest: string };
|
'dist-tags': { latest: string };
|
||||||
versions: Record<string, { repository?: { url?: string }; homepage?: string; description?: string }>;
|
versions: Record<string, { repository?: { url?: string }; homepage?: string; description?: string }>;
|
||||||
@@ -25,7 +46,7 @@ interface NpmPackageInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchNpmDocs(name: string, version?: string): Promise<Record<string, unknown>> {
|
async function fetchNpmDocs(name: string, version?: string): Promise<Record<string, unknown>> {
|
||||||
const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(name)}`;
|
const registryUrl = `https://registry.npmjs.org/${encodeNpmPackageName(name)}`;
|
||||||
const info = (await fetchJson(registryUrl)) as NpmPackageInfo;
|
const info = (await fetchJson(registryUrl)) as NpmPackageInfo;
|
||||||
const resolvedVersion = version ?? info['dist-tags'].latest;
|
const resolvedVersion = version ?? info['dist-tags'].latest;
|
||||||
const versionData = info.versions[resolvedVersion];
|
const versionData = info.versions[resolvedVersion];
|
||||||
@@ -93,13 +114,13 @@ export function registerSearchPackageDocsTool(server: McpServer): void {
|
|||||||
.enum(['npm', 'composer', 'auto'])
|
.enum(['npm', 'composer', 'auto'])
|
||||||
.optional()
|
.optional()
|
||||||
.default('auto')
|
.default('auto')
|
||||||
.describe('Which package ecosystem to search. Auto detects from the name ("laravel/framework" → composer).'),
|
.describe('Which package ecosystem to search. Auto detects from the name ("laravel/framework" → composer; "@vue/reactivity" → npm).'),
|
||||||
version: z.string().optional().describe('Specific version to look up. Defaults to latest.'),
|
version: z.string().optional().describe('Specific version to look up. Defaults to latest.'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ name, ecosystem, version }) => {
|
async ({ name, ecosystem, version }) => {
|
||||||
try {
|
try {
|
||||||
const resolvedEcosystem = ecosystem === 'auto' ? (name.includes('/') ? 'composer' : 'npm') : ecosystem;
|
const resolvedEcosystem = resolvePackageEcosystem(name, ecosystem);
|
||||||
if (resolvedEcosystem === 'composer') {
|
if (resolvedEcosystem === 'composer') {
|
||||||
return jsonResult(await fetchPackagistDocs(name, version));
|
return jsonResult(await fetchPackagistDocs(name, version));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user