302 lines
8.6 KiB
JavaScript
302 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Sandboxed Bash MCP Server
|
|
*
|
|
* Replaces Claude Code's built-in Bash tool with a sandboxed version
|
|
* that routes all commands through qiming-sandbox-helper.exe run.
|
|
*
|
|
* Environment variables:
|
|
* QIMING_SANDBOX_HELPER_PATH — Path to qiming-sandbox-helper.exe
|
|
* QIMING_SANDBOX_MODE — "read-only" | "workspace-write"
|
|
* QIMING_SANDBOX_NETWORK_ENABLED — "1" | "0"
|
|
* QIMING_SANDBOX_WRITABLE_ROOTS — JSON array of writable paths
|
|
* QIMING_SANDBOX_PATH — Pre-built PATH with bundled node/git/uv
|
|
* QIMING_SANDBOX_GIT_BASH_PATH — Path to bundled bash.exe (Git for Windows)
|
|
*
|
|
* The built-in Bash is disabled via _meta.claudeCode.options.disallowedTools,
|
|
* so this MCP tool becomes the only way to execute commands.
|
|
*
|
|
* Tool name: "Bash" (appears as mcp__sandboxed-bash__Bash to the model)
|
|
*/
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
import {
|
|
ListToolsRequestSchema,
|
|
CallToolRequestSchema,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import {
|
|
buildSandboxHelperEnv,
|
|
resolveSandboxWorkingDirectory,
|
|
} from "./sandboxed-bash-security.mjs";
|
|
|
|
// ---- Configuration from environment ----
|
|
|
|
const HELPER_PATH = process.env.QIMING_SANDBOX_HELPER_PATH;
|
|
const SANDBOX_MODE = process.env.QIMING_SANDBOX_MODE || "read-only";
|
|
const NETWORK_ENABLED = process.env.QIMING_SANDBOX_NETWORK_ENABLED !== "0";
|
|
const WRITABLE_ROOTS = JSON.parse(
|
|
process.env.QIMING_SANDBOX_WRITABLE_ROOTS || "[]",
|
|
);
|
|
const SANDBOX_PATH = process.env.QIMING_SANDBOX_PATH || "";
|
|
const GIT_BASH_PATH = process.env.QIMING_SANDBOX_GIT_BASH_PATH || "";
|
|
|
|
if (!HELPER_PATH) {
|
|
process.stderr.write(
|
|
"[sandboxed-bash] FATAL: QIMING_SANDBOX_HELPER_PATH not set\n",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// Resolve shell executable:
|
|
// 1. Git Bash (supports &&, ||, 2>/dev/null, pipes, etc.) — preferred
|
|
// 2. Fallback: PowerShell (limited bash compat but always available)
|
|
function resolveShell() {
|
|
if (GIT_BASH_PATH) {
|
|
return { cmd: GIT_BASH_PATH, args: ["-c"], type: "bash" };
|
|
}
|
|
return {
|
|
cmd: "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
|
|
args: ["-NoProfile", "-NonInteractive", "-Command"],
|
|
type: "powershell",
|
|
};
|
|
}
|
|
|
|
const shell = resolveShell();
|
|
const SANDBOX_CWD = resolveSandboxWorkingDirectory(
|
|
process.cwd(),
|
|
SANDBOX_MODE,
|
|
WRITABLE_ROOTS,
|
|
);
|
|
|
|
// ---- Tool definition (matches built-in Bash schema) ----
|
|
|
|
const BASH_TOOL = {
|
|
name: "Bash",
|
|
description: `Executes a bash command in a sandboxed environment.
|
|
|
|
The command runs inside a Windows restricted token sandbox that limits
|
|
file system writes and network access according to the configured policy.
|
|
|
|
In sessions with mcp__sandboxed-bash__Bash always use it instead of the
|
|
built-in Bash tool (which is disabled).`,
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
command: {
|
|
type: "string",
|
|
description: "The bash command to execute",
|
|
},
|
|
timeout: {
|
|
type: "number",
|
|
description:
|
|
"Optional timeout in milliseconds (max 120000). The sandbox helper has a 5-minute hard limit.",
|
|
},
|
|
description: {
|
|
type: "string",
|
|
description:
|
|
"Clear, concise description of what this command does in 5-10 words, in active voice.",
|
|
},
|
|
},
|
|
required: ["command"],
|
|
},
|
|
};
|
|
|
|
// ---- Create MCP server ----
|
|
|
|
const server = new Server(
|
|
{ name: "sandboxed-bash", version: "1.0.0" },
|
|
{ capabilities: { tools: {} } },
|
|
);
|
|
|
|
// ---- List tools handler ----
|
|
|
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
tools: [BASH_TOOL],
|
|
}));
|
|
|
|
// ---- Call tool handler ----
|
|
|
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
if (request.params.name !== "Bash") {
|
|
return {
|
|
content: [{ type: "text", text: `Unknown tool: ${request.params.name}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
const { command, timeout: timeoutMs } = request.params.arguments || {};
|
|
|
|
if (!command || typeof command !== "string") {
|
|
return {
|
|
content: [{ type: "text", text: "Error: 'command' argument is required" }],
|
|
isError: true,
|
|
};
|
|
}
|
|
|
|
// Build sandbox policy JSON (matches Rust SandboxPolicy enum)
|
|
const policy = {
|
|
type: SANDBOX_MODE === "workspace-write" ? "workspace-write" : "read-only",
|
|
network_access: NETWORK_ENABLED,
|
|
...(WRITABLE_ROOTS.length > 0 ? { writable_roots: WRITABLE_ROOTS } : {}),
|
|
};
|
|
|
|
// The sandbox helper's split_command() takes args.command[0] as the
|
|
// executable and command[1..] as its arguments. We wrap the user's
|
|
// command with Git Bash (-c) for full bash syntax compatibility
|
|
// (&&, ||, 2>/dev/null, pipes, redirects). Falls back to PowerShell.
|
|
const helperArgs = [
|
|
"run",
|
|
"--no-write-restricted",
|
|
"--mode",
|
|
SANDBOX_MODE,
|
|
"--cwd",
|
|
SANDBOX_CWD,
|
|
"--policy-json",
|
|
JSON.stringify(policy),
|
|
"--",
|
|
shell.cmd,
|
|
...shell.args,
|
|
command,
|
|
];
|
|
|
|
log("spawning sandbox helper", {
|
|
mode: SANDBOX_MODE,
|
|
cwd: SANDBOX_CWD,
|
|
networkEnabled: NETWORK_ENABLED,
|
|
shell: shell.type,
|
|
commandPreview: command.slice(0, 120),
|
|
});
|
|
|
|
try {
|
|
const result = await executeHelper(helperArgs, timeoutMs);
|
|
|
|
// Format output like built-in Bash
|
|
const parts = [];
|
|
if (result.stdout) parts.push(result.stdout);
|
|
if (result.stderr) parts.push(result.stderr);
|
|
const output = parts.join("\n") || "(no output)";
|
|
|
|
return {
|
|
content: [{ type: "text", text: output }],
|
|
isError: result.exit_code !== 0,
|
|
};
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
return {
|
|
content: [{ type: "text", text: `Sandbox execution error: ${msg}` }],
|
|
isError: true,
|
|
};
|
|
}
|
|
});
|
|
|
|
// ---- Helper execution ----
|
|
|
|
/**
|
|
* Spawn qiming-sandbox-helper.exe and collect JSON output.
|
|
*
|
|
* The helper's `run` subcommand returns:
|
|
* { exit_code: number, stdout: string, stderr: string, timed_out: boolean }
|
|
*/
|
|
function executeHelper(helperArgs, timeoutMs) {
|
|
return new Promise((resolve, reject) => {
|
|
// Build sanitized environment to avoid leaking host secrets to helper.
|
|
const env = buildSandboxHelperEnv(process.env, SANDBOX_PATH);
|
|
|
|
const child = spawn(HELPER_PATH, helperArgs, {
|
|
cwd: SANDBOX_CWD,
|
|
env,
|
|
windowsHide: true,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
|
|
child.stdout.on("data", (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
child.stderr.on("data", (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
// Optional Node.js-level timeout (supplement helper's built-in 5min limit)
|
|
let timer = null;
|
|
if (timeoutMs && timeoutMs > 0) {
|
|
const capped = Math.min(timeoutMs, 120_000);
|
|
timer = setTimeout(() => {
|
|
log("timeout reached, killing process", { timeoutMs: capped });
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, capped);
|
|
}
|
|
|
|
child.on("close", (code, signal) => {
|
|
if (timer) clearTimeout(timer);
|
|
|
|
// The helper outputs JSON to stdout on success
|
|
try {
|
|
const parsed = JSON.parse(stdout);
|
|
log("helper result", {
|
|
exit_code: parsed.exit_code,
|
|
stdoutLen: (parsed.stdout || "").length,
|
|
stderrLen: (parsed.stderr || "").length,
|
|
timed_out: parsed.timed_out,
|
|
});
|
|
resolve({
|
|
exit_code: parsed.exit_code ?? (code ?? 1),
|
|
stdout: parsed.stdout || "",
|
|
stderr: parsed.stderr || "",
|
|
timed_out: parsed.timed_out || false,
|
|
});
|
|
} catch {
|
|
// JSON parse failed — helper may have crashed or returned raw output
|
|
log("JSON parse failed, returning raw output", {
|
|
code,
|
|
signal,
|
|
stdoutLen: stdout.length,
|
|
stderrLen: stderr.length,
|
|
});
|
|
resolve({
|
|
exit_code: code ?? 1,
|
|
stdout: stdout,
|
|
stderr: stderr,
|
|
timed_out: signal === "SIGKILL",
|
|
});
|
|
}
|
|
});
|
|
|
|
child.on("error", (err) => {
|
|
if (timer) clearTimeout(timer);
|
|
reject(err);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---- Logging (to stderr — stdout is MCP JSON-RPC) ----
|
|
|
|
function log(message, data) {
|
|
process.stderr.write(
|
|
`[sandboxed-bash] ${message}${data ? " " + JSON.stringify(data) : ""}\n`,
|
|
);
|
|
}
|
|
|
|
// ---- Start ----
|
|
|
|
const transport = new StdioServerTransport();
|
|
await server.connect(transport);
|
|
log("ready", {
|
|
helper: HELPER_PATH,
|
|
mode: SANDBOX_MODE,
|
|
networkEnabled: NETWORK_ENABLED,
|
|
writableRoots: WRITABLE_ROOTS,
|
|
shell: shell.type,
|
|
shellCmd: shell.cmd,
|
|
sandboxPath: SANDBOX_PATH ? SANDBOX_PATH.split(";").length + " dirs" : "(none)",
|
|
});
|