Electron Apps skill for Codex

Treat the renderer like a hostile web page. Codex scans an Electron project with a bundled script for nodeIntegration, disabled isolation, exposed ipcRenderer and missing navigation guards, then rebuilds the preload as a small typed API whose handlers validate sender and arguments in main. It serves files over a custom protocol with a strict CSP, flips Electron fuses at package time, and asks before publishing anything to the update feed.

  • Mobile & Desktop
  • $electron-apps
  • 173 lines
  • 3 files
  • ~1,900 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- electron-apps

Installs to ~/.agents/skills/electron-apps/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $electron-apps, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds and hardens Electron desktop apps - main, preload and renderer separation, contextIsolation and sandbox, typed IPC through contextBridge with sender validation, CSP and navigation guards, custom protocols instead of file URLs, Electron Fuses, native modules, packaging with Electron Forge or electron-builder, and auto-update. Includes a static security scanner. Use when starting an Electron app, auditing an existing one, adding IPC or native features, upgrading Electron, or setting up packaging and updates.

Use it when

  • Starting a new Electron app or adding windows and IPC
  • Security review of an existing Electron codebase
  • Upgrading across Electron majors
  • Choosing or fixing packaging and auto-update

Use something else when

  • A smaller Rust-backed webview app is acceptable: use tauri-apps
  • The work is code signing, notarization or update channels across desktop stacks: use desktop-app-distribution

How to check the result

The bundled scanner exits 0, the packaged renderer console shows require, process and ipcRenderer undefined, and an N to N+1 update installs on each target OS.

The mistake it prevents

Turning off sandbox or contextIsolation so a preload can require Node modules, which hands any injected script full system access.

~/.agents/skills/electron-apps/SKILL.md

--- name: electron-apps description: Builds and hardens Electron desktop apps - main, preload and renderer separation, contextIsolation and sandbox, typed IPC through contextBridge with… license: MIT ---

Done well means: the renderer is treated as an untrusted web page, it reaches the system only through a small, typed, validated preload API, the app stays on a supported Electron major, and installers update themselves from a signed release feed.

Before changing anything

  1. Read AGENTS.md, package.json and the build config (forge.config.*, electron-builder.yml or the build key) to learn the toolchain and bundler (Vite, webpack, esbuild).
  2. Find the main entry ("main" in package.json), every new BrowserWindow, every preload script and every ipcMain handler.
  3. Run the scanner: node scripts/audit-electron-security.mjs . and read every HIGH.
  4. Check the Electron version against the release schedule. Electron ships a major every 8 weeks and supports the latest three; an unsupported major gets no Chromium security fixes, so plan the upgrade before feature work.
  5. npm install and Electron’s binary download need network; request approval in a sandboxed Codex session.

Process model

ProcessRunsMay use
MainNode.js, one per appEverything: fs, child processes, native modules, OS APIs
PreloadIsolated world before page scriptscontextBridge, ipcRenderer; Node APIs are limited when sandboxed
RendererChromium page, sandboxedDOM and the API the preload exposes, nothing else
Utility processutilityProcess.fork()Node work off the main thread (CPU work, crash-prone libs)

Never block the main process: synchronous fs on large files, execSync, or CPU loops freeze every window. Move heavy work to a utility process or worker.

Window defaults

const win = new BrowserWindow({
  width: 1100, height: 720, show: false,
  webPreferences: {
    preload: path.join(__dirname, 'preload.js'),
    contextIsolation: true,   // default, keep it
    sandbox: true,            // default for renderers, keep it
    nodeIntegration: false,   // default, keep it
    webSecurity: true,
  },
});
win.once('ready-to-show', () => win.show());

win.webContents.setWindowOpenHandler(({ url }) => {
  if (isAllowedExternal(url)) shell.openExternal(url);
  return { action: 'deny' };
});
win.webContents.on('will-navigate', (event, url) => {
  if (new URL(url).origin !== APP_ORIGIN) event.preventDefault();
});
session.defaultSession.setPermissionRequestHandler((_wc, permission, cb) => cb(permission === 'clipboard-sanitized-write'));

isAllowedExternal permits only https: (and mailto: if needed) to known hosts. shell.openExternal with an attacker-controlled URL can launch arbitrary protocol handlers.

IPC through the preload

Expose functions, not ipcRenderer. One channel per operation, invoke/handle for request-response.

// preload.ts
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('api', {
  readNote: (id: string) => ipcRenderer.invoke('note:read', id),
  onSyncStatus: (cb: (s: string) => void) => {
    const listener = (_e: unknown, s: string) => cb(s);
    ipcRenderer.on('sync:status', listener);
    return () => ipcRenderer.removeListener('sync:status', listener);
  },
});
// main.ts
ipcMain.handle('note:read', async (event, id: unknown) => {
  if (!isTrustedFrame(event.senderFrame)) throw new Error('untrusted sender');
  if (typeof id !== 'string' || !/^[a-z0-9-]{1,64}$/.test(id)) throw new Error('bad id');
  return notes.read(id);                 // never pass a raw path from the renderer to fs
});
  • Validate the sender (event.senderFrame.url origin) and every argument in main; the renderer can call any exposed channel with any value.
  • Do not forward the IPC event object to the renderer, and do not offer generic “run this command” or “read this path” channels.
  • Share channel names and payload types in one TypeScript module imported by preload, main and the renderer’s window.api declaration.

Loading content

  • Serve app files through a custom protocol registered with protocol.handle('app', ...) and protocol.registerSchemesAsPrivileged, rather than file://, so CSP and origin checks work. Resolve paths and refuse anything outside the app directory.
  • Set a strict CSP (meta tag or response header): default-src 'self'; script-src 'self' and no unsafe-eval. Bundlers in dev mode may need a looser dev-only policy.
  • Never load remote web content into a window that has a preload with powerful APIs. Show remote pages in a separate window or WebContentsView with no preload.

Fuses

Flip fuses at package time (Forge @electron-forge/plugin-fuses or @electron/fuses): RunAsNode off, EnableNodeOptionsEnvironmentVariable off, EnableNodeCliInspectArguments off, EnableEmbeddedAsarIntegrityValidation on, OnlyLoadAppFromAsar on, EnableCookieEncryption on. These stop the shipped binary from being reused as a signed Node.js runtime.

Packaging and updates

NeedDefault
New project, official toolingElectron Forge (npm init electron-app@latest app -- --template=vite-typescript)
Existing electron-builder setup, NSIS/AppImage/differential updatesKeep electron-builder + electron-updater
Public GitHub repo, simple updatesupdate-electron-app with update.electronjs.org
Private feedForge publisher to S3 or a static server; electron-builder generic provider
  • Native modules must be rebuilt against Electron’s Node ABI; Forge does this, otherwise run npx @electron/rebuild. Mark them asarUnpack if they load files at runtime.
  • macOS auto-update requires a signed and notarized app; Windows needs a signed installer for SmartScreen. Signing, notarization and release channels are covered by desktop-app-distribution.
  • Publishing a release to the update feed reaches every installed user: confirm the version and channel with the user before running publish.
  • Use app.requestSingleInstanceLock() and route second-instance arguments (deep links, files) to the existing window.

Upgrading Electron

  1. Read the breaking changes doc for every major you cross.
  2. Bump electron and rebuild native modules; run the app and tests.
  3. Re-run the scanner and check deprecated APIs (BrowserView gave way to WebContentsView; old protocol.register*Protocol to protocol.handle).

Gotchas

  • Disabling sandbox to make a preload require a Node module is the most common regression; move that code to main behind IPC instead.
  • webContents.executeJavaScript with interpolated strings is code injection.
  • Auto-update on Windows with Squirrel runs the app with --squirrel-* flags on install; handle them (electron-squirrel-startup) or the app launches during install.
  • macOS Info.plist usage strings (camera, microphone) are required or the app is killed on first access.
  • Dev-only flags (ELECTRON_DISABLE_SECURITY_WARNINGS, DevTools auto-open) must not ship.

Verification

  1. node scripts/audit-electron-security.mjs . exits 0, and each MEDIUM is either fixed or explained.
  2. Electron’s own security warnings in the DevTools console are gone in a dev build.
  3. In the packaged app, window.require, process and ipcRenderer are undefined in the renderer console.
  4. Install version N, publish N+1 to a test channel, and confirm the update downloads, applies and relaunches on each OS.

Output

**Electron:** <version> (supported: yes/no) **Tooling:** Forge | electron-builder
**Security:** scanner high/medium counts before -> after; fuses set
**IPC surface:** <channel -> purpose, validation>
**Updates:** <provider, channel, tested on macOS/Windows/Linux>

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Electron Apps"
  short_description: "Treat the renderer like a hostile web page"
  default_prompt: "$electron-apps Audit our Electron app's security settings and fix anything dangerous"
scripts/audit-electron-security.mjs96 lines, 5.1 KB
#!/usr/bin/env node
// Static scan of an Electron project for risky security settings.
// Usage: node scripts/audit-electron-security.mjs <project-dir>
// Node 20+, no dependencies. Exits 1 if any HIGH finding is reported.
// It is a grep-level scan: it finds candidates for review, not proof of a vulnerability.
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, extname } from 'node:path';

const root = process.argv[2];
if (!root) {
  console.error('usage: node audit-electron-security.mjs <project-dir>');
  process.exit(2);
}

const SKIP = new Set(['node_modules', '.git', 'dist', 'out', 'build', 'release', '.vite', '.webpack', 'coverage']);
const CODE = new Set(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts', '.jsx', '.tsx']);

const RULES = [
  ['HIGH', /nodeIntegration\s*:\s*true/, 'nodeIntegration: true gives renderer content full Node.js access'],
  ['HIGH', /nodeIntegrationInSubFrames\s*:\s*true/, 'nodeIntegrationInSubFrames: true exposes Node to iframes'],
  ['HIGH', /contextIsolation\s*:\s*false/, 'contextIsolation: false lets page scripts reach preload globals'],
  ['HIGH', /sandbox\s*:\s*false/, 'sandbox: false runs the renderer without the Chromium sandbox'],
  ['HIGH', /webSecurity\s*:\s*false/, 'webSecurity: false disables same-origin policy'],
  ['HIGH', /allowRunningInsecureContent\s*:\s*true/, 'allowRunningInsecureContent: true loads http content into https pages'],
  ['HIGH', /exposeInMainWorld\([^)]*\bipcRenderer\b\s*\)/, 'ipcRenderer exposed wholesale through contextBridge'],
  ['HIGH', /exposeInMainWorld\([\s\S]{0,200}?:\s*ipcRenderer\s*[,}]/, 'ipcRenderer object exposed as a property through contextBridge'],
  ['MEDIUM', /webviewTag\s*:\s*true/, '<webview> enabled; validate attributes in will-attach-webview'],
  ['MEDIUM', /experimentalFeatures\s*:\s*true/, 'experimentalFeatures enabled'],
  ['MEDIUM', /enableBlinkFeatures/, 'enableBlinkFeatures used; review each feature'],
  ['MEDIUM', /shell\.openExternal\(/, 'shell.openExternal: make sure the URL is validated (https allowlist)'],
  ['MEDIUM', /loadURL\(\s*['"`]http:\/\//, 'loadURL over plain http'],
  ['MEDIUM', /ipcRenderer\.sendSync\(/, 'sendSync blocks the renderer; prefer invoke/handle'],
  ['LOW', /\bremote\b.*require\(['"]@electron\/remote/, '@electron/remote in use; prefer explicit IPC'],
  ['LOW', /setPermissionRequestHandler/, 'permission handler present; confirm it denies by default'],
];

const findings = [];
const files = [];
let sawBrowserWindow = false;
let sawOpenHandler = false;
let sawNavigateGuard = false;
let sawIpcHandle = false;
let sawSenderCheck = false;

function walk(dir) {
  for (const name of readdirSync(dir)) {
    if (SKIP.has(name)) continue;
    const p = join(dir, name);
    const st = statSync(p);
    if (st.isDirectory()) walk(p);
    else if (st.size < 2_000_000) files.push(p);
  }
}

walk(root);

for (const file of files) {
  const ext = extname(file);
  const rel = relative(root, file);
  if (CODE.has(ext)) {
    const text = readFileSync(file, 'utf8');
    if (/new\s+BrowserWindow\s*\(/.test(text)) sawBrowserWindow = true;
    if (/setWindowOpenHandler/.test(text)) sawOpenHandler = true;
    if (/will-navigate/.test(text)) sawNavigateGuard = true;
    if (/ipcMain\.(handle|on)\(/.test(text)) sawIpcHandle = true;
    if (/senderFrame|event\.sender(Frame)?\.url|validateSender/.test(text)) sawSenderCheck = true;
    const lines = text.split('\n');
    for (const [sev, re, msg] of RULES) {
      const m = re.exec(text);
      if (!m) continue;
      const line = text.slice(0, m.index).split('\n').length;
      findings.push({ sev, where: `${rel}:${line}`, msg, code: lines[line - 1].trim().slice(0, 120) });
    }
  } else if (ext === '.html') {
    const text = readFileSync(file, 'utf8');
    if (!/Content-Security-Policy/i.test(text)) {
      findings.push({ sev: 'MEDIUM', where: rel, msg: 'HTML page without a Content-Security-Policy meta tag (fine only if CSP is set via headers)', code: '' });
    } else if (/unsafe-eval/.test(text)) {
      findings.push({ sev: 'MEDIUM', where: rel, msg: "CSP allows 'unsafe-eval'", code: '' });
    }
  }
}

if (sawBrowserWindow && !sawOpenHandler) findings.push({ sev: 'MEDIUM', where: '(project)', msg: 'No setWindowOpenHandler found: window.open and target=_blank are not controlled', code: '' });
if (sawBrowserWindow && !sawNavigateGuard) findings.push({ sev: 'MEDIUM', where: '(project)', msg: "No 'will-navigate' guard found: renderer can navigate to arbitrary origins", code: '' });
if (sawIpcHandle && !sawSenderCheck) findings.push({ sev: 'MEDIUM', where: '(project)', msg: 'IPC handlers found but no sender validation (event.senderFrame) detected', code: '' });

const order = { HIGH: 0, MEDIUM: 1, LOW: 2 };
findings.sort((a, b) => order[a.sev] - order[b.sev]);
for (const f of findings) {
  console.log(`${f.sev.padEnd(6)} ${f.where}  ${f.msg}${f.code ? `\n         ${f.code}` : ''}`);
}
const high = findings.filter((f) => f.sev === 'HIGH').length;
console.log(`\nScanned ${files.length} files: ${high} high, ${findings.length - high} other finding(s).`);
if (!sawBrowserWindow) console.log('Note: no new BrowserWindow() found; is this the right directory?');
process.exit(high ? 1 : 0);

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem