diff --git a/src/clients/standard-tools.js b/src/clients/standard-tools.js index 51e4163..61ac791 100644 --- a/src/clients/standard-tools.js +++ b/src/clients/standard-tools.js @@ -24,13 +24,17 @@ const STANDARD_TOOLS = [ }, { name: "Read", - description: "Reads a file from the local filesystem. You can access any file directly by using this tool.", + description: "Reads a file from the local filesystem. You can access any file directly by using this tool. For files outside the workspace, the user must approve access first.", input_schema: { type: "object", properties: { file_path: { type: "string", - description: "Relative path within workspace (e.g., 'config.js', 'src/index.ts'). DO NOT use absolute paths." + description: "Path to the file. Use relative paths for workspace files (e.g., 'src/index.ts'). For files outside the workspace use absolute paths or ~ for the home directory (e.g., '~/Documents/notes.md', '/etc/hosts'). Each call reads ONE file only — do not pass multiple paths." + }, + user_approved: { + type: "boolean", + description: "Set to true ONLY after the user has explicitly approved reading a file outside the workspace. Never set this to true without asking the user first." }, limit: { type: "number", diff --git a/src/tools/workspace.js b/src/tools/workspace.js index 144c6c1..3eaeeb0 100644 --- a/src/tools/workspace.js +++ b/src/tools/workspace.js @@ -1,8 +1,12 @@ +const path = require("path"); const { readFile, writeFile, applyFilePatch, resolveWorkspacePath, + expandTilde, + isExternalPath, + readExternalFile, fileExists, workspaceRoot, } = require("../workspace"); @@ -30,17 +34,44 @@ function registerWorkspaceTools() { registerTool( "fs_read", async ({ args = {} }) => { - const relativePath = validateString(args.path ?? args.file, "path"); + const targetPath = validateString(args.path ?? args.file ?? args.file_path, "path"); const encoding = normalizeEncoding(args.encoding); - const content = await readFile(relativePath, encoding); + + // Check if path is outside workspace + if (isExternalPath(targetPath)) { + if (args.user_approved !== true) { + const expanded = expandTilde(targetPath); + const resolved = path.resolve(expanded); + return { + ok: false, + status: 403, + content: JSON.stringify({ + error: "external_path_requires_approval", + message: `The file "${targetPath}" resolves to "${resolved}" which is outside the workspace. You MUST ask the user for permission before reading this file. If the user approves, call this tool again with the same path and set user_approved to true.`, + resolved_path: resolved, + }), + }; + } + // User approved — read external file + const { content, resolvedPath } = await readExternalFile(targetPath, encoding); + return { + ok: true, + status: 200, + content, + metadata: { path: targetPath, encoding, resolved_path: resolvedPath }, + }; + } + + // Normal workspace read (unchanged) + const content = await readFile(targetPath, encoding); return { ok: true, status: 200, content, metadata: { - path: relativePath, + path: targetPath, encoding, - resolved_path: resolveWorkspacePath(relativePath), + resolved_path: resolveWorkspacePath(targetPath), }, }; }, diff --git a/src/workspace/index.js b/src/workspace/index.js index da1a7e0..6cc058a 100644 --- a/src/workspace/index.js +++ b/src/workspace/index.js @@ -10,6 +10,33 @@ if (!fs.existsSync(workspaceRoot)) { fs.mkdirSync(workspaceRoot, { recursive: true }); } +function expandTilde(targetPath) { + if (typeof targetPath !== "string") return targetPath; + if (targetPath.startsWith("~")) { + const home = process.env.HOME || process.env.USERPROFILE; + if (home) { + return path.join(home, targetPath.slice(1)); + } + } + return targetPath; +} + +function isExternalPath(targetPath) { + const expanded = expandTilde(targetPath); + const resolved = path.resolve(workspaceRoot, expanded); + return !resolved.startsWith(workspaceRoot); +} + +async function readExternalFile(targetPath, encoding = "utf8") { + const expanded = expandTilde(targetPath); + const resolved = path.resolve(expanded); + const stats = await fsp.stat(resolved); + if (!stats.isFile()) { + throw new Error("Requested path is not a file."); + } + return { content: await fsp.readFile(resolved, { encoding }), resolvedPath: resolved }; +} + function resolveWorkspacePath(targetPath) { if (!targetPath || typeof targetPath !== "string") { throw new Error("Path must be a non-empty string."); @@ -110,6 +137,9 @@ function validateCwd(cwd) { module.exports = { workspaceRoot, resolveWorkspacePath, + expandTilde, + isExternalPath, + readExternalFile, readFile, writeFile, fileExists,