From c77a0cf69b32bf8c04bf5f191e210465f1fe2821 Mon Sep 17 00:00:00 2001 From: kyugao Date: Thu, 6 Aug 2026 00:23:11 +0800 Subject: [PATCH] feat(runtime): enforce miniapp capability policy --- tauri/src/runtime/app-management/app-sdk.ts | 2 +- .../runtime/capabilities/capability-audit.ts | 7 +++ .../coordination/lineup-runtime.test.ts | 19 +++++++ .../runtime/coordination/lineup-runtime.ts | 51 +++++++++++++++++-- .../03.sdk_and_coreapp/04.acceptance_review.md | 22 ++++---- 5 files changed, 84 insertions(+), 17 deletions(-) diff --git a/tauri/src/runtime/app-management/app-sdk.ts b/tauri/src/runtime/app-management/app-sdk.ts index 374ac1f..4672b12 100644 --- a/tauri/src/runtime/app-management/app-sdk.ts +++ b/tauri/src/runtime/app-management/app-sdk.ts @@ -50,7 +50,7 @@ export type ChatAction = export type CommandReceipt = | { disposition: "accepted"; local_id?: string } - | { disposition: "rejected"; code: "inactive" | "scope_mismatch" | "invalid_action" }; + | { disposition: "rejected"; code: "inactive" | "scope_mismatch" | "invalid_action" | "capability_not_declared" | "capability_unavailable" | "invalid_capability_request" | "capability_requires_foreground" }; export interface ChatRuntimeSDK { readonly app_scope: "chat"; diff --git a/tauri/src/runtime/capabilities/capability-audit.ts b/tauri/src/runtime/capabilities/capability-audit.ts index 4c256ef..4251189 100644 --- a/tauri/src/runtime/capabilities/capability-audit.ts +++ b/tauri/src/runtime/capabilities/capability-audit.ts @@ -30,5 +30,12 @@ export class CapabilityAuditLog { return { ...entry }; } + public recordDecision(callID: string, capability: string, risk: CapabilityRisk, status: CapabilityCallRecord["status"], occurredAt: string): CapabilityAuditEntry { + const entry: CapabilityAuditEntry = { call_id: callID, capability, risk, status, occurred_at: occurredAt }; + this.entries.push(entry); + if (this.entries.length > 200) this.entries.splice(0, this.entries.length - 200); + return { ...entry }; + } + public snapshot(): readonly CapabilityAuditEntry[] { return this.entries.map(entry => ({ ...entry })); } } diff --git a/tauri/src/runtime/coordination/lineup-runtime.test.ts b/tauri/src/runtime/coordination/lineup-runtime.test.ts index cdde40d..62b3896 100644 --- a/tauri/src/runtime/coordination/lineup-runtime.test.ts +++ b/tauri/src/runtime/coordination/lineup-runtime.test.ts @@ -258,6 +258,25 @@ describe("LineUpRuntime", () => { expect("workspace" in sdk).toBe(false); }); + it("requires bundled MiniApp capabilities to be declared and policy-valid before queuing an Agent request", async () => { + const conversationID = "user_1:2:channel_1"; + const transport = new FakeTransport(); + const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" }); + runtime.apps.install({ + app_scope: "capability-demo", kind: "bundled", enabled: true, default_eligible: false, recovery: false, + manifest: { app_scope: "capability-demo", version: "1.0.0", permissions: ["app.open_url"], tools: [] }, + }); + await runtime.login(login); + const instance = runtime.lifecycle.start({ app_scope: "capability-demo", instance_id: "capability-demo:1", app_session_id: "capability-demo:session", conversation_id: conversationID }, "2026-08-04T00:00:00.000Z"); + runtime.lifecycle.foreground(instance.instance_id, "2026-08-04T00:00:00.000Z"); + const sdk = runtime.openBundledMiniApp("capability-demo", instance.instance_id); + expect(await sdk.capabilities.request("clipboard.write", "复制文本", { text: "x" })).toEqual({ disposition: "rejected", code: "capability_not_declared" }); + expect(await sdk.capabilities.request("app.open_url", "打开文档", { url: "https://example.com" })).toEqual(expect.objectContaining({ disposition: "accepted" })); + await runtime.flushOutbox(); + expect(transport.sent.filter(entry => entry.payload.includes("lineup.v1.app.call"))).toHaveLength(1); + expect(transport.sent.at(-1)?.payload).toContain("app.open_url"); + }); + it("keeps bundled Surface requests local and scopes them to the current instance", async () => { const conversationID = "user_1:2:channel_1"; const launch = JSON.stringify({ diff --git a/tauri/src/runtime/coordination/lineup-runtime.ts b/tauri/src/runtime/coordination/lineup-runtime.ts index d5c0fb3..975b90e 100644 --- a/tauri/src/runtime/coordination/lineup-runtime.ts +++ b/tauri/src/runtime/coordination/lineup-runtime.ts @@ -52,6 +52,8 @@ import type { StandardInteractionRequest } from "@/runtime/coordination/standard import type { LineUpMiniAppSDK, MiniAppToolFailure, MiniAppToolProgress } from "@/runtime/app-management/miniapp-sdk"; import { MiniAppToolStateMachine, type MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state"; import { validateMiniAppToolSchema } from "@/runtime/coordination/miniapp-tool-schema"; +import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry"; +import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit"; export type RuntimeSession = { api: string; @@ -115,6 +117,8 @@ export class LineUpRuntime { private readonly prepareIncoming: (item: ConversationItem) => ConversationItem | undefined; private readonly orchestrator: AppOrchestrator; private readonly toolRouter: ToolRouter; + private readonly capabilityRegistry = new CapabilityRegistry(); + private readonly capabilityAudit = new CapabilityAuditLog(); /** SDK v1 inventory revision advertised by this Runtime instance. */ private readonly inventoryRevision = "catalog-1"; @@ -166,6 +170,8 @@ export class LineUpRuntime { return this.miniappTools.list(); } + public capabilityAuditEntries() { return this.capabilityAudit.snapshot(); } + /** Binds an ordinary MiniApp Tool to its Runtime instance before delivery. */ public bindToolCallToInstance(callID: string, instanceID: string): boolean { const instance = this.lifecycle.instances.get(instanceID); @@ -381,7 +387,7 @@ export class LineUpRuntime { request: async (event, data) => this.requestBundledSurface(appScope, instanceID, event, data), }, capabilities: { - request: (name, reason, input = {}) => this.queueProtocolAction("app-result", "lineup.v1.app.call", { instance_id: instanceID, app_scope: appScope, capability: name, reason, arguments: input, expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString() }), + request: (name, reason, input = {}) => this.requestMiniAppCapability(instanceID, name, reason, input), }, }; } @@ -456,10 +462,7 @@ export class LineUpRuntime { const name = text("name"); const reason = text("reason"); if (!name || !reason || !isJsonObject(payload.input)) throw new Error("invalid_capability_request"); - return this.bridgeReceipt(await this.queueProtocolAction("app-result", "lineup.v1.app.call", { - instance_id: instanceID, app_scope: instance.app_scope, capability: name, reason, arguments: payload.input, - expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString(), - })); + return this.bridgeReceipt(await this.requestMiniAppCapability(instanceID, name, reason, payload.input)); } throw new Error("unsupported_bridge_method"); } @@ -468,6 +471,44 @@ export class LineUpRuntime { return receipt.disposition === "accepted" ? { disposition: "accepted", ...(receipt.local_id ? { local_id: receipt.local_id } : {}) } : { disposition: "rejected", code: receipt.code }; } + /** + * Capability requests are proposals, not direct Host handles. Runtime checks + * the installed Manifest, local Registry/Policy, argument schema and lifecycle + * before putting a request on the normal Agent confirmation path. + */ + private requestMiniAppCapability(instanceID: string, name: string, reason: string, input: JsonObject): Promise { + const instance = this.lifecycle.instances.get(instanceID); + const app = instance ? this.apps.get(instance.app_scope) : undefined; + const resolution = this.capabilityRegistry.resolve(name); + const callID = this.newID("capability"); + if (!instance || !app || app.kind !== "bundled" || !app.manifest.permissions.includes(name)) { + this.capabilityAudit.recordDecision(callID, name, "restricted", "rejected", this.now()); + return Promise.resolve({ disposition: "rejected", code: "capability_not_declared" }); + } + if (resolution.disposition !== "available") { + this.capabilityAudit.recordDecision(callID, name, "restricted", "rejected", this.now()); + return Promise.resolve({ disposition: "rejected", code: "capability_unavailable" }); + } + if (!reason.trim() || reason.length > 500 || !this.capabilityRegistry.validateArguments(name, input)) { + this.capabilityAudit.recordDecision(callID, name, resolution.definition.risk, "rejected", this.now()); + return Promise.resolve({ disposition: "rejected", code: "invalid_capability_request" }); + } + if (resolution.definition.foreground_required && instance.state !== "foreground") { + this.capabilityAudit.recordDecision(callID, name, resolution.definition.risk, "rejected", this.now()); + return Promise.resolve({ disposition: "rejected", code: "capability_requires_foreground" }); + } + this.capabilityAudit.recordDecision(callID, name, resolution.definition.risk, "pending", this.now()); + return this.queueProtocolAction("app-result", "lineup.v1.app.call", { + call_id: callID, + instance_id: instanceID, + app_scope: instance.app_scope, + capability: name, + reason: reason.trim().replace(/\s+/gu, " ").slice(0, 500), + arguments: input, + expires_at: new Date(Date.parse(this.now()) + 15 * 60 * 1000).toISOString(), + }); + } + /** @deprecated Use openApp("chat") so App selection remains registry-driven. */ public openChatApp(): ChatRuntimeSDK { return this.openApp("chat"); diff --git a/迭代/03.sdk_and_coreapp/04.acceptance_review.md b/迭代/03.sdk_and_coreapp/04.acceptance_review.md index c1601cc..84ba5ac 100644 --- a/迭代/03.sdk_and_coreapp/04.acceptance_review.md +++ b/迭代/03.sdk_and_coreapp/04.acceptance_review.md @@ -16,7 +16,7 @@ | 状态 | 优先级 | 编号 | 问题 | 证据 | 当前结论 / 下一步 | |---|---:|---|---|---|---| -| 🔴 | P1 | A1 | MiniApp 的 Capability 请求绕过 Runtime 的 Manifest、Policy 和审计边界 | `tauri/src/runtime/coordination/lineup-runtime.ts:379-381` | 必须由 Runtime 校验 Manifest 声明、Capability Registry/Policy、输入 schema、前台要求和用户确认;不能直接拼接 `app.call` 发出。 | +| ✅ | P1 | A1 | MiniApp 的 Capability 请求绕过 Runtime 的 Manifest、Policy 和审计边界 | `tauri/src/runtime/coordination/lineup-runtime.ts`、`capability-audit.ts` | 已统一经过 Manifest 声明、Capability Registry/Policy、输入 schema、前台要求和审计记录,再进入普通 Agent 确认请求;拒绝原因也由 Runtime 返回。 | | ✅ | P1 | A2 | `bundled` MiniApp 的业务逻辑仍在可信 Host JS 中运行,没有真正的受限执行边界 | `tauri/src/main.ts`、`isolated-surface-host.ts` | 已移除 Host 直接实例化;Task Dashboard/Whiteboard 业务逻辑改在 opaque sandbox iframe 内运行,只能通过 Runtime Bridge 请求能力。待浏览器复验。 | | ✅ | P1 | A3 | SDK 暴露完整 workspace,MiniApp 可看到其他 App 实例和全局焦点栈 | `lineup-runtime.ts`、`miniapp-sdk.ts` | 已从 `LineUpMiniAppSDK` 删除 `workspace()`,不再把全局实例和焦点栈交给 bundled MiniApp。 | | ✅ | P1 | A4 | 同一个 App 的多个 instance 之间 Inbox 订阅没有隔离 | `lineup-runtime.ts` | 已按 `app_scope + conversation_id + instance_id` 过滤实时订阅,并补充双实例回归测试。 | @@ -31,7 +31,7 @@ ## 1. 已验证通过的部分 - `npm run build` 通过。 -- `npm test -- --run` 通过:29 个测试文件、126 个测试。 +- `npm test -- --run` 通过:29 个测试文件、127 个测试。 - `git diff --check` 通过。 - 使用独立 `agent-browser` 会话登录本地测试账号成功。 - 主 IM 页面、同步状态、应用子会话区域和“启用任务面板”入口可见。 @@ -42,14 +42,14 @@ ## 2. P1 问题说明 -### A1:Capability 请求没有经过 Runtime 的完整裁决 +### A1:Capability 请求没有经过 Runtime 的完整裁决(已修复) -现在 bundled MiniApp 可以调用 `sdk.capabilities.request(name, reason, input)`,实现直接把参数包装成 -`lineup.v1.app.call`。这意味着 MiniApp 可以请求一个没有写入自己 Manifest、没有经过当前 Capability Policy -检查、也没有经过 Host 支持检查的能力。即使后续 Agent 还要确认,这条入口也已经让 MiniApp 伪造了系统能力请求。 +现在 `sdk.capabilities.request(name, reason, input)` 只会进入 Runtime 的统一入口。Runtime 依次检查当前实例 +对应的 bundled Manifest 是否声明该能力、Capability Registry/Policy 是否可用、输入是否符合 schema,以及 +能力要求的前台状态;拒绝会返回明确原因,并写入不含敏感参数的审计记录。 -正确的边界应该是:MiniApp 提出请求 → Runtime 检查 Manifest 和 Policy → 校验参数 → 检查前台/生命周期 → -进入统一的 Capability 状态机和用户确认流程 → 再由 Host 执行。 +通过检查后,Runtime 生成唯一 `call_id`,记录 pending 审计项,再进入普通 `lineup.v1.app.call` Agent 确认流程。 +MiniApp 不会拿到 Host handler、Transport 或权限对象。 ### A2:bundled MiniApp 实际仍是可信代码(已修复,待浏览器复验) @@ -92,10 +92,10 @@ Interact 使用 `ChatRuntimeSDK` 和 Host 注入的 `toolInteractions`,而 Tas 功能回归:通过 基础构建与自动化测试:通过 真实登录和消息发送:通过 -App 架构边界:部分通过(A2 已实现,待浏览器复验;A1/A5/A6 仍不通过) -MiniApp 隔离与 SDK 规范:A2/A3/A4 已通过,Capability/Interact/状态机仍不通过 +App 架构边界:部分通过(A1/A2 已实现,A2 待浏览器复验;A5/A6 仍不通过) +MiniApp 隔离与 SDK 规范:A1~A4 已通过,Interact/状态机仍不通过 标准交互终态契约:不通过 -迭代整体验收:不通过,先继续处理 A1,再处理 A5/A6;A2~A4 需在最终浏览器复验中确认 +迭代整体验收:不通过,继续处理 A5/A6;A2 需在最终浏览器复验中确认 ``` 本次验收没有修改实现代码。下一步应先处理 P1,再重新运行自动化测试和真实浏览器验收;P2/P3 不能被静默