续上轮(banner+README),把 broader/沙箱档里 gamedef-当现行/迁移中清成「已废错误路线、 A-model 现行」(opus 代理 + 我补漏): - 00-系统总览:左轨现行 gameDefinition→A-model 写真 src/,无迁移/cutover - 03/架构产物沙箱:scanLogic(behavior.code/rule.condition 扫描)、new Function/unsafe-eval 标为已废 gamedef 路 build 段机制,A-model 真 src/ 经 esbuild 无 new Function; **沙箱层(iframe/CSP/桥/九门)对两路通用、保留**;装载路 L146 改 A-model 现行 - OpenGame对照:L325「当前实现 gamedef」→已废、L318 smart_edit 注 A-model 文本补丁回归 - 验收门:九门收窄 91.7% 标为 gamedef 路历史实测 + 该验收策略引擎无关对 A-model 适用 - 数据飞轮:去「待 gamedef→src 迁移协调」(无迁移,gamedef 已废) 门:全仓死链 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
25 KiB
产物执行沙箱
这是什么:一款 AI 生成的游戏是不可信代码,本文讲它怎么在玩家浏览器里被隔离着安全跑起来——取包、sha256 完整性校验、srcdoc 注入、iframe + CSP 隔离、postMessage 双向桥双校验、SDK 受控能力面、生命周期回流,一条端到端链路。 给谁看:要改宿主 / 桥 / CSP / 校验逻辑的前端工程师,做安全审计的人,想搞清"生成出来的游戏能调什么、不能碰什么"的产品与架构。
绘境AI 的生成主线已经从"禁代码、填参数"迁到"让 agent 写码"。游戏逻辑不再是一组受控的配置字符串,而是模型现写的 JavaScript 本体——注入面就是代码本身(.agents/rules/security-and-reliability.md §1.1 记的这次口径迁移)。平台必须把每一款生成游戏当成有恶意、有 bug 的不可信输入来对待:它可能想读宿主的 localStorage、想发网络请求把玩家数据外带、想用一个同步死循环冻死页面。这套执行沙箱就是兜住这些的那道边界。
它和另一条信任边界——"插件↔引擎"受控面(api.d.ts 的 PluginContext,讲游戏代码经哪些受控方法调引擎能力)——是两条正交的边界,别混。受控面管的是"游戏代码调引擎时只能走公开 API",活在 iframe 内部;本文这套沙箱管的是"整个 iframe 与外面的宿主平台之间的隔离",是 iframe 这层壳本身。受控面在沙箱里面,沙箱在受控面外面。软著 docs/ip/ruanzhu-5-game-runtime 和专利 docs/ip/patents/01-受控插件引擎 写的是前者(受控插件面),不是本文这套 iframe 产物沙箱,引用时不要张冠李戴。
现状是 🟡:iframe + 桥 + 双校验 + sha256 这套机制都已建成、真跑通过,但有三处真实债没填——CSP 文档口径和代码已经对不上、sandbox 属性三处不一致、后端下发的 sandboxAttr 前端根本没接线;而且整套现在跑在同源过渡态(同源 srcdoc + allow-same-origin),HJ-AUDIT-001 那条"独立源就绪前禁用 allow-same-origin"的红线当前并未在代码里强制。这些债在 §8 逐条带 file:line 记清楚,读完它再判合规,别看到"机制都建了"就以为已经安全收口。
1. 端到端链路:一款不可信游戏怎么跑起来
一款游戏从后端取包到在玩家面前可玩,要穿过取包、校验、注入、隔离、建桥、引导、回流这一串环节。每一环都假设上一环的产物可能有问题。
sequenceDiagram
participant BE as 后端 AppRuntimeController
participant GP as GamePlayer.vue
participant IJ as inject.ts
participant IF as iframe(srcdoc · origin=null)
participant BR as HostBridge
participant UP as 上层 Play/Feed
GP->>BE: GET /package/{versionId}(取运行包清单 + 沙箱策略)
BE-->>GP: RuntimePackageRespVO(manifestUrl · checksum · sandbox.allowOrigins)
GP->>BE: GET /package/{versionId}/manifest(取原始 JSON 文本)
BE-->>GP: manifest.json 原始字节(String,不包 CommonResult)
Note over GP: fetchAndVerifyManifest:对原始文本算 sha256<br/>与 checksum 严格比对,不过即退 demo 兜底
GP->>IJ: buildIframeSrcdoc(pkg, traceId, gameId, versionId)
Note over IJ: 函数序列化注入 + engineBundle 独立内联 script<br/>硬编码 CSP 写进 srcdoc meta
IJ-->>GP: 完整 HTML 文本
GP->>IF: <iframe sandbox srcdoc>挂载
GP->>BR: attachBridge(buildOriginAllowlist,含 'null')
IF->>IF: boot():分流 engineBundle / demo
IF->>BR: postMessage(game_loaded / game_start,经双校验)
BR->>UP: emit('ready') / emit('start')
IF->>BR: postMessage(game_end,宿主轮询 latch 代发)
BR->>UP: emit('end')
取包这一步在 GamePlayer.vue 的 resolvePackage(:131-176)分四态走。创作者预览时,props.manifest 直接传一个内存里的包进来,不走网络(:133);玩家试玩时按 versionId 调 getRuntimePackage 拿运行包清单,清单里带 manifestUrl 就去 fetchAndVerifyManifest 拉真包并做 sha256 校验(:152-154);清单没有 manifestUrl(mock 阶段后端还没回填)就退 buildDemoPackage 兜底(:169);取包或校验中途出任何错,也退 demo 兜底,只 console.warn 记一笔、不向上 emit error(:143-148、:155-163)。最后这条不向上抛错是有意的:父级 Play 把 error 当致命态处理,会用 v-else-if 把宿主整个隐藏掉显示"加载失败",而 demo 兜底本可以正常试玩,两者冲突,所以可恢复的失败只降级不上抛。
沙箱白名单 allowOrigins 在 launch(:184-197)里顺带读出来——只在用默认取包器时,从 respVO.sandbox?.allowOrigins 取(:191-192)。拿到包之后 launch 调 attachBridge(:228-250),用 buildOriginAllowlist(pendingAllowOrigins, includeNull=true) 建桥。
加载超时按装载路分流。引擎包路 ENGINE_LOAD_TIMEOUT_MS=12000(:90),旧 demo 路 LEGACY_LOAD_TIMEOUT_MS=5000(:85)。引擎冷启动要 4.8~5.3 倍于旧路、加上内联 bundle 解析会逼近甚至超过 5s,所以引擎路放宽到 12s 防误杀;首屏 P75<3s 由 perf_first_screen 单独监测,和这个防挂死上界正交(:86-89)。
2. 三重边界:游戏能调什么、不能碰什么
不可信代码被三层封堵关在笼子里。这三层在不同时机、不同位置生效,合起来才是完整的封锁。
flowchart TB
subgraph OUT["iframe 外 · 宿主平台(玩家数据 / 网络 / 真 localStorage)"]
SDKH["SDK 受控面回包<br/>ad / pay / storage / track"]
ENG["引擎能力<br/>ctx.getEngine() · render(mainContext)"]
end
subgraph IF["iframe 内 · 不可信生成代码"]
G["游戏逻辑(模型现写 JS)"]
end
G -->|✅ 唯一对外通道 postMessage| SDKH
G -->|✅ 受控面调用| ENG
G -.->|❌ CSP connect-src none| NET["fetch / XHR / 网络出站"]
G -.->|❌ CSP default-src none + sandbox| DOM["宿主 document / window 逃逸"]
G -.->|❌ sandbox 无 allow-top-navigation| NAV["顶层导航 / 弹窗"]
B1["build 段 scanLogic 静态门<br/>编译前拦 Function/eval/import/fetch/window/while(true)…"] -.->|出厂前已净化| G
第一层在 build 段,不在 runtime——但它属已废 gamedef 路的 build 段机制。 game-runtime/src/host/build-from-source.mjs 的 scanLogic(:73)在把 gameDefinition 装配成可玩工厂、交 esbuild 打包之前,对模型产出的 behavior.code 和 rule.condition 做静态扫描。这套扫描是 gamedef 专属的:它针对的 behavior.code / rule.condition 串字段只存在于 gameDefinition,而 A-model 写真 src/ 多文件没有这类逻辑串字段,故随 gamedef 废弃而不再是现行路径。LOGIC_BANS(:42-63)是实际的拦截清单:沙箱逃逸类拦 process、require、动态 import(、globalThis、eval(、.constructor、Function(;DOM 与网络类拦 document、window、fetch(、XMLHttpRequest、addEventListener、requestAnimationFrame;死循环类拦 while(true)、while(1)、for(;;);还有确定性逃逸类拦 Math.random、Date.now、new Date、performance.now(取证要可复现,逻辑里只许用 rt.random / rt.time.now)。rule.condition 另有专属禁则 CONDITION_BANS(:65-70):它必须是一个无副作用的布尔表达式,所以拦 ;(不许是语句)、拦赋值 =、拦调世界变更方法 addScore/setScore/spawn/destroy、拦 win()/lose()。命中任何一条就转成 validationError 回灌给 repair 反馈,而不是靠 prompt 自觉。这层为什么必须在 build 边界做:同步 JS 没法在进程内硬中断,死循环和逃逸一旦执行就晚了,所以在执行前的校验边界把危险模式拦掉(:13-15 的信任模型注释)。
第二层是 iframe 的 CSP。 inject.ts(:80-81)把 CSP 硬编码进 srcdoc 的 <meta> 标签,内容是 default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; style-src 'unsafe-inline'; img-src data: blob: https:; connect-src 'none';。connect-src 'none' 是这层最关键的一条——它禁掉一切网络出站。引擎 bundle 不在 iframe 内 fetch,而是由宿主层 fetch 后内联进来,所以 connect-src 'none' 不会卡死引擎装载(:72-73)。default-src 'none' 兜底封掉其余所有未显式放开的资源类型。这里有一处和文档对不上的真实债:script-src 实际是 'unsafe-inline' 'unsafe-eval',不是文档写的 'self',原因和承接见 §8 漂移①。
第三层是 sandbox 属性,以及由它决定的唯一对外通道。 iframe 带 sandbox 属性隔离,游戏与宿主平台之间只有 postMessage 一条通道。这条约束直接决定了产品形态:广告、支付、存储这些要碰平台资源的事,游戏自己在 iframe 内办不了——它没有网络(connect-src 'none')、碰不到宿主 DOM(sandbox)。所以这些全部经 postMessage 抛给 iframe 外的宿主侧去办,宿主办完再把结果回包进来。这就是为什么 SDK 的 ad/pay/storage 都是"游戏发请求→宿主侧执行→回包"的形状,而不是游戏直接调。
3. postMessage 双向桥与双校验
桥是 game-studio/src/host/bridge.ts 的 HostBridge 类。游戏与宿主之间的每一条消息都走它,而它把 iframe 来的消息当不可信边界处理:先过 origin 闸,再过 schema 闸,任一不过就丢弃,绝不分发。
flowchart LR
M["iframe postMessage 来一条消息"] --> O{origin 在白名单?}
O -->|否| R1["onReject('origin_not_allowed')<br/>丢弃"]
O -->|是| S{schema 逐字段校验过?}
S -->|否| R2["onReject(reason)<br/>丢弃"]
S -->|是| P{是宿主请求的回包?<br/>requestId 配对命中}
P -->|是| RP["消费 pending 回调<br/>clearTimeout + resolve"]
P -->|否| D["分发给上层 GamePlayer"]
onWindowMessage(:108-139)是入口。第一道 origin 白名单(:113),isOriginAllowed(:142-145)对空白名单默认拒绝——白名单为空视为不允许任何来源,这是安全默认。第二道 validateEnvelope(:151-199)逐字段校验信封:channel 必须严格等于固定标识 wanxiang-game-sdk(:162,否则是噪声或伪造),type 必须在 8 个枚举的 VALID_TYPES 内(:166),direction 必须合法(:170),traceId 必须是字符串(:177,贯穿链路必备),payload 必须存在(:181,只挡 undefined),requestId 若带则必须是字符串(:185)。两道闸任一不过都走 onReject 记录并丢弃(:114、:122)。这里有个细节是有意的:validateEnvelope 不抛异常,只返回判定结果——注释 :149 写得明白,"异常本身也是一种攻击面"。
消息过了两道闸,还要看是不是宿主先前请求的回包。onWindowMessage(:129-135)检查 requestId 是否命中 pending 表,命中就清掉定时器、resolve 对应的 Promise、消费掉,不再往上分发。不是回包才 onMessage 给上层(:138)。
宿主往游戏发消息用 post(:206-224),targetOrigin 用 '*'——因为 srcdoc 文档的 origin 是 null,没法精确指定(注释 :205、:219)。request(:230-244)是 Promise 化的"请求-响应",带 5s 超时(REQUEST_TIMEOUT_MS),超时就清理并以 undefined 兜底 resolve,绝不 reject(:233-237)——向上抛 reject 会打断游戏,所以宁可降级。dispose(:250-255)在 GamePlayer 卸载时移监听、清所有挂起请求的定时器,防泄漏。
桥能识别的 8 种 type 与方向如下(SDK_CHANNEL 恒为 wanxiang-game-sdk):
| type | 方向 | 用途 |
|---|---|---|
| init | host → game | 宿主下发初始化(游戏侧自启动兜底,幂等) |
| lifecycle | game → host | game_loaded / game_start / game_end 回流 |
| track | game → host | 遥测埋点上报 |
| error | game → host | 游戏侧错误上报 |
| ad | 双向 | 游戏请求广告 → 宿主弹桩层 → 回包 |
| pay | 双向 | 游戏请求支付 → 宿主弹桩层 → 回包 |
| storage | 双向 | idle 离线存档读写(经宿主受信边界) |
| ready | game → host | SDK 就绪信号 |
4. SDK 受控面契约
iframe 内的游戏只能经一个全局对象 window.WanxiangGameSDK 与外界打交道,加上引擎装载契约(bootGameHost / render(mainContext) / ctx.getEngine())。受控面之外一律被 CSP 和 sandbox 封死。受控面实现在 game-studio/src/host/sdk/index.ts,暴露 init / on / track / reportError / ad(:126)/ pay(:161)。
每个受控面都守一条降级铁律:异常绝不向游戏抛、超时就降级、写类操作 fire-and-forget。init(:181)注入 traceId 贯穿"生成→运行→上报"全链路;track(:203)是 10 条 / 5s 的批量缓冲;ad / pay 经桥的 request 走 5s 超时降级,超时静默兜底而不打断游戏。这条铁律的根源在玩家验收门那五条 AND 里的"无错"——游戏运行期一旦抛未捕获错误就判不过,所以受控面任何意外都得自己吞掉。
storage 这个面尤其要小心,因为它直接写宿主的 localStorage。GamePlayer.vue 的 handleStorage(:337-396)设了四道闸:
- 闸①·key 白名单:
key必须恰好等于idle:gameId:versionId(:342-343),其它一律丢弃。tycoon 这类无离线态的模板根本不发 storage,白名单也不含它的前缀。 - 闸②·大小上限:
value长度超 4KB 丢弃(:353),防滥用 localStorage 配额(idle 存档实际不到 200B)。 - 闸③·JSON 校验:写入前
JSON.parse校验是合法 JSON 才落(:359-365);回读时也校验、且只接受对象形态(:382-385),防有人手工篡改 localStorage 注入脏数据。 - 闸④·命名空间隔离:落盘时键加
wxgame:前缀(:348),不污染其它 localStorage。
读分支有个设计点:回包给游戏的是宿主侧已经解析好的对象,不是原始字符串(:373-374)。解析在宿主这道受信边界做,runtime 侧零 JSON.parse,守住"游戏代码不引可抛路径"的红线。整个 handleStorage 任何意外异常都静默吞、不崩宿主(:391-394)。
ad / pay 的桩弹层(:402-464)按 requestId 配对回包,把广告 / 支付请求弹成一层桩 UI,玩家点完再把结果回包给游戏。生命周期 handleLifecycle(:294-319)把 game_loaded 翻成 phase=ready 并清掉加载超时,把 game_end 翻成 emit('end')。
5. 完整性校验:sha256 双通道
manifest 从后端拉下来后,要确认它在传输途中没被篡改、和后端写包时算的 checksum 一致。inject.ts 的 sha256Hex(:335-348)走双通道:安全上下文(https / localhost)优先用原生 Web Crypto SubtleCrypto,性能最好(:339-345);非安全上下文回退一份纯 JS 的 SHA-256 实现 sha256BytesToHex(:356-413,FIPS 180-4 完整 64 轮压缩)。
为什么要这份纯 JS 回退:创始人用局域网 IP 明文 http(比如 http://100.64.0.7:4173)跨内网机访问时,浏览器判定为"非安全上下文",crypto.subtle 直接是 undefined(:322-330 注释)。旧实现这时会抛错,导致 fetchAndVerifyManifest 失败、退 demo 兜底,而 W-CLEAN 之后 demo 是"模板体系重构中"并发 game_error,最终页面误报"游戏加载失败 / 网络波动"——真因是上下文不安全,根本不是网络。回退纯 JS 后,完整性校验在明文 IP http 和安全上下文下行为一致,整款游戏都能正常装载。两通道对同一份 UTF-8 字节摘要,结果逐字节一致。
校验链路两端对齐:后端 PackageFactory 写包时算 checksum 落库,前端宿主拉 manifest 时验。后端这边 AppRuntimeController.getPackageManifestRaw(:81-96)返回的是 manifest 原始 JSON 文本——返回类型是 String,GlobalResponseBodyHandler 只拦 CommonResult 不拦它,StringHttpMessageConverter 原样写出字节、不重序列化,保证字节一致性(:88-89)。前端 fetchAndVerifyManifest(:450-482)对拿到的原始文本算 sha256 与清单的 checksum 严格比对(:464-468),一致才返回真包,否则抛错由 GamePlayer 退 demo 兜底。这条端点 @PermitAll 匿名可取(:76),门禁压在 Service 层做 scene+status 判定,匿名宿主能取 manifest 试玩。整个拉取只是 GET 拿数据、无脚本执行,拿到的 GamePackage 只作数据注入 iframe,不会被当可执行内容求值(:442-443)。
6. srcdoc 函数序列化注入
iframe 的内容由 inject.ts 的 buildIframeSrcdoc(:45-50)拼出来,作为 srcdoc 注入。它有一个不常见的手法叫"函数序列化注入":createWanxiangSDK 和 startRuntime 都是不依赖模块外部符号的纯工厂函数,这里用 Function.prototype.toString() 取它们的源码文本(:52-53),拼进 iframe 的内联 <script> 里在沙箱内重新求值执行。这样同一份 TS 源既被宿主工程类型检查(和 SDK / Runtime 契约对齐),又在 iframe 内真跑,不用为它们单独配打包产物(:7-14)。代价是工厂函数内不能出现只存在于 TS 的运行期符号(import 的值、装饰器、枚举值引用),现实现都满足。
引擎包分支在 boot()(:201-282)里按 pkg.engineBundle 是否存在分流。有 engineBundle 时,bundle 文本作一个**独立的内联 <script>**注入(:63-65),暴露 window.__GameBundle——这个全局名是契约冻结的,和 game-package.schema.json、contract.ts、entry-bundle.js 逐字一致。随后引导脚本调 window.__GameBundle.bootGameHost({canvas, seed}) 启动引擎掌帧,把"引擎就绪 / 游戏终态"桥接成 SDK lifecycle 的 game_loaded / game_end(:252-260)。没有 engineBundle 时走旧 startRuntime 兜底,显示"模板体系重构中"(:277-281)。
bundle 是可执行脚本,内联进 <script> 唯一的注入面就是它可能含能提前闭合脚本块的字面序列,所以经 escapeBundleForInlineScript(:313-317)断掉 </script>(含大小写变体)和 <!--,让它无法越出 <script> 边界。这是纵深防御——bundle 文本来自后端 engineBundle、整包 sha256 已覆盖其完整性,不是用户自由输入。
游戏没有自己的 emit 通道发 game_end,所以宿主轮询代发。watchGameEnd(:216-229)每 500ms 查 host.state().phase,到 gameover 就 latch 一个 game_end{completed:true}(:221-225),带上 gameoverReason 便于取证定位,并用 gameEndEmitted 幂等守卫防重复发。这是过渡口径:ref 游戏是纯引擎工厂、内部没 SDK,自发不出 game_end,只能由引导经装载契约 host.state() 探测终态后代发;W-G1 生成主线落地后,game_end 改由各品类标准信号产出,这个轮询桩可下线(:212-215)。
7. 两条装载路共用一套沙箱
绘境AI 有两条游戏装载路,装载形态不同,但执行沙箱是同一套。Tier0/1 的廉价线走 engineBundle 内嵌:现行由 A-model 写真 src/ 多文件、经 esbuild 打成 bundle、内联进 iframe(原 gameDefinition 中间表示经 new Function 解释那条路已于 2026-06-21 判错误路线、废弃,见生成引擎子树 README §3.2;无论早期 gamedef 还是现行 A-model 真 src/,最终都构建成同一形态的 bundle,本档这套沙箱不受影响)。tier2 的 Phaser 真工程走另一条:经独立源项目契约构建出 bundle(tier2实现详设.md:10 点到 tier2 复用同一装载路)。但两条路出来的 bundle 都暴露 window.__GameBundle、都调 bootGameHost、都跑在同一套 iframe + CSP + 桥里。
从沙箱视角看,这意味着不管哪条装载路,执行隔离的边界、CSP 的封锁、桥的双校验、__GameBundle 全局名和 bootGameHost 入口约定全部一致。tier2 换的是"游戏内容怎么造出来",换不掉"造出来之后怎么被关进笼子跑"。引擎宿主侧 game-runtime/src/host/boot-game-host.js 的 bootGameHost(:75-460)在 real 通道关掉自身 GL、由引擎五回调掌帧、绘制面用引擎的 mainContext,游戏经 boot.ctx.getEngine() 拿能力、零直接 import 引擎——这套对两条装载路是公共的。
8. 现状与已知债
机制建成不等于合规收口。下面四条债逐条带 file:line,审计和后续收敛照它走。
漂移①·CSP 文档 vs 代码。 .agents/rules/security-and-reliability.md §1.1(:31)、skills 手册、契约注释都写 script-src 'self',且说 CSP 经游戏包托管侧 HTTP 响应头下发、不以 meta 为唯一依赖。但 inject.ts:81 实际是 script-src 'unsafe-inline' 'unsafe-eval',且就硬编码在 srcdoc 的 <meta> 里。'unsafe-eval' 是 gamedef 运行时必需的:声明式 gameDefinition 的 behaviors / rules 各带逻辑 JS 串,gd-runtime.js 用 new Function(:248、:263)编译执行,缺它会抛 EvalError 被静默吞进 errors[]、tickBehaviors 空转、游戏只渲首帧静态、世界永不演进(bug#2 实证 2026-06-21,A/B 加后 errCnt 7→0、score 0→演进)。文档口径已过期,以代码为准。'unsafe-eval' 的风险由纵深防御承接:逻辑串先经 scanLogic 静态拦危险面,加 connect-src 'none' 无网络出站,加 iframe sandbox 隔离,三重边界使它不扩大真实攻击面。gd-runtime.js 的 new Function 本身只有 try-catch 隔离、没有静态扫描,但危险面已经在 build 边界被 scanLogic 拦掉了——这是"build 时静态拦截 + runtime 执行已净化代码"的纵深防御(inject.ts:71 注释提到 scanLogic 略有 build/runtime 混称,但实质成立)。
漂移②·sandbox 属性三处不一致。 DB 列 game_runtime_package.sandbox_attr 默认值是 'allow-scripts'(V3.0.0__create_game_runtime.sql:67,scaffold 脚本数据也是 'allow-scripts');前端 iframe 硬编码的是 'allow-scripts allow-same-origin'(GamePlayer.vue:544);文档和契约示例写的也是 'allow-scripts allow-same-origin'。三处不同源。
半孤儿③·sandboxAttr 下发但前端不消费。 后端确实填充并返回了 SandboxPolicyVO.sandboxAttr——RuntimeConvert.java:66 有 sandbox.setSandboxAttr(pkg.getSandboxAttr()),前端 contract.ts / types.ts / mock 也都声明了这个字段。但 GamePlayer 从不把 sandboxAttr 应用到 <iframe sandbox=> 元素上,它只消费 allowOrigins(:191-192)。也就是说 sandbox_attr 这条 DB→VO→前端的链在"属性维度"是没接线的半孤儿,实际生效的是前端 :544 那个硬编码值。这直接呼应 HJ-AUDIT-001:那条红线想靠下发的 sandboxAttr 在独立源就绪前去掉 allow-same-origin,而当前下发根本没被消费,红线在代码里没强制。
HJ-AUDIT-001 同源红线当前是过渡态。 红线(.agents/rules/security-and-reliability.md §1.1:30)说 allow-same-origin 只有当游戏包托管在独立源(usercontent 子域、与宿主不同源)时才能用,因为同源下 iframe 能触宿主 DOM / 存储、甚至自己移除 sandbox。但代码现状是:同源 srcdoc + allow-same-origin(:544),且 origin 白名单还把 'null' 含进去(buildOriginAllowlist 在 includeNull 时加字符串 'null',因为 srcdoc 文档 origin 就是 null)。这是已知的、独立源落地前的过渡态,不是已合规。别因为机制都建了就误判它安全收口。
收敛方向。 三条收敛要一起做才闭合:游戏包迁到独立的 usercontent 子域(让 allow-same-origin 真正安全)、CSP 从 srcdoc meta 改成游戏包托管侧的 HTTP 响应头下发、把 sandboxAttr 这条链在前端真接线(让 :544 的硬编码值改由后端下发的策略驱动)。三者落地后,§8 这几条债才算清掉。
9. 关键指针
代码锚:
- iframe 宿主容器 / 取包四态 / storage 四闸 / 加载超时 —
game-studio/src/host/GamePlayer.vue - postMessage 双向桥 / 双校验 / 超时降级 / dispose —
game-studio/src/host/bridge.ts - srcdoc 注入 / CSP / engineBundle 分支 / sha256 双通道 / escapeBundle —
game-studio/src/host/inject.ts - SDK 受控面(init/on/track/reportError/ad/pay)—
game-studio/src/host/sdk/index.ts - 引擎宿主 bootGameHost / real 通道掌帧 —
game-runtime/src/host/boot-game-host.js - build 段静态门 scanLogic / LOGIC_BANS / CONDITION_BANS —
game-runtime/src/host/build-from-source.mjs - 后端取包 / manifest 原始文本端点 —
game-cloud/game-module-runtime/.../controller/app/runtime/AppRuntimeController.java - 沙箱策略组装(SandboxPolicyVO)—
game-cloud/game-module-runtime/.../convert/runtime/RuntimeConvert.java
契约:
- postMessage 协议类型 —
contracts/sdk-interface.d.ts - engineBundle / checksum 字段 —
contracts/game-package.schema.json - SandboxPolicyVO / manifestUrl / GAP-2 取包约束 —
contracts/api-schemas/runtime.yaml - 沙箱列 sandbox_attr / allow_origins —
game-cloud/huijing-server/src/main/resources/db/migration/V3.0.0__create_game_runtime.sql - 装载契约 GameHostBootContext / GameInstance / GameHostFactory —
game-runtime/src/core/game-host.d.ts
关联档:
- 装载链与引擎能力边界 —
生成引擎/引擎与运行时.md(与本档正交:它讲装载 + 引擎能力,本档讲执行隔离 + 安全边界) - 沙箱高层红线 —
.agents/rules/security-and-reliability.md§1.1 - 运行时打包与多渠道手册 —
.agents/skills/runtime-and-multichannel.md - tier2 复用同一沙箱 —
生成引擎/tier2实现详设.md:10