This commit is contained in:
seb
2026-10-04 02:14:03 +02:00
parent 9a7f345d8b
commit e5ff4ef307
15 changed files with 1178 additions and 62 deletions

View File

@@ -50,8 +50,8 @@ export function isReadOnlyDatabasePath(filePath) {
function assertNotReadOnlyDatabasePath(filePath) {
if (isReadOnlyDatabasePath(filePath)) {
throw new Error(
`Access denied: "${filePath}" matches the read-only database image pattern and cannot be written to. Save artifacts under custom filenames (e.g. "candidate.avif" or "images/candidate.avif").`
throw new Error(
`Access denied: "${filePath}" matches the read-only database image pattern and cannot be written to. Save artifacts under custom filenames (e.g. "candidate.avif" or "images/candidate.avif").`
);
}
}
@@ -134,15 +134,32 @@ export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
export function createVfs(vfsRoot, options = {}) {
const { googleAuthId } = options;
// Node fs errors embed the REAL absolute on-disk path, which leaks the server's directory
// layout into the model-visible transcript. Rewrite them to use a stable `vfs://` prefix.
const sanitizeFsError = (err, virtualPath) => {
if (!err || typeof err.message !== 'string') return err;
const virtual = typeof virtualPath === 'string' ? virtualPath : String(virtualPath);
const message = err.message
.split(vfsRoot)
.join('vfs://')
.split(path.sep)
.join('/')
.replace(/vfs:\/\/\/+/g, 'vfs:///');
return Object.assign(Object.create(Object.getPrototypeOf(err)), err, {
message,
path: virtual,
});
};
const safeResolve = (p) => {
if (typeof p !== 'string' || !p.trim()) {
throw new Error('Path must be a non-empty string');
if (typeof p !== 'string' || !p.trim()) {
throw new Error('Path must be a non-empty string');
}
const cleanVirtual = p.trim().replace(/^([a-zA-Z]:|\/)+/, '');
const resolved = path.resolve(vfsRoot, cleanVirtual);
const relative = path.relative(vfsRoot, resolved);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
throw new Error(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
}
return resolved;
};
@@ -161,32 +178,43 @@ export function createVfs(vfsRoot, options = {}) {
const vfs = {
readFileSync: (p, opt) => {
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? buf.toString(encoding) : buf;
const hash = extractFullHash(p);
if (hash) {
const buf = syncFetchImage(hash);
if (buf) {
const encoding = typeof opt === 'string' ? opt : opt?.encoding;
return encoding ? buf.toString(encoding) : buf;
}
}
const diskPath = safeResolve(p);
return fs.readFileSync(diskPath, opt);
}
const diskPath = safeResolve(p);
try {
return fs.readFileSync(diskPath, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
writeFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.writeFileSync(diskPath, data, opt);
try {
return fs.writeFileSync(diskPath, data, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
appendFileSync: (p, data, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
fs.mkdirSync(path.dirname(diskPath), { recursive: true });
return fs.appendFileSync(diskPath, data, opt);
try {
return fs.appendFileSync(diskPath, data, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
existsSync: (p) => {
const hash = extractFullHash(p);
if (hash) {
@@ -233,11 +261,14 @@ export function createVfs(vfsRoot, options = {}) {
},
mkdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.mkdirSync(diskPath, opt);
},
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
try {
return fs.mkdirSync(diskPath, opt);
} catch (err) {
throw sanitizeFsError(err, p);
}
},
unlinkSync: (p) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
@@ -245,12 +276,12 @@ export function createVfs(vfsRoot, options = {}) {
},
rmdirSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmdirSync(diskPath, opt);
},
rmSync: (p, opt) => {
rmSync: (p, opt) => {
assertNotReadOnlyDatabasePath(p);
const diskPath = safeResolve(p);
return fs.rmSync(diskPath, opt);
@@ -262,9 +293,24 @@ export function createVfs(vfsRoot, options = {}) {
appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt),
stat: async (p, opt) => vfs.statSync(p, opt),
readdir: async (p, opt) => vfs.readdirSync(p, opt),
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
mkdir: async (p, opt) => vfs.mkdirSync(p, opt),
unlink: async (p) => vfs.unlinkSync(p),
rm: async (p, opt) => vfs.rmSync(p, opt),
rm: async (p, opt) => vfs.rmSync(p, opt),
},
/**
* Resolve a virtual path to an absolute on-disk path for writing, applying the SAME
* containment + read-only guards as `writeFileSync`. Exposed so non-`fs` writers that
* bypass the VFS (e.g. `sharp(...).toFile()`) can be funnelled back inside the sandbox
* boundary instead of writing wherever the process CWD happens to be.
*
* Deliberately does NOT create parent directories: callers such as `sharp().toFile()`
* must behave like `fs.writeFileSync`, which errors with ENOENT when the parent is
* missing. Auto-creating the whole tree turns a typo'd path into silent success.
*/
resolveWritePath: (p) => {
assertNotReadOnlyDatabasePath(p);
return safeResolve(p);
},
};