openvibers/openvibe.games Commits

Mod budgets are enforced on the offending mod only (roadmap WS-M task 4), and mod security tests (task 5). The runtime already timed each reconcile against the manifest's cpuMs.

OP OpenVibers committed 27 Sept 2026 · b5feaf8742 · 2 files, +236 −5

Enforcement (only the offending mod is disabled; the others keep running):
- CPU: a mod over its budget on CPU_STRIKES (5) reconciles in a row is disabled.
- Event flood: every host effect (an announcement, a prop placed or removed) spends one of the mod's EFFECTS_PER_MINUTE (60, configurable). Past that, the binding throws ModThrottledError (audited once a minute as throttled; content packs just skip that effect), and a mod throttled FLOOD_WINDOWS (3) minutes in a row is disabled.
A disable goes through the registry (audit budget_enforced with the reason, games.mod.disabled and a moderation event); its effects are retracted on the next reconcile, and staff enable it again once the pack is fixed.

apps/server/src/mods/security.test.ts:
- namespace escape: own placements only, frozen bindings, lootable items refused at install and at the binding;
- event flood: the allowance, one throttled audit, disabled after a sustained flood while another mod keeps announcing;
- resource budget: a slow pack disabled after five reconciles and retracted, the other stays enabled;
- forged ownership: a pack cannot name an owner, props are always the mod's, unknown principals and unrequested capabilities from Network change nothing.
pnpm test 880/880, typecheck clean.

Changes

ddiff --git a/apps/server/src/mods/runtime.ts b/apps/server/src/mods/runtime.ts
iindex 84a56ca..c67be76 100644
---- a/apps/server/src/mods/runtime.ts
++++ b/apps/server/src/mods/runtime.ts
@@@ -11,8 +11,16 @@
* executable runtime (a script sandbox) would receive the same bindings.
* Executable mods are not accepted yet: sandboxed execution with enforced
* CPU, memory, storage and network budgets belongs to OpenVibe.Host
-- * (Stage C). Until then the manifest's resource budget is metered, not
-- * enforced by a sandbox.
++ * (Stage C). Until then the runtime enforces what it can see (roadmap WS-M
++ * task 4), and only on the offending mod:
++ * - CPU: each reconcile is timed against the manifest's cpuMs; a mod over
++ * its budget on CPU_STRIKES reconciles in a row is disabled.
++ * - Event flood: every host effect (an announcement, a prop placed or
++ * removed) spends one of the mod's EFFECTS_PER_MINUTE; past that, calls
++ * throw ModThrottledError (audited once a minute), and a mod throttled
++ * FLOOD_WINDOWS minutes in a row is disabled.
++ * A disable is the registry's (audit `budget_enforced`, games.mod.disabled and
++ * a moderation event); staff enable it again once the pack is fixed.
*
* Each tick the runtime reconciles when the registry changed (and once a
* second otherwise): an inactive mod, or one that lost a capability, has
@@@ -23,7 +31,7 @@ import type { ContentRegistry } from '@openvibe/content'
import type { PersistenceStore } from '@openvibe/persistence'
import type { Logger } from '@openvibe/shared'
import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod } from './contentPack.js'
--import type { ModRegistry, ModView } from './registry.js'
++import { SYSTEM_ACTOR, type ModRegistry, type ModView } from './registry.js'
/** What the game server lends the runtime. Only the runtime holds it. */
export interface ModHost {
@@@ -60,10 +68,24 @@ export class ModCapabilityError extends Error {
}
}
++/** A mod spent its host effects for this minute (the event-flood throttle). */
++export class ModThrottledError extends Error {
++ readonly code = 'mod.throttled'
++ constructor(readonly modId: string) {
++ super(`${modId} is over its effects for this minute`)
++ }
++}
++
++export const CPU_STRIKES = 5
++export const EFFECTS_PER_MINUTE = 60
++export const FLOOD_WINDOWS = 3
++
export interface ModRuntimeOptions {
/** Reconcile at least this often when nothing changed (ticks). */
reconcileEveryTicks: number
now?: () => number
++ /** Host effects a mod may cause per minute (default EFFECTS_PER_MINUTE). */
++ effectsPerMinute?: number
}
export class ModRuntime {
@@@ -74,6 +96,10 @@ export class ModRuntime {
/** `${modId}|${cap}` denials already audited since the last registry change. */
private readonly deniedNoted = new Set<string>()
private readonly budgetNotedAt = new Map<string, number>()
++ /** Reconciles in a row over the CPU budget, per mod. */
++ private readonly cpuStrikes = new Map<string, number>()
++ /** Host effects per mod in the current minute, and minutes in a row that were throttled. */
++ private readonly effects = new Map<string, { windowStart: number; count: number; throttled: boolean; floodWindows: number }>()
private readonly now: () => number
constructor(
@@@ -95,6 +121,7 @@ export class ModRuntime {
if (!this.registry.isGranted(modId, capability)) {
throw new ModCapabilityError(modId, capability)
}
++ this.spend(modId)
}
const name = () => this.registry.get(modId)?.mod.name ?? modId
const api: ModApi = {
@@@ -210,10 +237,45 @@ export class ModRuntime {
fn()
} catch (err) {
if (err instanceof ModCapabilityError) this.noteDenied(id, err.capability)
++ else if (err instanceof ModThrottledError) return
else throw err
}
}
++ /** One host effect for `id`: throws ModThrottledError past the minute's allowance; a sustained flood disables it. */
++ private spend(id: string): void {
++ const limit = this.opts.effectsPerMinute ?? EFFECTS_PER_MINUTE
++ const nowMs = this.now()
++ let e = this.effects.get(id)
++ if (!e || nowMs - e.windowStart >= 60_000) {
++ const floodWindows = e && e.throttled && nowMs - e.windowStart < 120_000 ? e.floodWindows + 1 : 0
++ e = { windowStart: nowMs, count: 0, throttled: false, floodWindows }
++ this.effects.set(id, e)
++ if (floodWindows >= FLOOD_WINDOWS) {
++ this.enforce(id, 'event_flood', { effects_per_minute: limit, minutes: floodWindows })
++ throw new ModThrottledError(id)
++ }
++ }
++ if (e.count >= limit) {
++ if (!e.throttled) {
++ e.throttled = true
++ this.registry.audit(id, 'throttled', null, { effects_per_minute: limit })
++ }
++ throw new ModThrottledError(id)
++ }
++ e.count++
++ }
++
++ /** Disables the offending mod only; the others keep running. */
++ private enforce(id: string, reason: 'cpu' | 'event_flood', detail: Record<string, unknown>): void {
++ if (!this.registry.isActive(id)) return
++ this.registry.audit(id, 'budget_enforced', null, { reason, ...detail })
++ this.registry.disable(id, SYSTEM_ACTOR)
++ this.cpuStrikes.delete(id)
++ this.effects.delete(id)
++ this.log.warn('mod disabled: over its budget', { mod: id, reason })
++ }
++
/** Despawns everything the mod placed. */
private retractProps(id: string, host: ModHost): void {
const placements = this.store.mods.placements(id)
@@@ -234,10 +296,19 @@ export class ModRuntime {
this.registry.audit(id, 'deny', capability)
}
-- /** Meters the declared per-tick CPU budget (enforcement: OpenVibe.Host, Stage C). */
++ /** Meters the declared per-reconcile CPU budget; CPU_STRIKES reconciles over it in a row disable the mod. */
private meter(view: ModView, ms: number): void {
const budget = view.manifest.resources.cpuMs
-- if (ms <= budget) return
++ if (ms <= budget) {
++ this.cpuStrikes.delete(view.mod.id)
++ return
++ }
++ const strikes = (this.cpuStrikes.get(view.mod.id) ?? 0) + 1
++ this.cpuStrikes.set(view.mod.id, strikes)
++ if (strikes >= CPU_STRIKES) {
++ this.enforce(view.mod.id, 'cpu', { cpu_ms: Math.round(ms * 1000) / 1000, budget_ms: budget, reconciles: strikes })
++ return
++ }
const nowMs = this.now()
if (nowMs - (this.budgetNotedAt.get(view.mod.id) ?? 0) < 60_000) return
this.budgetNotedAt.set(view.mod.id, nowMs)
ddiff --git a/apps/server/src/mods/security.test.ts b/apps/server/src/mods/security.test.ts
nnew file mode 100644
iindex 0000000..0adfd26
---- /dev/null
++++ b/apps/server/src/mods/security.test.ts
@@@ -0,0 +1,160 @@
++import { createContent } from '@openvibe/content'
++import { openSqliteStore } from '@openvibe/persistence/sqlite'
++import { createConsoleLogger } from '@openvibe/shared'
++import { describe, expect, it } from 'vitest'
++import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod, validateContentPack } from './contentPack.js'
++import { ModRegistry, type ModActor } from './registry.js'
++import { CPU_STRIKES, FLOOD_WINDOWS, ModRuntime, ModThrottledError, type ModHost } from './runtime.js'
++import { sampleManifest } from './testFixtures.js'
++
++/**
++ * Mod security (roadmap WS-M tasks 4 and 5, ADR-013):
++ * namespace escape a mod reaches only its own placements, through frozen bindings, with items a player cannot
++ * loot or use to move items; nothing in a pack can name another owner
++ * event flood host effects past the minute's allowance are refused, and a sustained flood disables only
++ * that mod
++ * resource budget a mod over its declared CPU budget reconcile after reconcile is disabled; the others run
++ * forged ownership every prop a mod places is owned by that mod, whatever the pack says; an unknown or
++ * another runtime's principal changes nothing here
++ */
++const STAFF: ModActor = { audit: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0', subject: { type: 'user', id: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' } }
++const content = createContent()
++const log = createConsoleLogger({ app: 'test' }, 'error')
++const A = 'mod_01JABCDEFGHJKMNPQRSTVWXYZA'
++const B = 'mod_01JABCDEFGHJKMNPQRSTVWXYZB'
++
++function world(opts: { slowMs?: number } = {}) {
++ const entities = new Map<string, { item: string; owner: string }>()
++ const said: string[] = []
++ let n = 0
++ const host: ModHost = {
++ announce: (text) => said.push(text),
++ placeProp: ({ item, owner }) => {
++ if (opts.slowMs) {
++ const until = performance.now() + opts.slowMs
++ while (performance.now() < until) {
++ // a pack that is expensive to run
++ }
++ }
++ const id = `ent_${++n}`
++ entities.set(id, { item, owner })
++ return id
++ },
++ removeEntity: (id) => entities.delete(id),
++ entityExists: (id) => entities.has(id),
++ }
++ return { host, entities, said }
++}
++
++function setup(effectsPerMinute = 60) {
++ let now = 1_000_000
++ const clock = { now: () => now, advance: (ms: number) => (now += ms) }
++ const store = openSqliteStore(':memory:')
++ const registry = new ModRegistry(store, content, undefined, clock.now)
++ const runtime = new ModRuntime(registry, store, content, log, { reconcileEveryTicks: 1, now: clock.now, effectsPerMinute })
++ const install = (id: string, pack: unknown, approve: string[], cpuMs = 50) =>
++ registry.install({ manifest: sampleManifest({ id, resources: { cpuMs, memoryMb: 0, storageMb: 0 } }), pack, approve, enable: true }, STAFF)
++ return { store, registry, runtime, clock, install }
++}
++
++const prop = (key: string, pos: [number, number, number] = [10, 0, 10]) => ({ key, item: 'workbench', pos })
++
++describe('mod security', () => {
++ it('namespace escape: a mod reaches only its own placements, through frozen bindings', () => {
++ const { registry, runtime, install } = setup()
++ const w = world()
++ install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])
++ install(B, { props: [prop('bench', [20, 0, 20])] }, [CAP_PLACE_PROP])
++ runtime.tick(1, w.host)
++ expect([...w.entities.values()].map((e) => e.owner).sort()).toEqual([A, B])
++ const apiA = runtime.bindingsFor(A, w.host)
++ expect(Object.isFrozen(apiA)).toBe(true)
++ expect(() => {
++ ;(apiA as unknown as Record<string, unknown>).placeProp = () => 'x'
++ }).toThrow()
++ // The same key under B is B's: A removing "bench" removes only its own.
++ expect(apiA.removeProp('bench')).toBe(true)
++ expect([...w.entities.values()].map((e) => e.owner)).toEqual([B])
++ expect(apiA.removeProp('bench')).toBe(false)
++ expect(registry.auditLog(B).some((a) => a.action === 'use' && JSON.stringify(a.detail ?? {}).includes('removed'))).toBe(false)
++ // Only inert items: anything with health, storage, a shop, a machine or a vehicle part is refused, at install
++ // and at the binding.
++ const loot = content.allItems().find((i) => placeableByMod(content, i.id) !== null)
++ expect(loot).toBeDefined()
++ expect(validateContentPack({ props: [{ key: 'x', item: loot!.id, pos: [0, 0, 0] }] }, content).ok).toBe(false)
++ expect(() => apiA.placeProp('x', loot!.id, [0, 0, 0])).toThrow(/not allowed/)
++ })
++
++ it('event flood: past the allowance calls are refused, and only the flooding mod is disabled', () => {
++ const { registry, runtime, clock, install } = setup(10)
++ const w = world()
++ install(A, { announcements: [{ text: 'spam', everySeconds: 60 }] }, [CAP_ANNOUNCE])
++ install(B, { announcements: [{ text: 'hello', everySeconds: 60 }] }, [CAP_ANNOUNCE])
++ const apiA = runtime.bindingsFor(A, w.host)
++ const apiB = runtime.bindingsFor(B, w.host)
++ let refused = 0
++ for (let i = 0; i < 25; i++) {
++ try {
++ apiA.announce(`spam ${i}`)
++ } catch (err) {
++ if (err instanceof ModThrottledError) refused++
++ }
++ }
++ expect(w.said.length).toBe(10)
++ expect(refused).toBe(15)
++ expect(registry.auditLog(A).filter((a) => a.action === 'throttled').length).toBe(1)
++ apiB.announce('still here')
++ expect(w.said.at(-1)).toContain('still here')
++ // Flooding minute after minute: the mod is disabled, the other is not.
++ for (let minute = 0; minute < FLOOD_WINDOWS; minute++) {
++ clock.advance(60_000)
++ for (let i = 0; i < 12; i++) {
++ try {
++ apiA.announce('spam')
++ } catch {
++ // refused
++ }
++ }
++ }
++ expect(registry.get(A)?.mod.status).toBe('disabled')
++ expect(registry.auditLog(A).some((a) => a.action === 'budget_enforced' && JSON.stringify(a.detail).includes('event_flood'))).toBe(true)
++ expect(registry.get(B)?.mod.status).toBe('enabled')
++ expect(() => apiA.announce('after')).toThrow()
++ })
++
++ it('resource budget: a mod over its CPU budget reconcile after reconcile is disabled; the others run', () => {
++ const { registry, runtime, install } = setup()
++ const slow = world({ slowMs: 3 })
++ install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP], 1)
++ install(B, { props: [] }, [], 50)
++ // A's prop keeps vanishing, so every reconcile places it again: slow, over its 1 ms budget.
++ for (let t = 1; t <= CPU_STRIKES + 2; t++) {
++ slow.entities.clear()
++ runtime.tick(t, slow.host)
++ }
++ expect(registry.get(A)?.mod.status).toBe('disabled')
++ const enforced = registry.auditLog(A).find((a) => a.action === 'budget_enforced')
++ expect(enforced?.detail).toMatchObject({ reason: 'cpu', budget_ms: 1, reconciles: CPU_STRIKES })
++ expect(registry.get(B)?.mod.status).toBe('enabled')
++ // Disabled means retracted on the next reconcile.
++ runtime.tick(CPU_STRIKES + 3, slow.host)
++ expect([...slow.entities.values()].filter((e) => e.owner === A)).toEqual([])
++ })
++
++ it('forged ownership: props are the mod\'s whatever the pack says; unknown or foreign principals change nothing', () => {
++ const { registry, runtime, install } = setup()
++ const w = world()
++ expect(validateContentPack({ props: [{ ...prop('x'), owner: 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0' }] }, content).ok).toBe(false)
++ install(A, { props: [prop('bench')] }, [CAP_PLACE_PROP])
++ runtime.tick(1, w.host)
++ expect([...w.entities.values()]).toEqual([{ item: 'workbench', owner: A }])
++ // A principal Games does not have, or that another runtime owns, is not applied here.
++ expect(registry.applyNetwork({ mod_id: B, status: 'active', approved: [CAP_PLACE_PROP, CAP_ANNOUNCE] })).toBeNull()
++ expect(registry.get(B)).toBeNull()
++ // A grant the manifest never requested cannot be applied, even from Network.
++ const manifestA = sampleManifest({ id: A, permissions: { capabilities: [CAP_PLACE_PROP] } })
++ expect(manifestA.permissions.capabilities).not.toContain('games.world.teleport')
++ registry.applyNetwork({ mod_id: A, status: 'active', approved: [CAP_PLACE_PROP, 'games.world.teleport'] })
++ expect([...(registry.get(A)?.granted ?? [])]).toEqual([CAP_PLACE_PROP])
++ })
++})