From 3b8068b94316df3ab86ec451fd0547d3b713cdaa Mon Sep 17 00:00:00 2001 From: bjoern Date: Tue, 10 Feb 2026 17:09:29 +0100 Subject: [PATCH] Add external file read with tilde expansion and user approval flow Problem: The fs_read tool could only read files inside the workspace directory. Users working with external configuration files, documentation, or system files had no way to read them through the tool interface, forcing manual workarounds. Changes implemented: 1. External path detection and tilde expansion (src/workspace/index.js) - Added expandTilde() to resolve ~/... paths to the user's home directory - Added isExternalPath() to detect when a path resolves outside the workspace - Added readExternalFile() to safely read files at absolute/external paths - Only regular files are readable (directories, symlinks rejected) 2. Two-phase approval flow (src/tools/workspace.js) - When fs_read receives an external path, it returns a 403 with a structured error asking the LLM to get explicit user confirmation first - On a second call with user_approved=true, the file is read and returned - Normal workspace reads are completely unchanged 3. Tool schema update (src/clients/standard-tools.js) - Updated Read tool description to document external file support - Added user_approved boolean parameter to the input schema - Updated file_path description to explain path formats (relative, absolute, ~) Testing: - Workspace-relative reads continue to work unchanged - ~/Documents/file.txt correctly resolves and reads after user approval - Absolute paths like /etc/hosts work with approval flow - Paths inside workspace still use the fast path (no approval needed) - Write and edit operations remain workspace-only (no external writes) - npm run test:unit passes with no regressions --- src/clients/standard-tools.js | 8 +++++-- src/tools/workspace.js | 39 +++++++++++++++++++++++++++++++---- src/workspace/index.js | 30 +++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) 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,