framework
Version:
The (AI) Framework: turnkey, zero-config AI orchestration that wraps a coding-agent CLI (Claude Code) as a black box and takes you from an idea to a running app. Vite for AI.
67 lines • 3.4 kB
JavaScript
// Reading one file out of a checkout, for the panel's hover card: the diff of a changed file
// (#816, file-diff.ts) and the contents of an unchanged one (#828). Both take a path from the
// client, so the guard and the confined read live here, once, and every caller goes through them.
/** Cap what a hover card renders, whether it is a patch or a file. */
export const MAX_PREVIEW_LINES = 500;
/**
* Whether a client-supplied path may be read: repo-relative, no traversal, no absolute path, no
* leading `-` (git would read it as a flag), and never into `.git` (config and credentials live
* there). Rejecting is the whole contract; the caller resolves it against a checkout it chose.
*/
export function safeRepoPath(path) {
if (!path || path.length > 1024 || path.includes('\0'))
return false;
if (path.startsWith('/') || path.startsWith('-') || /^[a-zA-Z]:/.test(path))
return false;
const parts = path.split(/[\\/]/);
// Any `.git` segment, not just a leading one: a nested repo's `.git/config` holds credentials too.
if (parts.includes('.git'))
return false;
return parts.every(part => part !== '' && part !== '.' && part !== '..');
}
/**
* Read a repo-relative file from `cwd`, or null when it is unsafe, outside the checkout, or
* unreadable.
*
* The confinement check is a `realpath` on both sides, not a string compare on the resolved path.
* `resolve` does not follow symlinks, so `src/link.txt -> /etc/passwd` resolves to a path that sits
* happily under `cwd` and passes a textual check while the read leaves the repo. `realpath` is what
* makes the containment real; it also normalizes the platform's own links (macOS `/tmp`), which is
* why `cwd` goes through it too rather than being compared raw.
*/
export async function readConfinedFile(cwd, path) {
if (!safeRepoPath(path))
return null;
const { readFile, realpath } = await import('node:fs/promises');
const { resolve, sep } = await import('node:path');
const root = await realpath(resolve(cwd)).catch(() => null);
if (!root)
return null;
// A missing file has no realpath, so this also answers "not there" before the read.
const full = await realpath(resolve(cwd, path)).catch(() => null);
if (!full || !full.startsWith(root + sep))
return null;
return readFile(full).catch(() => null);
}
/** Cut a body to {@link MAX_PREVIEW_LINES}, reporting whether anything was dropped. */
export function cutToPreview(body) {
const lines = body.split('\n');
if (lines.length <= MAX_PREVIEW_LINES)
return { body, truncated: false };
return { body: lines.slice(0, MAX_PREVIEW_LINES).join('\n'), truncated: true };
}
/**
* One unchanged file's contents (#828). Null when the path is unsafe, outside the checkout, or
* unreadable. A file git has not touched still reads from the resolved checkout, so a session's
* hover shows its worktree's copy rather than the project root's (#815).
*/
export async function readFileContent(cwd, path) {
const raw = await readConfinedFile(cwd, path);
if (!raw)
return null;
if (raw.includes(0))
return { path, text: '', truncated: false, binary: true };
const { body, truncated } = cutToPreview(raw.toString('utf8').replace(/\n$/, ''));
return { path, text: body, truncated, binary: false };
}
//# sourceMappingURL=file-read.js.map