From baa30a78f263bfd0a53e96fa7966a6932cb375dd Mon Sep 17 00:00:00 2001 From: Atharva Singh Date: Sun, 12 Apr 2026 14:28:26 +0530 Subject: [PATCH] fix(nodejs): scope npm/npx host fallback roots --- packages/nodejs/src/kernel-runtime.ts | 261 +++++++++++++++----- packages/nodejs/test/kernel-runtime.test.ts | 118 ++++++++- 2 files changed, 312 insertions(+), 67 deletions(-) diff --git a/packages/nodejs/src/kernel-runtime.ts b/packages/nodejs/src/kernel-runtime.ts index c8634977..085cd1b4 100644 --- a/packages/nodejs/src/kernel-runtime.ts +++ b/packages/nodejs/src/kernel-runtime.ts @@ -10,7 +10,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'; import * as fsPromises from 'node:fs/promises'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; import type { KernelRuntimeDriver as RuntimeDriver, KernelInterface, @@ -74,6 +74,10 @@ export interface NodeRuntimeOptions { packageRoots?: Array<{ hostPath: string; vmPath: string }>; } +interface HostFallbackOptions { + allowedHostRoots?: readonly string[]; +} + const allowKernelProcSelfRead: Pick = { fs: (request) => { const rawPath = typeof request?.path === 'string' ? request.path : ''; @@ -123,6 +127,79 @@ export function createNodeRuntime(options?: NodeRuntimeOptions): RuntimeDriver { return new NodeRuntimeDriver(options); } +function isNotFoundError(error: unknown): boolean { + if (typeof error === 'object' && error !== null && 'code' in error) { + if ((error as { code?: unknown }).code === 'ENOENT') { + return true; + } + } + + return error instanceof Error && /\bENOENT\b/.test(error.message); +} + +function normalizeComparisonPath(path: string): string { + const normalized = resolve(path); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function isPathWithinRoot(path: string, root: string): boolean { + const normalizedPath = normalizeComparisonPath(path); + const normalizedRoot = normalizeComparisonPath(root); + const pathRelativeToRoot = relative(normalizedRoot, normalizedPath); + return pathRelativeToRoot === '' || ( + !pathRelativeToRoot.startsWith('..') && + !isAbsolute(pathRelativeToRoot) + ); +} + +function normalizeAllowedHostRoots(allowedHostRoots: readonly string[]): string[] { + return [...new Set( + allowedHostRoots + .filter((root): root is string => root.length > 0) + .map((root) => resolve(root)), + )]; +} + +async function resolveAllowedHostFallbackPath( + path: string, + allowedHostRoots: readonly string[], +): Promise { + if (!isAbsolute(path)) { + return null; + } + + const resolvedPath = resolve(path); + if (!allowedHostRoots.some((root) => isPathWithinRoot(resolvedPath, root))) { + return null; + } + + try { + const realPath = await fsPromises.realpath(resolvedPath); + if (!allowedHostRoots.some((root) => isPathWithinRoot(realPath, root))) { + return null; + } + } catch (error) { + if (!isNotFoundError(error)) { + throw error; + } + } + + return resolvedPath; +} + +function resolveHostFallbackRoots(entryPath: string): string[] { + const packageRoot = dirname(dirname(entryPath)); + const roots = new Set([resolve(packageRoot)]); + + try { + roots.add(realpathSync(packageRoot)); + } catch { + // Ignore realpath failures and rely on the resolved package root. + } + + return [...roots]; +} + // --------------------------------------------------------------------------- // npm/npx host entry-point resolution // --------------------------------------------------------------------------- @@ -302,51 +379,108 @@ export function createKernelVfsAdapter(kernelVfs: KernelInterface['vfs']): Virtu * and falls back to the host filesystem for reads. Writes always go to the * kernel VFS. */ -export function createHostFallbackVfs(base: VirtualFileSystem): VirtualFileSystem { - return { - readFile: async (path) => { - try { return await base.readFile(path); } - catch { return new Uint8Array(await fsPromises.readFile(path)); } - }, - readTextFile: async (path) => { - try { return await base.readTextFile(path); } - catch { return await fsPromises.readFile(path, 'utf-8'); } - }, - readDir: async (path) => { - try { return await base.readDir(path); } - catch { return await fsPromises.readdir(path); } - }, - readDirWithTypes: async (path) => { - try { return await base.readDirWithTypes(path); } - catch { - const entries = await fsPromises.readdir(path, { withFileTypes: true }); - return entries.map(e => ({ name: e.name, isDirectory: e.isDirectory() })); +export function createHostFallbackVfs( + base: VirtualFileSystem, + options?: HostFallbackOptions, +): VirtualFileSystem { + const allowedHostRoots = normalizeAllowedHostRoots(options?.allowedHostRoots ?? []); + + async function withHostFallback( + path: string, + readFromBase: () => Promise, + readFromHost: (hostPath: string) => Promise, + ): Promise { + try { + return await readFromBase(); + } catch (error) { + if (!isNotFoundError(error)) { + throw error; } - }, + + const hostPath = await resolveAllowedHostFallbackPath(path, allowedHostRoots); + if (!hostPath) { + throw error; + } + + return readFromHost(hostPath); + } + } + + return { + readFile: (path) => withHostFallback( + path, + () => base.readFile(path), + async (hostPath) => new Uint8Array(await fsPromises.readFile(hostPath)), + ), + readTextFile: (path) => withHostFallback( + path, + () => base.readTextFile(path), + (hostPath) => fsPromises.readFile(hostPath, 'utf-8'), + ), + readDir: (path) => withHostFallback( + path, + () => base.readDir(path), + (hostPath) => fsPromises.readdir(hostPath), + ), + readDirWithTypes: (path) => withHostFallback( + path, + () => base.readDirWithTypes(path), + async (hostPath) => { + const entries = await fsPromises.readdir(hostPath, { withFileTypes: true }); + return entries.map((entry) => ({ + name: entry.name, + isDirectory: entry.isDirectory(), + })); + }, + ), exists: async (path) => { - if (await base.exists(path)) return true; - try { await fsPromises.access(path); return true; } catch { return false; } + try { + if (await base.exists(path)) { + return true; + } + } catch (error) { + if (!isNotFoundError(error)) { + throw error; + } + } + + const hostPath = await resolveAllowedHostFallbackPath(path, allowedHostRoots); + if (!hostPath) { + return false; + } + + try { + await fsPromises.access(hostPath); + return true; + } catch (error) { + if (isNotFoundError(error)) { + return false; + } + + throw error; + } }, - stat: async (path) => { - try { return await base.stat(path); } - catch { - const s = await fsPromises.stat(path); + stat: (path) => withHostFallback( + path, + () => base.stat(path), + async (hostPath) => { + const stat = await fsPromises.stat(hostPath); return { - mode: s.mode, - size: s.size, - isDirectory: s.isDirectory(), + mode: stat.mode, + size: stat.size, + isDirectory: stat.isDirectory(), isSymbolicLink: false, - atimeMs: s.atimeMs, - mtimeMs: s.mtimeMs, - ctimeMs: s.ctimeMs, - birthtimeMs: s.birthtimeMs, - ino: s.ino, - nlink: s.nlink, - uid: s.uid, - gid: s.gid, + atimeMs: stat.atimeMs, + mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, + birthtimeMs: stat.birthtimeMs, + ino: stat.ino, + nlink: stat.nlink, + uid: stat.uid, + gid: stat.gid, }; - } - }, + }, + ), writeFile: (path, content) => base.writeFile(path, content), createDir: (path) => base.createDir(path), mkdir: (path, options?) => base.mkdir(path, options), @@ -361,34 +495,29 @@ export function createHostFallbackVfs(base: VirtualFileSystem): VirtualFileSyste chown: (path, uid, gid) => base.chown(path, uid, gid), utimes: (path, atime, mtime) => base.utimes(path, atime, mtime), truncate: (path, length) => base.truncate(path, length), - realpath: async (path) => { - try { return await base.realpath(path); } - catch { return await fsPromises.realpath(path); } - }, - pread: async (path, offset, length) => { - try { return await base.pread(path, offset, length); } - catch { - const handle = await fsPromises.open(path, 'r'); - try { - const buf = new Uint8Array(length); - const { bytesRead } = await handle.read(buf, 0, length, offset); - return buf.slice(0, bytesRead); - } finally { - await handle.close(); - } - } - }, - pwrite: async (path, offset, data) => { - try { return await base.pwrite(path, offset, data); } - catch { - const handle = await fsPromises.open(path, 'r+'); + realpath: (path) => withHostFallback( + path, + () => base.realpath(path), + (hostPath) => fsPromises.realpath(hostPath), + ), + pread: (path, offset, length) => withHostFallback( + path, + () => base.pread(path, offset, length), + async (hostPath) => { + const handle = await fsPromises.open(hostPath, 'r'); try { - await handle.write(data, 0, data.length, offset); + const buffer = new Uint8Array(length); + const { bytesRead } = await handle.read(buffer, 0, length, offset); + return buffer.slice(0, bytesRead); } finally { await handle.close(); } - } - }, + }, + ), + pwrite: (path, offset, data) => base.pwrite(path, offset, data), + ...(base.fsync ? { fsync: (path: string) => base.fsync!(path) } : {}), + ...(base.copy ? { copy: (srcPath: string, dstPath: string) => base.copy!(srcPath, dstPath) } : {}), + ...(base.readDirStat ? { readDirStat: (path: string) => base.readDirStat!(path) } : {}), }; } @@ -687,7 +816,9 @@ class NodeRuntimeDriver implements RuntimeDriver { // npm/npx need host filesystem fallback and fs permissions for module resolution let permissions: Partial = { ...this._permissions }; if (command === 'npm' || command === 'npx') { - filesystem = createHostFallbackVfs(filesystem); + filesystem = createHostFallbackVfs(filesystem, { + allowedHostRoots: filePath ? resolveHostFallbackRoots(filePath) : [], + }); permissions = { ...permissions, ...allowAllFs }; } diff --git a/packages/nodejs/test/kernel-runtime.test.ts b/packages/nodejs/test/kernel-runtime.test.ts index f66d2d12..4cd9e6ea 100644 --- a/packages/nodejs/test/kernel-runtime.test.ts +++ b/packages/nodejs/test/kernel-runtime.test.ts @@ -7,10 +7,10 @@ */ import { describe, it, expect, afterEach } from 'vitest'; -import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { createNodeRuntime } from '../src/kernel-runtime.ts'; +import { createHostFallbackVfs, createNodeRuntime } from '../src/kernel-runtime.ts'; import type { NodeRuntimeOptions } from '../src/kernel-runtime.ts'; import { createKernel } from '@secure-exec/core'; import type { @@ -19,6 +19,7 @@ import type { ProcessContext, DriverProcess, Kernel, + VirtualFileSystem, } from '@secure-exec/core'; /** @@ -160,6 +161,43 @@ class SimpleVFS { async truncate(_path: string, _length: number) {} } +function createTestError(code: string, path: string): Error & { code: string } { + const error = new Error(`${code}: ${path}`) as Error & { code: string }; + error.code = code; + return error; +} + +function createRejectingVfs( + overrides: Partial = {}, +): VirtualFileSystem { + return { + readFile: async (path) => { throw createTestError('ENOENT', path); }, + readTextFile: async (path) => { throw createTestError('ENOENT', path); }, + readDir: async (path) => { throw createTestError('ENOENT', path); }, + readDirWithTypes: async (path) => { throw createTestError('ENOENT', path); }, + writeFile: async () => {}, + createDir: async () => {}, + mkdir: async () => {}, + exists: async () => false, + stat: async (path) => { throw createTestError('ENOENT', path); }, + removeFile: async () => {}, + removeDir: async () => {}, + rename: async () => {}, + realpath: async (path) => { throw createTestError('ENOENT', path); }, + symlink: async () => {}, + readlink: async (path) => { throw createTestError('ENOENT', path); }, + lstat: async (path) => { throw createTestError('ENOENT', path); }, + link: async () => {}, + chmod: async () => {}, + chown: async () => {}, + utimes: async () => {}, + truncate: async () => {}, + pread: async (path) => { throw createTestError('ENOENT', path); }, + pwrite: async (path) => { throw createTestError('ENOENT', path); }, + ...overrides, + }; +} + // ------------------------------------------------------------------------- // Tests // ------------------------------------------------------------------------- @@ -199,6 +237,82 @@ describe('Node RuntimeDriver', () => { }); }); + describe('host fallback scoping', () => { + let tmpDir: string | undefined; + + afterEach(() => { + if (tmpDir) { + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + tmpDir = undefined; + } + }); + + it('falls back to host reads inside allowed roots', async () => { + tmpDir = join(tmpdir(), `se-host-fallback-${Date.now()}-allowed`); + const allowedRoot = join(tmpDir, 'npm'); + const allowedFile = join(allowedRoot, 'lib', 'entry.js'); + mkdirSync(join(allowedRoot, 'lib'), { recursive: true }); + writeFileSync(allowedFile, 'module.exports = "ok";'); + + const vfs = createHostFallbackVfs(createRejectingVfs(), { + allowedHostRoots: [allowedRoot], + }); + + await expect(vfs.readTextFile(allowedFile)).resolves.toBe('module.exports = "ok";'); + await expect(vfs.exists(allowedFile)).resolves.toBe(true); + }); + + it('does not fall back outside allowed roots', async () => { + tmpDir = join(tmpdir(), `se-host-fallback-${Date.now()}-blocked`); + const allowedRoot = join(tmpDir, 'npm'); + const outsideFile = join(tmpDir, 'outside.txt'); + mkdirSync(allowedRoot, { recursive: true }); + writeFileSync(outsideFile, 'host secret'); + + const vfs = createHostFallbackVfs(createRejectingVfs(), { + allowedHostRoots: [allowedRoot], + }); + + await expect(vfs.readTextFile(outsideFile)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(vfs.exists(outsideFile)).resolves.toBe(false); + }); + + it('does not retry host reads when the base VFS returns EACCES', async () => { + tmpDir = join(tmpdir(), `se-host-fallback-${Date.now()}-eacces`); + const allowedRoot = join(tmpDir, 'npm'); + const allowedFile = join(allowedRoot, 'lib', 'entry.js'); + mkdirSync(join(allowedRoot, 'lib'), { recursive: true }); + writeFileSync(allowedFile, 'host file'); + + const vfs = createHostFallbackVfs(createRejectingVfs({ + readTextFile: async (path) => { throw createTestError('EACCES', path); }, + }), { + allowedHostRoots: [allowedRoot], + }); + + await expect(vfs.readTextFile(allowedFile)).rejects.toMatchObject({ code: 'EACCES' }); + }); + + it('never falls back to host writes', async () => { + tmpDir = join(tmpdir(), `se-host-fallback-${Date.now()}-write`); + const allowedRoot = join(tmpDir, 'npm'); + const allowedFile = join(allowedRoot, 'lib', 'entry.js'); + mkdirSync(join(allowedRoot, 'lib'), { recursive: true }); + writeFileSync(allowedFile, 'before'); + + const vfs = createHostFallbackVfs(createRejectingVfs(), { + allowedHostRoots: [allowedRoot], + }); + + await expect( + vfs.pwrite(allowedFile, 0, new TextEncoder().encode('after')), + ).rejects.toMatchObject({ code: 'ENOENT' }); + expect(readFileSync(allowedFile, 'utf-8')).toBe('before'); + }); + }); + describe('driver lifecycle', () => { it('throws when spawning before init', () => { const driver = createNodeRuntime();