forked from bai/curriculum-project-hub
feat(hub): RoleEntry 完整 bundle + per-run tool 白名单 + per-role 触发权限
RoleEntry 扩成 (model, systemPrompt, tools) bundle,id 兼任 slash command 名。 ToolRegistry.subset() 支持按白名单构造 per-run 视图,模型永远看不到 role 外的工具。 trigger 解析 /<role> 命令,查 RoleEntry 组装 systemPrompt + 子集 registry 传给 runner。 新增 RoleTriggerGrant 表 + canTriggerRole gate,与 ADR-0004 canTriggerAgent 串联: 先问'能不能触发 agent',再问'能触发哪个 role'。未配置 role 放行(back-compat)。 - models.ts: RoleEntry 加 systemPrompt + tools 字段 - tools.ts: ToolRegistry.subset(names) 返回共享 handler 的子集视图 - trigger.ts: extractRole 解析 slash; 传 systemPrompt + runTools; catch 链容错 P2025 - runner.ts: RunRequest.systemPrompt 改 string | undefined (exactOptionalPropertyTypes) - schema.prisma + migration: RoleTriggerGrant(projectId, roleId, principal, revokedAt) - permission.ts: canTriggerRole gate (有 grant 记录即白名单模式,含 revoked) - server.ts: draft/review 两个 role 加 systemPrompt + tools 白名单 - 测试: role-permission.test.ts (5) + trigger.test.ts (+3), 53 全绿
This commit is contained in:
@@ -68,6 +68,57 @@ export async function canTriggerAgent(
|
||||
return { allowed: false, reason: `permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Per-role trigger gate (ADR-0017: roles are product config). Orthogonal to
|
||||
* {@link canTriggerAgent}: that decides "can trigger an agent at all"
|
||||
* (ADR-0004 triggerAgent capability); this decides "can trigger *which* role".
|
||||
* Two gates in series — both must pass.
|
||||
*
|
||||
* Semantics: if **no** `RoleTriggerGrant` row exists for `(projectId, roleId)`
|
||||
* (regardless of principal), the role is unconfigured on this project →
|
||||
* **allow** (back-compat: a project that hasn't configured per-role grants
|
||||
* doesn't get locked down). Once any grant exists for that role on the
|
||||
* project, only principals with an active grant may trigger it. "Grants exist
|
||||
* but this principal has none" → deny (admin explicitly restricted the role).
|
||||
*
|
||||
* `roleId` matches `RoleEntry.id` (the slash-command name). `principal` is the
|
||||
* same opaque string ADR-0004 uses (sender open_id here; principal
|
||||
* sub-typology OPEN).
|
||||
*/
|
||||
export async function canTriggerRole(
|
||||
prisma: PrismaClient,
|
||||
projectId: string,
|
||||
roleId: string,
|
||||
principal: string,
|
||||
): Promise<PermissionResult> {
|
||||
try {
|
||||
// Any grant record (active OR revoked) for this (project, role)? If none,
|
||||
// the role is unconfigured → allow (back-compat: no per-role restriction).
|
||||
// A revoked grant still counts as "configured" — revoking one person must
|
||||
// not silently reopen the role to everyone.
|
||||
const anyGrant = await prisma.roleTriggerGrant.findFirst({
|
||||
where: { projectId, roleId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (anyGrant === null) {
|
||||
return { allowed: true, reason: `role ${roleId} unconfigured on project (open)` };
|
||||
}
|
||||
// Grants exist — this principal must hold one.
|
||||
const grant = await prisma.roleTriggerGrant.findFirst({
|
||||
where: { projectId, roleId, principal, revokedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (grant !== null) {
|
||||
return { allowed: true, reason: `granted role ${roleId}` };
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
reason: `no active ${roleId} grant for ${principal} on project ${projectId}`,
|
||||
};
|
||||
} catch (e) {
|
||||
return { allowed: false, reason: `role permission check failed: ${e instanceof Error ? e.message : String(e)}` };
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a role grants edit capability (edit or manage). Exported for tests.
|
||||
export function roleGrantsEdit(role: PermissionRole): boolean {
|
||||
|
||||
Reference in New Issue
Block a user