9.3 KiB
Q7-Q9: 权限与安全
Q7: 用户身份怎么确定?owner / admin / member 三级权限检查在哪里完成?
答案
用户身份确定(两步):
src/modules/permissions/index.ts:67-103 — extractAndUpsertUser 函数:
- 解析原始 handle: 从入站消息 JSON payload 的三处位置按优先级查找:
senderId、sender、author.userId(chat-sdk-bridge 的嵌套格式)。三处都没有返回null - 命名空间标准化: 如果原始 handle 已含
:前缀(如slack:U0ABC),直接使用;否则前缀channelType:→ 全局唯一userId - Upsert:
users表中不存在则创建(db/users.ts:13-22),kind记录 channel 类型,display_name从消息中提取
这个 resolver 通过 setSenderResolver(extractAndUpsertUser) 注册到 router(index.ts:171),在 agent 解析之前执行(router.ts:252),确保即使后续被拒绝访问,users 行也已存在。
三级权限表:
| 表 | 用途 | 关键字段 |
|---|---|---|
users |
用户身份 | id(命名空间化的userId),kind,display_name |
user_roles |
特权角色 | user_id,role(owner/admin),agent_group_id(NULL=全局) |
agent_group_members |
非特权成员 | user_id,agent_group_id |
canAccessAgentGroup 完整逻辑:
src/modules/permissions/access.ts:21-28 — 五步短路求值:
function canAccessAgentGroup(userId: string, agentGroupId: string): AccessDecision {
if (!getUser(userId)) return { allowed: false, reason: 'unknown_user' }; // Gate 0
if (isOwner(userId)) return { allowed: true, reason: 'owner' }; // Gate 1
if (isGlobalAdmin(userId)) return { allowed: true, reason: 'global_admin' };// Gate 2
if (isAdminOfAgentGroup(userId, agentGroupId)) return { allowed: true, reason: 'admin_of_group' };// Gate 3
if (isMember(userId, agentGroupId)) return { allowed: true, reason: 'member' };// Gate 4
return { allowed: false, reason: 'not_member' };
}
查库函数(db/user-roles.ts):
isOwner:role='owner' AND agent_group_id IS NULL(line 36-41)—— owner 必须全局isGlobalAdmin:role='admin' AND agent_group_id IS NULL(line 43-48)isAdminOfAgentGroup:role='admin' AND agent_group_id=<id>(line 50-55)
isMember 的隐式 admin 语义(db/agent-group-members.ts:28-36):
先检查 isOwner || isGlobalAdmin || isAdminOfAgentGroup 再查 agent_group_members 表——owner 和 admin 自动成为 member,不需要额外插入行。
Q8: 陌生人在群里 @bot,系统怎么决定忽略、审批、还是直接响应?
答案
unknown_sender_policy 三态(src/types.ts:31,默认 'strict'):
| 值 | 行为 |
|---|---|
public |
完全跳过访问检查。router.ts access gate 在 index.ts:175 直接返回 { allowed: true } |
strict |
非 member 的消息静默丢弃,记录到 unregistered_senders,不发送通知 |
request_approval |
非 member 的消息被丢弃 + 发起一轮审批流程:DM 一个 admin/owner,展示 Approve/Deny 卡片 |
注意:即使 unknown_sender_policy='public',sender_scope='known' 仍可作为更严格的叠加层(router.ts:284 senderScopeGate)。
完整决策链路(src/router.ts routeInbound):
- Message interceptor 先检查拦截器 hook 是否消费了该消息
- Messaging group 解析 → sender resolver → agent fan-out
- Access gate(line 283):就是
canAccessAgentGroup—— 此处unknown_sender_policy生效 - Sender scope gate(line 284):per-wiring 的
sender_scope='known'额外检查 - 如果 access gate 拒绝 →
handleUnknownSender
sender-approval 完整流程
Phase 1: 触发与去重(permissions/index.ts:113-169):
- 记录
dropped_message,reason=unknown_sender_request_approval - 调用
requestSenderApproval - 去重门控(
sender-approval.ts:59-65):检查pending_sender_approvals表中的UNIQUE(messaging_group_id, sender_identity)—— 同一个人已有 pending 卡片时,后续消息静默丢弃
Phase 2: 选择审批者(sender-approval.ts:67-87):
pickApprover(agentGroupId)→ scoped admins → global admins → owners(primitive.ts:76-93)pickApprovalDelivery(primitive.ts:103-119):遍历审批者,调用ensureUserDm找到可 DM 的人,优先同 channel 类型
Phase 3: 创建 pending + 发送卡片:
- 标题和内容:
"New sender / <name> wants to talk to your agent. Allow?" ask_question卡片,通过deliveryAdapter.deliver发到审批者 DM
Phase 4: 审批响应(index.ts:225-283):
APPROVE:
- 授权验证(line 234-244):检查点击者是不是
approver_user_id或有 admin 权限——防止随机用户通过转发卡片自我授权 addMember添加到agent_group_members(line 249-254)- 先删除 pending 行(line 264),再重新调用
routeInbound(event)(line 268)——此时重试经过 access gate,user 已是 member
DENY:
- 仅删除
pending_sender_approvals行 - 不创建拒收列表——同一发送者的新消息会重新触发新审批卡片
失败模式
- 没有 owner/admin → 消息永久丢弃
- 没有任何审批者可达(无 DM channel)→ 同上
- 没有 delivery adapter → pending 行仍创建(可手动查看),但卡片不发送
- 卡片发送异常 → error log,消息已丢弃
Q9: Agent 在容器里能用 ncl 命令吗?能查其他 agent group 的数据吗?cli_scope 的三个值在哪里被检查?
答案
cli_scope 的三个值(container_configs 表,迁移 015 添加,默认 'group'):
| 值 | 含义 |
|---|---|
disabled |
Agent 完全不知道 ncl 存在;host 拒绝所有 CLI 请求 |
group(默认) |
Agent 只能操作自己的 group(4个资源),args 被自动绑定到自己 group;无法查看其他 group 数据 |
global |
无限制。仅通过 init-first-agent 脚本为 owner 的 agent group 设置 |
四层防御
防御层 1:CLAUDE.md 层面 — src/claude-md-compose.ts:82-90
cli_scope === 'disabled' 时,cli.instructions.md 从 CLAUDE.md 的 fragment 列表中排除。Agent 学不到 ncl 命令的存在。
防御层 2:Host dispatch 核心强制 — src/cli/dispatch.ts
- disabled 检查(line 46-48):所有 CLI 请求立即拒绝
- 资源白名单(line 51-55):
group只允许groups、sessions、destinations、members四种资源 - 参数强制绑定(line 60-72):检查
agent_group_id、group、--id参数,不匹配调用者的 group 则拒绝 - 特权升级阻止(line 74-77):拒绝任何包含
cli_scope或cli-scope参数的命令 - 自动填充(line 81-90):主动填充
agent_group_id、group、--id为调用者的 group ID——agent 不需要也不能指定 - sessions-get 防 existence oracle(line 95-100):如果传入 session UUID 属于其他 group,返回 "not found" 而非 "forbidden"
- Post-handler 过滤(line 150-173):对
list返回的 rows 按scopeField再次过滤,丢弃不属于调用者 group 的行
各资源的 scopeField 映射(src/cli/resources/):
groups→'id'(groups.ts:41)sessions→'agent_group_id'(sessions.ts:10)destinations→'agent_group_id'(destinations.ts:11)members→'agent_group_id'(members.ts:11)
防御层 3:Container 端 — container/agent-runner/src/cli/ncl.ts
Container 内的 ncl 不做任何权限检查——它是纯粹的 DB transport 客户端。将 CLI 请求写入 outbound.db,从 inbound.db poll 响应。权限检查全在 host 端。Container 无法绕过,因为它只能访问 session DB,不能直接连中央库或 socket server。
防御层 4:Command Gate(斜杠命令) — src/command-gate.ts:23-63
独立于 cli_scope,在消息写入 messages_in 之前运行(router.ts:430-448):
- Filtered commands(
/help,/login,/logout,/doctor,/config,/remote-control)→ 静默丢弃永不进容器 - Admin commands(
/clear,/compact,/context,/cost,/files)→ 仅 owner / admin 可通过。否则直接写messages_out返回权限拒绝 - 降级安全(line 51):
user_roles表不存在 → 所有 admin 命令放行
跨层防御矩阵总结
| 防御层 | disabled | group | global |
|---|---|---|---|
| CLAUDE.md 隐藏 ncl | ✅ 排除 | ❌ 包含 | ❌ 包含 |
| Host dispatch 拒绝所有 | ✅ 拒绝 | ❌ | ❌ |
| 资源白名单 | N/A | ✅ 4种 | ❌ 无限制 |
| 参数强制绑定 | N/A | ✅ 自动填+拒绝跨界 | ❌ |
cli_scope 自我修改阻止 |
N/A | ✅ 拒绝 | ❌ |
| Post-handler rows 过滤 | N/A | ✅ scopeField | ❌ |
| Sessions oracle 防护 | N/A | ✅ fail-closed | ❌ |
| Command gate 斜杠命令 | ✅ | ✅ | ✅ |
| Approval gating(非host) | N/A | ✅ | ✅ |
关键 gotcha: group-scoped agent 可以通过 ncl groups config get 读到自己的完整 config(包括 cli_scope 字段),所以知道自己在 group 范围内。但由于 cli_scope 参数被第 74-77 行硬阻止,不能修改自己的 scope。