Phase 8 parallel validation flagged two boundaries where malicious URLs
(javascript:, file:, external http:, data:text/html, ...) could be
persisted despite the AssetUrl allowlist added in Phase 7 A7:
1. `save_scene({ includeCurrentScene: false, graph })` — the graph arg
was treated as opaque (`z.record(z.string(), z.unknown())`) and
written to the store without re-running AnyNode.safeParse.
2. `POST /api/scenes { graph }` in the editor API — same issue; the
Zod `graphSchema` accepted anything object-shaped.
Fixes:
- `save-scene.ts`: when `includeCurrentScene === false`, iterate every
node and run `AnyNode.safeParse`; collect issues and throw
`McpError(InvalidParams, 'graph_invalid', { errors })` on any
failure.
- `app/api/scenes/route.ts`: replace `graphSchema` with a structured
`z.object({ nodes, rootNodeIds, collections? })` + `superRefine`
that runs `AnyNode.safeParse` on every node. Invalid → 400 with
detailed issue paths.
Tests:
- Added `save_scene` regression test for the P4 attack
(item.asset.src = 'javascript:alert(1)') — expected error.
- Fixed the existing `includeCurrentScene=false` test to use a
schema-compliant site node id (the prior `id: 'root'` now fails
the AnyNode parse, which is the desired strict behaviour).
- Full suite: 294 pass / 0 fail.
Also adds Phase 8 test-reports/phase8/** (10 agents, ~15 scripts +
markdown reports) documenting the validation run, plus minor biome
cleanups to the Phase 5/7 test artefacts (removed stale
`// biome-ignore` suppression comments that now resolve to the
already-off `noConsole` rule).
Phase 8 result summary (10 parallel agents, stdio MCP transport with
isolated data dirs):
- P1 templates: 18/18 PASS
- P2 variants: 6/7 mutations + determinism + save + combined + error
- P3 locking: 12/12 PASS (MCP + editor HTTP If-Match)
- P4 URL hardening: fixed 2 bypasses (see above)
- P5 photo-to-scene: 6/6 PASS
- P6 Casa del Sol via save_scene: 13/13 PASS
- P7 editor HTTP API: 18/18 PASS
- P8 concurrency: 4/5 PASS, flagged 2 real filesystem-store races
(expectedVersion CAS gap + .index.json drift under parallel writes)
- P9 edge cases: 13/13 PASS (size cap, slug safety, bad inputs)
- P10 full sweep: 37/37 PASS (30 tools + 4 resources + 3 prompts)
Known follow-ups:
- FilesystemSceneStore needs a proper lockfile / atomic CAS to fix
the P8 concurrency bugs (low priority: single-writer MCP is the
typical case).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
41 lines
1.5 KiB
TypeScript
41 lines
1.5 KiB
TypeScript
import type { SceneGraph } from '@pascal-app/core/clone-scene-graph'
|
|
import type { AnyNode } from '@pascal-app/core/schema'
|
|
|
|
/**
|
|
* `cloneSceneGraph` normalises `SiteNode.children` to an array of node IDs,
|
|
* but core's `SiteNode` schema expects an array of embedded `BuildingNode` /
|
|
* `ItemNode` objects (see `packages/mcp/CROSS_CUTTING.md` §2). To keep the
|
|
* cloned graph validating against `AnyNode`, re-embed the site children from
|
|
* the flat dict.
|
|
*
|
|
* Pure: returns a new graph without mutating the input.
|
|
*/
|
|
export function rehydrateSiteChildren(graph: SceneGraph): SceneGraph {
|
|
const out: SceneGraph = {
|
|
nodes: { ...graph.nodes },
|
|
rootNodeIds: [...graph.rootNodeIds],
|
|
...(graph.collections ? { collections: graph.collections } : {}),
|
|
}
|
|
for (const [id, node] of Object.entries(out.nodes)) {
|
|
if (node.type !== 'site') continue
|
|
const childrenField = (node as { children?: unknown[] }).children
|
|
if (!Array.isArray(childrenField)) continue
|
|
const rehydrated: AnyNode[] = []
|
|
for (const child of childrenField) {
|
|
if (typeof child === 'string') {
|
|
const target = out.nodes[child as keyof typeof out.nodes]
|
|
if (target && (target.type === 'building' || target.type === 'item')) {
|
|
rehydrated.push(target)
|
|
}
|
|
} else if (child && typeof child === 'object' && 'id' in (child as Record<string, unknown>)) {
|
|
rehydrated.push(child as AnyNode)
|
|
}
|
|
}
|
|
out.nodes[id as keyof typeof out.nodes] = {
|
|
...(node as AnyNode),
|
|
children: rehydrated,
|
|
} as AnyNode
|
|
}
|
|
return out
|
|
}
|