9 Commits

Author SHA1 Message Date
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
Jack Kavanagh
3842690f60 feat(templating): vetted npm libraries in the sandbox — uuid + ajv (M3) (#10244) 2026-07-16 12:53:23 +03:00
Jack Kavanagh
afe4c91705 feat(templating): surface profiles + ceiling + migration warning (P1) (#10242)
* feat(templating): surface profiles + ceiling + manifest-less migration warning (P1)

Add per-surface sandbox profiles (surface-profiles.ts): a profile is the
ceiling of what a plugin on a surface may be granted, effective grant =
floor ∪ (declared ∩ ceiling).

- TEMPLATE_TAG_PROFILE excludes 'credentials' from its capability ceiling —
  cloud-credential access is reserved for first-party bundle plugins; a user
  template-tag plugin can't obtain it even by declaring it.
- SCRIPTING_PROFILE (broad, enumerated) defined for the later scripting-engine
  reuse; unit-tested, not wired to a live surface yet.
- resolveTemplateTag{Modules,Capabilities} move here from module-registry/
  host-bridge and delegate to the profile (capabilities enforce the ceiling;
  modules pass declared names through so the require gate gives the precise
  'not available' vs 'not permitted' message).
- Migration warning: a manifest-less plugin denied a non-baseline module gets a
  one-time (per-plugin-per-session) toast via the existing show-toast channel,
  naming the module + the insomnia.permissions field to add.
- Docs: PERMISSIONS.md; demo README + capability ceiling note.

Tests: surface-profiles.test.ts (effectiveGrant matrix, floor⊆ceiling, credentials
ceiling exclusion); migration-warning dedup unit test; C2 shape tests decoupled
from the profile (grant caps directly); e2e P1 test (ceiling + one-time warning).
107 unit + 4 smoke green.

* feat(templating): typed module denial (#10254)

* fix(templating): typed module-denial error instead of message regex (P1)

maybeWarnMissingManifest matched the sandbox's thrown error message
("Module 'X' not permitted by manifest") with a regex to decide whether
to show the migration toast. That couples a UI feature to English error
prose crossing the QuickJS VM boundary — any future wrapping or wording
change breaks the toast silently, with no compiler signal.

__require now throws with `code`/`moduleName` own properties
(SANDBOX_MODULE_NOT_PERMITTED vs SANDBOX_MODULE_NOT_AVAILABLE), which
toError() in plugin-tag-sandbox.ts carries through onto the rebuilt
Error. maybeWarnMissingManifest branches on that discriminant instead of
regexing the message text; the message strings remain unchanged and
still asserted verbatim by the existing tests.

* fix(templating): key migration-warning dedup by (plugin, module), add ceiling tripwire

maybeWarnMissingManifest deduped its one-time toast by plugin name alone,
so a manifest-less plugin that trips on two different ungranted modules in
one session only ever saw a hint for the first one it hit — the second,
equally actionable denial was silently suppressed. Key by (plugin, module)
instead so each distinct missing grant still gets its own toast, while a
repeat denial for the same module still doesn't re-toast.

Also added a regression test pinning that a manifest with a malformed axis
(declared:true, per-card warning) is correctly routed away from this toast
path, and a tripwire test on TEMPLATE_TAG_PROFILE.moduleCeiling: resolveTemplateTagModules
deliberately doesn't enforce the ceiling (an unregistered module must still
reach require() for the precise "not available" message), which is only
safe while the ceiling equals the full module registry — this test fails
first if a future change narrows it, as a signal to revisit that function.

---------

Co-authored-by: kwburns-kong <kyle.burns@konghq.com>
2026-07-14 16:45:04 +00:00
Jack Kavanagh
911e188654 feat(templating): capability-aware sandbox context construction (C2) (#10226)
__buildContext now builds the full context, then removes branches whose
capability is not granted, so context.network/store/app/util.readFile/
util.openInBrowser/util.models.cloudCredential are undefined (not
present-but-rejecting stubs) unless declared. Driven by __hasCap reading the
captured grantedCapabilities via the pristine __arrIndexOf (unspoofable),
kept in lockstep with C1's BRIDGE_PATH_CAPABILITIES. Baseline caps (render,
models.read, util, crypto) and context/meta/renderPurpose data are never gated.

Plugins can feature-detect (if (context.network)) and degrade gracefully;
touching an ungranted branch fails at the property access in the plugin's own
code, not as a later async rejection. C1's bridge gate remains the backstop.

- unit: context-shape matrix, feature-detect, attach-on-grant, and a C1<->C2
  consistency test (branch present iff its bridge-path capability is granted)
- reframed the C1 e2e denial test to prove the bridge backstop (branch granted
  to C2 but withheld from the bridge filter); no-manifest denial now covered by
  C2's absent-branch test
- smoke: capdenied tag feature-detects (returns 'no-storage-capability'); test
  retitled (C1 + C2)
2026-07-13 16:14:02 +02:00
Jack Kavanagh
e4f4cd5945 feat(templating): capability-gate the sandbox host bridge (C1) (#10222) 2026-07-10 16:16:30 +00:00
Jack Kavanagh
a91f1056f3 feat(templating): seed ambient sandbox stdlib globals — Buffer/process/crypto/URL (M2) (#10220)
* feat(templating): seed ambient sandbox stdlib globals (M2)

Add always-present, ungated safe-equivalent globals to the QuickJS sandbox:
Buffer (from/alloc/concat/isBuffer + utf8/base64/hex/latin1), a frozen
process stub (platform/arch from the envelope, empty frozen env, microtask
nextTick), Web-Crypto crypto.getRandomValues + crypto.subtle.digest
(host-backed), and URL/URLSearchParams. Lives in sandbox-globals.ts, eval'd
after the bootstrap + host-crypto installs.

Because the sandbox now provides a process stub, the old
'typeof process === undefined' sandbox-detection heuristic no longer holds;
add a non-writable INSOMNIA_TEMPLATE_SANDBOX marker and switch the canary +
demo probe to it.

- +19 unit parity tests vs node: Buffer/subtle/URL/URLSearchParams/getRandomValues + escape + marker
- e2e: stdlibprobe renders each API; flag-off captured and asserted byte-identical
  under the flag (parity), subtle pinned to a known hash, process.env '{}' + frozen sandbox-only
- smoke helpers wait for modal close + poll the post-toggle canary (re-open until
  the flag propagates), de-flaking the mid-test toggle
- demo plugin gains a stdlibprobe tag

require() module wrappers (url/buffer/util/querystring/string_decoder/assert)
are the M2b follow-up; they mostly re-export these globals.

* test(sandbox): pass M2 global-probe values as tag args, not interpolated code

CodeQL flagged the runGlobal test helper for constructing eval'd plugin
source from interpolated values. Make body a constant literal at every call
site and thread dynamic values through the tag's args (envelope data, read as
arguments[1..]) so no test value is concatenated into sandbox-eval'd code.

* fix(sandbox): enforce WebCrypto 65536-byte quota in getRandomValues + review nits

- getRandomValues now throws past 65536 bytes (matching WebCrypto's
  QuotaExceededError) instead of silently zero-filling the tail beyond the
  host __cryptoRandomBytes clamp — no predictable output.
- Correct the enableSandbox comment: assertions are expect.soft (mandated by
  the smoke ESLint config); the authoritative flag-applied signal is the
  downstream polling canary.
- Fix a doc-comment typo: typeof process === "undefined" (quoted).

* fix(sandbox): M2 stdlib parity fixes + bridge hardening (#10224)

* fix(sandbox): plumb host arch into process stub

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox): add crypto.randomUUID to ambient web crypto

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox): URLSearchParams.set keeps first occurrence

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox): support URLSearchParams copy-construct

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox): guard bridge dispatch against inherited keys

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sandbox): hide raw host bridge from plugin code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(lint): fixed linting issue

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: kwburns-kong <kyle.burns@konghq.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 04:16:43 +00:00
Jack Kavanagh
30650f9dad feat(templating): plugin manifest permissions loader + Plugins-UI display (C3) (#10211) 2026-07-07 21:54:19 +02:00
Jack Kavanagh
9b1f17f70f feat(templating): manifest-gated module registry for the sandbox require (M1) (#10208)
* feat(templating): manifest-gated module registry for the sandbox require (M1)

Replace the hardcoded path/crypto require shim with a curated module
registry (single source of truth in module-registry.ts) resolved by a
default-deny __require against the envelope's grantedModules:

- ungranted name  -> "Module 'X' not permitted by manifest"
- granted, no impl -> "Module 'X' not available in sandbox"

Grants are the hardcoded path/crypto baseline until the manifest loader
(C3) lands. node:-prefixed aliases resolve to canonical names; exports
are cached per context. E2E: requireprobe tag asserts the baseline works
through the registry and that npm/builtin names fail with the exact
denial message in the tag Live Preview.

* sec(templating): capture envelope before plugin eval; lock registry; null-proto maps

- Inject __envelopeJSON/__tagName before the bootstrap, capture them into
  closure state, and delete the globals — plugin top-level code can no
  longer rewrite grantedModules (or any envelope field) before __invoke().
- delete __registerModule once the registry is populated so plugin code
  cannot register or replace factories.
- Null-prototype registry/alias/cache maps so __proto__/constructor behave
  as absent keys.
- Test helper defaults grants to TEMPLATE_TAG_BASELINE_MODULES; new tests
  cover envelope tampering and prototype-chain module names.

* sec(templating): harden module gate against intrinsic tampering

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* sec(templating): lock sandbox-internal globals from plugin reassignment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* sec(templating): enforce trusted-literal invariant for module factorySource

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kyle <kyle.burns@konghq.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:19:07 +00:00
Jack Kavanagh
cc9288c218 feat(templating): QuickJS template-tag sandbox behind a flag, with e2e canary (#10207)
* feat(templating): PoC run plugin template tags in a QuickJS-WASM sandbox

Behind a new `templateTagSandboxEnabled` setting (default off), route plugin
template-tag execution through a QuickJS-WASM sandbox instead of invoking the
plugin's `run()` directly in the main process.

Approach (per PR #10072): bulk-copy render state into the sandbox as JSON,
rebuild the plugin `context` API in pure JS inside the sandbox, and bridge only
async work back to the host via the existing `pluginToMainAPI` handlers.
`node:crypto` is exposed as synchronous host functions so `require('crypto')`
works without a sync/async mismatch.

- templating/sandbox/: quickjs-runtime, marshal, host-bridge, in-sandbox-bootstrap,
  plugin-tag-sandbox (+ parity tests vs in-process tags and node:crypto)
- main/templating-worker-database.ts: route execute handlers through the sandbox
  when the flag is on; legacy path unchanged otherwise
- esbuild: keep quickjs-emscripten external so its .wasm resolves at runtime
- settings + scripting-settings UI toggle
- examples/insomnia-plugin-sandbox-demo: manual E2E fixture

Scope: template tags only; sandbox runs in main. require shim covers path + crypto
(other modules throw a clear error — follow-up work).

* test(smoke): e2e canary for the template-tag sandbox flag

Installs an inline probe plugin, renders its tags via the tag editor Live
Preview, and asserts the execution path flips main-process -> sandbox when
templateTagSandboxEnabled is toggled in Preferences > Scripting, with a
require('crypto') sha256 workload staying byte-identical across both paths.

* test(sandbox): suppress hardcoded-hmac-key semgrep finding on parity fixture

The HMAC key is a test vector for sandbox-vs-node:crypto parity, not a
credential; rename it to make that self-evident and add the repo-standard
nosemgrep suppression.

* fix(templating): contain sandbox plugin entry resolution to the plugin directory

Reject a package.json "main" that resolves outside the plugin's own folder
and bundled-plugin names that look like paths, so the sandbox source loader
cannot be steered into reading arbitrary files.

* fix(review): inline nosemgrep placement, plugin-load error context, cross-arch-safe canary

- Move the hardcoded-hmac-key suppression onto the flagged line (line-above
  placement was not honored by the scanner).
- Wrap getPluginEntrySource failures with the plugin name for diagnosability.
- Derive the canary's expected arch from the Electron main process instead of
  the Playwright runner so cross-arch setups can't flake the assertion.

* sec(templating): QuickJS template-tag sandbox additions  (#10209)

* fix(sandbox): enforce timeout on synchronous plugin loops

QuickJS's executePendingJobs() blocks the host thread until a synchronous
call returns, so the wall-clock deadline in drivePromiseToString was never
checked during a tight sync loop in plugin code, hanging the Electron main
process indefinitely. Add a QuickJS interrupt handler, which is polled
during synchronous execution, to enforce the deadline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sandbox): clamp crypto.randomBytes size to prevent OOM

hostCrypto.randomBytes(size) passed the sandboxed number straight to
Node's crypto.randomBytes with no upper bound, letting a plugin request
a multi-GB allocation (e.g. crypto.randomBytes(2 ** 31)) and crash the
host process. Clamp to 64KB before the call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sandbox): cap QuickJS heap to prevent unbounded allocation

QuickJS.newContext() had no memory limit, so a plugin allocating without
bound could exhaust the WASM heap and crash the host process. Set a 32MB
ceiling via ctx.runtime.setMemoryLimit().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sandbox): resolve symlinks before validating plugin entry path

getPluginEntrySource's containment check compared raw path strings, so a
plugin directory with a symlinked entry (e.g. index.js -> ../../../etc/secret)
passed the check while fs.readFileSync followed the symlink and read the
out-of-directory target. Re-run the check against fs.realpathSync'd paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sandbox): close util.render sandbox escape

context.util.render() bridged to the shared render() pipeline, whose Liquid
engine dispatches any registered tag's real run() directly, in-process,
regardless of templateTagSandboxEnabled. A sandboxed plugin could hand it a
string containing "{% anyTag %}" (including its own tag) and have that tag
execute completely unsandboxed. Verified with a PoC that reached
child_process execution from inside a plugin tag with no require() or Node
access.

util.render is now restricted to plain {{ variable }} interpolation (the
only real existing use, confirmed against all built-in tag call sites) via
a second Liquid engine with no tags registered; {% tag %} syntax now fails
to parse instead of dispatching. Default render() behavior is unchanged for
every other caller.

Also drops the dead renderDepth field's misleading doc comment: within one
sandboxed execution the envelope's renderDepth is always 0, so depth could
never exceed 1 regardless of enforcement — it couldn't have caught this
recursion anyway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sandbox): fixed linting issue

* fix(sandbox): fixed linting issue

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: kwburns-kong <kyle.burns@konghq.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 19:01:48 +00:00