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
parent 8fc4d6db71
commit 711f6e9881
17 changed files with 565 additions and 80 deletions
@@ -81,11 +81,12 @@ describe('PluginsProxyController', () => {
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toBe('application/json');
expect(res.body).toBe('{"ok":true}');
// the child receives only a whitelisted user view, never the token/cookie
// the child receives only a whitelisted user view, never the token/cookie;
// the acting user (5) is bound server-side as the 4th invoke arg
expect(runtime.invoke).toHaveBeenCalledWith('p', 'invoke.route', expect.objectContaining({
routeId: 0,
req: expect.objectContaining({ user: { id: 5, username: 'ada', isAdmin: false } }),
}));
}), 5);
});
it('a public (auth:false) route skips the session check', async () => {
@@ -94,9 +95,10 @@ describe('PluginsProxyController', () => {
await new PluginsProxyController(runtime).proxy('p', fakeReq('POST', '/webhook'), res as never);
expect(res.statusCode).toBe(200);
expect(extractTokenMock).not.toHaveBeenCalled();
// a public route has no session user → no bound acting user (undefined)
expect(runtime.invoke).toHaveBeenCalledWith('p', 'invoke.route', expect.objectContaining({
req: expect.objectContaining({ user: null }),
}));
}), undefined);
});
it('strips unsafe response headers from the child', async () => {