mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
* 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>
105 lines
3.8 KiB
TypeScript
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)
|
|
)
|
|
}
|
|
}
|