All 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.

ygwa2 min read
securityipcadr

For a long time, the renderer in Fabriq Desktop could call any engine capability through a single escape hatch: dapei:capability:run. The convenience was real — but so was the risk.

The threat model

Any code path in the renderer that touches user input (a markdown preview, a search box, a feature name field) becomes a potential XSS surface. If that surface can also fire repos.remove or feature.close through a generic channel, a single injection becomes a destructive action.

We chose to make the renderer honest: it can ask questions, but it cannot change things without going through a typed, schema-validated handler.

The decision (ADR-0021)

The full text lives in the ADR. The shape:

  1. dapei:capability:run becomes read-only. The main process consults a renderer-specific allowlist (RENDERER_CAPABILITY_ALLOWLIST) before forwarding.
  2. All writes route through typed channels. Examples:
    • dapei:repos:sync
    • dapei:feature:runStage
    • dapei:advisor:executeAction
  3. Every typed channel carries a Zod schema. registerIpcHandler attaches the schema from REQUEST_SCHEMAS in @dapei/desktop-contracts and validates in main before the handler runs.

What this costs

Some duplication. Each new write capability needs a typed channel and a schema. We accept that cost — the alternative is one renderer-side vulnerability compounding into irreversible damage.

What this buys

  • A misbehaving renderer (or XSS) cannot execute write capabilities.
  • New capabilities land in the typed surface, which makes the IPC layer self-documenting.
  • Engine allowlist becomes the single source of truth for "what can the renderer see."

Migration in practice

If you're adding a new write capability:

  1. Define the channel in @dapei/desktop-contracts (request/response types).
  2. Add a Zod schema to REQUEST_SCHEMAS.
  3. Register the handler in main using registerIpcHandler — schema validation is automatic.
  4. Add a typed wrapper in the renderer.

That's the price of admission. It's small. It's worth it.

Related articles