Typed IPC: Why Your Electron App Needs a Type-Safe Customs Office
We killed the generic 'run any capability' channel from the renderer after a threat model review. Every IPC call in Fabriq now goes through a typed, Zod-validated handler. Here's the threat model, the migration, and how it makes the app auditable by default.
TL;DR. Fabriq's renderer initially had a generic
dapei:capability:runIPC channel — call any engine capability with any payload. A security review revealed this was one XSS away from catastrophic damage. We replaced it with typed IPC channels: each operation has its own channel, its own Zod schema, and its own handler. Write operations are gated behind explicit user confirmation. This post walks through the threat model, the migration pattern, and the unexpected benefits.
The convenience that scared us
Early in Fabriq's development, the renderer (the UI process) communicated with the engine through a single generic IPC channel:
// Before: one channel to rule them all
ipcMain.handle("dapei:capability:run", async (event, capabilityId, input) => {
return engine.run(capabilityId, input);
});This was convenient. Add a new capability? Call it from the renderer immediately. No new IPC handler, no new schema. Just pass the capability ID and the payload through the same generic pipe.
Convenient — and terrifying.
The threat model
Fabriq's renderer renders user content: markdown previews, feature documents, code diffs. Any XSS vulnerability — a malicious markdown file, a crafted feature name, an external link with javascript: — becomes a potential vector.
The question we asked: if an attacker controls the renderer, what can they do?
With the generic capability:run channel, the answer was: everything.
repos.remove(".")— delete the workspacefeature.close("core-module")— close an in-progress feature without reviewcdr.behavior.upsert(...)— write unverified cognitive artifacts to the workspace baselineengine.exec("rm -rf /")— direct shell access through a capability
A single compromised renderer path could execute any engine capability with any arguments. The renderer had the keys to the entire system because we never stopped to audit what "the entire system" included.
The fix: typed IPC channels
We replaced the single generic channel with a family of typed channels:
// After: one channel per operation, each with a Zod schema
export const IPC_CHANNELS = {
// Read-only (renderer can invoke directly)
"capability:query:codegraph": { schema: codegraphQuerySchema, write: false },
"capability:query:features": { schema: listFeaturesSchema, write: false },
"capability:query:knowledge": { schema: queryKnowledgeSchema, write: false },
// Write operations (main process routes them, renderer can't invoke directly)
"feature:create": { schema: featureCreateSchema, write: true },
"feature:runStage": { schema: runStageSchema, write: true },
"feature:close": { schema: closeFeatureSchema, write: true },
"repos:sync": { schema: reposSyncSchema, write: true },
"cdr:behavior:upsert": { schema: upsertBehaviorSchema, write: true },
// ...
} as const;Each channel declares:
- A Zod schema — validates the payload before it reaches the handler. Malformed requests are rejected with a structured error, never forwarded to the engine.
- A
writeflag — read-only channels are invokable from the renderer. Write channels require the main process to construct the call, typically triggered by user confirmation in the UI.
The result: even if the renderer is compromised, the attacker can only query data. They cannot mutate state. To delete a repo, they'd need to forge a specific IPC message with a valid Zod-validated payload on a write-only channel — and those channels don't accept renderer-originated calls.
The confirmation layer
Write operations go one step further: they surface in the UI as explicit prompts before execution.
// In the main process, a write handler looks like this:
ipcMain.handle("feature:close", async (event, payload) => {
// 1. Validate payload via Zod
const parsed = closeFeatureSchema.parse(payload);
// 2. Check dimension access rules
assertDimensionAccess("feature:close", parsed.dimension);
// 3. Emit confirmation request to renderer
const confirmed = await requestUserConfirmation(
"close-feature",
{
feature: parsed.featureName,
stage: parsed.currentStage,
pendingReviews: parsed.pendingReviews,
}
);
// 4. Only execute if confirmed
if (!confirmed) return { ok: false, reason: "rejected" };
return engine.closeFeature(parsed);
});The user sees a confirmation dialog with the operation details — the target, the dimension, the expected effect. Nothing hits disk without explicit approval.
The audit benefit
An unexpected side effect of typed IPC: auditability by default.
Because every channel has a known schema and a known purpose, we can log every call with structured metadata:
// Middleware in the IPC router
ipcMain.on("before-handle", (channel, payload, userInfo) => {
auditLog.write({
timestamp: new Date().toISOString(),
channel,
userId: userInfo.id,
payload: maskSecrets(payload), // Strip credentials before logging
dimension: payload.dimension,
result: null, // Filled in by after-handle
});
});The audit log becomes a structured, queryable record of every state change in the system. You can answer:
- "Who closed feature X and when?"
- "What was the last mutation to the CDR knowledge base?"
- "How many times was the 'delete repo' capability invoked?"
With the generic channel, none of this was possible. The audit log would say capability:run with a blob of JSON that could mean anything.
Migration pattern
If you're migrating an existing Electron app, the pattern is straightforward:
Step 1: Inventory all renderer-triggered operations
Search your codebase for every ipcRenderer.invoke("capability:run", ...) or equivalent. List every capability ID. This is your attack surface.
Step 2: Classify as read or write
- Read: Queries, data fetching, status checks. Renderer can invoke these.
- Write: Mutations, deletions, configuration changes. These need typed channels + confirmation.
Step 3: Define Zod schemas for write operations
export const closeFeatureSchema = z.object({
featureName: z.string().min(1).max(100),
currentStage: z.enum(["analysis", "design", "implementation", "verification", "closed"]),
dimension: z.literal("feature"),
force: z.boolean().optional().default(false),
});Step 4: Replace call sites
Replace generic invocations with channel-specific calls:
// Before
await ipcRenderer.invoke("dapei:capability:run", "feature.close", { featureName });
// After
await ipcRenderer.invoke("feature:close", { featureName, dimension: "feature" });Step 5: Remove the generic channel
Once all call sites are migrated, delete the generic handler. Your app is now type-safe at the IPC boundary.
What we gained
| Concern | Before | After |
|---|---|---|
| XSS blast radius | Full system compromise | Read-only queries only |
| Payload validation | None (passthrough to engine) | Zod schema per channel |
| Audit granularity | "Capability run" with opaque blob | Structured event per operation |
| Developer experience | "Just add a capability" | "Write a schema and a handler" |
| Error messages | Engine error (inconsistent) | Structured Zod or dimension errors |
The last row matters more than you'd think. With typed schemas, the renderer gets precise error messages: "Field 'featureName' must be a string, got undefined" instead of "engine error: something went wrong." Development velocity improved, not regressed.
Bottom line
Killing the generic capability:run channel was one of the best architectural decisions we made. It forced us to be explicit about the IPC contract, document every operation's expected payload, and design the confirmation UX early — before we needed it.
In a world where your app runs LLM-generated content in the renderer, a generic IPC channel is not just technical debt. It's a security vulnerability waiting to be triggered.
The full ADR (ADR-0021) is available on GitHub. Fabriq is open source — browse the IPC contracts.
Related articles
Why We Killed the Generic capability.run Channel from the Renderer
ADR-0021: read-only capabilities are the only ones the renderer can invoke directly. Everything else goes through a typed IPC handler with a Zod-validated payload. Here's the threat model and the migration path.
Local-First vs Cloud-First: Why AI Dev Tools Should Run on Your Machine
The AI coding industry trends toward cloud-hosted agents. Fabriq goes the other way: local-first, offline-capable, putting data sovereignty and team control above convenience. Here's the reasoning behind that bet.
Why We Chose Electron (and We're Not Sorry)
Electron gets a bad rap. For a v0.1 desktop shell that must integrate deeply with local processes, Node.js native modules, and external LLM backends — the alternatives were worse. Here's the trade-off analysis that led to our decision.
