Files
editor/packages/mcp/test-reports/phase8/p3-locking.md
T
Adrian PerezandClaude Opus 4.7 0b84e7b7b1 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>
2026-04-19 20:26:26 +02:00

3.8 KiB

P3 — Phase 8: Version Conflict / Optimistic Locking Report

Run: 2026-04-19T18:19:49.835Z

Summary

  • PASS: 12
  • WARN: 0
  • FAIL: 0
  • Total: 12

Matrix

ID Part Description Verdict
A1 A save_scene fresh → version === 1 PASS
A2 A save_scene expectedVersion=1 → version === 2 PASS
A3 A save_scene expectedVersion=5 (stale) → version_conflict PASS
A4 A save_scene WITHOUT expectedVersion on existing id PASS
A5 A rename_scene expectedVersion=99 (current=2) → version_conflict PASS
A6 A delete_scene expectedVersion=99 (stale) → version_conflict PASS
B1 B POST /api/scenes { id: "p3-http-mo63c61z", name: "p3-http" } → 201 PASS
B2 B GET /api/scenes/p3-http-mo63c61z — ETag header matches "1" PASS
B3 B PUT with If-Match: "1" (matching current) → 200 PASS
B4 B PUT with If-Match: "99" (stale) → 409 PASS
B5 B DELETE with If-Match: "99" (stale) → 409 PASS
B6 B DELETE with correct If-Match: "2" → 204 PASS

Details

A1 — part A — save_scene fresh → version === 1

Verdict: PASS

Expected: success, version=1

Actual: success, version=1, id=p3-mcp

A2 — part A — save_scene expectedVersion=1 → version === 2

Verdict: PASS

Expected: success, version=2

Actual: success, version=2

A3 — part A — save_scene expectedVersion=5 (stale) → version_conflict

Verdict: PASS

Expected: McpError / tool_error with code=version_conflict

Actual: tool_error: MCP error -32600: version_conflict

A4 — part A — save_scene WITHOUT expectedVersion on existing id

Verdict: PASS

Expected: Document behaviour: lenient overwrite OR strict reject

Actual: tool_error: MCP error -32600: Scene with id "p3-mcp" already exists. Pass a different id or provide expectedVersion to overwrite.

Note: STRICT: save without expectedVersion rejected — existing scene protected

A5 — part A — rename_scene expectedVersion=99 (current=2) → version_conflict

Verdict: PASS

Expected: McpError / tool_error with code=version_conflict

Actual: tool_error: MCP error -32600: version_conflict

A6 — part A — delete_scene expectedVersion=99 (stale) → version_conflict

Verdict: PASS

Expected: McpError / tool_error with code=version_conflict

Actual: tool_error: MCP error -32600: version_conflict

B1 — part B — POST /api/scenes { id: "p3-http-mo63c61z", name: "p3-http" } → 201

Verdict: PASS

Expected: status 201, body has version=1

Actual: status=201, body={"id":"p3-http-mo63c61z","name":"p3-http","projectId":null,"thumbnailUrl":null,"version":1,"createdAt":"2026-04-19T18:19:49.805Z","updatedAt":"2026-04-19T18:19:49.805Z","ownerId":null,"sizeBytes":928,"nodeCount":1}

B2 — part B — GET /api/scenes/p3-http-mo63c61z — ETag header matches "1"

Verdict: PASS

Expected: status 200, ETag: "1"

Actual: status=200, ETag=""1""

B3 — part B — PUT with If-Match: "1" (matching current) → 200

Verdict: PASS

Expected: status 200, version=2, ETag: "2"

Actual: status=200, version=2, ETag=""2""

B4 — part B — PUT with If-Match: "99" (stale) → 409

Verdict: PASS

Expected: status 409, body { error: "version_conflict" }

Actual: status=409, body={"error":"version_conflict","currentVersion":2}

B5 — part B — DELETE with If-Match: "99" (stale) → 409

Verdict: PASS

Expected: status 409, body { error: "version_conflict" }

Actual: status=409, body={"error":"version_conflict","currentVersion":2}

B6 — part B — DELETE with correct If-Match: "2" → 204

Verdict: PASS

Expected: status 204, empty body

Actual: status=204, body=(empty)