diff --git a/src/cli.ts b/src/cli.ts index b8d0579..20af7f2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -44,12 +44,12 @@ program url, savePath, }); - if (savedTo !== undefined) { - // Confirmation message — the only stdout newline the CLI ever adds. - console.log(`Saved ${bytes} bytes to ${savedTo}`); - } else { + if (savedTo === undefined) { // Raw markdown body — no added newline, matches MCP content[0].text. process.stdout.write(markdown); + } else { + // Confirmation message — the only stdout newline the CLI ever adds. + console.log(`Saved ${bytes} bytes to ${savedTo}`); } } catch (err) { const { code, message } = classifyError(err); diff --git a/src/sandbox.ts b/src/sandbox.ts index e3de74a..c291255 100644 --- a/src/sandbox.ts +++ b/src/sandbox.ts @@ -39,70 +39,73 @@ export async function buildAllowedRoots( env: NodeJS.ProcessEnv, ): Promise { const raw = env[ENV_VAR]; - if (raw != null && raw !== "") { - const resolved: string[] = []; - for (const entry of raw.split(delimiter)) { - if (!isAbsolute(entry)) { - throw new Error( - `Invalid ${ENV_VAR} entry ${JSON.stringify(entry)} — every entry must be an absolute path.`, - ); - } - let resolvedEntry: string; - try { - resolvedEntry = await realpath(entry); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error( - `Invalid ${ENV_VAR} entry ${JSON.stringify(entry)} — could not resolve: ${message}`, - ); - } - const stats = await stat(resolvedEntry); - if (!stats.isDirectory()) { - throw new Error( - `Invalid ${ENV_VAR} entry ${JSON.stringify(entry)} — resolved to ${JSON.stringify(resolvedEntry)} which is not a directory.`, - ); - } - resolved.push(resolvedEntry); + if (raw == null || raw === "") { + return [await realpath(tmpdir()), await realpath(process.cwd())]; + } + const resolved: string[] = []; + for (const entry of raw.split(delimiter)) { + if (!isAbsolute(entry)) { + throw new Error( + `Invalid ${ENV_VAR} entry ${JSON.stringify(entry)} — every entry must be an absolute path.`, + ); + } + let resolvedEntry: string; + try { + resolvedEntry = await realpath(entry); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error( + `Invalid ${ENV_VAR} entry ${JSON.stringify(entry)} — could not resolve: ${message}`, + ); + } + const stats = await stat(resolvedEntry); + if (!stats.isDirectory()) { + throw new Error( + `Invalid ${ENV_VAR} entry ${JSON.stringify(entry)} — resolved to ${JSON.stringify(resolvedEntry)} which is not a directory.`, + ); } - return resolved; + resolved.push(resolvedEntry); } - return [await realpath(tmpdir()), await realpath(process.cwd())]; + return resolved; } -// Resolve savePath through fs.realpath (defeating symlink escape) and check -// containment against allowed roots. Walks up to the deepest extant ancestor -// because the leaf usually doesn't exist yet — that's the point of "save". -export async function checkPath( - savePath: string, - roots: string[], -): Promise { - const normalized = resolve(savePath); - - let ancestor = normalized; +async function walkToExtantAncestor( + start: string, +): Promise<{ ancestor: string; trailing: string[] } | null> { + let ancestor = start; const trailing: string[] = []; while (true) { try { await stat(ancestor); - break; + return { ancestor, trailing }; } catch { const parent = dirname(ancestor); - if (parent === ancestor) { - // Reached filesystem root with no extant ancestor — fail closed. - return { - ok: false, - reason: `cannot resolve any extant ancestor for '${savePath}'`, - }; - } + if (parent === ancestor) return null; trailing.unshift(parse(ancestor).base); ancestor = parent; } } +} - const resolvedAncestor = await realpath(ancestor); - const reattached = - trailing.length === 0 - ? resolvedAncestor - : join(resolvedAncestor, ...trailing); +// Resolve savePath through fs.realpath (defeating symlink escape) and check +// containment against allowed roots. Walks up to the deepest extant ancestor +// because the leaf usually doesn't exist yet — that's the point of "save". +export async function checkPath( + savePath: string, + roots: string[], +): Promise { + const normalized = resolve(savePath); + + const walked = await walkToExtantAncestor(normalized); + if (walked === null) { + return { + ok: false, + reason: `cannot resolve any extant ancestor for '${savePath}'`, + }; + } + + const resolvedAncestor = await realpath(walked.ancestor); + const reattached = join(resolvedAncestor, ...walked.trailing); // Win32 case-fold: filesystem is case-insensitive and fs.realpath doesn't // reliably canonicalize case, so compare both sides lowercased. @@ -114,13 +117,14 @@ export async function checkPath( for (const root of roots) { const rel = relative(fold(root), foldedTarget); - if (rel === "") return { ok: true, resolved: reattached }; if (!rel.startsWith("..") && !isAbsolute(rel)) { return { ok: true, resolved: reattached }; } } + + const rootsList = roots.map((r) => `'${r}'`).join(", "); return { ok: false, - reason: `'${reattached}' is outside the allowed write roots: [${roots.map((r) => `'${r}'`).join(", ")}]`, + reason: `'${reattached}' is outside the allowed write roots: [${rootsList}]`, }; }