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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- electron-apps Installs to ~/.agents/skills/electron-apps/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install electron-apps The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project electron-apps Installs to .agents/skills/electron-apps/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/electron-apps.zipunzip -o electron-apps.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) electron-apps PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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.
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
- Read
AGENTS.md,package.jsonand the build config (forge.config.*,electron-builder.ymlor thebuildkey) to learn the toolchain and bundler (Vite, webpack, esbuild). - Find the main entry (
"main"in package.json), everynew BrowserWindow, every preload script and everyipcMainhandler. - Run the scanner:
node scripts/audit-electron-security.mjs .and read every HIGH. - 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.
npm installand Electron’s binary download need network; request approval in a sandboxed Codex session.
Process model
| Process | Runs | May use |
|---|---|---|
| Main | Node.js, one per app | Everything: fs, child processes, native modules, OS APIs |
| Preload | Isolated world before page scripts | contextBridge, ipcRenderer; Node APIs are limited when sandboxed |
| Renderer | Chromium page, sandboxed | DOM and the API the preload exposes, nothing else |
| Utility process | utilityProcess.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.urlorigin) and every argument in main; the renderer can call any exposed channel with any value. - Do not forward the IPC
eventobject 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.apideclaration.
Loading content
- Serve app files through a custom protocol registered with
protocol.handle('app', ...)andprotocol.registerSchemesAsPrivileged, rather thanfile://, 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 nounsafe-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
WebContentsViewwith 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
| Need | Default |
|---|---|
| New project, official tooling | Electron Forge (npm init electron-app@latest app -- --template=vite-typescript) |
| Existing electron-builder setup, NSIS/AppImage/differential updates | Keep electron-builder + electron-updater |
| Public GitHub repo, simple updates | update-electron-app with update.electronjs.org |
| Private feed | Forge 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 themasarUnpackif 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
- Read the breaking changes doc for every major you cross.
- Bump
electronand rebuild native modules; run the app and tests. - Re-run the scanner and check deprecated APIs (
BrowserViewgave way toWebContentsView; oldprotocol.register*Protocoltoprotocol.handle).
Gotchas
- Disabling
sandboxto make a preloadrequirea Node module is the most common regression; move that code to main behind IPC instead. webContents.executeJavaScriptwith 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.plistusage 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
node scripts/audit-electron-security.mjs .exits 0, and each MEDIUM is either fixed or explained.- Electron’s own security warnings in the DevTools console are gone in a dev build.
- In the packaged app,
window.require,processandipcRendererare undefined in the renderer console. - 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.yaml
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.mjs
#!/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.