A2UI 用户手势受限函数机制解析:用 Action Context 阻断渲染期函数自动执行
【免费下载链接】a2ui项目地址: https://gitcode.com/GitHub_Trending/a2/a2ui
A2UI 是一套声明式 JSON UI 协议,渲染器(Renderer)的数据上下文引擎会在求值包含函数调用的表达式时自动执行函数。本提案(specification/proposals/user_initiated_functions.md)针对openUrl等具有副作用(导航、剪贴板、修改系统状态)的客户端目录函数,设计了一套Action Context Enforcement with Event Classification(Action 执行作用域 + 激活事件分类)约束机制:通过目录函数元数据requiresUserActivation与渲染器运行时的isExecutingAction/actionIntent状态,保证此类函数只在用户真实意图的激活事件(点击、触摸、提交)作用域内执行,从根源上阻断布局渲染、字符串插值、响应式状态更新与被动事件中的自动调用。读完本文,你将理解该提案的问题模型、完整协议设计、四个平台(Web/Flutter/Android/iOS)的实现方案,以及它在当前仓库 v1.0 规范中的实际落地状态。
1. 背景:A2UI 声明式协议下的函数自动调用问题
A2UI 客户端目录函数(如openUrl)是在渲染器上执行动作的。当前机制下,只要表达式引擎在求值一个包含函数调用的表达式,该函数就会被执行。与具备内联代码执行能力的 JavaScript 方案不同,A2UI 是纯声明式 JSON 协议,LLM 或服务端生成的 Payload 无法直接执行任意脚本、也无法派发合成.click()DOM 事件——但这并不妨碍函数在"非用户主动触发"的场景下被自动执行,提案归纳出三类主要向量:
1.1 初始 Surface 渲染自动触发
服务端或 LLM 的 Payload 可以在组件属性中携带函数调用,而该属性在初始布局构建阶段即被求值。一旦 Surface 渲染完成,openUrl就会在没有任何用户交互的情况下立即执行。
1.2 动态属性插值(formatString)滥用
表达式引擎在组件渲染或模板列表迭代时求值字符串插值表达式。如果 Payload 包含如下文本:
{ "component": "Text", "text": "${openUrl('https://example.com/phish')}" }渲染器会将该openUrl()作为字符串格式化的一部分进行求值,从而在渲染过程中未经用户同意自动打开外部浏览器窗口。
1.3 响应式数据模型重求值
当后台数据同步或状态更新修改了DataContext时,响应式依赖会触发绑定表达式的重新求值,任何绑定到状态更新的函数调用都会自动执行。
1.4 安全与用户体验影响
- 钓鱼与非法跳转:用户未点击任何链接或按钮,就可能被导航到外部网站或被深度链接进原生 App。
- 弹窗拦截冲突:浏览器会拦截非用户手势发起的
window.open,导致运行时错误或静默失败。 - 用户失控:带副作用的行为(导航、复制数据、修改系统状态)未被明确发起,违反了"副作用动作必须由用户意图显式发起"的核心要求。
2. 方案权衡:Pros & Cons
2.1 优势
- 自动调用防护:阻止布局渲染、动态属性插值、后台状态更新以及被动输入事件(
onBlurAction、onChangeAction)期间触发窗口/标签页导航或副作用。 - 目录兼容性:不需要改动目录定义中的组件元数据,无需向组件 Schema 添加
userInteractionLevel交互注解。 - 标准组件绑定:自定义组件作者继续编写标准事件处理器(Lit 中
@click=${props.action}、React 中onClick={props.action}、Flutter 中onPressed: props.action),由 Binder 自动包裹回调以设置 Action Execution Scope。 - 协议统一处理:避免按
protocolVersion >= "1.0"与< "1.0"分流的渲染器版本判断,跨所有协议版本统一生效。 - 跨平台一致性:在 Web、Flutter、Jetpack Compose、SwiftUI 上复用各平台原生事件原语。
2.2 代价与权衡
- 可信事件检查要求:渲染器必须在 action binder 内检查原生事件类型(
click/touchend对比blur/input)并验证event.isTrusted,否则脚本通过.dispatchEvent()或未受控定时器可以程序化地打开"激活 Action 上下文"。
3. 核心方案:Action 上下文强制与事件分类
方案由两部分组成:
- 在目录函数定义上增加
requiresUserActivation: boolean(如openUrl为true); - 框架在渲染器层强制实施Action Execution Scope(Action 执行作用域)与 Activation Event Classification(激活事件分类)。
当函数定义设置requiresUserActivation: true时,A2UI 框架保证该函数只在某个有意的用户激活 Action(如 click、tap、submit)的执行作用域内被派发时执行。布局渲染、动态属性插值、响应式模型更新、被动输入处理器(onBlurAction、onChangeAction)中的非发起式调用都会被渲染器引擎拦截并拒绝。
3.1 目录函数元数据:requiresUserActivation
目录函数定义中,requiresUserActivation声明函数在调用时是否需要用户激活上下文:
{ "functions": { "openUrl": { "type": "object", "description": "Opens the specified URL in a browser or handler. Requires user activation.", "returnType": "void", "requiresUserActivation": true, "properties": { "call": {"const": "openUrl"}, "args": { "type": "object", "properties": { "url": {"type": "string", "format": "uri"} }, "required": ["url"] } } } } }3.2 运行时 Action 执行作用域与事件分类
与"给目录组件定义加元数据"或"在 JSON Payload 中引入新 Action 类型"不同,渲染器引擎在运行时对底层交互事件的意图进行分类:
激活意图(
actionIntent = "activation")由物理的、有意的用户激活事件触发:- Web:可信 DOM 事件(
click、auxclick、touchend、submit、聚焦交互节点上的Enter/Space按键); - 移动端:主手势回调(Flutter 的
onPressed/onTap、Compose 的onClick、SwiftUI 的Button(action:))。 - 允许执行标记了
requiresUserActivation: true的函数。
- Web:可信 DOM 事件(
被动意图(
actionIntent = "passive")由连续输入、焦点变化或被动事件触发:- Web:DOM 事件(
blur、focus、input、change、pointermove、mouseenter); - 移动端:输入/焦点回调(
onChangeAction、onBlurAction、onFocusAction)。 - 阻止标记了
requiresUserActivation: true的函数,同时允许标准状态模型更新或数据校验。
- Web:DOM 事件(
非 Action 作用域(
isExecutingAction = false)在 Surface 布局构建、动态字符串插值(${openUrl(...)})、模板迭代、响应式状态更新或直接 Agent 函数调用消息期间生效。- 阻止标记了
requiresUserActivation: true的函数。
- 阻止标记了
Agent 调用拒绝:声明为
callableFrom: "rendererOrAgent"(或"rendererOnly")且设置了requiresUserActivation: true的函数,如果被服务端/Agent 消息直接调用,必须返回运行时安全错误。因为后端 Agent 的直接调用没有物理客户端用户激活上下文(isExecutingAction = false),禁止自动执行用户激活受限函数。
3.3 异步执行边界
用户发起 Action 后,执行链可能包含异步处理回合(异步状态更新、数据转换、awaitpromise/future)。
await回合间的作用域保持:Action 执行作用域(isExecutingAction = true且actionIntent = "activation")会跨异步任务边界(await、Promises、Futures、Coroutines、Tasks)持续存在,只要该执行流由用户激活事件发起。- 规则:只要初始执行由有意的用户激活 Action 触发,该 Action 处理器流水线内后续的异步操作与函数调用就仍处于激活的 Action Context 中,允许执行
requiresUserActivation函数。
4. 渲染器 Action 执行模型
渲染器通过Action Execution Scopes(isExecutingAction与actionIntent)强制执行用户手势约束,完整决策流程如下:
5. 开发者体验与组件编写
自定义组件作者无需修改组件实现即可支持该特性。
5.1 自动 Action 包装
当自定义组件通过 props(props.action)接收 action 回调时,渲染器框架 Binder(如web_core中的GenericBinder)负责生成这些回调。Binder 自动捕获原生触摸/点击事件,并将回调执行包裹在dataContext.executeInActionScope(intent, callback)中。
运行时启停(Runtime Enablement)
组件作者在标准框架代码中处理运行时启停(disabled、enabled或自定义状态 props):
- Lit:
<button ?disabled=${props.disabled} @click=${e => !props.disabled && props.action(e)}>Click Me</button> - React:
<button disabled={props.disabled} onClick={e => !props.disabled && props.action(e)}>Click Me</button> - Flutter:
ElevatedButton(onPressed: props.disabled ? null : () => props.action(), child: Text("Click Me")) - Jetpack Compose:
Button(onClick = props.action, enabled = !props.disabled) { Text("Click Me") }
如果组件实例在运行时处于禁用状态,props.action()会被跳过:不打开 Action Context、不执行任何函数。
6. 四平台实现设计
6.1 Web 引擎(web_core)设计
在web_core(Lit、React、Angular、Vue 渲染器共享)中,Action Context 处理集中在GenericBinder与DataContext内实现。
web_coreAction Binder 实现(提案目标设计):
// web_core: src/v0_9/data/data-context.ts export type ActionIntent = 'activation' | 'passive'; export class DataContext { private _isExecutingAction = false; private _actionIntent: ActionIntent = 'passive'; public get isExecutingAction(): boolean { return this._isExecutingAction; } public get actionIntent(): ActionIntent { return this._actionIntent; } public executeInActionScope<T>(intent: ActionIntent, callback: () => T): T { const prevAction = this._isExecutingAction; const prevIntent = this._actionIntent; this._isExecutingAction = true; this._actionIntent = intent; try { const result = callback(); if (result instanceof Promise) { return result.finally(() => { this._isExecutingAction = prevAction; this._actionIntent = prevIntent; }) as unknown as T; } this._isExecutingAction = prevAction; this._actionIntent = prevIntent; return result; } catch (error) { this._isExecutingAction = prevAction; this._actionIntent = prevIntent; throw error; } } } // web_core: src/v0_9/rendering/generic-binder.ts const ACTIVATION_EVENTS = new Set(['click', 'auxclick', 'touchend', 'submit']); export function bindAction(dataContext: DataContext, actionCall: ActionDefinition) { return (eventOrOptions?: Event | {event?: Event}) => { const domEvent = eventOrOptions instanceof Event ? eventOrOptions : ((eventOrOptions as any)?.nativeEvent ?? (eventOrOptions as any)?.event); // Classify event intent based on DOM event type const isActivationEvent = domEvent && domEvent.isTrusted && (ACTIVATION_EVENTS.has(domEvent.type) || (domEvent instanceof KeyboardEvent && (domEvent.key === 'Enter' || domEvent.key === ' '))); const intent: ActionIntent = isActivationEvent ? 'activation' : 'passive'; return dataContext.executeInActionScope(intent, () => { return dataContext.invokeFunction(actionCall.call, actionCall.args); }); }; }Catalog.invoker校验(提案目标设计):
// web_core: src/v0_9/catalog/types.ts this.invoker = (name, rawArgs, ctx) => { const fn = this.functions.get(name); if (!fn) throw new A2uiExpressionError(`Function not found: ${name}`, name); if (fn.requiresUserActivation) { const isValidScope = ctx.isExecutingAction && ctx.actionIntent === 'activation'; if (!isValidScope) { throw new A2uiSecurityError( `Execution blocked: Function '${name}' requires a user activation Action context (e.g. click, tap, submit). ` + `It cannot be executed during layout rendering, interpolation, passive events (blur/change), or reactive updates.`, name, ); } } const safeArgs = fn.schema.parse(rawArgs); return fn.execute(safeArgs, ctx); };与当前源码的对照:在仓库现有的 renderers/web_core/src/v0_9/catalog/types.ts 中,Catalog.invoker已具备"函数不存在抛A2uiExpressionError、fn.schema.parse(rawArgs)校验并剥离非法参数、再执行fn.execute"的核心骨架;DataContext的实际实现在 renderers/web_core/src/v0_9/rendering/data-context.ts,GenericBinder.bindAction的现有实现通过dispatchAction(resolveDeepSync(value))分发 action(见 generic-binder.ts),错误体系则集中在 v0_9/errors.ts(A2uiError基类携带机器可读code,派生出A2uiExpressionError、A2uiStateError等)。上文中的executeInActionScope、bindAction的isTrusted分类逻辑属于提案的设计草图,与当前 v0_9 源码存在差异——这正是本提案待落地部分。
6.2 Flutter(Dart)设计
Flutter/Dart 中,Action Context 作用域通过DataContext.runInActionScope传播:
enum ActionIntent { activation, passive } class DataContext { bool _isExecutingAction = false; ActionIntent _actionIntent = ActionIntent.passive; bool get isExecutingAction => _isExecutingAction; ActionIntent get actionIntent => _actionIntent; R runInActionScope<R>(ActionIntent intent, R Function() block) { final prevAction = _isExecutingAction; final prevIntent = _actionIntent; _isExecutingAction = true; _actionIntent = intent; try { final result = block(); if (result is Future) { return (result.whenComplete(() { _isExecutingAction = prevAction; _actionIntent = prevIntent; })) as R; } _isExecutingAction = prevAction; _actionIntent = prevIntent; return result; } catch (error) { _isExecutingAction = prevAction; _actionIntent = prevIntent; rethrow; } } } // Button Widget Binder: ElevatedButton( onPressed: props.disabled ? null : () { context.runInActionScope(ActionIntent.activation, () { actionDispatcher.dispatch(props.action, context: context); }); }, child: Text(props.label), ); // Function Invocation Guard: void verifyFunctionExecution(FunctionDefinition fn, DataContext context) { if (fn.requiresUserActivation) { if (!context.isExecutingAction || context.actionIntent != ActionIntent.activation) { throw SecurityException( "Function '${fn.name}' requires execution within an active intentional user activation Action context.", ); } } }6.3 Android(Kotlin / Jetpack Compose)设计
Android 上,Jetpack Compose 的点击处理器在携带ActionContextElement的协程中执行:
enum class ActionIntent { ACTIVATION, PASSIVE } class ActionContextElement( val isExecutingAction: Boolean = true, val intent: ActionIntent = ActionIntent.ACTIVATION ) : CoroutineContext.Element { companion object Key : CoroutineContext.Key<ActionContextElement> override val key: CoroutineContext.Key<*> = Key } // Jetpack Compose Button Component @Composable fun A2UIButton(componentId: String, label: String, enabled: Boolean = true, onAction: suspend () -> Unit) { val coroutineScope = rememberCoroutineScope() Button( onClick = { if (!enabled) return@Button coroutineScope.launch(ActionContextElement(isExecutingAction = true, intent = ActionIntent.ACTIVATION)) { onAction() } }, enabled = enabled ) { Text(text = label) } }ActionContextElement实现了CoroutineContext.Element,从而在协程切换(suspend/launch)之间传递激活上下文,对应提案 3.3 节的"跨异步边界保持作用域"。
6.4 iOS(Swift / SwiftUI)设计
SwiftUI 中,TaskLocal值(A2UIActionScope)将 Action Context 作用域绑定到async/await任务上:
public enum ActionIntent { case activation case passive } public enum A2UIActionScope { @TaskLocal public static var isExecutingAction: Bool = false @TaskLocal public static var actionIntent: ActionIntent = .passive } // SwiftUI Button Component struct A2UIButton: View { let actionCall: ActionDefinition let context: DataContext var body: some View { Button(action: { Task { await A2UIActionScope.$isExecutingAction.withValue(true) { await A2UIActionScope.$actionIntent.withValue(.activation) { await context.invokeFunction(actionCall.call, args: actionCall.args) } } } }) { Text(actionCall.label) } } }利用 Swift 的@TaskLocal特性,withValue块内的所有await任务天然继承 Action 作用域,天然满足异步边界要求。
7. 安全有效性与威胁矩阵
| Threat / Scenario | Risk Level | Protection Mechanism |
|---|---|---|
Initial Render Auto-Trigger(Model payload 加载时调用openUrl) | High | Blocked:初始布局渲染处于isExecutingAction = false。 |
Interpolation Abuse(在文本节点注入${openUrl('...')}) | High | Blocked:表达式引擎以isExecutingAction = false运行。 |
| Reactive Model Update(状态同步触发函数执行) | Medium | Blocked:响应式状态更新在 Action Context 之外运行。 |
Passive Event Exploitation(openUrl绑定到onBlurAction/onChangeAction) | Medium | Blocked:blur/change 事件以actionIntent = "passive"运行。 |
Synthetic Event Attack(脚本调用.click()) | Medium | Blocked:Binder 要求domEvent.isTrusted === true。 |
8. 提案落地状态与仓库证据
本提案当前状态为Draft(作者 Greg Spencer,创建于 2026-08-05),但其核心元数据已在 v1.0 规范与基础目录中正式落地:
- Schema 定义:specification/v1_0/json/catalog_definition.json 在
FunctionDefinition中新增requiresUserActivation(boolean,默认false,"Specifies whether this function requires a user activation context to execute"),并通过if/then条件约束:一旦requiresUserActivation: true,allowedCallers只能为rendererOnly——这与提案第 3.2 节"Agent 调用拒绝"直接呼应,保证受限函数绝不可能被 Agent 消息直接自动执行。 - 目录落地:specification/v1_0/catalogs/basic/catalog.json 中
openUrl已设置"requiresUserActivation": true、"returnType": "void",其url参数除string (format: uri)外还支持DataBinding与FunctionCall引用(见common_types.json),说明即使 URL 来自数据绑定或嵌套函数调用,执行同样受用户激活约束。 - 版本演进:specification/v1_0/docs/evolution_guide.md 记录 v1.0 起为
FunctionDefinition增加requiresUserActivation(默认false),并更新openUrl定义为"requiresUserActivation": true;同时明确 wire 层FunctionCallPayload 不携带allowedCallers/returnType,这类静态元数据只放在目录函数定义中。
也就是说,元数据层(Catalog 声明)已随 v1.0 落地,运行时强制层(Action Execution Scope、isTrusted校验、安全异常)则处于本提案的设计阶段,等待各渲染器按第 4、6 节模型实现。对于协议规范读者,可结合 specification/v1_0/docs/a2ui_protocol.md 理解函数调用在协议中的位置;对于渲染器开发者,可从 renderers/web_core/src/v0_9/rendering/data-context.ts、generic-binder.ts 与 catalog/types.ts 出发,将提案中的executeInActionScope、bindAction分类逻辑接入现有dispatchAction调用链。
【免费下载链接】a2ui项目地址: https://gitcode.com/GitHub_Trending/a2/a2ui
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考