Files
Jack Kavanagh 6c021150f2 feat(templating): multi-file plugins via in-sandbox module map (M4) (#10256)
* feat(templating): multi-file plugins via in-sandbox module map (M4)

esbuild is dev-only, so instead of bundling plugins at load time this reads the
plugin's own source files into a module map (readPluginModuleMap, host-side)
and teaches the in-sandbox require to resolve RELATIVE specifiers from that map
(./util, ../x, ./dir -> ./dir/index.js) with a CommonJS cache. Bare specifiers
still route through the grant-gated registry.

Security (the poison guarantee): the host walker reads ONLY within the plugin
directory — node_modules and dot-dirs are skipped, symlink-escapes dropped,
file count/size bounded — so a plugin's own node_modules is never consulted and
a bare require('uuid') always binds the vetted registry copy, never a plugin's
substitute. Plugin source now travels as envelope DATA (moduleFiles) compiled by
the loader via new Function; no host-side eval of plugin code. Single-file
callers keep working via a synthesized one-entry map.

Tests: multi-file-plugins.test.ts (7: relative/nested/dir-index resolution,
hybrid bare-uuid, CJS cache, not-found, syntax-error propagation, bare-vs-map
shadow) + readPluginModuleMap tests (map shape, node_modules-excluded poison
guarantee, custom main, entry-escape). E2E: multi-file resolution + poison
(fake node_modules/uuid ignored). Demo converted to multi-file
(require('./lib/greeting')). PERMISSIONS.md documents multi-file + the
require-vetted-libs-inside-run note.

No esbuild runtime dependency added.

* fix(templating): tighten M4 module-map byte guard and relative-path escape

Address review feedback on the multi-file plugin loader:

- readPluginModuleMap counted source.length (UTF-16 code units), which
  undercounts multi-byte UTF-8 payloads against MAX_PLUGIN_MODULE_BYTES, and
  the custom-"main" entry fallback was added to the map without counting its
  bytes at all. Count Buffer.byteLength(...'utf8') via a shared addModule
  helper used by both the walk and the entry fallback.

- __normalizeKey clamped above-root '..' traversal back into the plugin root,
  so require('../../util') from a shallow dir could masquerade as an in-plugin
  module. Return null for above-root traversal (unresolvable), matching Node
  semantics; __resolveRelative propagates it as MODULE_NOT_FOUND.
2026-07-17 09:44:17 -04:00
..