fix(mcp,editor): close URL-validation bypasses surfaced by Phase 8 P4
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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e8d0b13ff5
commit
0b84e7b7b1
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* Phase 8 P1 — scene templates lifecycle end-to-end via stdio MCP.
|
||||
*
|
||||
* Spawns a dedicated stdio MCP child (isolated PASCAL_DATA_DIR), instantiates
|
||||
* every seed template, saves/validates/inspects them, exercises `measure`,
|
||||
* deletes them, and asserts error-path behaviour for unknown template ids
|
||||
* and missing scene ids.
|
||||
*
|
||||
* Run: bun packages/mcp/test-reports/phase8/p1-templates.ts
|
||||
*/
|
||||
import { rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
||||
import { McpError } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
const REPO_ROOT = resolve(__dirname, '../../../..')
|
||||
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
|
||||
const REPORT_PATH = resolve(__dirname, 'p1-templates.md')
|
||||
const DATA_DIR = '/tmp/pascal-phase8-p1'
|
||||
|
||||
type StepStatus = 'PASS' | 'FAIL'
|
||||
type Step = { id: string; title: string; status: StepStatus; detail: string }
|
||||
const steps: Step[] = []
|
||||
|
||||
function record(id: string, title: string, status: StepStatus, detail: string): void {
|
||||
steps.push({ id, title, status, detail })
|
||||
const icon = status === 'PASS' ? '[PASS]' : '[FAIL]'
|
||||
console.log(`${icon} ${id} ${title} — ${detail}`)
|
||||
}
|
||||
|
||||
type TextContent = Array<{ type?: string; text?: string }>
|
||||
function parseText(content: unknown): any {
|
||||
const arr = content as TextContent
|
||||
const first = Array.isArray(arr) ? arr[0] : undefined
|
||||
if (!first || typeof first.text !== 'string') return null
|
||||
try {
|
||||
return JSON.parse(first.text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type TemplateSummary = { id: string; name: string; description: string; nodeCount: number }
|
||||
|
||||
const TEMPLATE_IDS = ['empty-studio', 'two-bedroom', 'garden-house'] as const
|
||||
|
||||
// Per-template snapshot recorded in step 2c.
|
||||
type SceneSnapshot = {
|
||||
templateId: string
|
||||
sceneId: string
|
||||
sceneName: string
|
||||
nodeCount: number
|
||||
zoneCount: number
|
||||
wallCount: number
|
||||
doorCount: number
|
||||
windowCount: number
|
||||
}
|
||||
const snapshots: SceneSnapshot[] = []
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// Idempotent cleanup.
|
||||
try {
|
||||
rmSync(DATA_DIR, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const t0 = Date.now()
|
||||
const transport = new StdioClientTransport({
|
||||
command: 'bun',
|
||||
args: [BIN_PATH, '--stdio'],
|
||||
env: { ...process.env, PASCAL_DATA_DIR: DATA_DIR },
|
||||
stderr: 'inherit',
|
||||
})
|
||||
const client = new Client({ name: 'p1-templates', version: '0.0.0' })
|
||||
await client.connect(transport)
|
||||
|
||||
// ========================================================================
|
||||
// 1. list_templates — assert 3 templates with required fields.
|
||||
// ========================================================================
|
||||
{
|
||||
const r = await client.callTool({ name: 'list_templates', arguments: {} })
|
||||
const payload = parseText(r.content) ?? (r.structuredContent as any)
|
||||
const list = payload?.templates as TemplateSummary[] | undefined
|
||||
const ids = (list ?? []).map((t) => t.id).sort()
|
||||
const expected = [...TEMPLATE_IDS].sort()
|
||||
const idsOk = JSON.stringify(ids) === JSON.stringify(expected)
|
||||
const fieldsOk =
|
||||
!!list &&
|
||||
list.every(
|
||||
(t) =>
|
||||
typeof t.id === 'string' &&
|
||||
typeof t.name === 'string' &&
|
||||
typeof t.description === 'string' &&
|
||||
typeof t.nodeCount === 'number' &&
|
||||
t.nodeCount > 0,
|
||||
)
|
||||
const status: StepStatus = idsOk && fieldsOk ? 'PASS' : 'FAIL'
|
||||
const summary = list
|
||||
? list.map((t) => `${t.id}(${t.nodeCount})`).join(', ')
|
||||
: 'missing templates array'
|
||||
record('1', 'list_templates', status, `ids=[${ids.join(',')}], ${summary}`)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 2. For each template: create_from_template → save_scene → validate → get_scene.
|
||||
// ========================================================================
|
||||
for (const id of TEMPLATE_IDS) {
|
||||
const sceneName = `p1-${id}`
|
||||
// 2a. create_from_template + save_scene
|
||||
let sceneId: string | null = null
|
||||
{
|
||||
const cr = await client.callTool({
|
||||
name: 'create_from_template',
|
||||
arguments: { id, name: sceneName },
|
||||
})
|
||||
const cpayload = parseText(cr.content) ?? (cr.structuredContent as any)
|
||||
const createOk = !cr.isError && cpayload?.templateId === id && (cpayload?.nodeCount ?? 0) > 0
|
||||
const sr = await client.callTool({
|
||||
name: 'save_scene',
|
||||
arguments: { name: sceneName },
|
||||
})
|
||||
const spayload = parseText(sr.content) ?? (sr.structuredContent as any)
|
||||
sceneId = spayload?.id ?? null
|
||||
const saveOk = !sr.isError && !!sceneId
|
||||
const status: StepStatus = createOk && saveOk ? 'PASS' : 'FAIL'
|
||||
record(
|
||||
`2a/${id}`,
|
||||
'create_from_template + save_scene',
|
||||
status,
|
||||
`templateId=${cpayload?.templateId}, createdNodes=${cpayload?.nodeCount}, sceneId=${sceneId}, version=${spayload?.version}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 2b. validate_scene
|
||||
{
|
||||
const vr = await client.callTool({ name: 'validate_scene', arguments: {} })
|
||||
const vp = parseText(vr.content) ?? (vr.structuredContent as any)
|
||||
const ok =
|
||||
!vr.isError && vp?.valid === true && Array.isArray(vp?.errors) && vp.errors.length === 0
|
||||
record(
|
||||
`2b/${id}`,
|
||||
'validate_scene',
|
||||
ok ? 'PASS' : 'FAIL',
|
||||
`valid=${vp?.valid}, errors=${vp?.errors?.length}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 2c. get_scene — record counts per type.
|
||||
{
|
||||
const gr = await client.callTool({ name: 'get_scene', arguments: {} })
|
||||
const gp = parseText(gr.content) ?? (gr.structuredContent as any)
|
||||
const nodes = (gp?.nodes as Record<string, { type?: string }> | undefined) ?? {}
|
||||
const counts = { zone: 0, wall: 0, door: 0, window: 0 }
|
||||
for (const n of Object.values(nodes)) {
|
||||
const t = n?.type
|
||||
if (t === 'zone' || t === 'wall' || t === 'door' || t === 'window') {
|
||||
counts[t] += 1
|
||||
}
|
||||
}
|
||||
const nodeCount = Object.keys(nodes).length
|
||||
const ok = !gr.isError && nodeCount > 0
|
||||
snapshots.push({
|
||||
templateId: id,
|
||||
sceneId: sceneId ?? '(missing)',
|
||||
sceneName,
|
||||
nodeCount,
|
||||
zoneCount: counts.zone,
|
||||
wallCount: counts.wall,
|
||||
doorCount: counts.door,
|
||||
windowCount: counts.window,
|
||||
})
|
||||
record(
|
||||
`2c/${id}`,
|
||||
'get_scene counts',
|
||||
ok ? 'PASS' : 'FAIL',
|
||||
`nodes=${nodeCount}, zones=${counts.zone}, walls=${counts.wall}, doors=${counts.door}, windows=${counts.window}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 3. list_scenes — expect 3 scenes with our names.
|
||||
// ========================================================================
|
||||
{
|
||||
const lr = await client.callTool({ name: 'list_scenes', arguments: {} })
|
||||
const lp = parseText(lr.content) ?? (lr.structuredContent as any)
|
||||
const names = (lp?.scenes ?? []).map((s: any) => s.name).sort()
|
||||
const expected = TEMPLATE_IDS.map((id) => `p1-${id}`).sort()
|
||||
const ok =
|
||||
!lr.isError && names.length === 3 && JSON.stringify(names) === JSON.stringify(expected)
|
||||
record(
|
||||
'3',
|
||||
'list_scenes',
|
||||
ok ? 'PASS' : 'FAIL',
|
||||
`scenes=${lp?.scenes?.length}, names=[${names.join(',')}]`,
|
||||
)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 4. Load two-bedroom; measure between two zones; distance > 0.
|
||||
// ========================================================================
|
||||
{
|
||||
const twoBedroom = snapshots.find((s) => s.templateId === 'two-bedroom')
|
||||
if (!twoBedroom || twoBedroom.sceneId === '(missing)') {
|
||||
record('4', 'measure between zones', 'FAIL', 'no two-bedroom scene recorded')
|
||||
} else {
|
||||
const load = await client.callTool({
|
||||
name: 'load_scene',
|
||||
arguments: { id: twoBedroom.sceneId },
|
||||
})
|
||||
const loadOk = !load.isError
|
||||
const gs = await client.callTool({ name: 'get_scene', arguments: {} })
|
||||
const gsp = parseText(gs.content) ?? (gs.structuredContent as any)
|
||||
const nodes = (gsp?.nodes as Record<string, { id?: string; type?: string }> | undefined) ?? {}
|
||||
const zones = Object.values(nodes).filter((n) => n.type === 'zone')
|
||||
if (!loadOk || zones.length < 2) {
|
||||
record('4', 'measure between zones', 'FAIL', `loadOk=${loadOk}, zones=${zones.length}`)
|
||||
} else {
|
||||
const from = zones[0]!.id as string
|
||||
const to = zones[1]!.id as string
|
||||
const mr = await client.callTool({
|
||||
name: 'measure',
|
||||
arguments: { fromId: from, toId: to },
|
||||
})
|
||||
const mp = parseText(mr.content) ?? (mr.structuredContent as any)
|
||||
const dist = mp?.distanceMeters as number | undefined
|
||||
const ok = !mr.isError && typeof dist === 'number' && dist > 0
|
||||
record(
|
||||
'4',
|
||||
'measure between zones',
|
||||
ok ? 'PASS' : 'FAIL',
|
||||
`from=${from}, to=${to}, distance=${dist?.toFixed?.(3)}m`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 5. delete each scene, then list_scenes → 0.
|
||||
// ========================================================================
|
||||
for (const snap of snapshots) {
|
||||
if (snap.sceneId === '(missing)') continue
|
||||
const dr = await client.callTool({
|
||||
name: 'delete_scene',
|
||||
arguments: { id: snap.sceneId },
|
||||
})
|
||||
const dp = parseText(dr.content) ?? (dr.structuredContent as any)
|
||||
const ok = !dr.isError && dp?.deleted === true
|
||||
record(
|
||||
`5/${snap.templateId}`,
|
||||
'delete_scene',
|
||||
ok ? 'PASS' : 'FAIL',
|
||||
`id=${snap.sceneId}, deleted=${dp?.deleted}`,
|
||||
)
|
||||
}
|
||||
{
|
||||
const lr = await client.callTool({ name: 'list_scenes', arguments: {} })
|
||||
const lp = parseText(lr.content) ?? (lr.structuredContent as any)
|
||||
const count = lp?.scenes?.length ?? -1
|
||||
const ok = !lr.isError && count === 0
|
||||
record('5/final', 'list_scenes empty', ok ? 'PASS' : 'FAIL', `remaining scenes=${count}`)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 6a. Error: create_from_template with unknown id → McpError(InvalidParams).
|
||||
// ========================================================================
|
||||
{
|
||||
let status: StepStatus = 'FAIL'
|
||||
let detail = ''
|
||||
try {
|
||||
const r = await client.callTool({
|
||||
name: 'create_from_template',
|
||||
arguments: { id: 'nonexistent' },
|
||||
})
|
||||
if (r.isError) {
|
||||
const textArr = r.content as TextContent
|
||||
const text = textArr?.[0]?.text ?? ''
|
||||
const looksInvalid = /unknown_template|InvalidParams|nonexistent/i.test(text)
|
||||
status = looksInvalid ? 'PASS' : 'FAIL'
|
||||
detail = `tool_error text="${String(text).slice(0, 160)}"`
|
||||
} else {
|
||||
detail = 'unexpected success'
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof McpError) {
|
||||
// ErrorCode.InvalidParams = -32602
|
||||
const ok = err.code === -32602
|
||||
status = ok ? 'PASS' : 'FAIL'
|
||||
detail = `McpError code=${err.code} msg="${err.message}"`
|
||||
} else {
|
||||
detail = `threw non-McpError: ${err instanceof Error ? err.message : String(err)}`
|
||||
}
|
||||
}
|
||||
record('6a', 'create_from_template unknown id', status, detail)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 6b. Error: load_scene missing id → expect error.
|
||||
// ========================================================================
|
||||
{
|
||||
let status: StepStatus = 'FAIL'
|
||||
let detail = ''
|
||||
try {
|
||||
const r = await client.callTool({
|
||||
name: 'load_scene',
|
||||
arguments: { id: 'missing-id-xyz' },
|
||||
})
|
||||
if (r.isError) {
|
||||
const textArr = r.content as TextContent
|
||||
const text = textArr?.[0]?.text ?? ''
|
||||
const looksMissing = /scene_not_found|missing|not found/i.test(text)
|
||||
status = looksMissing ? 'PASS' : 'FAIL'
|
||||
detail = `tool_error text="${String(text).slice(0, 160)}"`
|
||||
} else {
|
||||
detail = 'unexpected success'
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof McpError) {
|
||||
const ok = err.code === -32602 || /scene_not_found|not found/i.test(err.message)
|
||||
status = ok ? 'PASS' : 'FAIL'
|
||||
detail = `McpError code=${err.code} msg="${err.message}"`
|
||||
} else {
|
||||
detail = `threw non-McpError: ${err instanceof Error ? err.message : String(err)}`
|
||||
}
|
||||
}
|
||||
record('6b', 'load_scene missing id', status, detail)
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - t0
|
||||
await client.close()
|
||||
|
||||
// --- Write markdown report ---
|
||||
const passed = steps.filter((s) => s.status === 'PASS').length
|
||||
const failed = steps.filter((s) => s.status === 'FAIL').length
|
||||
const total = steps.length
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push('# Phase 8 P1 — templates lifecycle (stdio MCP)')
|
||||
lines.push('')
|
||||
lines.push(`Generated: ${new Date().toISOString()}`)
|
||||
lines.push(
|
||||
`Transport: stdio (\`bun packages/mcp/dist/bin/pascal-mcp.js --stdio\`), data dir \`${DATA_DIR}\`.`,
|
||||
)
|
||||
lines.push('')
|
||||
lines.push(`**Summary:** ${passed}/${total} PASS, ${failed} FAIL, ${elapsed} ms.`)
|
||||
lines.push('')
|
||||
lines.push('## Steps')
|
||||
lines.push('')
|
||||
lines.push('| # | Step | Status | Detail |')
|
||||
lines.push('|---|------|--------|--------|')
|
||||
for (const s of steps) {
|
||||
const safe = s.detail.replace(/\|/g, '\\|').replace(/\n/g, ' ')
|
||||
lines.push(`| ${s.id} | ${s.title} | ${s.status} | ${safe} |`)
|
||||
}
|
||||
lines.push('')
|
||||
lines.push('## Per-template snapshot (step 2c)')
|
||||
lines.push('')
|
||||
lines.push('| Template | Scene name | nodes | zones | walls | doors | windows |')
|
||||
lines.push('|----------|------------|-------|-------|-------|-------|---------|')
|
||||
for (const snap of snapshots) {
|
||||
lines.push(
|
||||
`| ${snap.templateId} | ${snap.sceneName} | ${snap.nodeCount} | ${snap.zoneCount} | ${snap.wallCount} | ${snap.doorCount} | ${snap.windowCount} |`,
|
||||
)
|
||||
}
|
||||
lines.push('')
|
||||
|
||||
writeFileSync(REPORT_PATH, lines.join('\n'), 'utf8')
|
||||
console.log(`\n[p1] report: ${REPORT_PATH}`)
|
||||
console.log(`[p1] ${passed}/${total} PASS, ${failed} FAIL in ${elapsed}ms`)
|
||||
|
||||
if (failed > 0) process.exitCode = 1
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[p1] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
|
||||
process.exit(2)
|
||||
})
|
||||
Reference in New Issue
Block a user