chore: initialize qiming workspace repository
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
#!/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)",
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import path from "node:path";
|
||||
|
||||
export const SANDBOX_SAFE_ENV_KEYS = [
|
||||
"PATH",
|
||||
"Path",
|
||||
"SYSTEMROOT",
|
||||
"SystemRoot",
|
||||
"WINDIR",
|
||||
"windir",
|
||||
"SYSTEMDRIVE",
|
||||
"SystemDrive",
|
||||
"COMSPEC",
|
||||
"ComSpec",
|
||||
"PATHEXT",
|
||||
"PATHExt",
|
||||
"TEMP",
|
||||
"TMP",
|
||||
"USERPROFILE",
|
||||
"HOME",
|
||||
"LOCALAPPDATA",
|
||||
"APPDATA",
|
||||
"COMPUTERNAME",
|
||||
"USERNAME",
|
||||
"OS",
|
||||
"PROCESSOR_ARCHITECTURE",
|
||||
"LANG",
|
||||
"TZ",
|
||||
];
|
||||
|
||||
export function isWithinRoot(candidate, root) {
|
||||
const rel = path.relative(root, candidate);
|
||||
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
||||
}
|
||||
|
||||
export function resolveSandboxWorkingDirectory(
|
||||
requestedCwd,
|
||||
sandboxMode,
|
||||
writableRoots,
|
||||
) {
|
||||
const resolvedCwd = path.resolve(requestedCwd);
|
||||
if (sandboxMode !== "workspace-write") {
|
||||
return resolvedCwd;
|
||||
}
|
||||
|
||||
const roots = writableRoots.map((root) => path.resolve(root));
|
||||
if (roots.length === 0) {
|
||||
return resolvedCwd;
|
||||
}
|
||||
|
||||
if (roots.some((root) => isWithinRoot(resolvedCwd, root))) {
|
||||
return resolvedCwd;
|
||||
}
|
||||
|
||||
return roots[0];
|
||||
}
|
||||
|
||||
export function buildSandboxHelperEnv(baseEnv, sandboxPath) {
|
||||
const env = {};
|
||||
for (const key of SANDBOX_SAFE_ENV_KEYS) {
|
||||
const value = baseEnv[key];
|
||||
if (value !== undefined && value !== null) {
|
||||
env[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (sandboxPath) {
|
||||
env.PATH = sandboxPath + ";" + (env.PATH || "");
|
||||
}
|
||||
|
||||
// Avoid inheriting electron-specific execution mode.
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
return env;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Sandboxed File System MCP Server
|
||||
*
|
||||
* Replaces Claude Code's built-in Write and Edit tools with sandboxed versions
|
||||
* that validate all target paths against QIMING_SANDBOX_WRITABLE_ROOTS before
|
||||
* performing file I/O.
|
||||
*
|
||||
* Environment variables:
|
||||
* QIMING_SANDBOX_WRITABLE_ROOTS — JSON array of writable paths
|
||||
* QIMING_SANDBOX_MODE — "strict" | "compat" (permissive skips this MCP entirely)
|
||||
* TEMP / TMP — System temp directories (always allowed)
|
||||
* APPDATA / LOCALAPPDATA — Application data (allowed in compat mode)
|
||||
*
|
||||
* The built-in Write/Edit/NotebookEdit are disabled via
|
||||
* _meta.claudeCode.options.disallowedTools, so this MCP provides the only
|
||||
* way to write files in sandbox mode.
|
||||
*
|
||||
* Tools:
|
||||
* - Write (mcp__sandboxed-fs__Write)
|
||||
* - Edit (mcp__sandboxed-fs__Edit)
|
||||
*/
|
||||
|
||||
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||
import { existsSync, realpathSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
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 { isWithinRoot } from "./sandboxed-bash-security.mjs";
|
||||
|
||||
// ---- Configuration from environment ----
|
||||
|
||||
// Resolve a path, falling back gracefully if it does not exist yet.
|
||||
function resolveReal(p) {
|
||||
try {
|
||||
return realpathSync(p);
|
||||
} catch {
|
||||
return path.resolve(p);
|
||||
}
|
||||
}
|
||||
|
||||
const SANDBOX_MODE = process.env.QIMING_SANDBOX_MODE || "strict";
|
||||
|
||||
const WRITABLE_ROOTS = JSON.parse(
|
||||
process.env.QIMING_SANDBOX_WRITABLE_ROOTS || "[]",
|
||||
).map((p) => resolveReal(path.resolve(p)));
|
||||
|
||||
// Always include TEMP/TMP as writable (matching Rust allow.rs behavior)
|
||||
const ADDITIONAL_ROOTS = [process.env.TEMP, process.env.TMP]
|
||||
.filter(Boolean)
|
||||
.map((p) => resolveReal(path.resolve(p)));
|
||||
|
||||
// compat mode: also allow APPDATA / LOCALAPPDATA (application data dirs)
|
||||
const COMPAT_ROOTS = SANDBOX_MODE === "compat"
|
||||
? [process.env.APPDATA, process.env.LOCALAPPDATA]
|
||||
.filter(Boolean)
|
||||
.map((p) => resolveReal(path.resolve(p)))
|
||||
: [];
|
||||
|
||||
const ALL_WRITABLE_ROOTS = [...WRITABLE_ROOTS, ...ADDITIONAL_ROOTS, ...COMPAT_ROOTS];
|
||||
|
||||
// ---- Path validation ----
|
||||
|
||||
function validatePath(targetPath) {
|
||||
if (!targetPath || typeof targetPath !== "string") {
|
||||
return { allowed: false, error: "Sandbox: file_path is required" };
|
||||
}
|
||||
|
||||
let resolved = path.resolve(targetPath);
|
||||
|
||||
// Resolve symlinks for defense-in-depth
|
||||
try {
|
||||
resolved = realpathSync(resolved);
|
||||
} catch {
|
||||
// File doesn't exist yet — resolve the parent directory
|
||||
try {
|
||||
const parentReal = realpathSync(path.dirname(resolved));
|
||||
resolved = path.join(parentReal, path.basename(resolved));
|
||||
} catch {
|
||||
// Parent also doesn't exist — best effort with path.resolve
|
||||
}
|
||||
}
|
||||
|
||||
if (ALL_WRITABLE_ROOTS.length === 0) {
|
||||
return {
|
||||
allowed: false,
|
||||
error: `Sandbox: No writable roots configured. Path rejected: ${resolved}`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const root of ALL_WRITABLE_ROOTS) {
|
||||
if (isWithinRoot(resolved, root)) {
|
||||
return { allowed: true, resolved };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
error: `Sandbox: Path outside writable roots: ${resolved}. Allowed: ${ALL_WRITABLE_ROOTS.join(", ")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
async function ensureParentDir(filePath) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
}
|
||||
|
||||
// ---- Tool definitions ----
|
||||
|
||||
const WRITE_TOOL = {
|
||||
name: "Write",
|
||||
description: `Writes content to a file, creating it if it does not exist.
|
||||
All file paths are validated against the sandbox writable roots.
|
||||
In sessions with mcp__sandboxed-fs__Write always use it instead of the
|
||||
built-in Write tool (which is disabled in sandbox mode).`,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file_path: {
|
||||
type: "string",
|
||||
description:
|
||||
"Absolute path to the file to write (must be within writable roots)",
|
||||
},
|
||||
content: {
|
||||
type: "string",
|
||||
description: "The content to write to the file",
|
||||
},
|
||||
},
|
||||
required: ["file_path", "content"],
|
||||
},
|
||||
};
|
||||
|
||||
const EDIT_TOOL = {
|
||||
name: "Edit",
|
||||
description: `Performs exact string replacements in a file.
|
||||
All file paths are validated against the sandbox writable roots.
|
||||
In sessions with mcp__sandboxed-fs__Edit always use it instead of the
|
||||
built-in Edit tool (which is disabled in sandbox mode).`,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
file_path: {
|
||||
type: "string",
|
||||
description:
|
||||
"Absolute path to the file to edit (must be within writable roots)",
|
||||
},
|
||||
old_string: {
|
||||
type: "string",
|
||||
description: "The text to replace",
|
||||
},
|
||||
new_string: {
|
||||
type: "string",
|
||||
description: "The text to replace it with",
|
||||
},
|
||||
replace_all: {
|
||||
type: "boolean",
|
||||
description: "Replace all occurrences of old_string (default false)",
|
||||
},
|
||||
},
|
||||
required: ["file_path", "old_string", "new_string"],
|
||||
},
|
||||
};
|
||||
|
||||
// ---- Create MCP server ----
|
||||
|
||||
const server = new Server(
|
||||
{ name: "sandboxed-fs", version: "1.0.0" },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
// ---- List tools handler ----
|
||||
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
||||
tools: [WRITE_TOOL, EDIT_TOOL],
|
||||
}));
|
||||
|
||||
// ---- Call tool handler ----
|
||||
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name } = request.params;
|
||||
const args = request.params.arguments || {};
|
||||
|
||||
if (name === "Write") {
|
||||
return await handleWrite(args);
|
||||
} else if (name === "Edit") {
|
||||
return await handleEdit(args);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
||||
isError: true,
|
||||
};
|
||||
});
|
||||
|
||||
// ---- Write handler ----
|
||||
|
||||
async function handleWrite(args) {
|
||||
const { file_path, content } = args;
|
||||
|
||||
if (!file_path || typeof file_path !== "string") {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: file_path is required" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (typeof content !== "string") {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: content must be a string" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const validation = validatePath(file_path);
|
||||
if (!validation.allowed) {
|
||||
return { content: [{ type: "text", text: validation.error }], isError: true };
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureParentDir(validation.resolved);
|
||||
await writeFile(validation.resolved, content, "utf-8");
|
||||
log("file written", { path: validation.resolved, size: content.length });
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Successfully wrote to ${validation.resolved}` },
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Write error: ${msg}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Edit handler ----
|
||||
|
||||
async function handleEdit(args) {
|
||||
const { file_path, old_string, new_string, replace_all } = args;
|
||||
|
||||
if (!file_path || typeof file_path !== "string") {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: file_path is required" }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (!old_string || typeof old_string !== "string") {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: "Error: old_string must be a non-empty string" },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (typeof new_string !== "string") {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Error: new_string must be a string",
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const validation = validatePath(file_path);
|
||||
if (!validation.allowed) {
|
||||
return { content: [{ type: "text", text: validation.error }], isError: true };
|
||||
}
|
||||
|
||||
try {
|
||||
if (!existsSync(validation.resolved)) {
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `File not found: ${validation.resolved}` },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
const currentContent = await readFile(validation.resolved, "utf-8");
|
||||
|
||||
if (!currentContent.includes(old_string)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `old_string not found in file. The exact text was not found.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
let newContent;
|
||||
|
||||
if (replace_all) {
|
||||
// Count occurrences
|
||||
let idx = 0;
|
||||
while ((idx = currentContent.indexOf(old_string, idx)) !== -1) {
|
||||
count++;
|
||||
idx += old_string.length;
|
||||
}
|
||||
newContent = currentContent.split(old_string).join(new_string);
|
||||
} else {
|
||||
// Check for multiple occurrences when replace_all is not set
|
||||
const firstIdx = currentContent.indexOf(old_string);
|
||||
const secondIdx = currentContent.indexOf(old_string, firstIdx + 1);
|
||||
if (secondIdx !== -1) {
|
||||
const total =
|
||||
currentContent.split(old_string).length - 1;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `old_string matches ${total} occurrences. Use replace_all: true to replace all, or provide a larger old_string to uniquely identify the location.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
count = 1;
|
||||
newContent = currentContent.replace(old_string, new_string);
|
||||
}
|
||||
|
||||
await writeFile(validation.resolved, newContent, "utf-8");
|
||||
log("file edited", {
|
||||
path: validation.resolved,
|
||||
replacements: count,
|
||||
replaceAll: !!replace_all,
|
||||
});
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Successfully edited ${validation.resolved} (${count} replacement${count > 1 ? "s" : ""})`,
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
content: [{ type: "text", text: `Edit error: ${msg}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Logging (to stderr — stdout is MCP JSON-RPC) ----
|
||||
|
||||
function log(message, data) {
|
||||
process.stderr.write(
|
||||
`[sandboxed-fs] ${message}${data ? " " + JSON.stringify(data) : ""}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Start ----
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
log("ready", {
|
||||
mode: SANDBOX_MODE,
|
||||
writableRoots: ALL_WRITABLE_ROOTS,
|
||||
});
|
||||
Reference in New Issue
Block a user