feat(plugins): sandbox hardening — OS permission model, egress choke point, bound acting user, author signatures

Closes the four gaps the security review surfaced:

- OS permission model on the prod plugin child (Node --permission with
  fs-read scoped to the compiled server dir + the plugin's own code dir, no
  fs-write/child_process/worker/native). A plugin can no longer read trek.db
  or the .jwt_secret/.encryption_key files, nor shell out — the direct-fs and
  RCE escapes that bypassed the RPC layer. Opt-out via TREK_PLUGIN_PERMISSIONS=off.
- Egress guard extended from fetch to the net.Socket connect choke point, so
  node:http/https/net/tls obey the declared-egress allowlist too (no declared
  egress = no outbound). Under the permission model there is no clean escape to
  an unwrapped runtime. Kernel/network-namespace containment remains the
  container step.
- Trip reads are membership-checked against the acting user the HOST binds from
  the authenticated invocation, not an asUserId the plugin supplies; a job/onLoad
  (no user) can't read user-scoped trips.
- Optional minisign (Ed25519) author signatures verified offline, TOFU-pinned
  (migration 158). Unsigned plugins install on sha256 alone; a signed plugin
  can't silently drop its signature or swap its author key.

Server suite green (permission-model activation verified against the Koffi
reference plugin on dev1).
This commit is contained in:
Maurice
2026-07-04 00:31:01 +02:00
committed by Maurice
parent 45ef63cbc1
commit 332e5c3c42
17 changed files with 565 additions and 80 deletions
@@ -48,16 +48,23 @@ export class PluginsProxyController {
}
try {
const reply = (await this.runtime.invoke(pluginId, 'invoke.route', {
routeId: route.i,
req: {
method: req.method,
path: sub,
query: req.query,
body: req.body ?? null,
user: user ? { id: user.id, username: user.username, isAdmin: !!user.is_admin } : null,
const reply = (await this.runtime.invoke(
pluginId,
'invoke.route',
{
routeId: route.i,
req: {
method: req.method,
path: sub,
query: req.query,
body: req.body ?? null,
user: user ? { id: user.id, username: user.username, isAdmin: !!user.is_admin } : null,
},
},
})) as { status?: number; headers?: Record<string, string>; body?: unknown };
// Bind the authenticated session user as the acting user for any trip
// reads this invocation makes — the plugin cannot override it.
user?.id,
)) as { status?: number; headers?: Record<string, string>; body?: unknown };
res.status(reply?.status ?? 200);
for (const [k, v] of Object.entries(reply?.headers ?? {})) {