跳到主要内容

数据截至 (上游 commit 53ea1e8ba6fd)

实体模型、权限与 guard

本章讲什么: 「谁能让哪个 agent 干什么」这件事怎么建模。分两半:静态的实体模型(表结构),和动态的决策闸门(guard + 审批)。


1. 实体模型:五个名词

中央库在 data/v2.db,参考 schema 在 src/db/schema.ts:7

users ← 平台身份,id = "<渠道>:<handle>"

├── user_roles ← owner / admin(全局或限定到某个 agent 组)
├── agent_group_members ← 无特权的「已知成员」
└── user_dms ← 冷启动私聊的缓存

agent_groups ← 一个 agent 工作区(目录、记忆、CLAUDE.md、容器配置)

╲ 多对多
╲ messaging_group_agents ← 「接线」:触发规则 + 会话模式 + 优先级


messaging_groups ← 某个平台上的某一个聊天窗口


sessions ← (agent 组, 聊天群, 线程) → 一个会话 → 一个容器

一句话理解每个名词

名词白话关键列
users「Telegram 上的张三」这个身份id 是带命名空间的:telegram:123phone:+1555…
agent_groups一个 agent 的「家」:工作目录、记忆、人格folder(唯一)、agent_provider
messaging_groups某个平台上的某一个群/私聊(channel_type, platform_id, instance) 唯一
messaging_group_agents接线:把一个聊天窗口连到一个 agent四个正交轴,见下
sessions一段活的对话 = 一个容器container_statusthread_id

一条重要的设计原则

特权在用户身上,不在 agent 组身上。(src/db/schema.ts:9 的注释)

所有 agent 工作区是平等的。「谁是管理员」记在 user_roles,可以是全局的,也可以限定到某个 agent 组。不存在「这个 agent 组更高级」这种事。


2. 接线的四个正交轴

messaging_group_agents 表(src/db/schema.ts:46)。注释说这四列是用来替换 v1 的一坨不透明 trigger_rules JSON 的:

取值管什么
engage_modepattern / mention / mention-sticky什么消息算「在叫我」
sender_scopeall / known只理已知的人吗
ignored_message_policydrop / accumulate没触发的消息存不存
session_modeshared / per-thread / agent-shared会话怎么切分

再加两个:threads(NULL = 继承渠道默认,1/0 = 每接线覆盖)、priority

「正交」是关键词:每个轴单独调,组合出的行为不需要额外分支。这比一坨 JSON 好在——ncl wirings update --engage-mode mention 这种操作是可枚举、可校验、可解释的。

三种会话模式的隔离效果

shared per-thread agent-shared
────── ────────── ────────────
群A ──┐ 群A#线程1 ── 会话1 群A ──┐
├─ 会话1 群A#线程2 ── 会话2 群B ──┼── 同一个会话
(群里所有线程共享) (每线程独立上下文) Slack ┘
(跨渠道一段对话)

对应三种隐私取舍:

你想要
每个渠道完全隔离(各自的记忆)不同的 agent 组
一个 agent 服务多个渠道,但对话分开同一个 agent 组 + shared
一段对话横跨多个渠道agent-shared

3. 渠道默认值:只有两级,没有第三级

每个适配器静态声明自己的接线默认值(ChannelDefaults,src/channels/adapter.ts:173),分 DM 和 group 两个上下文各一套:

ChannelDefaults
├── dm: { engageMode, engagePattern?, threads, sessionMode?, unknownSenderPolicy }
├── group: { 同上 }
└── mentions: 'platform' | 'dm-only' | 'never'

注释里反复强调「恰好两级」(adapter.ts:167-172):适配器的声明,和建接线时选的每接线覆盖值。没有「每实例的 DB 配置表」这一层。想全局改?去改适配器那份代码——反正它是 skill 装进来的、属于用户自己的 fork。

向后兼容的兜底

没声明 defaults 的旧适配器拷贝,走 fallbackChannelDefaults(supportsThreads)。注释说这个兜底是行为忠实的:一条 threads=NULL 的接线在兜底下会精确复现历史上那套「由 supportsThreads 推导」的路由行为。所以单纯升级主干不会改变任何现有行为。

这是个值得学的兼容姿势:新机制的默认路径必须逐字节复现旧行为,才能做到「升级即无感」。

一个不可表达的错误组合

sessionMode: 'per-thread'推导出 threads = 1(resolveWiringDefaults,src/channels/channel-defaults.ts:72)。注释说明理由:每线程会话在结构上就要求线程 id 被保留,所以「per-thread 会话 + 线程 id 被剥掉」这个自相矛盾的组合在这里根本表达不出来


4. 权限:四层访问决策

canAccessAgentGroup(src/modules/permissions/access.ts:21)按顺序解析:

owner(全局)
→ 全局 admin
→ 限定到该 agent 组的 admin
→ agent_group_members 里的成员
→ 都不是 → 按 unknown_sender_policy 处理

不变式(src/db/schema.ts:79):admin@A 隐含 A 的成员身份,不需要额外的成员行。

unknown_sender_policy 的四种取值

行为
strict直接拒
request_approval给管理员发审批卡;同一个未知发送者在卡还挂着时再发消息会被去重丢弃(pending_sender_approvals 上的 UNIQUE 约束)
decline_notify在渠道里礼貌拒绝 + 给主人发一行 FYI,不发审批卡
public放行

权限模块是可拆的

src/router.ts 只定义了钩子的形状,实现在 src/modules/permissions/index.ts 里注册:

钩子什么时候跑不装模块时
setSenderResolveragent 解析之前(所以即使消息最后被丢,用户行也已经 upsert 了)userId = null,下游容忍
setAccessGateagent 解析之后(所以策略能按目标 agent 组分支)全放行
setSenderScopeGate与 access gate 并行,针对单条接线no-op
setChannelRequestGate有 @ 但没接线时只记一条 warn 日志

注意顺序的理由被写在注释里——这不是随手排的:sender resolver 必须在前面,否则被丢弃的消息里的用户永远不会被记录下来,后面的角色查询就找不到人。


5. guard:所有特权动作的唯一决策点

5.1 三态决策

// src/guard/types.ts:59
type GuardDecision =
| { effect: 'allow'; reason: string }
| { effect: 'hold'; reason: string; approverUserId?: string }
| { effect: 'deny'; reason: string }

hold = 「要人批」。批准者可以由决策指定(比如 a2a 策略行里写死的审批人),不指定就走默认链:限定 admin → 全局 admin → owner(pickApprover,src/modules/approvals/primitive.ts:139)。

5.2 三条设计不变式

guard()(src/guard/guard.ts:29)很短,但每一行都在守一条不变式:

① 没有名字查找,所以没有 fail-open 的未知动作。

if (!isGuardedAction(action)) {
return DENY('guard consulted with an undefined action (failing closed)');
}

消费点持有的是 defineGuardedAction 返回的值本身,不是一个字符串键。接错线是编译错误;运行时的这个检查是 JS 层的兜底,防手搓对象。

② 抛异常 = 拒绝。

try { decision = action.decide(input); }
catch (err) { return DENY('guard failure (failing closed)'); }

③ 批准只能满足 hold,永远不能推翻 deny。

if (!input.grant || decision.effect !== 'hold') return decision;
if (grantSatisfies(action, input)) return ALLOW(`hold satisfied by approval ${input.grant.approval_id}`);
return DENY('replay carried an invalid or mismatched grant');

这条最微妙。审批通过后不是直接跑处理器,而是带着审批行重新进入同一个入口,让结构性检查再跑一遍。于是「先批准、后撤权」的时序漏洞被堵死:批准时你还是 admin,重放时你已经不是了,decide 会给出 deny,grant 救不了。

5.3 grant 的有效性检查

grantSatisfies(src/guard/guard.ts:62)三步:

① grant.action 必须等于该动作声明的 grantActionName
② getPendingApproval(id) 必须查得到活行 ← 解析时会删行,所以只能用一次
③ 可选的 grantCoversRequest 领域绑定检查 ← 「这张批条覆盖这个请求吗」

第②步同时解决了两个问题:重放只能执行一次,以及手搓一个假的审批行对象通不过

5.4 unguarded():把「不设防」变成显式声明

// src/guard/types.ts:45
export function unguarded(reason: string): Unguarded {
return Object.freeze({ reason, [unguardedBrand]: true as const });
}

注册投递动作时,要么给 guard spec,要么给这个标记——「省略」在类型上是不可表达的。于是「决定不设防」这件事会出现在注册它的那个 diff 里,而且 grep "unguarded(" 就是完整清单。

品牌符号是模块私有的,所以一个长得像 { reason } 的对象混不进来。

5.5 还有一道:不许把有 guard 的动作换成没 guard 的

// src/delivery.ts:571
if (isUnguarded(guardDecl) && !isUnguardedEntry(existing)) {
throw new Error(`delivery action "${action}" is guard-wrapped; re-registering it without a guard spec would disarm the guard`);
}

一个 skill 想扩展有 guard 的动作?去在模块导出的函数上组合,或者自己带一份 guard spec 重新注册。不能把闸门拆了还留着目录条目。


6. 审批流:一张卡片的一生

模块调 requestApproval()

├─ pickApprover: 限定 admin → 全局 admin → owner
├─ pickApprovalDelivery: 通过 user_dms 解析出一个能到达的私聊
├─ 写 pending_approvals 行
└─ 通过投递适配器发一张三按钮卡
[ ✅ 批准 ] [ ❌ 拒绝 ] [ 📝 拒绝并说明原因… ]

用户点击 → 适配器 onAction → 主机响应注册表

├─ approve → reenterGuardedDeliveryAction:带着审批行重进 guard
├─ reject → 立即结束
└─ reject with reason → 挂起,抓管理员的下一条私聊当理由
(超时由 sweep 的 sweepAwaitingReasonRejects 兜底)

「拒绝并说明原因」那条路会挂住这一行,并注册一个消息拦截器抓下一条自由文本。管理员如果人间蒸发(或者主机重启了),sweep 每 tick 扫一次中央库把过期的挂起收尾(src/host-sweep.ts:150)。


7. ncl:同一个 dispatcher,两种传输

主机侧: bin/ncl → Unix socket(data/ncl.sock)──┐
├──▶ dispatch(frame, ctx)
容器侧: ncl → 写 outbound.db 的 cli_request ───┘

dispatch(src/cli/dispatch.ts:40)对两种调用者做同样的事,只是 CallerContext 不同。

容器调用者的作用域机制

container_configs.cli_scope 三档:

行为
disabledagent 根本学不到 ncl 的存在(CLAUDE.md 里排除相关说明),主机侧直接拒绝任何 cli_request
group(默认)只能碰 groups / sessions / destinations / members / tasks,且自动填成自己的组 id;跨组访问拒绝;改 cli_scope 本身被禁
global不限

「自动填」而不是「校验参数」(dispatch.ts:80-91):agent 根本不用传自己的组 id,主机直接把 agent_group_idgroup、必要时的 id 覆盖成上下文里的真值。注释写得很直白:Never trust a caller ID.

存在性预言机的堵法

// src/cli/dispatch.ts:106-115
const s = getSession(req.args.id as string);
if (!s || s.agent_group_id !== ctx.agentGroupId) {
return err(req.id, 'handler-error', `session not found: ${req.args.id}`);
}

注释点明这是防「存在性预言机」:不管这个 UUID 在别的组里存不存在,一律回 not found。否则 agent 可以靠错误消息的差异枚举出别组的会话 id。


8. 代码地图(导航索引)

主题文件路径符号名
中央库参考 schemasrc/db/schema.tsSCHEMA
访问层级解析src/modules/permissions/access.tscanAccessAgentGroup
权限模块的四个钩子注册src/modules/permissions/index.ts模块顶层
路由侧钩子定义src/router.tssetSenderResolver / setAccessGate / setSenderScopeGate / setChannelRequestGate
guard 决策函数src/guard/guard.tsguard / grantSatisfies
guard 词汇表src/guard/types.tsGuardDecision / ALLOW / HOLD / DENY
显式不设防标记src/guard/types.tsunguarded / isUnguarded
投递动作注册(禁止解除武装)src/delivery.tsregisterDeliveryAction
批准后重进 guardsrc/delivery.tsreenterGuardedDeliveryAction
审批原语src/modules/approvals/primitive.tsrequestApproval / pickApprover / registerApprovalHandler
CLI 分发与作用域src/cli/dispatch.tsdispatch
斜杠命令闸门src/command-gate.tsgateCommand
接线默认值解析src/channels/channel-defaults.tsresolveWiringDefaults / resolveUnknownSenderPolicy
渠道默认值声明src/channels/adapter.tsChannelDefaults