# 安全边界与已实现保护 本文描述 0.2.0 的 P0/P1/P2 保护及其边界。模型产生的 UI、历史 metadata、控件输入、Remote 请求与流式 checkpoint 均视为不可信数据。不以“模型应该遵守提示”代替执行前验证。P3 的独立审查和实际修复见 [VALIDATION](VALIDATION.md)。 相关实现入口: - [shared/safe-json.ts](../shared/safe-json.ts):JSON 预检查与资源预算。 - [shared/schema.ts](../shared/schema.ts):严格结构验证和文档语义检查。 - [shared/expression.ts](../shared/expression.ts):受限表达式解释器。 - [shared/state.ts](../shared/state.ts):共享状态值域和原子更新。 - [host/ui-generator.ts](../host/ui-generator.ts):工具参数与规范化输出验证。 - [client/metadata.ts](../client/metadata.ts):历史结果 envelope 检查与 Client 重新验证。 - [client/component-registry.tsx](../client/component-registry.tsx):固定 Renderer 与 Markdown 策略。 ## 信任边界 | 输入或能力 | 当前边界 | | --- | --- | | 模型提交的 `document` | 必须通过安全 JSON 预检查、Zod 严格 Schema、语义检查和默认计算检查。 | | 工具输出或策略替换后的 presentation value | Host 生成 presentation metadata 时再次验证文档及配置字节上限。 | | Client 收到或从历史恢复的 metadata | 先检查固定 envelope,再独立调用 `parseDocument`,不沿用 Host 的信任结论。 | | 输入框、滑块、Select、按钮动作 | 只能更新已声明状态;范围、类型、步长、选项域与总大小统一校验。 | | 组件选择与布局 | 固定的 21 个组件白名单,没有按名称动态 import 或任意标签工厂。 | | 本地计算 | 只有 18 个明确支持的运算;仅能读取文档自己的状态和派生值。 | | 本地动作 | `set/reset` 只更新本地状态;没有任意网络、URL导航或系统命令动作。 | | 外部动作 | DSL1.1的 `invoke` 只能引用声明动作;Host重新确认来源、主体、Session、capability、参数、工具策略与审批。 | | Harness Host 服务和凭据 | 不放入组件上下文;Client只得到当前Session作用域的有限Action API,不得到密钥或任意工具执行器。 | 文档和表达式没有读取文件系统、环境变量、浏览器存储、Harness 密钥或任意上下文属性的接口。模型也不能通过 UI 类型名指定 JavaScript 模块、CSS、DOM 事件源代码或 SVG 路径。 ## 验证流程 ### 1. 先检查 JSON,再调用递归 Schema `assertSafeJSON` 使用有界的显式遍历,并读取属性描述符,避免在遍历时执行普通对象上的 getter。它在 `JSON.stringify` 和递归 Zod 解析之前执行。 接受范围是普通 JSON 值:null、有限数值、布尔值、字符串、普通对象、稠密数组。对象原型仅接受 `Object.prototype` 或 null,数组只接受标准数组原型。 以下输入会被拒绝: - `undefined`、函数、BigInt、Symbol、NaN、Infinity。 - Date、自定义类实例、继承额外原型的对象。 - getter/setter、非枚举对象属性、Symbol 属性、带自定义属性的数组、稀疏数组。 - 任意层级的 `__proto__`、`prototype`、`constructor` 属性。 - 循环对象和重复使用同一对象引用的别名图;合法输入应为 JSON 树。 - 超过字节、字符串长度、值数量、属性数量或深度预算的输入。 预检查不会调用模型提供的 `toJSON` 方法;这样的函数属性本身不属于合法 JSON。工具入口要求 JSON 对象,不接受把 JavaScript 对象构造器、脚本或 JSON 字符串伪装成文档。 这些检查用于处理 JSON 数据和防御不规范调用者,不能被理解为 JavaScript 运行时级沙箱。已经能在可信页面中执行任意代码的恶意插件拥有不同的攻击能力,例如构造带陷阱的 Proxy;当前插件不能隔离同一进程中的任意恶意代码。 ### 2. 严格结构验证 所有文档、节点、属性、状态字段、表达式和动作对象均按封闭 Schema 验证。未知字段不会被默默删除。未知组件、版本、事件属性、动作类型、URL 属性或 HTML 属性都会拒绝。 例如,以下能力不存在: ```text Text.props.dangerouslySetInnerHTML Button.props.onClick(源代码字符串) action.type = "execute_code" action.type = "fetch" Expr.op = "eval" ``` Zod 4 使用 `jitless: true`,Shared 在创建 Schema 前设置该配置,并在解析时明确要求无 JIT 路径。安全主张是“不执行模型生成的代码,且本验证路径不依赖 Zod 的可选代码生成优化”。不主张整个依赖包或最终 bundle 的文本中不存在 `Function`、其他代码生成实现或所有第三方内部机制。 ### 3. 文档语义验证 结构合法之后还会检查: - 节点 ID 合法且文档内唯一;图表点 ID 与表格列键在各自集合内唯一。 - 状态与派生引用存在,派生依赖无环。 - 表达式参数个数、静态类型、数值预算和默认求值结果合法。 - 输入组件绑定字段的类型正确。 - 数值控件与状态定义共用相同的 `min/max/step`。 - Select 初始值和全部选项符合字段限制,共享字段的多个 Select 具有相同选项值集合。 - `set` 目标明确存在,默认动作结果符合目标字段及 Select 域。 - Chart、Progress、格式化 Result 的值始终为数值。 - Table 每一行恰好包含声明的列,不能夹带隐藏字段。 - `structured_ui` 不包含交互组件,Form 不嵌套,文档节点及表达式预算未超限。 成功的文档会深冻结,降低后续代码无意修改已验证结构的风险。冻结不替代 Client 对历史或网络输入的重新验证。 ### 标准 JSON Schema 的作用范围 构建导出的 `schema/ui.schema.json` 是 Draft 2020-12 结构 Schema。它可用于工具说明、编辑器提示和早期类型校验,但无法替代上述跨节点、跨字段、依赖图、默认计算和总资源检查。 **只有通过 `parseDocument` 的文档才可进入 Renderer。** 通过其他 JSON Schema 库,或 TypeScript 类型断言为 `UIDocument`,都不等于获得了这种保证。完整协议见 [UI_DSL.md](UI_DSL.md)。 ## 资源与可用性限制 下表逐项对应当前 `UI_LIMITS`。字节数按 JSON 序列化结果的 UTF-8 大小计数,包含标点和字段名。字符串长度上限使用 JavaScript 字符串长度,即 UTF-16 码元数。 | 常量 | 上限 | 应用范围 | | --- | ---: | --- | | `maxBytes` | 262,144 字节 | 单份 UI 文档;本地完整状态对象也使用同一字节预算。 | | `maxNodes` | 200 | 一个文档的全部 UI 组件节点。 | | `maxUIDepth` | 16 | UI 树深度,根组件为第 1 层。 | | `maxJSONDepth` | 64 | JSON 对象及数组的嵌套,输入根值深度为 0。 | | `maxJSONValues` | 32,768 | 一次 JSON 预检查访问的数据值节点数。 | | `maxObjectKeys` | 4,096 | 单对象属性数;数组的标准 `length` 单独处理。 | | `maxArrayItems` | 4,096 | 通用 JSON 数组长度;组件自身可能有更小上限。 | | `maxStringLength` | 16,384 | JSON 预检查单字符串上限,也是 Markdown 文本上限。 | | `maxStateFields` | 64 | 状态定义字段数。 | | `maxDerivedFields` | 64 | 派生定义字段数,也限制派生引用递归活动集合。 | | `maxExpressionNodes` | 2,048 | 文档内派生与组件/动作值表达式的累计节点预算,含相应字面量。 | | `maxExpressionDepth` | 24 | 单个表达式 AST 深度,表达式根为第 1 层。 | | `maxEvaluationSteps` | 8,192 | 一次全部派生求值或一次单值求值的访问步骤预算。 | | `maxExpressionArgs` | 16 | 可变参数运算的最大参数数目;固定参数运算另行约束。 | | `maxStateStringLength` | 4,096 | 单个字符串状态的默认及最大允许长度。 | | `maxScalarStringLength` | 4,096 | Scalar 字符串及表达式字符串结果上限。 | | `maxNumericMagnitude` | `1e15` | 数值绝对值及算术中间结果绝对值上限。 | | `maxTableRows` | 500 | 单个 Table 的数据行数。 | | `maxTableColumns` | 32 | 单个 Table 的列数。 | | `maxChartPoints` | 200 | 单个 Chart 的数据点数。 | 其他严格 Schema 上限如下: | 项目 | 约束 | | --- | --- | | 文档标题 | 长度 1–256。 | | 标识符 | ASCII 英文字母开头,最长 64,字符集合见 DSL。 | | 必填控件/列/图表点标签、提交标签 | 长度 1–512。 | | 可选标签、Card 标题、Input 占位文字 | 最长 512。 | | `string.maxLength` | 1–4,096 的整数。 | | Select 选项 | 1–100 项,值不得重复。 | | Table 列/行 | 1–32 列,0–500 行。 | | Chart 数据 | 1–200 项。 | | Grid 列数 | 1、2、3、4。 | | Heading 级别 | 1、2、3。 | | Result 小数位、`round` 小数位 | 0–6 的整数。 | | 数值 `step`、Progress `max` | 有限正数,且不超过全局数值绝对值上限。 | | 结构验证诊断 | `UISchemaError.issues` 最多保留前 20 项 Zod 结构诊断。 | Host 配置 `maxDocumentBytes` 默认等于 Shared 的 262,144 字节,只能降低,最小为 1,024。固定 presentation envelope 的 `protocol/version/document` 外壳由 Client 单独检查;它的标点和字段名不会再扣减文档已约定的字节额度。 这些限制降低资源耗尽风险,但没有实现 Worker 隔离、CPU 时间抢占、按 Session 的墙钟生成速率限制或所有设备上的固定帧率保证。一次完整校验仍是同步调用。P1/P2有操作数、代数及流累计字节预算,不能把这些数量限制等同于完整存储保留策略。 ## 受限求值与恢复能力 解释器按固定运算列表执行,没有 `eval`、`new Function`、模型源码编译、任意属性访问、方法调用或动态 import。数值、逻辑运算不进行隐式类型转换;字符串连接也受中间结果长度预算限制。除零、无效幂、NaN、Infinity、越界中间值和超预算都会失败。 `if`、`and`、`or` 具有短路语义。所有引用和分支类型在生成时检查,运行时只计算需要的分支。派生求值对本次已算出的字段复用结果。 Client 正常情况下批量计算派生值;某个派生失败后,节点可以通过 `resolveDocumentValue` 只计算自己使用的依赖,因此无关组件仍可工作。本地 `set` 动作也按需求值,不会因为一个无关的除零字段而阻断“把分母设置回合法值”的恢复按钮。 ## 本地状态:一致性与原子拒绝 数值状态定义是所有绑定控件的共同值域。控件的 `min/max/step` 只能省略或精确重复状态定义,不能为同一状态建立相互矛盾的范围。 有 `step` 时,初始值和每次写入都检查以 `min` 或 0 为基点的步长网格,并处理浮点表示容差。无 `step` 时允许连续数值。Select 的选项域同样应用于全部写入来源,而不只是下拉框自身。 `updateState` 对当前状态和候选状态执行验证,只有候选完整合法时才返回新对象。任何失败都保留旧对象。特别是总状态超过 256 KiB 时,不能通过调用恢复默认值的代码路径继续编辑,造成之前字段静默清空。 对应回归案例使用 23 个字符串字段:前 21 个字段填入 4,096 个中文字符后仍可接受;继续提交第 22 个同样的大字段时明确拒绝;随后填写一个短字段,先前 21 个大字段全部保留。测试同时覆盖对旧版超限状态的再次编辑:明确抛错,不篡改输入对象。 `restoreState` 只面向恢复场景:接受安全 JSON,挑选已声明且满足全部值域约束的保存字段。危险、不完整或旧数据不会触发动作;无法安全恢复时使用默认值。当前 Client 的刷新行为尚未接入交互状态持久化,详见后文。 ## XSS、URL 与浏览器侧能力 React Renderer 从固定注册表取得可信组件实现,没有把模型属性整体展开到 DOM。普通字符串通过 React 文本节点输出,不使用 `dangerouslySetInnerHTML`。 Markdown 使用 `react-markdown`,配置为: ```tsx {text} ``` 没有启用原始 HTML 解析插件。`a`、`img`、`iframe`、`script`、`style`、表单、音视频、SVG 等元素不在 Markdown 白名单中。链接文字可以保留,但没有链接元素和可导航的 href;图片不会产生资源加载。HTML 样式、事件处理器和危险协议都没有对应的输出位置。 Chart 的 SVG 元素由可信代码创建,坐标和路径由已验证数值计算,模型不能直接提供路径、标签名、外部引用或事件属性。表单提交调用 `preventDefault` 并派发声明的本地动作或有限invoke,没有action URL。模型ID作为React key和数据标记;label/input关联ID由React `useId`生成。 DSL不开放跨源fetch或URL导航。P1仅通过Harness当前Session的正式Remote提交动作;目标能力由可信Host配置,不从模型URL、工具名或Client确认标记获得。链接与命令文本可以作为普通内容显示,但渲染本身不会执行它们。 ## 错误隔离与日志 生成失败、错误工具结果、不合法或未知版本的历史 metadata 不会进入交互式渲染,而是显示局部失败提示。尚未完成的工具参数只显示生成状态,不提前执行或渲染部分文档。 文档级和节点级错误边界隔离渲染失败。状态 revision 改变后允许失败节点恢复。输入或动作错误保留已接受的状态;计算错误不会修改状态。 Client 错误边界不会把模型文档、组件 props 或 Harness 上下文输出到日志。普通错误文案也不会附带密钥或服务对象。原生 Harness 工具记录仍会包含模型主动提交的文档内容,因此不要把秘密放入生成文档;插件不会自动识别和删除所有可能的敏感文本。 ## 历史恢复与副作用边界 完整文档通过 Harness 原有工具结果与 presentation metadata 随 Session 记录保存。Client 利用成功的 `tool/result` 和相应的 PTC 工具结果重建对话中的 UI,并保留 Harness 原有工具记录用于检查。历史数据不是例外:每次进入 Renderer 前都要重新验证。 UI保存和恢复**文档结构及声明的初始值**。当前输入和未提交草稿不跨刷新持久化;同一generation流式更新保留有效值,刷新使用初始值。明确点击提交的参数另行保存于操作账本,不能误称为“所有输入均不保存”。 渲染、默认计算、历史恢复、重连和热启用均不派发动作。恢复时只读查询真实回执,不重新执行,也不重放历史resultMap。只有新的明确手势允许提交,网络重试复用同一requestId。每次本地键盘编辑不会写入操作账本。 文档受Harness Session保存、删除和访问控制影响。动作与流状态使用正式storage-domain,包含明确提交的参数和操作事实,需要与Harness数据一并保护;不是加密保险库。插件不自动同步这些数据,也不清理用户历史。 ## P1:授权、审批与重放 `intelligent_ui`只创建数据,不执行其中动作。外部动作默认关闭,管理员显式配置capabilityId→已注册toolName、风险与resultFields。模型只能引用capabilityId,不能命名任意工具或降低风险。管理员应按真实副作用标记risk;插件不能证明一个被错误标为read的第三方工具没有副作用。 Remote同时声明Session资源并检查实际`ctx.invocation.peer.principal`。请求只包含documentRef、requestId、actionId和values;Host从当前Session的真正工具结果取回文档,核对工具名、call、generation和revision。浏览器自报的文档、主体验证标志和权限均不构成授权。 Host按已验证值重新计算参数,限制为平面标量字典,再使用目标工具的真实参数Schema。结果映射只允许管理员开放的canonical标量字段;不回传整个工具结果、堆栈、Agent上下文或环境。公开错误是有界通用信息;模型修复文档的诊断仅给有界结构路径与原因,不回显未知属性值。 操作在排队前落盘,ID绑定主体、Session和requestId;相同请求只查询已有记录,不再排队。复用ID但修改输入安全拒绝。bridge必须由匹配的真实Agent turn调用,只能原子认领一次。它通过`tools.execute`和真实parent/rootCallId进入现有权限管线,不直接调用工具定义。额外guard阻止模型在保留动作turn中替换目标。 所有write capability必须经过真正的`approval.request`,并在用户确认之后重新检查工具可见性、capability、Schema与参数。缺少审批服务、无合法turn、拒绝或取消均不能默认允许。UI自行写出的“已成功”属于不可信文案;独立操作面板的状态来自Host回执。 运行中取消只表示请求停止,不能推断外部效果已撤回。重启后的运行中操作标为unknown,不自动重试;callback入队不确定时也不能假称未执行。accepted仅表示Agent请求入队成功。日志包含原生bridge调用/审批和插件账本,不伪造新的Session事件。 账本默认最多2048项、每Session128项,容量到顶拒绝新增;不会通过自动驱逐旧记录破坏幂等。单Host存储不是跨主机共识或外部事务,不提供通用分布式exactly-once。清空账本后旧去重保证不再成立。 ## P2:流顺序、增量安全和复合预算 open/patch/complete/cancel均是正常Harness工具,继续受Agent scope和tools guards限制。每帧携完整合法snapshot;patch只允许节点add/update/remove,没有对象路径或可执行补丁。先验证候选树,再保存;非法patch保留旧快照。 sourceCallId来自真实tool call,generation由Host发放;每步核对Session、generation、baseRevision与运行epoch。同代state、derived、actions、Select域、root identity和已有节点type不能变更。invoke在complete前关闭,避免用户对正在变化的操作定义授权。 重复相同帧可忽略,同seq不同内容拒绝;迟到帧不能更新终态。完整checkpoint可恢复缺失帧,但不能改变固定声明或倒退版本。Host另保存全代有界call身份,防止非相邻重复callId携新内容重新生效。重启后旧epoch不能继续patch,历史只恢复显示。 | 独立预算 | 上限 | |---|---:| | 动作定义 | 16个 | | 单次请求(含values) | 32KiB | | 工具平面参数 | 32个字段、16KiB | | 公开结果 | 32个标量字段、32KiB | | 单个回执 | 36KiB | | 单个capability描述 | 64KiB;Host配置最多32个 | | Agent callback文本 | 4096个UTF-16码元 | | 单个stream checkpoint | 文档256KiB + 外壳4KiB | | 单次mutation | 64KiB | | 每代checkpoint /累计字节 | 256帧 /16MiB | | 默认持久化generation数量 | 256个,可配置上限2048 | 集合先验证固定数组形状、长度和own descriptors,再对各条目按独立预算验证。Host存储的指纹/计数/调用身份与嵌套checkpoint分层检查,不能把内部记录的额度传给模型文档验证器而扩大其权限或错误拒绝空列表。精确256KiB文档和有界复合记录有独立回归。 在同代流更新中保留组件ID、DOM、焦点与有效输入;不执行任何事件。完整验证与同步计算仍受现有CPU/规模边界,不承诺任意设备帧率。组件异常不影响整个聊天页面。 ## 其他边界与验证证据 安全的数据驱动 UI 仍可能包含不准确、有误导性的文字或错误业务假设。Schema 可以阻止执行不可信代码,但不能证明模型分析正确或界面文字可信。插件也不保护被攻破的 Harness、浏览器或同一进程内的任意恶意扩展。 当前支持DSL1.0/1.1与组件级Streaming;未知版本拒绝或局部错误。没有自动跨版本迁移、逐Token编译器、跨设备草稿同步或恶意插件进程隔离。 回归包括shared Schema/Action/Stream/AJV、Host Gateway/账本/bridge/stream、Client Remote/dispatcher/assembler/renderer,以及独立P3边界审查。真实Harness进一步验证原生工具、批准与拒绝、重复请求、流式状态保留和恢复;真实模型验证与确定性基础设施测试分别记录。测试是已知路径的证据,不是所有攻击的形式化证明,最终数量和实际执行结果见[VALIDATION](VALIDATION.md)。