Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/clients/standard-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 35 additions & 4 deletions src/tools/workspace.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
const path = require("path");
const {
readFile,
writeFile,
applyFilePatch,
resolveWorkspacePath,
expandTilde,
isExternalPath,
readExternalFile,
fileExists,
workspaceRoot,
} = require("../workspace");
Expand Down Expand Up @@ -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),
},
};
},
Expand Down
30 changes: 30 additions & 0 deletions src/workspace/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down Expand Up @@ -110,6 +137,9 @@ function validateCwd(cwd) {
module.exports = {
workspaceRoot,
resolveWorkspacePath,
expandTilde,
isExternalPath,
readExternalFile,
readFile,
writeFile,
fileExists,
Expand Down
Loading