fix(runtime): enforce all miniapp tool permissions
This commit is contained in:
@@ -22,7 +22,7 @@ export type AppToolDefinition = {
|
|||||||
handling: "direct" | "interactive" | "launch" | "foreground" | "operation";
|
handling: "direct" | "interactive" | "launch" | "foreground" | "operation";
|
||||||
/** JSON-object property names accepted by this tool. */
|
/** JSON-object property names accepted by this tool. */
|
||||||
parameters: readonly string[];
|
parameters: readonly string[];
|
||||||
requires_permission?: string;
|
requires_permissions?: readonly string[];
|
||||||
input_schema?: JsonObject;
|
input_schema?: JsonObject;
|
||||||
output_schema?: JsonObject;
|
output_schema?: JsonObject;
|
||||||
target?: {
|
target?: {
|
||||||
@@ -126,7 +126,7 @@ export class CoreAppRegistry {
|
|||||||
input_schema: tool.input_schema,
|
input_schema: tool.input_schema,
|
||||||
output_schema: tool.output_schema,
|
output_schema: tool.output_schema,
|
||||||
target: tool.target,
|
target: tool.target,
|
||||||
...(tool.permissions?.[0] ? { requires_permission: tool.permissions[0] } : {}),
|
...(tool.permissions?.length ? { requires_permissions: [...tool.permissions] } : {}),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -162,7 +162,10 @@ function isManifest(manifest: AppManifest): boolean {
|
|||||||
&& tool.parameters.every(parameter => /^[a-z][a-z0-9_]{0,63}$/.test(parameter))
|
&& tool.parameters.every(parameter => /^[a-z][a-z0-9_]{0,63}$/.test(parameter))
|
||||||
&& (!tool.input_schema || isJsonObject(tool.input_schema))
|
&& (!tool.input_schema || isJsonObject(tool.input_schema))
|
||||||
&& (!tool.output_schema || isJsonObject(tool.output_schema))
|
&& (!tool.output_schema || isJsonObject(tool.output_schema))
|
||||||
&& (!tool.requires_permission || manifest.permissions.includes(tool.requires_permission));
|
&& (!tool.requires_permissions || (tool.requires_permissions.length > 0
|
||||||
|
&& new Set(tool.requires_permissions).size === tool.requires_permissions.length
|
||||||
|
&& tool.requires_permissions.every(permission => /^[a-z][a-z0-9._-]{0,63}$/.test(permission)
|
||||||
|
&& manifest.permissions.includes(permission))));
|
||||||
toolNames.add(tool.name);
|
toolNames.add(tool.name);
|
||||||
return valid;
|
return valid;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,4 +52,25 @@ describe("ToolRouter", () => {
|
|||||||
expect(router.route(launch("not-installed"), { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "rejected", code: "not_installed" });
|
expect(router.route(launch("not-installed"), { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "rejected", code: "not_installed" });
|
||||||
expect(router.route(launch("draw-and-guess", { bundle_url: "https://evil.invalid" }), { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "rejected", code: "invalid_request" });
|
expect(router.route(launch("draw-and-guess", { bundle_url: "https://evil.invalid" }), { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "rejected", code: "invalid_request" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("checks every permission declared by a MiniApp Tool", () => {
|
||||||
|
const apps = new CoreAppRegistry();
|
||||||
|
apps.install({
|
||||||
|
app_scope: "permissioned-app", kind: "bundled", enabled: true, default_eligible: false, recovery: false,
|
||||||
|
manifest: {
|
||||||
|
app_scope: "permissioned-app", version: "1.0.0", permissions: ["one", "two"],
|
||||||
|
tools: [{ name: "permissioned.run", handling: "operation", parameters: [], requires_permissions: ["one", "two"], input_schema: { type: "object" } }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const router = new ToolRouter(apps);
|
||||||
|
const raw = JSON.stringify({ v: 1, id: "permissioned-call", type: "lineup.v1.miniapp.tool.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: { call_id: "permissioned-call", tool_id: "permissioned.run", app_scope: "permissioned-app", inventory_revision: "catalog-1", input: {} } });
|
||||||
|
expect(router.route(raw, { app_scope: "chat", conversation_id: "c1", inventory_revision: "catalog-1" })).toMatchObject({ disposition: "miniapp" });
|
||||||
|
expect(() => apps.install({
|
||||||
|
app_scope: "incomplete-permission-app", kind: "bundled", enabled: true, default_eligible: false, recovery: false,
|
||||||
|
manifest: {
|
||||||
|
app_scope: "incomplete-permission-app", version: "1.0.0", permissions: ["one"],
|
||||||
|
tools: [{ name: "permissioned.run", handling: "operation", parameters: [], requires_permissions: ["one", "two"], input_schema: { type: "object" } }],
|
||||||
|
},
|
||||||
|
})).toThrow("invalid manifest");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export class ToolRouter {
|
|||||||
const definition = app.manifest.tools.find(tool => tool.name === toolID);
|
const definition = app.manifest.tools.find(tool => tool.name === toolID);
|
||||||
if (!definition || definition.handling === "interactive") return { disposition: "rejected", code: "manifest_denied" };
|
if (!definition || definition.handling === "interactive") return { disposition: "rejected", code: "manifest_denied" };
|
||||||
if (!validateMiniAppToolSchema(input, definition.input_schema)) return { disposition: "rejected", code: "invalid_request" };
|
if (!validateMiniAppToolSchema(input, definition.input_schema)) return { disposition: "rejected", code: "invalid_request" };
|
||||||
if (definition.requires_permission && !app.manifest.permissions.includes(definition.requires_permission)) return { disposition: "rejected", code: "permission_denied" };
|
if (definition.requires_permissions && !definition.requires_permissions.every(permission => app.manifest.permissions.includes(permission))) return { disposition: "rejected", code: "permission_denied" };
|
||||||
return { disposition: "miniapp", call: { call_id: callID, tool_id: toolID, app_scope: appScope, inventory_revision: revision, input, ...(instanceID ? { instance_id: instanceID } : {}) }, handling: definition.handling, app_scope: appScope, ...(instanceID ? { instance_id: instanceID } : {}), requires_foreground: definition.target?.requires_foreground ?? true, restore_previous_focus: definition.target?.restore_previous_focus ?? false };
|
return { disposition: "miniapp", call: { call_id: callID, tool_id: toolID, app_scope: appScope, inventory_revision: revision, input, ...(instanceID ? { instance_id: instanceID } : {}) }, handling: definition.handling, app_scope: appScope, ...(instanceID ? { instance_id: instanceID } : {}), requires_foreground: definition.target?.requires_foreground ?? true, restore_previous_focus: definition.target?.restore_previous_focus ?? false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ export class ToolRouter {
|
|||||||
if (!record.enabled) return { disposition: "rejected", code: "disabled" };
|
if (!record.enabled) return { disposition: "rejected", code: "disabled" };
|
||||||
const definition = record.manifest.tools.find(tool => tool.handling === "launch");
|
const definition = record.manifest.tools.find(tool => tool.handling === "launch");
|
||||||
if (!definition) return { disposition: "rejected", code: "manifest_denied" };
|
if (!definition) return { disposition: "rejected", code: "manifest_denied" };
|
||||||
if (definition.requires_permission && !record.manifest.permissions.includes(definition.requires_permission)) return { disposition: "rejected", code: "permission_denied" };
|
if (definition.requires_permissions && !definition.requires_permissions.every(permission => record.manifest.permissions.includes(permission))) return { disposition: "rejected", code: "permission_denied" };
|
||||||
const instanceID = args.instance_id === undefined ? undefined : text(args.instance_id);
|
const instanceID = args.instance_id === undefined ? undefined : text(args.instance_id);
|
||||||
if (args.instance_id !== undefined && (!instanceID || !/^[A-Za-z0-9._:-]{1,128}$/.test(instanceID))) return { disposition: "rejected", code: "invalid_request" };
|
if (args.instance_id !== undefined && (!instanceID || !/^[A-Za-z0-9._:-]{1,128}$/.test(instanceID))) return { disposition: "rejected", code: "invalid_request" };
|
||||||
const parameters = args.parameters === undefined ? {} : object(args.parameters);
|
const parameters = args.parameters === undefined ? {} : object(args.parameters);
|
||||||
|
|||||||
Reference in New Issue
Block a user