数据访问半区
@yiong/railguard/data,自生产环境的守卫管线沉淀。三条纪律贯穿: fail-closed(身份/角色/规则缺失一律拒)、防存在性枚举预言机 (「不存在」与「越权」同形拒绝)、防重放(审批一票一次)。
RBAC 配置模型
宿主传结构化对象(YAML 解析留给宿主),validateRbacConfig 做零依赖结构校验—— 配置错误要在启动期炸,不能到请求期才发现。
ts
const config: RbacConfig = {
roles: {
admin: { tools: '*', approver: true },
support: {
tools: ['list_orders', 'get_customer', 'refund'],
rows: [{ field: 'assignee', equals: '$self.id' }], // 行级:只看自己名下
mask: ['id_card', 'salary'], // 字段脱敏
approval: [{ tool: 'refund', when: { field: 'amount', gt: 1000 } }], // 分级审批
},
},
}$self.id/$self.attrs.<key>引用提问人。刻意不做表达式 DSL—— 可求值字符串是注入面,结构化对象不是。- 多帽子:
principal.attrs['role:<system>']按目标系统覆盖角色,缺省回落role。 - 审批条件数值缺失/非数值一律判真——fail-safe,「不填金额」绕不过分级。
rbacToolGate(beforeToolCall)
角色 → 工具白名单。未知角色、无身份一律拦(fail-closed,failMode: 'closed')。 配套 allowedTools() 在 tools/list 阶段就收窄:不该用的工具连列表都不出现, 而不是等模型调了再拦。
rowFilter(afterToolCall)
行级过滤:数组逐行筛,单对象不匹配即整体拒绝。两条硬语义:
- 「不存在」与「越权」返回同一种拒绝——差异会成为存在性枚举预言机 (攻击者用报错措辞探测数据是否存在);
- 规则字段缺失或
$self解析失败按不匹配处理——宁可错拒,不可错放。
一行未滤时返回 pass 而非 modified(1.1.0 起)——verdict 语义要诚实, 评测里的良性用例不该显示「被改写」。
fieldMask(afterToolCall)
按角色递归抹除字段(含嵌套对象与数组)。数据里不含目标字段时报 pass(1.1.0 起)。
approvalGate(beforeToolCall)
分级人工审批,事件溯源存储:
涉审调用 → 幂等开单(escalated,附单号 + 原始调用)
→ 审批人批复
→ 同人同参重试 → 消费该单放行(approved)或拦截(denied)- 幂等开单:重复调用不重复开单;一票一次:已决单消费后失效,防重放;
- 重试消票的恢复语义天然适配无状态 HTTP——不需要挂起的长连接;
- 分级条件求值上下文 = 调用参数 + 宿主注入的
signals(如「本月已退款几笔」); - 涉审但未接存储 = fail-closed。
存储:MemoryApprovalStore(内存,评测/测试用)、JsonlApprovalStore (@yiong/railguard/node,预写日志 + 崩溃残行容忍回放)。审批单存 args 原文 (审批人要看清在批什么),持久化文件与审计日志同级保密对待。
与规则半区合流
两半区共用一条流水线与一份上下文——RBAC 拦截和注入检测出现在同一条审计流里:
ts
const guard = createGuard({
hooks: {
beforeToolCall: [rbacToolGate({ config }), approvalGate({ config, store }), lethalTrifecta({ ... })],
afterToolCall: [rowFilter({ config }), fieldMask({ config })],
},
})