fix(plugins): make the permission-model child load from the real plugin path

The prod data dir is a symlink (server/data -> volume), so resolving the plugin
under it tripped the permission model, and Node's module-type lookup walked up
into the (denied) data dir. Fork the child from the plugin's realpath and drop a
{"type":"commonjs"} package.json at its root so resolution stops there — trek.db
and the secret files stay unreadable, verified against Koffi.
This commit is contained in:
Maurice
2026-07-04 00:38:32 +02:00
committed by Maurice
parent 332e5c3c42
commit ef5f8b9d38
2 changed files with 39 additions and 4 deletions
+32 -1
View File
@@ -27,6 +27,36 @@ export function pluginCodeDir(id: string): string {
return path.join(pluginsCodeRoot(), id);
}
/**
* The plugin code dir with symlinks resolved. The data root is often a symlink
* (Docker mounts `server/data` -> a volume), and the OS permission model checks
* REAL paths — forking the child from the real path keeps path resolution from
* ever touching the symlinked parent (which would need a broad read grant).
* Falls back to the lexical path if the dir does not exist yet.
*/
export function pluginRealCodeDir(id: string): string {
try {
return fs.realpathSync(pluginCodeDir(id));
} catch {
return pluginCodeDir(id);
}
}
/**
* Give a plugin its own `package.json` (only if it ships none) so Node resolves
* the child's module type AT the plugin root and never walks up into the data
* dir — which, under the permission model, is denied (that's the whole point).
* Defaults to CommonJS, matching the plain `module.exports` plugins ship.
*/
export function ensurePluginModuleType(codeDir: string): void {
try {
const pkg = path.join(codeDir, 'package.json');
if (!fs.existsSync(pkg)) fs.writeFileSync(pkg, '{"type":"commonjs"}\n');
} catch {
/* best effort — a missing package.json only matters under --permission */
}
}
/** A plugin's writable data directory (its own sqlite file + any blobs). */
export function pluginDataDir(id: string): string {
return path.join(pluginsDataRoot(), id);
@@ -90,6 +120,7 @@ export function pluginPermissionArgs(pluginId: string): string[] {
// resolve the child's module type. Grant just that file — never its dir,
// which also holds the `data` symlink to trek.db and the secret files.
`--allow-fs-read=${path.join(codeRoot, '..', 'package.json')}`,
`--allow-fs-read=${pluginCodeDir(pluginId)}`,
// The plugin's own code, by REAL path (see pluginRealCodeDir).
`--allow-fs-read=${pluginRealCodeDir(pluginId)}`,
];
}
@@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { fork, type ChildProcess } from 'node:child_process';
import { resolveChildEntry, pluginCodeDir, pluginPermissionArgs } from '../paths';
import { resolveChildEntry, pluginCodeDir, pluginRealCodeDir, pluginPermissionArgs, ensurePluginModuleType } from '../paths';
import type { Envelope, RpcError, RpcRequest } from '../protocol/envelope';
import type { PluginRpcHost } from '../host/rpc-host';
@@ -177,9 +177,13 @@ export class PluginSupervisor {
const { entry, execArgv, forkCwd, jsMode } = resolveChildEntry();
// Prod (compiled) children get the OS permission model — a real kernel-level
// fs/child_process/native jail on top of the env scrub and RPC boundary.
// Use the plugin's REAL path + ensure it has a package.json so the sandboxed
// child can resolve its own module type without a broad read grant.
const codeDir = jsMode ? pluginRealCodeDir(sup.id) : pluginCodeDir(sup.id);
if (jsMode) ensurePluginModuleType(codeDir);
const argv = jsMode ? [...execArgv, ...pluginPermissionArgs(sup.id)] : execArgv;
const child = fork(entry, [sup.id, pluginCodeDir(sup.id)], {
cwd: forkCwd ?? pluginCodeDir(sup.id),
const child = fork(entry, [sup.id, codeDir], {
cwd: forkCwd ?? codeDir,
execArgv: argv,
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
// Whitelist env — nothing inherited. No JWT_SECRET, no DB creds, no PATH-leaked secrets.