mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +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>
107 lines
3.3 KiB
TypeScript
107 lines
3.3 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
parsePluginLanguagePackArtifact,
|
|
PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH,
|
|
pluginLanguageResourceId
|
|
} from './plugin-language-pack-artifact'
|
|
|
|
describe('plugin language-pack artifacts', () => {
|
|
it('maps qualified IDs to distinct alphanumeric i18next resource languages', () => {
|
|
const first = pluginLanguageResourceId('plugin:a.bc/d-ef')
|
|
const second = pluginLanguageResourceId('plugin:ab.c/de-f')
|
|
|
|
expect(first).toMatch(/^plugin[0-9a-f]+$/)
|
|
expect(second).toMatch(/^plugin[0-9a-f]+$/)
|
|
expect(first).not.toBe(second)
|
|
})
|
|
|
|
it('accepts nested string catalogs and reports their bounded entry count', () => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
settings: { appearance: { title: 'Aparência' } },
|
|
common: { save: 'Salvar' }
|
|
})
|
|
)
|
|
).toEqual({
|
|
ok: true,
|
|
catalog: {
|
|
settings: { appearance: { title: 'Aparência' } },
|
|
common: { save: 'Salvar' }
|
|
},
|
|
entries: 5
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
'PluginConsentDialog',
|
|
// The provenance badge and install-error copy carry the trust decision.
|
|
'PluginConsentProvenance',
|
|
'pluginError',
|
|
'PluginKeybindingConsentPreview',
|
|
'PluginMarketplaceListingRow',
|
|
'PluginMarketplacePreviewDialog',
|
|
'PluginMarketplaceSourceDialog',
|
|
'PluginVmRecipeConsentPreview'
|
|
])('prevents language packs from rewriting %s security copy', (component) => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
auto: {
|
|
components: {
|
|
settings: { [component]: { disclaimer: 'This plugin is perfectly safe.' } }
|
|
}
|
|
}
|
|
})
|
|
)
|
|
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
|
})
|
|
|
|
it('forges no trust badge: the community→Official swap is refused', () => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
auto: {
|
|
components: {
|
|
settings: { PluginConsentProvenance: { community: 'Official' } }
|
|
}
|
|
}
|
|
})
|
|
)
|
|
).toMatchObject({
|
|
ok: false,
|
|
error: expect.stringContaining('auto.components.settings.PluginConsentProvenance')
|
|
})
|
|
})
|
|
|
|
it('still lets language packs translate non-plugin settings copy', () => {
|
|
expect(
|
|
parsePluginLanguagePackArtifact(
|
|
JSON.stringify({
|
|
auto: { components: { settings: { AppearanceSection: { title: 'Apariencia' } } } }
|
|
})
|
|
).ok
|
|
).toBe(true)
|
|
})
|
|
|
|
it.each([
|
|
['array leaf', { settings: { choices: ['one'] } }],
|
|
['numeric leaf', { settings: { count: 1 } }],
|
|
['dotted key', { 'settings.title': 'Title' }],
|
|
['prototype key', JSON.parse('{"__proto__":"bad"}')]
|
|
])('rejects %s', (_label, catalog) => {
|
|
expect(parsePluginLanguagePackArtifact(JSON.stringify(catalog)).ok).toBe(false)
|
|
})
|
|
|
|
it('rejects excessive nesting without recursive validation', () => {
|
|
let catalog: Record<string, unknown> = { value: 'deep' }
|
|
for (let index = 0; index <= PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH; index += 1) {
|
|
catalog = { nested: catalog }
|
|
}
|
|
expect(parsePluginLanguagePackArtifact(JSON.stringify(catalog))).toMatchObject({
|
|
ok: false,
|
|
error: expect.stringContaining('depth')
|
|
})
|
|
})
|
|
})
|