chore: initialize qiming workspace repository
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,300 @@
|
||||
#!/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",
|
||||
"--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-mcp/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