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: 4 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
106 changes: 55 additions & 51 deletions src/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,70 +39,73 @@ export async function buildAllowedRoots(
env: NodeJS.ProcessEnv,
): Promise<string[]> {
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<CheckResult> {
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<CheckResult> {
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.
Expand All @@ -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}]`,
};
}
Loading