Files
orca/src/main/plugins/plugin-content-pack-registry.ts
T
NeilandOrca 5c59c84c7a fix(plugins): close four trust-boundary holes in the plugin system (#11232)
* fix(plugins): close trust-boundary holes in the plugin system

Move five security decisions to their chokepoints rather than leaving them
enumerated at individual call sites.

- Kill-list revocation reaches content packs: PluginContentPackRegistry now
  takes an isKilled predicate and intersects it with any caller-supplied
  approval, so a killed plugin's VM recipes can no longer reach
  spawn(..., { shell: true }) through either reconcile() call site.
- Bound kill-list generatedAt to a 24h future skew at the parse chokepoint.
  A far-future timestamp previously made every genuine later list look
  "older" and disabled revocation permanently, persisted across restarts.
- Protect the whole auto.components.settings.Plugin* translation subtree
  instead of an enumerated prefix list, so language packs cannot forge the
  consent provenance badge or rewrite install-error security copy.
- Resolve manifest panel icons by own-key only; "constructor"/"__proto__"
  previously yielded non-component prototype members that crashed the
  right sidebar to its error boundary.
- Give panel liveness frames a reserved control budget so a panel that
  saturates its action budget can still answer the watchdog.

Co-authored-by: Orca <help@stably.ai>

* fix(plugins): keep the kill-list future bound off the cache read path

The schema-level generatedAt bound re-judged the on-disk cache against the
device clock at every launch, so a client whose clock ran behind the last
genuine publication discarded its whole cached kill list and started with
zero revocations. Move the bound to the two fetch chokepoints instead.

Co-authored-by: Orca <help@stably.ai>

* fix(plugins): remove the reserved-lane starvation window and the revocation TOCTOU

Review follow-ups on the trust-boundary fixes:

- The reserved liveness lane had a per-window count equal to the ping
  interval, so a panel's own pong-shaped traffic could spend it and drop
  the next genuine reply — reintroducing the starvation the lane exists to
  prevent. The lane is now size-bounded only; rate stays bounded because
  every pong is also charged to the data budget.
- Only schema-valid pongs take the lane now, so near-miss pong-shaped junk
  cannot drain it. readPanelPongId replaces the zod parse on this
  guest-controlled path (a rejected safeParse allocates an issue list, ~90x
  the accepted-path cost) and is pinned to the schema by a parity test.
- Re-read the kill list inside approveAtomically: approvedKeys is snapshotted
  before an awaited verification phase, so a plugin killed during that wait
  could still publish VM recipes and language packs.
- Assert the curated icon resolves to FileText; the old equality also passed
  when both sides fell back to Plug.

Co-authored-by: Orca <help@stably.ai>

* fix(plugins): match zod's safe-integer bound in the pong reader

readPanelPongId used Number.isInteger, but zod's .int() rejects anything
above 2**53-1, so pingIds like 1e100 took the reserved lane the schema
would have refused. The parity test never probed that boundary.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-07-28 17:49:20 -07:00

105 lines
3.8 KiB
TypeScript

import type { PluginContentVerifier } from './plugin-content-integrity'
import {
isInvalidDiscoveredPlugin,
type DiscoveredPlugin,
type ValidDiscoveredPlugin
} from './plugin-discovery'
import { PluginLanguagePackRegistry } from './plugin-language-pack-registry'
import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry'
import { PluginCommandRegistry } from './plugin-command-registry'
import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity'
import type { KeybindingOverrides } from '../../shared/keybindings'
export class PluginContentPackRegistry {
readonly languagePacks: PluginLanguagePackRegistry
readonly vmRecipes: PluginVmRecipeRegistry
readonly commands: PluginCommandRegistry
private readonly activationErrors = new Map<string, string>()
constructor(
contentVerifier: PluginContentVerifier,
/** Revocation chokepoint: no caller-supplied predicate can readmit a
* killed plugin's language packs, VM recipes, or commands. */
private readonly isKilled: (pluginKey: string) => boolean
) {
this.languagePacks = new PluginLanguagePackRegistry(contentVerifier)
this.vmRecipes = new PluginVmRecipeRegistry()
this.commands = new PluginCommandRegistry()
}
async reconcile(
discovered: readonly DiscoveredPlugin[],
isApproved: (plugin: ValidDiscoveredPlugin) => boolean,
keybindings: KeybindingOverrides = {}
): Promise<void> {
const approvedKeys = new Set(
discovered
.filter((plugin): plugin is ValidDiscoveredPlugin => !isInvalidDiscoveredPlugin(plugin))
.filter((plugin) => isApproved(plugin) && !this.isKilled(plugin.pluginKey))
.map((plugin) => plugin.pluginKey)
)
const excluded = new Set<string>()
this.activationErrors.clear()
await Promise.all(
discovered.map(async (plugin) => {
if (
isInvalidDiscoveredPlugin(plugin) ||
!approvedKeys.has(plugin.pluginKey) ||
plugin.manifest.contributes.vmRecipes.length > 0
) {
return
}
try {
await verifyInstructionalPluginContent(plugin)
} catch (error) {
excluded.add(plugin.pluginKey)
this.activationErrors.set(
plugin.pluginKey,
error instanceof Error ? error.message : String(error)
)
}
})
)
while (true) {
// `approvedKeys` is a snapshot from before the awaited verification
// above, so a kill list arriving during that wait would otherwise still
// publish. Re-read revocation here, the last gate before publication.
const approveAtomically = (plugin: ValidDiscoveredPlugin): boolean =>
approvedKeys.has(plugin.pluginKey) &&
!excluded.has(plugin.pluginKey) &&
!this.isKilled(plugin.pluginKey)
const languagePacks = this.languagePacks.reconcile(discovered, approveAtomically)
const vmRecipes = this.vmRecipes.reconcile(discovered, approveAtomically)
this.commands.reconcile(discovered, approveAtomically, keybindings)
await Promise.all([languagePacks, vmRecipes])
let foundNewError = false
for (const pluginKey of approvedKeys) {
const error = this.registryError(pluginKey)
if (error && !excluded.has(pluginKey)) {
excluded.add(pluginKey)
this.activationErrors.set(pluginKey, error)
foundNewError = true
}
}
if (!foundNewError) {
break
}
}
}
error(pluginKey: string): string | null {
return this.activationErrors.get(pluginKey) ?? this.registryError(pluginKey)
}
private registryError(pluginKey: string): string | null {
return (
this.languagePacks.error(pluginKey) ??
this.vmRecipes.error(pluginKey) ??
this.commands.error(pluginKey)
)
}
}