feat(templating): (T1) flip trust model — pluginSandboxEnabled + per-plugin elevated opt-in (#10318)

* feat(templating): (T1) pluginSandboxEnabled + per-plugin elevated opt-in, centralized gate

Introduces the trust-model flip's core logic and wires every untrusted-execution surface to it.

- New setting `pluginSandboxEnabled` (default off) that supersedes/absorbs `templateTagSandboxEnabled`:
  either flag on activates the sandbox, so existing template-tag opt-ins keep working (migration bridge).
- New per-plugin `pluginConfig.elevated` escape hatch: a user plugin marked elevated runs in-process
  with full host access even while the sandbox is on. Widened PluginConfig/PluginConfigMap/Plugin.config.
- New pure resolver `common/plugins/sandbox-mode.ts` (isSandboxEnabled / resolvePluginExecutionMode /
  shouldSandboxPlugin) — the single source of truth replacing the 7 duplicated
  `templateTagSandboxEnabled && directory !== ''` conditions. Dependency-free so main, the plugin
  window, and the inso CLI node runtime all share it. Unit-tested (12 cases).
- Wired all surfaces to shouldSandboxPlugin: load-time discovery (now per-plugin, so an elevated
  plugin is nodeRequire-d for live functions), request/response hooks (plugin-window + node runtime),
  actions, and user template tags. Bundle-tag path reads isSandboxEnabled (bundle stays trusted).

No UI yet (next commit); default-off means no behavior change until a flag is toggled.

* feat(templating): (T1) Preferences UI — plugin-sandbox toggle + per-plugin elevated + mode indicator

- Scripting settings: new "Sandbox all plugin code (experimental)" toggle for pluginSandboxEnabled
  (data-testid toggle-plugin-sandbox), beside the existing template-tag toggle.
- Plugins settings: each user plugin card now shows its resolved execution mode
  (Sandboxed / Elevated / In-process, data-testid plugin-mode-<name>) and a "Full host access"
  checkbox (data-testid plugin-elevated-<name>) that writes pluginConfig.<name>.elevated. Mode +
  toggle read from live settings so they update immediately, before the plugin list reloads.
- Widened SerializablePlugin.config to carry the optional `elevated` flag through the bridge.

* test(templating): (T1) e2e — pluginSandboxEnabled sandboxes a user plugin; elevating runs it in-process

Composes the trust-flip's two user-visible behaviors on one action-probe plugin:
- Enabling the new pluginSandboxEnabled toggle (not the legacy template-tag flag) routes the user
  plugin's action into the sandbox (canary reports ranin-sandboxed).
- Toggling "Full host access" in Preferences → Plugins flips the mode indicator to "Elevated" and the
  same action then runs in-process (marker absent, ranin-mainprocess) — the per-plugin escape hatch.
Reuses the sandbox-action-collection.yaml fixture and the established writePlugin/clearPluginToast
helpers; adds enablePluginSandbox + setPluginElevated helpers.

* fix(templating): (T1) close two plugin-registry trust-cache gaps (#10326)

* fix(templating): close two plugin-registry trust-cache gaps

- pluginConfig.elevated is keyed by declared plugin name, not by folder;
  a same-named folder placed alongside an already-elevated plugin
  inherited its trust grant and ran in-process before any collision was
  even noticed. traversePluginPath now pre-scans for duplicate names
  (order-independent) and refuses to load any colliding folder.

- applyRequestHooks/applyResponseHooks tagged a caught error with
  `error.plugin = plugin`, a plain assignment that a plugin-thrown Error
  could intercept via its own `plugin` property setter, handing the hook
  a live, mutable reference to its own cached registry entry and letting
  it flip `directory`/`config.elevated` to defeat later sandboxing.
  Switched to Object.defineProperty, which bypasses any such setter.

* fix(plugins): use relative-path containment check instead of startsWith

A bare .startsWith(base) on a resolved path accepts a sibling directory
whose name happens to prefix-match the base (e.g. /plugins-evil vs
/plugins). Added a shared isContainedIn helper (path.relative, rejects
.. or an absolute result) and applied it to both the existing
plugin-path containment check and the new duplicate-name pre-pass.

* fix(templating): (T1) address Copilot review — skip CLI settings read, scope bundle-tag sandbox to legacy flag

- network-adapter.node.ts: only read services.settings.get() when canSandbox (Electron). The pure-Node
  inso CLI can never reach the sandbox host, so the read was wasted work; pass undefined otherwise
  (shouldSandboxPlugin treats it as off). Both applyRequestHooks and applyResponseHooks.
- templating-worker-database.ts: bundle (first-party/trusted) template tags now sandbox only under the
  legacy templateTagSandboxEnabled experiment, not the new pluginSandboxEnabled. The T1 flag isolates
  *untrusted* plugins; sandboxing trusted bundle tags under it was an unintended behavior change.
  Dropped the now-unused isSandboxEnabled import.

---------

Co-authored-by: kwburns-kong <kyle.burns@konghq.com>
This commit is contained in:
Jack Kavanaghandkwburns-kong authored and GitHub committed 2026-08-05 14:02:45 +00:00
1 parent e906a6a0df
commit e759bbceaf
16 files changed
+711 -36

No files matched your search

@@ -95,9 +95,12 @@ export type SettingsOfType<MatchType> = NonNullable<
export interface PluginConfig {
disabled: boolean;
// T1: per-plugin escape hatch. When true, a user plugin runs in-process with full host access
// instead of the sandbox, even while the sandbox is enabled. Off/absent = sandboxed (default-deny).
elevated?: boolean;
}
export type PluginConfigMap = Record<string, { disabled: boolean }>;
export type PluginConfigMap = Record<string, PluginConfig>;
export interface Settings {
autoDetectColorScheme: boolean;
@@ -168,6 +171,11 @@ export interface Settings {
scriptStrictModeEnabled: boolean;
// Experimental: execute plugin template tags inside the QuickJS-WASM sandbox instead of directly in the main process.
templateTagSandboxEnabled: boolean;
// T1: sandbox ALL untrusted (user) plugin surfaces — template tags, request/response hooks, actions,
// and load-time module code. Supersedes templateTagSandboxEnabled: either flag on activates the
// sandbox (migration bridge). User plugins are default-deny; per-plugin `pluginConfig.elevated` opts
// an individual plugin back into full-host in-process execution. Bundle plugins are always trusted.
pluginSandboxEnabled: boolean;
// Names of security rules that have been individually disabled.
disabledSecurityRules: string[];
// AST blocked-property names that have been individually disabled.
@@ -85,6 +85,7 @@ export function init(): BaseSettings {
scriptSandboxEnabled: true,
scriptStrictModeEnabled: true,
templateTagSandboxEnabled: false,
pluginSandboxEnabled: false,
disabledSecurityRules: [],
disabledBlockedProperties: [],
disabledBlockedRoots: [],
@@ -137,7 +137,11 @@ const clearPluginToast = async (page: Page) => {
.catch(() => {});
const deadline = Date.now() + 5000;
while (Date.now() < deadline && (await dismissButtons.count()) > 0) {
await dismissButtons.first().click({ force: true, timeout: 500 }).catch(() => {});
// eslint-disable-next-line playwright/no-force-option -- necessary to avoid flakiness with re-rendering toast
await dismissButtons
.first()
.click({ force: true, timeout: 500 })
.catch(() => {});
}
};
@@ -892,7 +896,9 @@ test('Plugin action sandbox (A1): a user plugin request action runs in the sandb
// Flag OFF (default): the action runs in-process (control). Its write is observable via the tag,
// and the domain models reached it.
await runAction();
await expect.poll(() => readTagPreview('{% actionreadback'), { timeout: 20_000 }).toContain('ranin-mainprocess|got-req');
await expect
.poll(() => readTagPreview('{% actionreadback'), { timeout: 20_000 })
.toContain('ranin-mainprocess|got-req');
// Flag ON: the same action now runs in the QuickJS sandbox (its in-process fn is a throw-stub after
// discovery, so a successful write proves routing). Poll run+readback so the just-toggled setting
@@ -908,3 +914,187 @@ test('Plugin action sandbox (A1): a user plugin request action runs in the sandb
)
.toContain('ranin-sandboxed|got-req');
});
// T1: enable the *new* pluginSandboxEnabled flag (distinct from templateTagSandboxEnabled) via
// Preferences → Scripting. Same soft-assertion + Escape-to-close shape as enableSandbox.
const enablePluginSandbox = async (page: Page) => {
await page.getByTestId('settings-button').click();
const toggle = page.getByTestId('toggle-plugin-sandbox');
await page.getByRole('tab', { name: 'Scripting' }).click();
await toggle.getByRole('switch').waitFor();
await toggle.click();
await expect.soft(toggle.getByRole('switch')).toBeChecked();
await page.locator('.app').press('Escape');
await expect.soft(page.getByTestId('toggle-plugin-sandbox')).toBeHidden();
};
// T1: toggle a user plugin's "Full host access" (elevated) in Preferences → Plugins, assert the mode
// indicator reflects the new mode, then close. Toggling pluginConfig triggers a plugin reload (the
// Plugins pane re-discovers on settings.pluginConfig change), so the plugin is re-loaded elevated.
const setPluginElevated = async (page: Page, pluginName: string, expectedMode: string) => {
await page.getByTestId('settings-button').click();
await page.getByRole('tab', { name: 'Plugins' }).click();
const elevatedToggle = page.getByTestId(`plugin-elevated-${pluginName}`);
await elevatedToggle.waitFor();
await elevatedToggle.click();
await expect.soft(page.getByTestId(`plugin-mode-${pluginName}`)).toHaveText(expectedMode);
await page.locator('.app').press('Escape');
await expect.soft(elevatedToggle).toBeHidden();
};
test('Plugin sandbox trust flip (T1): pluginSandboxEnabled sandboxes a user plugin; elevating it runs it in-process', async ({
page,
app,
dataPath,
insomnia,
}) => {
// One plugin, two surfaces sharing its store: a request action records where it ran (the sandbox
// marker INSOMNIA_TEMPLATE_SANDBOX exists only in the sandbox), and a template tag reads it back.
// `storage` is declared so context.store is granted on both the sandboxed and elevated paths.
writePlugin(
dataPath,
'insomnia-plugin-t1-probe',
{ permissions: { capabilities: ['storage'] } },
`
module.exports.requestActions = [
{
label: 'T1 Probe',
async action(context) {
var ranIn = typeof INSOMNIA_TEMPLATE_SANDBOX !== 'undefined' ? 'ranin-sandboxed' : 'ranin-mainprocess';
await context.store.setItem('t1_ran_in', ranIn);
},
},
];
module.exports.templateTags = [
{
name: 'actionreadback', displayName: 'actionreadback', args: [],
async run(context) {
return (await context.store.getItem('t1_ran_in')) || 'unset';
},
},
];
`,
);
const fixture = await loadFixture('sandbox-action-collection.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture);
await clearPluginToast(page);
await page.getByLabel('Import').click();
await page.locator('[data-test-id="import-from-clipboard"]').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());
await insomnia.navigationSidebar.clickRequestOrFolder('Action Probe');
await page.getByText('Body', { exact: true }).click();
const runAction = () =>
page.evaluate(() =>
(window as any).main.plugins.executeAction({
type: 'request',
pluginName: 'insomnia-plugin-t1-probe',
label: 'T1 Probe',
projectId: 'proj_t1_probe',
domainData: {},
}),
);
const readTagPreview = async (tagPrefix: string): Promise<string> => {
await page.locator(`[data-template^="${tagPrefix}"]`).click();
const modal = page.getByRole('dialog');
const preview = modal.getByLabel('Live Preview');
await expect.soft(preview).not.toHaveValue('rendering...');
const value = (await preview.inputValue()).trim();
await modal.getByRole('button', { name: 'Done' }).click();
await expect.soft(modal).toBeHidden();
return value;
};
// Turn on the NEW pluginSandboxEnabled flag (the legacy template-tag flag stays off). The user
// plugin's action must now run in the QuickJS sandbox — proving the new flag drives every surface.
await enablePluginSandbox(page);
await expect
.poll(
async () => {
await runAction();
return readTagPreview('{% actionreadback');
},
{ timeout: 25_000 },
)
.toContain('ranin-sandboxed');
// Grant this plugin full host access. The mode indicator flips to "Elevated" and the same action
// now runs in-process (marker absent) — the deliberate, per-plugin escape hatch.
await setPluginElevated(page, 'insomnia-plugin-t1-probe', 'Elevated');
await expect
.poll(
async () => {
await runAction();
return readTagPreview('{% actionreadback');
},
{ timeout: 25_000 },
)
.toContain('ranin-mainprocess');
});
test('A hook throwing a crafted Error cannot self-elevate via a "plugin" property setter', async ({
page,
app,
dataPath,
insomnia,
}) => {
// First run (sandbox off) throws once via a poisoned `plugin` setter, attempting to flip its own
// cached `directory` to '' and pass as trusted; the second run reports where it actually executed.
writePlugin(
dataPath,
'insomnia-plugin-registry-integrity-probe',
{ permissions: { capabilities: ['storage'] } },
`
module.exports.requestHooks = [
async function (context) {
var alreadyAttempted = await context.store.getItem('poison_attempted');
if (!alreadyAttempted) {
await context.store.setItem('poison_attempted', 'yes');
var err = new Error('poison-attempt');
Object.defineProperty(err, 'plugin', {
set: function (assignedPlugin) { assignedPlugin.directory = ''; },
configurable: true,
});
throw err;
}
var ranIn = typeof INSOMNIA_TEMPLATE_SANDBOX !== 'undefined' ? 'ranin-sandboxed' : 'ranin-mainprocess';
context.request.setHeader('X-Ran-In', ranIn);
},
];
`,
);
const fixture = await loadFixture('sandbox-hook-collection.yaml');
await app.evaluate(async ({ clipboard }, text) => clipboard.writeText(text), fixture);
await clearPluginToast(page);
await page.getByLabel('Import').click();
await page.locator('[data-test-id="import-from-clipboard"]').click();
await page.getByRole('button', { name: 'Scan' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Import' }).click();
await page.evaluate(() => (window as any).main.plugins.reloadPlugins());
await insomnia.navigationSidebar.clickRequestOrFolder('Hook Request');
const responsePane = page.getByTestId('response-pane');
// First send: hook runs in-process (sandbox off) and throws; dismiss the resulting error state.
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
await page.waitForURL(/error=/, { timeout: 5000 }).catch(() => {});
await page.locator('.app').press('Escape').catch(() => {});
// Enable the sandbox only after the poison attempt, since nothing else forces a plugin reload here.
await enablePluginSandbox(page);
await expect
.poll(
async () => {
await page.getByTestId('request-pane').getByRole('button', { name: 'Send' }).click();
return (await responsePane.textContent()) || '';
},
{ timeout: 25_000 },
)
.toContain('ranin-sandboxed');
});
@@ -76,7 +76,7 @@ export interface SerializablePlugin {
description: string;
version: string;
directory: string;
config: { disabled: boolean };
config: { disabled: boolean; elevated?: boolean };
/** Parsed `insomnia.permissions` manifest (sandbox plan C3), surfaced in Preferences → Plugins. */
permissions: { modules: string[]; capabilities: string[] };
/** Validation warnings from parsing `permissions`; shown on the plugin card. Empty when clean. */
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest';
import { isSandboxEnabled, resolvePluginExecutionMode, shouldSandboxPlugin } from './sandbox-mode';
const userPlugin = (elevated?: boolean) => ({ directory: '/plugins/insomnia-plugin-x', config: { elevated } });
const bundlePlugin = { directory: '', config: {} };
describe('isSandboxEnabled (flag supersession)', () => {
it('is off when neither flag is set', () => {
expect(isSandboxEnabled({})).toBe(false);
expect(isSandboxEnabled()).toBe(false);
});
it('the legacy templateTagSandboxEnabled still activates the sandbox (migration bridge)', () => {
expect(isSandboxEnabled({ templateTagSandboxEnabled: true })).toBe(true);
});
it('the new pluginSandboxEnabled activates the sandbox', () => {
expect(isSandboxEnabled({ pluginSandboxEnabled: true })).toBe(true);
});
it('either flag on is enough', () => {
expect(isSandboxEnabled({ templateTagSandboxEnabled: true, pluginSandboxEnabled: false })).toBe(true);
expect(isSandboxEnabled({ templateTagSandboxEnabled: false, pluginSandboxEnabled: true })).toBe(true);
});
});
describe('resolvePluginExecutionMode', () => {
it('bundle plugins are always trusted, regardless of flags/elevated', () => {
expect(resolvePluginExecutionMode({ pluginSandboxEnabled: true }, bundlePlugin)).toBe('trusted');
expect(resolvePluginExecutionMode({}, bundlePlugin)).toBe('trusted');
});
it('user plugin with sandbox off is unsandboxed (legacy in-process), not trusted', () => {
expect(resolvePluginExecutionMode({}, userPlugin())).toBe('unsandboxed');
});
it('user plugin with sandbox on and not elevated is sandboxed (default-deny)', () => {
expect(resolvePluginExecutionMode({ pluginSandboxEnabled: true }, userPlugin(false))).toBe('sandboxed');
expect(resolvePluginExecutionMode({ templateTagSandboxEnabled: true }, userPlugin())).toBe('sandboxed');
});
it('user plugin explicitly elevated runs in-process even with sandbox on', () => {
expect(resolvePluginExecutionMode({ pluginSandboxEnabled: true }, userPlugin(true))).toBe('elevated');
});
});
describe('shouldSandboxPlugin (the gate every surface reads)', () => {
it('true only for a user plugin, sandbox on, not elevated', () => {
expect(shouldSandboxPlugin({ pluginSandboxEnabled: true }, userPlugin(false))).toBe(true);
});
it('false for bundle plugins', () => {
expect(shouldSandboxPlugin({ pluginSandboxEnabled: true }, bundlePlugin)).toBe(false);
});
it('false when sandbox is off', () => {
expect(shouldSandboxPlugin({}, userPlugin(false))).toBe(false);
});
it('false for an elevated user plugin (the escape hatch)', () => {
expect(shouldSandboxPlugin({ pluginSandboxEnabled: true }, userPlugin(true))).toBe(false);
});
});
@@ -0,0 +1,70 @@
// T1: the single source of truth for "does this plugin surface run in the sandbox?".
//
// Before T1 the condition `settings.templateTagSandboxEnabled && plugin.directory !== ''` was
// duplicated across every execution surface (template tags, request/response hooks, actions, and
// load-time discovery). T1 centralises it here so all surfaces agree, reads the superseding
// `pluginSandboxEnabled` flag, and honours the per-plugin `elevated` escape hatch.
//
// Pure and dependency-free (no electron, no node builtins) so it is safe to import from the main
// process, the plugin (renderer) window, and the inso CLI's node runtime alike.
/** The minimal settings shape the sandbox decision depends on. */
export interface SandboxSettings {
templateTagSandboxEnabled?: boolean;
pluginSandboxEnabled?: boolean;
}
/** The minimal plugin shape the sandbox decision depends on. */
export interface SandboxPlugin {
/** '' = bundle/first-party (trusted); non-empty = user plugin on disk. */
directory: string;
config?: { elevated?: boolean };
}
/**
* Is the sandbox globally active? `pluginSandboxEnabled` supersedes/absorbs the older
* `templateTagSandboxEnabled`: during migration either flag being on activates the sandbox, so
* users who already opted into the template-tag experiment keep sandboxing without re-toggling.
*/
export const isSandboxEnabled = (settings?: SandboxSettings): boolean =>
!!settings?.pluginSandboxEnabled || !!settings?.templateTagSandboxEnabled;
export type PluginExecutionMode =
// Bundle/first-party plugin — shipped by us, always runs in-process with full host access.
| 'trusted'
// User plugin, sandbox on, not elevated — runs in the QuickJS sandbox (default-deny).
| 'sandboxed'
// User plugin the user explicitly elevated — runs in-process with full host access.
| 'elevated'
// User plugin, sandbox off — legacy in-process execution (not a deliberate trust decision).
| 'unsandboxed';
/**
* The execution mode for a single plugin, given the current settings. Used for the 7 gate sites
* (via `shouldSandboxPlugin`) and for the Preferences → Plugins mode indicator.
*/
export const resolvePluginExecutionMode = (
settings: SandboxSettings | undefined,
plugin: SandboxPlugin,
): PluginExecutionMode => {
// Bundle plugins are trusted by design (e.g. external-vault's unsafePluginMainActions).
if (!plugin.directory) {
return 'trusted';
}
// Sandbox off entirely → user plugins run in-process as they always did (legacy path).
if (!isSandboxEnabled(settings)) {
return 'unsandboxed';
}
// Sandbox on, but the user opted this specific plugin back into full-host execution.
if (plugin.config?.elevated) {
return 'elevated';
}
return 'sandboxed';
};
/**
* The one boolean every execution surface asks: should THIS plugin's untrusted code run in the
* sandbox right now? True only for a user plugin, sandbox enabled, not elevated.
*/
export const shouldSandboxPlugin = (settings: SandboxSettings | undefined, plugin: SandboxPlugin): boolean =>
resolvePluginExecutionMode(settings, plugin) === 'sandboxed';
@@ -73,7 +73,7 @@ export interface Plugin {
description: string;
version: string;
directory: string;
config: { disabled: boolean };
config: { disabled: boolean; elevated?: boolean };
/** Parsed, validated `insomnia.permissions` manifest (sandbox plan C3). */
permissions: PluginPermissions;
/** Validation warnings from parsing `permissions`; shown on the plugin card. Empty when clean. */
@@ -11,6 +11,7 @@ import { services } from 'insomnia-data';
import { v4 as uuidv4 } from 'uuid';
import { jarFromCookies } from '~/common/cookies';
import { shouldSandboxPlugin } from '~/common/plugins/sandbox-mode';
import { type Plugin, type TemplateTag } from '~/common/plugins/types';
import type {
AppPromptOptions,
@@ -19,7 +20,11 @@ import type {
PluginToMainAPIPaths,
} from '~/common/templating/types';
import { getPluginCommonContext, getPlugins, getTemplateTags } from '~/plugins';
import { HOOK_REQUEST_FIELDS, type PluginExportManifest, stripDangerousKeysReviver } from '~/templating/sandbox/marshal';
import {
HOOK_REQUEST_FIELDS,
type PluginExportManifest,
stripDangerousKeysReviver,
} from '~/templating/sandbox/marshal';
import type { SandboxModuleDenialError } from '~/templating/sandbox/plugin-tag-sandbox';
import { getAppBundlePlugins, RESPONSE_CODE_REASONS } from '../common/constants';
@@ -886,6 +891,10 @@ export const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => P
const targetTag = templateTags.find(tag => tag.name === tagName);
if (targetTag) {
const settings = await services.settings.get();
// Bundle plugins are first-party/trusted, so the new pluginSandboxEnabled flag (which isolates
// *untrusted* plugins) deliberately leaves them in-process. Their tags run in the sandbox only
// under the legacy templateTagSandboxEnabled experiment, with the broad all-modules/all-caps
// profile — a hardening opt-in, not part of the T1 trust flip.
if (settings.templateTagSandboxEnabled) {
const { ALL_CAPABILITIES } = await import('../templating/sandbox/host-bridge');
const { ALL_SANDBOX_MODULES } = await import('../templating/sandbox/module-registry');
@@ -1002,7 +1011,9 @@ export const pluginToMainAPI: Record<PluginToMainAPIPaths, (...args: any[]) => P
const targetTag = tags.find(t => t.plugin.name === pluginName && t.templateTag.name === tagName);
if (targetTag) {
const settings = await services.settings.get();
if (settings.templateTagSandboxEnabled) {
// T1: a user plugin's tag runs in the sandbox unless the plugin is elevated (then its `run` is a
// live in-process fn from nodeRequire discovery). Bundle tags are handled by the sibling handler.
if (shouldSandboxPlugin(settings, targetTag.plugin)) {
const { resolveTemplateTagModules, resolveTemplateTagCapabilities } = await import(
'../templating/sandbox/surface-profiles'
);
@@ -27,6 +27,7 @@ import { fetchFromTemplateWorkerDatabase } from '~/common/templating/liquid-exte
import type { Plugin } from '../index';
import {
_testOnlyIsContainedIn,
_testOnlySetPlugins,
executePluginMainAction,
getDocumentActions,
@@ -347,3 +348,18 @@ describe('executePluginMainAction', () => {
);
});
});
describe('_testOnlyIsContainedIn', () => {
it('accepts the base path itself and a real child path', () => {
expect(_testOnlyIsContainedIn('/plugins', '/plugins')).toBe(true);
expect(_testOnlyIsContainedIn('/plugins', '/plugins/my-plugin')).toBe(true);
});
it('rejects a sibling directory whose name merely prefix-matches the base', () => {
expect(_testOnlyIsContainedIn('/plugins', '/plugins-evil/my-plugin')).toBe(false);
});
it('rejects a path that escapes the base via ..', () => {
expect(_testOnlyIsContainedIn('/plugins', '/plugins/../other')).toBe(false);
});
});
@@ -0,0 +1,70 @@
// @ts-nocheck
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { services } from 'insomnia-data';
import { afterEach, describe, expect, it } from 'vitest';
import { _testOnlySetPlugins, getPlugins } from '../index';
const SHARED_NAME = 'insomnia-plugin-collision-poc';
const MARKER = '__probe_plugin_ran_in_process';
const writePluginFolder = (baseDir: string, folderName: string, indexJs: string) => {
const dir = path.join(baseDir, folderName);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, 'package.json'),
JSON.stringify({ name: SHARED_NAME, version: '1.0.0', main: 'index.js', insomnia: {} }),
);
fs.writeFileSync(path.join(dir, 'index.js'), indexJs);
return dir;
};
describe('getPlugins — pluginConfig.elevated is keyed by plugin name, not by folder', () => {
let tempDir: string;
let originalSettings: Record<string, any>;
afterEach(async () => {
delete (globalThis as any)[MARKER];
_testOnlySetPlugins(null);
if (tempDir) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
if (originalSettings) {
await services.settings.update(await services.settings.get(), {
pluginPath: originalSettings.pluginPath,
pluginConfig: originalSettings.pluginConfig,
pluginSandboxEnabled: originalSettings.pluginSandboxEnabled,
});
}
});
it('nodeRequires (runs in-process) an unrelated folder that merely declares an already-elevated plugin name', async () => {
const settings = await services.settings.get();
originalSettings = {
pluginPath: settings.pluginPath,
pluginConfig: settings.pluginConfig,
pluginSandboxEnabled: settings.pluginSandboxEnabled,
};
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'insomnia-plugin-collision-'));
// The folder the user actually saw and elevated in Preferences → Plugins.
writePluginFolder(tempDir, 'trusted-folder', 'module.exports = {};');
// An unrelated folder that merely declares the same package.json "name".
writePluginFolder(tempDir, 'probe-folder', `globalThis.${MARKER} = true; module.exports = {};`);
await services.settings.update(await services.settings.get(), {
pluginPath: tempDir,
// The user elevated one specific folder, not this name in the abstract.
pluginConfig: { [SHARED_NAME]: { disabled: false, elevated: true } },
pluginSandboxEnabled: true,
});
await getPlugins(true);
// The probe folder must not run in-process just because it shares a name with an elevated one.
expect((globalThis as any)[MARKER]).not.toBe(true);
});
});
+73 -9
View File
@@ -7,6 +7,7 @@ import { database as db, models, services } from 'insomnia-data';
import type { PluginConfigMap } from 'insomnia-data/common';
import { parsePluginPermissions } from '~/common/plugins/permissions';
import { type SandboxSettings, shouldSandboxPlugin } from '~/common/plugins/sandbox-mode';
import type {
DocumentAction,
Plugin,
@@ -106,11 +107,62 @@ function buildUserPluginModuleFromManifest(pluginName: string, manifest: PluginE
} as Plugin['module'];
}
// A bare `.startsWith(base)` string check passes for a sibling directory whose name happens to
// prefix-match (e.g. `/plugins-evil` starts with `/plugins`); this compares the resolved relative
// path instead, so containment can't be spoofed by a similarly-named sibling.
const isContainedIn = (base: string, target: string): boolean => {
const rel = path.relative(base, target);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
};
export const _testOnlyIsContainedIn = isContainedIn;
// Finds package.json `name`s claimed by more than one folder under `allPaths`, read-only and before
// any folder is trusted, so the result doesn't depend on filesystem read order.
async function findDuplicatePluginNames(allPaths: string[]): Promise<Set<string>> {
const nameCounts: Record<string, number> = {};
const walk = (paths: string[]) => {
for (const p of paths) {
if (!fs.existsSync(p)) {
continue;
}
for (const filename of fs.readdirSync(p)) {
const modulePath = path.resolve(p, filename);
if (!fs.statSync(modulePath).isDirectory()) {
continue;
}
if (filename.startsWith('@')) {
walk([modulePath]);
}
if (!fs.readdirSync(modulePath).includes('package.json')) {
continue;
}
if (!isContainedIn(p, path.resolve(modulePath))) {
continue;
}
try {
// package.json is plain data — reading it runs no plugin code.
const pluginJson = getNodeRequire()(path.resolve(modulePath, 'package.json'));
if ('insomnia' in pluginJson && typeof pluginJson.name === 'string') {
nameCounts[pluginJson.name] = (nameCounts[pluginJson.name] ?? 0) + 1;
}
} catch {
// Any read/parse error here will be hit (and reported) again by the real pass below.
}
}
}
};
walk(allPaths);
return new Set(Object.keys(nameCounts).filter(name => nameCounts[name] > 1));
}
async function traversePluginPath(
pluginMap: Record<string, Plugin>,
allPaths: string[],
allConfigs: PluginConfigMap,
sandboxEnabled: boolean,
settings: SandboxSettings,
duplicatePluginNames: Set<string>,
) {
for (const p of allPaths) {
if (!fs.existsSync(p)) {
@@ -131,7 +183,7 @@ async function traversePluginPath(
// Is it a scoped directory?
if (filename.startsWith('@')) {
await traversePluginPath(pluginMap, [modulePath], allConfigs, sandboxEnabled);
await traversePluginPath(pluginMap, [modulePath], allConfigs, settings, duplicatePluginNames);
}
// Is it a Node module?
@@ -145,7 +197,7 @@ async function traversePluginPath(
const pluginBasePath = p;
// Check if the resolved module path is inside the base plugin path (to prevent directory traversal)
if (!safeModulePath.startsWith(pluginBasePath)) {
if (!isContainedIn(pluginBasePath, safeModulePath)) {
console.warn(`[plugin] Ignored potentially unsafe plugin path: ${modulePath}`);
continue;
}
@@ -168,17 +220,28 @@ async function traversePluginPath(
continue;
}
// pluginConfig is keyed by declared name, not by folder — never load a name claimed by more
// than one folder, so a colliding folder can't inherit another folder's `elevated` grant.
if (duplicatePluginNames.has(pluginJson.name)) {
console.warn('[plugin] Ignoring %s at %s: multiple plugin folders declare this name.', pluginJson.name, modulePath);
continue;
}
const parsedPermissions = parsePluginPermissions(pluginJson.insomnia);
if (parsedPermissions.warnings.length > 0) {
// Constant format string; interpolated values passed as args so a plugin name can't forge log output.
console.warn('[plugin] %s has invalid insomnia.permissions: %o', pluginJson.name, parsedPermissions.warnings);
}
// L1: with the sandbox on, discover a user plugin's exports by evaluating its source *inside*
// the sandbox (main process) instead of nodeRequire-ing it here — so installing/enabling it
// never runs its top-level code with host (Node) privileges. Off → legacy in-process require.
const config = pluginJson.name in allConfigs ? allConfigs[pluginJson.name] : { disabled: false };
// L1/T1: a sandboxed user plugin's exports are discovered by evaluating its source *inside* the
// sandbox (main process) instead of nodeRequire-ing it here — so installing/enabling it never
// runs its top-level code with host (Node) privileges. An elevated plugin (or sandbox-off) is
// nodeRequire-d so its hooks/actions/tags are live in-process functions. Decision is per-plugin
// because `elevated` is per-plugin.
let module: Plugin['module'];
if (sandboxEnabled) {
if (shouldSandboxPlugin(settings, { directory: modulePath, config })) {
const manifest = await discoverUserPluginExports(modulePath, pluginJson.name, parsedPermissions.permissions);
module = buildUserPluginModuleFromManifest(pluginJson.name, manifest);
} else {
@@ -190,7 +253,7 @@ async function traversePluginPath(
description: pluginJson.description || pluginJson.insomnia.description || '',
version: pluginJson.version || 'unknown',
directory: modulePath || '',
config: pluginJson.name in allConfigs ? allConfigs[pluginJson.name] : { disabled: false },
config,
permissions: parsedPermissions.permissions,
permissionWarnings: parsedPermissions.warnings,
permissionsDeclared: parsedPermissions.declared,
@@ -235,7 +298,8 @@ export async function getPlugins(force = false): Promise<Plugin[]> {
// Store plugins in a map so that plugins with the same name only get added once
const pluginMap: Record<string, Plugin> = {};
await traversePluginPath(pluginMap, allPaths, allConfigs, settings.templateTagSandboxEnabled);
const duplicatePluginNames = await findDuplicatePluginNames(allPaths);
await traversePluginPath(pluginMap, allPaths, allConfigs, settings, duplicatePluginNames);
const bundlePluginMap = getBundlePluginMap();
const fullPluginMap = { ...pluginMap, ...bundlePluginMap };
plugins = Object.keys(fullPluginMap).map(name => fullPluginMap[name]);
+14 -14
View File
@@ -5,6 +5,7 @@ import type {
ExecutePluginMainActionArgs,
RunTemplateTagActionArgs,
} from '~/common/plugins/bridge-types';
import { shouldSandboxPlugin } from '~/common/plugins/sandbox-mode';
import type { Plugin } from '~/common/plugins/types';
import { fetchFromTemplateWorkerDatabase } from '~/common/templating/liquid-extension-worker';
import { deserializeRenderContext } from '~/common/templating/render-context-serialization';
@@ -137,12 +138,11 @@ export async function invokePluginMethod(method: PluginInvokeMethod, args?: unkn
throw new Error(`[plugin-window] Action not found: ${pluginName}/${label}`);
}
// A1: with the sandbox on, a user plugin's action (a throw-stub here after discovery) runs in the
// main-process sandbox over the templating-worker-database protocol; bundle plugins and the
// flag-off path run in-process.
// A1/T1: a sandboxed user plugin's action (a throw-stub here after discovery) runs in the
// main-process sandbox over the templating-worker-database protocol; bundle plugins, an
// elevated plugin, and the flag-off path run in-process (see resolvePluginExecutionMode).
const settings = await fetchFromTemplateWorkerDatabase('settings.get', {});
const sandboxEnabled = !!settings?.templateTagSandboxEnabled;
if (sandboxEnabled && entry.plugin.directory !== '') {
if (shouldSandboxPlugin(settings, entry.plugin)) {
await fetchFromTemplateWorkerDatabase('plugin.runUserAction', {
plugin: { directory: entry.plugin.directory, name: entry.plugin.name, permissions: entry.plugin.permissions },
actionKind: type,
@@ -210,17 +210,17 @@ export async function invokePluginMethod(method: PluginInvokeMethod, args?: unkn
const { renderedRequest, projectId, environment } = args as ApplyRequestHooksArgs;
const newRenderedRequest = { ...renderedRequest };
const renderedContext = deserializeRenderContext(environment);
// H1: with the sandbox on, a user plugin's hook (a throw-stub here after discovery) runs in the
// main-process sandbox over the templating-worker-database protocol; bundle plugins and the
// flag-off path run in-process. Per-plugin counter recovers the hook's index within its array.
// H1/T1: a sandboxed user plugin's hook (a throw-stub here after discovery) runs in the
// main-process sandbox over the templating-worker-database protocol; bundle plugins, an
// elevated plugin, and the flag-off path run in-process. Per-plugin counter recovers the
// hook's index within its array.
const settings = await fetchFromTemplateWorkerDatabase('settings.get', {});
const sandboxEnabled = !!settings?.templateTagSandboxEnabled;
const hookIndexByPlugin: Record<string, number> = {};
for (const { plugin, hook } of await getRequestHooks()) {
const hookIndex = (hookIndexByPlugin[plugin.name] = (hookIndexByPlugin[plugin.name] ?? -1) + 1);
try {
if (sandboxEnabled && plugin.directory !== '') {
if (shouldSandboxPlugin(settings, plugin)) {
const mutated = await fetchFromTemplateWorkerDatabase('plugin.runUserRequestHook', {
plugin: { directory: plugin.directory, name: plugin.name, permissions: plugin.permissions },
hookIndex,
@@ -253,16 +253,16 @@ export async function invokePluginMethod(method: PluginInvokeMethod, args?: unkn
const newResponse = { ...response };
const newRequest = { ...renderedRequest };
const renderedContext = deserializeRenderContext(environment);
// H1: with the sandbox on, a user plugin's response hook runs in the main-process sandbox over
// the templating-worker-database protocol; bundle plugins and the flag-off path run in-process.
// H1/T1: a sandboxed user plugin's response hook runs in the main-process sandbox over the
// templating-worker-database protocol; bundle plugins, an elevated plugin, and the flag-off
// path run in-process.
const settings = await fetchFromTemplateWorkerDatabase('settings.get', {});
const sandboxEnabled = !!settings?.templateTagSandboxEnabled;
const hookIndexByPlugin: Record<string, number> = {};
for (const { plugin, hook } of await getResponseHooks()) {
const hookIndex = (hookIndexByPlugin[plugin.name] = (hookIndexByPlugin[plugin.name] ?? -1) + 1);
try {
if (sandboxEnabled && plugin.directory !== '') {
if (shouldSandboxPlugin(settings, plugin)) {
const mutated = await fetchFromTemplateWorkerDatabase('plugin.runUserResponseHook', {
plugin: { directory: plugin.directory, name: plugin.name, permissions: plugin.permissions },
hookIndex,
@@ -0,0 +1,87 @@
// @ts-nocheck
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../plugins/context/app', () => ({ init: vi.fn().mockReturnValue({}) }));
vi.mock('../../plugins/context/data', () => ({ init: vi.fn().mockReturnValue({}) }));
vi.mock('../../plugins/context/network', () => ({ init: vi.fn().mockReturnValue({}) }));
vi.mock('../../plugins/context/request', () => ({ init: vi.fn().mockReturnValue({}) }));
vi.mock('../../plugins/context/response', () => ({ init: vi.fn().mockReturnValue({}) }));
vi.mock('../../plugins/context/store', () => ({ init: vi.fn().mockReturnValue({}) }));
vi.mock('insomnia-data', () => ({
services: { settings: { get: vi.fn() } },
}));
import { services } from 'insomnia-data';
import { _testOnlySetPlugins, getPlugins } from '../../plugins/index';
import { applyRequestHooks, applyResponseHooks } from './network-adapter.node';
const makePlugin = (overrides: Record<string, any> = {}) => ({
name: 'poc-plugin',
description: '',
version: '1.0.0',
directory: '/plugins/poc-plugin',
config: {},
permissions: { modules: [], capabilities: [] },
permissionWarnings: [],
permissionsDeclared: false,
module: {},
...overrides,
});
// Throws an Error whose own `plugin` property is a setter, so assigning to it (as the catch block does) runs `onSet` instead of just storing data.
const throwWithPoisonedPluginSetter = (onSet: (assignedPlugin: any) => void) => async () => {
const err = new Error('hook failed');
Object.defineProperty(err, 'plugin', {
set: onSet,
configurable: true,
});
throw err;
};
afterEach(() => {
_testOnlySetPlugins(null);
vi.clearAllMocks();
});
describe('applyRequestHooks / applyResponseHooks — plugin cache integrity on hook failure', () => {
it('does not let a hook-thrown error mutate the cached Plugin object via a "plugin" setter trap', async () => {
(services.settings.get as any).mockResolvedValue({ pluginSandboxEnabled: true });
let setterInvoked = false;
const hook = throwWithPoisonedPluginSetter(assigned => {
setterInvoked = true;
assigned.directory = '';
});
_testOnlySetPlugins([makePlugin({ module: { requestHooks: [hook] } })]);
const renderedRequest = { headers: [] } as any;
const renderedContext = { getProjectId: () => 'proj_x', DEFAULT_HEADERS: undefined };
await expect(applyRequestHooks(renderedRequest, renderedContext)).rejects.toThrow('hook failed');
// Attaching plugin info to the error must never go through an assignment the error can intercept.
expect(setterInvoked).toBe(false);
// The cached registry entry other code reads for trust decisions stays unaffected.
const [cachedPlugin] = await getPlugins();
expect(cachedPlugin.directory).toBe('/plugins/poc-plugin');
});
it('does not let a response-hook-thrown error mutate the cached Plugin object via a "plugin" setter trap', async () => {
(services.settings.get as any).mockResolvedValue({ pluginSandboxEnabled: true });
const hook = throwWithPoisonedPluginSetter(assigned => {
assigned.config.elevated = true;
});
_testOnlySetPlugins([makePlugin({ config: { elevated: false }, module: { responseHooks: [hook] } })]);
const response = {} as any;
const renderedRequest = { headers: [] } as any;
const renderedContext = { getProjectId: () => 'proj_x', DEFAULT_HEADERS: undefined };
await expect(applyResponseHooks(response, renderedRequest, renderedContext)).rejects.toThrow('hook failed');
const [cachedPlugin] = await getPlugins();
expect(cachedPlugin.config.elevated).toBe(false);
});
});
@@ -4,6 +4,7 @@ import nodePath from 'node:path';
import clone from 'clone';
import type { Cookie, RequestHeader, ResponseTimelineEntry } from 'insomnia-data';
import { shouldSandboxPlugin } from '~/common/plugins/sandbox-mode';
import type { RenderedRequest } from '~/common/templating/types';
import type { RequestContext } from '../../../../insomnia-scripting-environment/src/objects';
@@ -20,6 +21,16 @@ import * as pluginResponse from '../../plugins/context/response';
import * as pluginStore from '../../plugins/context/store';
import { runScript as executeScript } from '../../script-executor';
// Uses defineProperty, not assignment, so a plugin-thrown error can't define its own `plugin` setter
// to intercept the write and grab a live, mutable reference to its own cached registry entry.
const attachPluginToError = (error: Error, plugin: unknown): void => {
try {
Object.defineProperty(error, 'plugin', { value: plugin, enumerable: true, configurable: true });
} catch {
// Best-effort annotation only; nothing downstream depends on this property.
}
};
export const getTimelinePath = async (responseId: string): Promise<string> => {
const electron = require('electron') as { app: { getPath: (name: string) => string } };
const dataDir = process.env['INSOMNIA_DATA_PATH'] || electron.app.getPath('userData');
@@ -85,14 +96,18 @@ export async function applyRequestHooks(
// reached from an Electron process. This node runtime also backs the pure-Node inso CLI, which has
// no `electron` — there the sandbox is unavailable, so hooks run in-process as they always have.
const canSandbox = !!process.type;
const sandboxEnabled = canSandbox && (await services.settings.get()).templateTagSandboxEnabled;
// Only fetch settings when a sandbox host is reachable — the pure-Node inso CLI (process.type
// falsy) can never sandbox, so skip the read entirely; shouldSandboxPlugin treats undefined as off.
const settings = canSandbox ? await services.settings.get() : undefined;
// getRequestHooks flattens each plugin's requestHooks in order, so a per-plugin running counter
// recovers the hook's index within its own array (what the sandbox loads by).
const hookIndexByPlugin: Record<string, number> = {};
for (const { plugin, hook } of await pluginIndex.getRequestHooks()) {
const hookIndex = (hookIndexByPlugin[plugin.name] = (hookIndexByPlugin[plugin.name] ?? -1) + 1);
try {
if (sandboxEnabled && plugin.directory !== '') {
// T1: sandbox a user plugin unless it's elevated; bundle + flag-off run in-process. canSandbox
// stays gated on process.type because the inso CLI has no Electron sandbox host.
if (canSandbox && shouldSandboxPlugin(settings, plugin)) {
const { runRequestHookInSandbox } = await import('../../main/templating-worker-database');
const { mergeHookRequestMutation } = await import('../../templating/sandbox/marshal');
// The hook mutates the request in the sandbox; merge the returned fields back so the next
@@ -112,7 +127,7 @@ export async function applyRequestHooks(
await hook(context);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
(error as any).plugin = plugin;
attachPluginToError(error, plugin);
throw error;
}
}
@@ -132,12 +147,15 @@ export async function applyResponseHooks(
// only from an Electron process. This node runtime also backs the pure-Node inso CLI (no electron),
// where the sandbox is unavailable and hooks run in-process — gate on process.type accordingly.
const canSandbox = !!process.type;
const sandboxEnabled = canSandbox && (await services.settings.get()).templateTagSandboxEnabled;
// Only fetch settings when a sandbox host is reachable — the pure-Node inso CLI (process.type
// falsy) can never sandbox, so skip the read entirely; shouldSandboxPlugin treats undefined as off.
const settings = canSandbox ? await services.settings.get() : undefined;
const hookIndexByPlugin: Record<string, number> = {};
for (const { plugin, hook } of await pluginIndex.getResponseHooks()) {
const hookIndex = (hookIndexByPlugin[plugin.name] = (hookIndexByPlugin[plugin.name] ?? -1) + 1);
try {
if (sandboxEnabled && plugin.directory !== '') {
// T1: sandbox a user plugin unless it's elevated; bundle + flag-off run in-process.
if (canSandbox && shouldSandboxPlugin(settings, plugin)) {
const { runResponseHookInSandbox } = await import('../../main/templating-worker-database');
// The hook rewrites the body via the response.setBody bridge (on-disk) and returns the
// mutated response fields (e.g. bytesContent); merge them so downstream sees the change.
@@ -156,7 +174,7 @@ export async function applyResponseHooks(
await hook(context);
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
(error as any).plugin = plugin;
attachPluginToError(error, plugin);
throw error;
}
}
@@ -13,6 +13,7 @@ import {
} from 'react-aria-components';
import type { SerializablePlugin } from '~/common/plugins/bridge-types';
import { resolvePluginExecutionMode } from '~/common/plugins/sandbox-mode';
import { validatePluginName } from '~/common/utils/plugin-name';
import { useRootLoaderData } from '~/root';
import { plugins as pluginsBridge } from '~/ui/plugins/renderer-bridge';
@@ -498,6 +499,28 @@ export const Plugins: FC = () => {
? 'Declared empty permissions (baseline access)'
: 'No permissions declared (baseline access)';
// T1: this plugin's resolved execution mode + the per-plugin "elevated" escape hatch.
// Only user plugins reach this list (bundle plugins are filtered out above). Read
// `elevated` from live settings (not the load-time plugin.config snapshot) so the toggle
// and badge update immediately, before the plugin list reloads.
const isElevated = settings.pluginConfig?.[plugin.name]?.elevated === true;
const executionMode = resolvePluginExecutionMode(settings, {
directory: plugin.directory,
config: { elevated: isElevated },
});
const modeLabel =
executionMode === 'sandboxed'
? 'Sandboxed'
: executionMode === 'elevated'
? 'Elevated'
: 'In-process';
const modeTitle =
executionMode === 'sandboxed'
? 'Runs in the QuickJS sandbox (default-deny host access).'
: executionMode === 'elevated'
? 'Runs in the main process with full host access (you granted this).'
: 'Sandbox is off — runs in the main process with full host access.';
return (
<GridListItem
textValue={plugin.name}
@@ -540,6 +563,13 @@ export const Plugins: FC = () => {
>
{permissionLabel}
</span>
<span
data-testid={`plugin-mode-${plugin.name}`}
className="rounded-sm bg-(--hl-xs) px-1.5 text-xs whitespace-nowrap text-(--hl)"
title={modeTitle}
>
{modeLabel}
</span>
{plugin.permissionWarnings && plugin.permissionWarnings.length > 0 && (
<span
data-testid={`plugin-permission-warning-${plugin.name}`}
@@ -554,6 +584,37 @@ export const Plugins: FC = () => {
</div>
<div className="flex items-center gap-6">
<Checkbox
data-testid={`plugin-elevated-${plugin.name}`}
isSelected={isElevated}
isDisabled={isRefreshingPlugins}
className="group flex items-center gap-1.5 p-0 text-xs disabled:animate-pulse"
onChange={isSelected => {
patchSettings({
pluginConfig: {
...settings.pluginConfig,
[plugin.name]: {
...plugin.config,
...settings.pluginConfig?.[plugin.name],
elevated: isSelected,
},
},
});
}}
>
<div className="flex h-4 w-4 items-center justify-center rounded-sm ring-1 ring-(--hl-sm) transition-colors group-focus:ring-2 group-data-selected:bg-(--hl-xs)">
<Icon
icon="check"
className="h-3 w-3 opacity-0 group-data-selected:text-(--color-warning) group-data-selected:opacity-100"
/>
</div>
<span
className="whitespace-nowrap text-(--hl)"
title="Run this plugin in the main process with full host access instead of the sandbox."
>
Full host access
</span>
</Checkbox>
<div className="flex w-[8ch] items-center justify-center gap-2">
{plugin.version}
<a className="space-left" href={link} title={link}>
@@ -144,6 +144,7 @@ export const ScriptingSettings = () => {
const sandboxEnabled = settings.scriptSandboxEnabled !== false;
const strictModeEnabled = settings.scriptStrictModeEnabled !== false;
const templateTagSandboxEnabled = settings.templateTagSandboxEnabled === true;
const pluginSandboxEnabled = settings.pluginSandboxEnabled === true;
const disabledRules = settings.disabledSecurityRules ?? [];
const disabledProperties = settings.disabledBlockedProperties ?? [];
const disabledRoots = settings.disabledBlockedRoots ?? [];
@@ -340,6 +341,29 @@ export const ScriptingSettings = () => {
</div>
</div>
<div className="rounded-md border border-solid border-(--hl-sm) bg-(--hl-xs) p-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<span className="text-sm font-medium text-(--color-font)">Sandbox all plugin code (experimental)</span>
<p className="text-xs text-(--hl)">
Run every untrusted plugin surface — template tags, request/response hooks, actions, and load-time code —
inside the QuickJS-WASM sandbox. Installed plugins are default-deny; grant an individual plugin full host
access with "Run with full host access" in Preferences → Plugins.
</p>
</div>
<Switch
data-testid="toggle-plugin-sandbox"
isSelected={pluginSandboxEnabled}
onChange={enabled => patchSettings({ pluginSandboxEnabled: enabled })}
className="group flex items-center gap-2"
>
<div className="flex h-6 w-11 cursor-pointer items-center rounded-full border-2 border-solid border-transparent bg-(--hl-md) transition-colors group-data-selected:bg-(--color-surprise)">
<span className="h-5 w-5 translate-x-0 rounded-full bg-white transition-transform group-data-selected:translate-x-5" />
</div>
</Switch>
</div>
</div>
<RuleCard
title="Mask Rules"
description="Overwrites specific global variables with undefined so scripts cannot access them."