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.
Changes
@@@ -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: numbernow?: () => 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: () => numberconstructor(@@@ -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 ?? modIdconst 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) returnelse 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) returnthis.budgetNotedAt.set(view.mod.id, nowMs)nnew file mode 100644@@@ -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])++ })++})
@@ -11,8 +11,16 @@
@@ -11,8 +11,16 @@
* executable runtime (a script sandbox) would receive the same bindings.
* executable runtime (a script sandbox) would receive the same bindings.
* Executable mods are not accepted yet: sandboxed execution with enforced
* Executable mods are not accepted yet: sandboxed execution with enforced
* CPU, memory, storage and network budgets belongs to OpenVibe.Host
* 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
* Each tick the runtime reconciles when the registry changed (and once a
* second otherwise): an inactive mod, or one that lost a capability, has
* second otherwise): an inactive mod, or one that lost a capability, has
@@ -23,7 +31,7 @@ import type { ContentRegistry } from '@openvibe/content'
@@ -23,7 +31,7 @@ import type { ContentRegistry } from '@openvibe/content'
import type { PersistenceStore } from '@openvibe/persistence'
import type { PersistenceStore } from '@openvibe/persistence'
import type { Logger } from '@openvibe/shared'
import type { Logger } from '@openvibe/shared'
import { CAP_ANNOUNCE, CAP_PLACE_PROP, placeableByMod } from './contentPack.js'
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. */
/** What the game server lends the runtime. Only the runtime holds it. */
export interface ModHost {
export interface ModHost {
@@ -60,10 +68,24 @@ export class ModCapabilityError extends Error {
@@ -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 {
export interface ModRuntimeOptions {
/** Reconcile at least this often when nothing changed (ticks). */
/** Reconcile at least this often when nothing changed (ticks). */
reconcileEveryTicks: number
reconcileEveryTicks: number
now?: () => number
now?: () => number
+ /** Host effects a mod may cause per minute (default EFFECTS_PER_MINUTE). */
+ effectsPerMinute?: number
}
}
export class ModRuntime {
export class ModRuntime {
@@ -74,6 +96,10 @@ export class ModRuntime {
@@ -74,6 +96,10 @@ export class ModRuntime {
/** `${modId}|${cap}` denials already audited since the last registry change. */
/** `${modId}|${cap}` denials already audited since the last registry change. */
private readonly deniedNoted = new Set<string>()
private readonly deniedNoted = new Set<string>()
private readonly budgetNotedAt = new Map<string, number>()
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
private readonly now: () => number
constructor(
constructor(
@@ -95,6 +121,7 @@ export class ModRuntime {
@@ -95,6 +121,7 @@ export class ModRuntime {
if (!this.registry.isGranted(modId, capability)) {
if (!this.registry.isGranted(modId, capability)) {
throw new ModCapabilityError(modId, capability)
throw new ModCapabilityError(modId, capability)
}
}
+ this.spend(modId)
}
}
const name = () => this.registry.get(modId)?.mod.name ?? modId
const name = () => this.registry.get(modId)?.mod.name ?? modId
const api: ModApi = {
const api: ModApi = {
@@ -210,10 +237,45 @@ export class ModRuntime {
@@ -210,10 +237,45 @@ export class ModRuntime {
fn()
fn()
} catch (err) {
} catch (err) {
if (err instanceof ModCapabilityError) this.noteDenied(id, err.capability)
if (err instanceof ModCapabilityError) this.noteDenied(id, err.capability)
+ else if (err instanceof ModThrottledError) return
else throw err
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. */
/** Despawns everything the mod placed. */
private retractProps(id: string, host: ModHost): void {
private retractProps(id: string, host: ModHost): void {
const placements = this.store.mods.placements(id)
const placements = this.store.mods.placements(id)
@@ -234,10 +296,19 @@ export class ModRuntime {
@@ -234,10 +296,19 @@ export class ModRuntime {
this.registry.audit(id, 'deny', capability)
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 {
private meter(view: ModView, ms: number): void {
const budget = view.manifest.resources.cpuMs
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()
const nowMs = this.now()
if (nowMs - (this.budgetNotedAt.get(view.mod.id) ?? 0) < 60_000) return
if (nowMs - (this.budgetNotedAt.get(view.mod.id) ?? 0) < 60_000) return
this.budgetNotedAt.set(view.mod.id, nowMs)
this.budgetNotedAt.set(view.mod.id, nowMs)
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -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])
+ })
+})