feat(runtime): enforce miniapp capability policy

This commit is contained in:
2026-08-06 00:23:11 +08:00
parent 58e77462ad
commit c77a0cf69b
5 changed files with 84 additions and 17 deletions
+1 -1
View File
@@ -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";
@@ -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 })); }
}
@@ -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({
@@ -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<CommandReceipt> {
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");
@@ -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 暴露完整 workspaceMiniApp 可看到其他 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 问题说明
### A1Capability 请求没有经过 Runtime 的完整裁决
### A1Capability 请求没有经过 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 或权限对象
### A2bundled 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 规范:A1A4 已通过,Interact/状态机仍不通过
标准交互终态契约:不通过
迭代整体验收:不通过,继续处理 A1,再处理 A5/A6A2A4 需在最终浏览器复验中确认
迭代整体验收:不通过,继续处理 A5/A6;A2 需在最终浏览器复验中确认
```
本次验收没有修改实现代码。下一步应先处理 P1,再重新运行自动化测试和真实浏览器验收;P2/P3 不能被静默