diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e92106c0..205ba5c27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -206,6 +206,9 @@ jobs: - name: Run UI unit tests run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs ui/services/sensing.service.test.mjs ui/components/DashboardTab.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs + - name: Run virtual-view adapter boundary tests + run: node --test integrations/virtual-view/adapter.test.mjs + # Unit and Integration Tests # Python pytest matrix — runs against the archived v1 Python tree. # `continue-on-error: true` for the same reason as code-quality above: diff --git a/integrations/virtual-view/README.md b/integrations/virtual-view/README.md index 74e59f00b..2d5680593 100644 --- a/integrations/virtual-view/README.md +++ b/integrations/virtual-view/README.md @@ -4,6 +4,11 @@ Opt-in integration with Dream Machine's `scripts/virtual-view-broker.mjs`. Disabled by default. No cameras, model calls, network, hardware commands or automatic promotion. It is NOT part of the Rust production perception pipeline. +The reviewed companion is Dream Machine commit +[`5707c825b2c065de44abed02594e1abf5eab95ea`](https://github.com/ruvnet/dream-machine/tree/5707c825b2c065de44abed02594e1abf5eab95ea), +merged in [Dream Machine PR #162](https://github.com/ruvnet/dream-machine/pull/162). +Use a checkout at that revision when reproducing the integration. + ```js import { generateKeyPairSync } from 'node:crypto'; import { broker } from '../../../dream-machine/scripts/virtual-view-broker.mjs'; @@ -21,6 +26,16 @@ threshold, trust boundary, receipt and acceptance rule. The adapter does not transform raw `spatial.evidence.v1` RF Gaussians or `ruview.lidar.depth.v1` packets: neither supplies the independently tracked manipulation scene this method needs. +The injected broker is trusted executable code, not sandboxed by this adapter. +The no-network/no-actuation behavior applies to this adapter and the reviewed +companion broker. Before reading scene markers or calling the broker, the adapter +checks the entire snapshot as bounded plain JSON using property descriptors. +It rejects proxies, getters, inherited/custom prototypes, nonfinite values, +sparse arrays and non-JSON data, including nested values. Structural limits match +the reviewed companion: depth 12, 20,000 values, 1,024 array elements, 64 object +properties, 128-character keys and 4,096-character strings. Full geometry, +provenance-label and signing-key validation remain the companion broker's responsibility. + ```sh node --test integrations/virtual-view/adapter.test.mjs # From the sibling Dream Machine checkout: diff --git a/integrations/virtual-view/adapter.mjs b/integrations/virtual-view/adapter.mjs index 157105858..ced2c1f8b 100644 --- a/integrations/virtual-view/adapter.mjs +++ b/integrations/virtual-view/adapter.mjs @@ -1,12 +1,55 @@ // Opt-in cross-repository prototype. Imports no hardware, network or model client. +import { types } from 'node:util'; + +const snapshotError = () => new TypeError( + 'calibrated metric scene snapshot required; raw RF/LiDAR packets and non-JSON data are not supported', +); + +// Structural preflight only, matching the reviewed companion's JSON budgets. +// Never invoke input methods, getters or proxy traps while inspecting data. +function validateSnapshotData(value, depth = 0, budget = { nodes: 0 }) { + if (depth > 12 || ++budget.nodes > 20000) throw snapshotError(); + if (value === null || typeof value === 'boolean') return; + if (typeof value === 'string' && value.length <= 4096) return; + if (typeof value === 'number' && Number.isFinite(value)) return; + if (typeof value !== 'object' || types.isProxy(value)) throw snapshotError(); + + const array = Array.isArray(value); + if (Object.getPrototypeOf(value) !== (array ? Array.prototype : Object.prototype)) { + throw snapshotError(); + } + if (array) { + const length = Object.getOwnPropertyDescriptor(value, 'length').value; + if (length > 1024) throw snapshotError(); + if (Reflect.ownKeys(value).length !== length + 1) throw snapshotError(); + for (let index = 0; index < length; index++) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) throw snapshotError(); + validateSnapshotData(descriptor.value, depth + 1, budget); + } + return; + } + const keys = Reflect.ownKeys(value); + if (keys.length > 64) throw snapshotError(); + for (const key of keys) { + if (typeof key !== 'string' || key.length > 128) throw snapshotError(); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor.enumerable || !Object.hasOwn(descriptor, 'value')) throw snapshotError(); + validateSnapshotData(descriptor.value, depth + 1, budget); + } +} + export function createVirtualViewAdapter({ broker, privateKey, enabled = false }) { if (typeof broker !== 'function' || typeof enabled !== 'boolean') throw new TypeError('adapter configuration'); return Object.freeze({ observe(snapshot) { if (!enabled) return { decision: 'BLOCK', reason: 'disabled', authority: 'none', svg: null }; + validateSnapshotData(snapshot); // Explicit metric scene contract only. RF Gaussians are not manipulation geometry. - if (snapshot?.schema !== 'virtual-view.v1' || snapshot?.frame !== 'room_enu') { - throw new TypeError('calibrated metric scene snapshot required; raw RF/LiDAR packets are not supported'); + const schema = snapshot && Object.getOwnPropertyDescriptor(snapshot, 'schema'); + const frame = snapshot && Object.getOwnPropertyDescriptor(snapshot, 'frame'); + if (schema?.value !== 'virtual-view.v1' || frame?.value !== 'room_enu') { + throw snapshotError(); } return broker(snapshot, { privateKey }); }, diff --git a/integrations/virtual-view/adapter.test.mjs b/integrations/virtual-view/adapter.test.mjs index f98eb816e..b49d9e4c2 100644 --- a/integrations/virtual-view/adapter.test.mjs +++ b/integrations/virtual-view/adapter.test.mjs @@ -3,12 +3,168 @@ import assert from 'node:assert/strict'; import { createVirtualViewAdapter } from './adapter.mjs'; test('disabled by default and never actuates', () => { const adapter = createVirtualViewAdapter({ broker: () => { throw Error('not called'); } }); - assert.equal(adapter.observe({}).reason, 'disabled'); assert.equal(adapter.hardwareActuation, false); + let reads = 0; + const snapshot = { get schema() { reads++; throw Error('not read'); } }; + assert.deepEqual(adapter.observe(snapshot), { + decision: 'BLOCK', reason: 'disabled', authority: 'none', svg: null, + }); + assert.equal(reads, 0); + assert.equal(adapter.hardwareActuation, false); + assert.equal(Object.isFrozen(adapter), true); }); -test('raw RF is rejected; explicit scene is forwarded to validating broker', () => { - let received; - const adapter = createVirtualViewAdapter({ enabled: true, broker: s => { received = s; return { authority: 'none' }; } }); - assert.throws(() => adapter.observe({ schema: 'spatial.evidence.v1' })); - const s = { schema: 'virtual-view.v1', frame: 'room_enu' }; - assert.equal(adapter.observe(s).authority, 'none'); assert.equal(received, s); + +test('raw RF, LiDAR and malformed scene markers never reach the broker', () => { + let calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + for (const snapshot of [ + null, undefined, 'virtual-view.v1', [], {}, + { schema: 'spatial.evidence.v1', frame: 'room_enu' }, + { schema: 'ruview.lidar.depth.v1', frame: 'room_enu' }, + { schema: 'virtual-view.v1' }, + { schema: 'virtual-view.v1', frame: 'camera' }, + Object.create({ schema: 'virtual-view.v1', frame: 'room_enu' }), + Object.defineProperty({ frame: 'room_enu' }, 'schema', { value: 'virtual-view.v1' }), + ]) { + assert.throws(() => adapter.observe(snapshot), /calibrated metric scene snapshot required/); + } + assert.equal(calls, 0); +}); + +test('scene marker getters are rejected without executing input code', () => { + let reads = 0, calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + for (const key of ['schema', 'frame']) { + const snapshot = { schema: 'virtual-view.v1', frame: 'room_enu' }; + Object.defineProperty(snapshot, key, { + enumerable: true, + get() { reads++; return key === 'schema' ? 'virtual-view.v1' : 'room_enu'; }, + }); + assert.throws(() => adapter.observe(snapshot), /calibrated metric scene snapshot required/); + } + assert.equal(reads, 0); + assert.equal(calls, 0); +}); + +test('proxy snapshots are rejected without running inspection traps', () => { + let traps = 0, calls = 0; + const snapshot = new Proxy({ schema: 'virtual-view.v1', frame: 'room_enu' }, { + get() { traps++; throw Error('get trap'); }, + getPrototypeOf() { traps++; throw Error('prototype trap'); }, + getOwnPropertyDescriptor() { traps++; throw Error('descriptor trap'); }, + }); + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + assert.throws(() => adapter.observe(snapshot), /calibrated metric scene snapshot required/); + assert.equal(traps, 0); + assert.equal(calls, 0); +}); + +test('nested object and array getters are rejected without reads or broker calls', () => { + let reads = 0, calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + const getter = { enumerable: true, get() { reads++; return 0; } }; + const object = Object.defineProperty({}, 'uncertaintyM', getter); + const array = Object.defineProperty([0], '0', getter); + for (const extra of [object, [object], { nested: array }]) { + assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu', extra }), + /calibrated metric scene snapshot required/); + } + assert.equal(reads, 0); + assert.equal(calls, 0); +}); + +test('nested proxies and custom array prototypes never execute input code', () => { + let traps = 0, calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + const handler = { + get() { traps++; throw Error('get trap'); }, + getPrototypeOf() { traps++; throw Error('prototype trap'); }, + ownKeys() { traps++; throw Error('keys trap'); }, + getOwnPropertyDescriptor() { traps++; throw Error('descriptor trap'); }, + }; + const custom = Object.setPrototypeOf([], { + get [Symbol.iterator]() { traps++; throw Error('iterator getter'); }, + }); + class SceneArray extends Array {} + for (const extra of [ + { nested: new Proxy({}, handler) }, + [new Proxy([], handler)], + { nested: custom }, + new SceneArray(), + ]) { + assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu', extra }), + /calibrated metric scene snapshot required/); + } + assert.equal(traps, 0); + assert.equal(calls, 0); +}); + +test('cyclic, non-JSON and oversized structures are rejected before the broker', () => { + let calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { calls++; } }); + const cyclic = {}; cyclic.self = cyclic; + let deep = 0; + for (let index = 0; index < 13; index++) deep = { next: deep }; + const sparse = Array(2); sparse[1] = 0; + const extraProperty = [0]; extraProperty.extra = 0; + for (const extra of [ + cyclic, deep, sparse, extraProperty, NaN, Infinity, undefined, () => {}, new Date(), + 'x'.repeat(4097), Array(1025).fill(0), + { ['x'.repeat(129)]: 0 }, + Object.fromEntries(Array.from({ length: 65 }, (_, index) => [`k${index}`, 0])), + Array.from({ length: 20 }, () => Array(1024).fill(0)), + ]) { + assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu', extra }), + /calibrated metric scene snapshot required/); + } + assert.equal(calls, 0); +}); + +test('a complete 256-object synthetic scene stays within the structural budget', () => { + const scene = { + schema: 'virtual-view.v1', sceneId: 'fixture', requestId: 'request:1', policyId: 'fixed', + revision: 1, frame: 'room_enu', proof: 'SYNTHETIC', nowMs: 1000, observedMs: 990, + contact: 'static', target: 'o0', reference: 'o1', physicalAxis: 'x', + objects: Array.from({ length: 256 }, (_, index) => ({ + id: `o${index}`, observed: [index / 100, index % 3, 0.1], + predicted: [index / 100, index % 3, 0.1], size: [0.01, 0.01, 0.01], + uncertaintyM: 0.015, sourceReceipt: `fixture:${index}`, + })), + }; + let calls = 0; + const result = { authority: 'none' }; + const adapter = createVirtualViewAdapter({ enabled: true, broker: received => { + calls++; + assert.equal(received, scene); + return result; + } }); + assert.equal(adapter.observe(scene), result); + assert.equal(calls, 1); +}); + +test('explicit immutable scene and signer are forwarded unchanged to trusted broker', () => { + const snapshot = Object.freeze({ schema: 'virtual-view.v1', frame: 'room_enu' }); + const privateKey = Object.freeze({ researchSigner: true }); + const result = Object.freeze({ decision: 'SKIP', authority: 'none', svg: null }); + let calls = 0; + const adapter = createVirtualViewAdapter({ enabled: true, privateKey, broker: (received, options) => { + calls++; + assert.equal(received, snapshot); + assert.equal(options.privateKey, privateKey); + return result; + } }); + assert.equal(adapter.observe(snapshot), result); + assert.equal(calls, 1); +}); + +test('full scene validation remains delegated and broker rejection propagates', () => { + const rejection = new TypeError('missing tracked scene geometry'); + const adapter = createVirtualViewAdapter({ enabled: true, broker: () => { throw rejection; } }); + assert.throws(() => adapter.observe({ schema: 'virtual-view.v1', frame: 'room_enu' }), + error => error === rejection); +}); + +test('enablement must be explicit and configuration types are checked', () => { + for (const configuration of [ + { broker: null }, { broker: () => {}, enabled: 'true' }, { broker: () => {}, enabled: 1 }, + ]) assert.throws(() => createVirtualViewAdapter(configuration), /adapter configuration/); });