# Q7-Q9: 权限与安全 --- ## Q7: 用户身份怎么确定?owner / admin / member 三级权限检查在哪里完成? ### 答案 **用户身份确定(两步):** `src/modules/permissions/index.ts:67-103` — `extractAndUpsertUser` 函数: 1. **解析原始 handle:** 从入站消息 JSON payload 的三处位置按优先级查找:`senderId`、`sender`、`author.userId`(chat-sdk-bridge 的嵌套格式)。三处都没有返回 `null` 2. **命名空间标准化:** 如果原始 handle 已含 `:` 前缀(如 `slack:U0ABC`),直接使用;否则前缀 `channelType:` → 全局唯一 `userId` 3. **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` — 五步短路求值: ```typescript 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=`(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`): 1. Message interceptor 先检查拦截器 hook 是否消费了该消息 2. Messaging group 解析 → sender resolver → agent fan-out 3. **Access gate**(line 283):就是 `canAccessAgentGroup` —— 此处 `unknown_sender_policy` 生效 4. **Sender scope gate**(line 284):per-wiring 的 `sender_scope='known'` 额外检查 5. 如果 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 / wants to talk to your agent. Allow?"` - `ask_question` 卡片,通过 `deliveryAdapter.deliver` 发到审批者 DM **Phase 4: 审批响应**(`index.ts:225-283`): **APPROVE:** 1. **授权验证**(line 234-244):检查点击者是不是 `approver_user_id` **或**有 admin 权限——防止随机用户通过转发卡片自我授权 2. `addMember` 添加到 `agent_group_members`(line 249-254) 3. **先删除 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。