lili 2ce0ed3a9d feat(save-cap): W-SAVE-CAP 存档面定容+schemaVersion——单槽版本信封,北极星三款共享基建
契约先行(sdk-interface.d.ts+镜像 additive):白名单新增恰等键族 save:{gameId}:{versionId}(≤65536 码元,
写入必须 {schemaVersion≥1, data} 信封,宿主闸 fail-closed);idle: 族 4096 逐字不动;弃多 key 分片。
宿主闸抽纯函数模块 storageGate.ts(四闸合一可 node 直测);save-progress 插件 v1.0.0→v1.1.0 additive:
setVersioned/getVersioned 逐级迁移/缺级兜底/降档读新档不毁档/无版本旧档按 0 版进链+探针两计数。
证据:插件单测 18/18、game-runtime 31/31、宿主闸负向演示脚本入仓真跑全断言过、vue-tsc+vite build 绿。
三书 M 门影子存档降级口径解除条件就位(卡牌 M2/割草 M4/夜市 M5 切换)。设计页 docs/plans/2026-07-06-002。
2026-07-06 09:24:44 -07:00

196 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
topic: 产物执行沙箱
canonical: true
date: 2026-06-21
---
# 产物执行沙箱
> **这是什么**:一款 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. 端到端链路:一款不可信游戏怎么跑起来
一款游戏从后端取包到在玩家面前可玩,要穿过取包、校验、注入、隔离、建桥、引导、回流这一串环节。每一环都假设上一环的产物可能有问题。
```mermaid
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: RuntimePackageRespVOmanifestUrl · 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: attachBridgebuildOriginAllowlist含 'null'
IF->>IF: boot():分流 engineBundle / demo
IF->>BR: postMessagegame_loaded / game_start经双校验
BR->>UP: emit('ready') / emit('start')
IF->>BR: postMessagegame_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. 三重边界:游戏能调什么、不能碰什么
不可信代码被三层封堵关在笼子里这三层在不同时机不同位置生效,合起来才是完整的封锁
```mermaid
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 ,任一不过就丢弃,绝不分发
```mermaid
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
W-SAVE-CAP 增补 2026-07-06白名单增第二个恰等键族 `save:gameId:versionId`(复杂游戏主存档,北极星三款起用):单槽上限 65536 码元,写入值必须是 `{schemaVersion: 整数≥1, data: 纯对象}` 版本信封闸内 fail-closed;`idle:` 族行为逐字不变闸逻辑抽成纯函数 `game-studio/src/host/storageGate.ts`( node 直测),`handleStorage` 改为调它;本节原行号引用以现码为准契约 = `contracts/sdk-interface.d.ts` SaveEnvelope;游戏侧配套 = save-progress 插件 v1.1.0 `setVersioned/getVersioned`(逐级迁移+降档不毁档兜底);设计与验收 = `docs/plans/2026-07-06-002-feat-W-SAVE-CAP-存档定容与schemaVersion-plan.md`
读分支有个设计点:回包给游戏的是**宿主侧已经解析好的对象**,不是原始字符串(: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(生成运行时 SoT [`生成引擎/agentic运行时架构图说.md`](生成引擎/agentic运行时架构图说.md) 点明 tier2 复用同一装载路,tier2实现详设已并入该 SoT)。但两条路出来的 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 70score 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` 这条 DBVO前端的链在"属性维度"是没接线的半孤儿,实际生效的是前端 :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`
关联档:
- 装载链与引擎能力边界 [`生成引擎/agentic运行时架构图说.md`](生成引擎/agentic运行时架构图说.md)(与本档正交:它讲装载 + 引擎能力,本档讲执行隔离 + 安全边界)
- 沙箱高层红线 `.agents/rules/security-and-reliability.md` §1.1
- 运行时打包与多渠道手册 `.agents/skills/runtime-and-multichannel.md`
- tier2 复用同一沙箱 [`生成引擎/agentic运行时架构图说.md`](生成引擎/agentic运行时架构图说.md)5.6 源项目契约与装载)