mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 13:40:37 +00:00
* feat(authz): construction-enforced async decision batches (ActivationDecisions) The follow-up promised in the #4541 review threads. Four review rounds traced four instances of one failure family: an async execution path whose decision-map miss silently fell back to the synchronous authorize() from a worker thread — the wrong API for loop-affine providers, and under fail_closed:false a provider error there flips a denial into an allow. The carrier was a plain dict|None held together by caller discipline. ActivationDecisions (deerflow/authz/activation_decisions.py) makes the contract construction-enforced: - a covered name returns the batched decision (unchanged semantics); - a MISS resolves per the configured provider-error policy (fail-closed denies, fail-open allows) with a loud WARNING — a miss is a prepass/consumer divergence, a construction bug, not a policy question — and never touches a provider; - None remains the sync-chain signal (synchronous authorize() is correct there); - an empty candidate set still yields an empty BATCH, not None, so an unexpected consumer name fails per policy instead of silently taking the sync path; - get_or_none lets publishers distinguish 'uncovered' from False; - fail_closed travels with the batch from the resolved authorization context. Both middlewares (_activation_allowed in SkillActivationMiddleware and SkillToolPolicyMiddleware) migrate to the type; their collectors return batches; the render-decision publisher adapts. Tests 65->70+5: type unit tests (covered/miss both directions with loud-log assertions/ get_or_none/container), middleware-level miss semantics (fail-open allows with provider.sync_calls == [], production-default fail-closed denies, None sync chain still synchronous), and mutation M18 (restoring the miss's sync fallback) verified red. 252 tests green on the affected surface. * docs(authz): sync every decision-batch contract description to the new miss semantics Review round 1 on #6508 — all documentation-level: the docstrings still described the miss-as-synchronous-fallback contract this PR removes. - _activation_allowed (both middlewares): a covered name returns the batched decision; a miss resolves per the carried provider-error policy with a WARNING and never touches a provider; None (sync chain / disabled) is the only synchronous case. - _collect_activation_decisions / _resolve_policy_registry / _active_skills_for_paths: the reason not to diverge is now a policy-resolved miss (denied under the production fail-closed default; allowed without any provider consult under fail-open; logged loudly), not a silent sync fallback. - The two _REGISTRY_LOAD_FAILED sentinel comments, same rewording. - Drop unused tmp_path/monkeypatch fixtures from the two new miss tests. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
1196 lines
97 KiB
Markdown
1196 lines
97 KiB
Markdown
# 可插拔授权系统实施记录
|
||
|
||
<!-- Language: Chinese. This document is the cumulative handoff record for the
|
||
pluggable authorization system (RFC #4063). An English summary is provided
|
||
below for navigation; the detailed content is in Chinese. -->
|
||
|
||
> **English summary:** This is the cumulative implementation log for the
|
||
> pluggable authorization RFC ([#4063](https://github.com/bytedance/deer-flow/issues/4063)).
|
||
> It records merged contracts, reviewer-confirmed decisions, and required
|
||
> regression coverage for each phase. Sections:
|
||
> - **每个 RFC PR 的必读要求** — Pre-PR checklist for every authorization change
|
||
> - **信息优先级** — Information precedence when sources conflict
|
||
> - **Phase 0:已合并基线** — Phase 0 merged baseline (PR #4127)
|
||
> - **PR #4127 多轮修改的原因** — Root causes of multi-round review iterations
|
||
> - **所有后续阶段必须保持的不变量** — Invariants all phases must preserve
|
||
> - **Phase 1 实施前确认** — Phase 1 pre-implementation confirmations
|
||
> - **每次更新 PR 前的固定清单** — Fixed checklist before each PR update
|
||
> - **决策日志** — Append-only decision log
|
||
> - **当前连续性风险** — Current continuity risks
|
||
|
||
本文档是可插拔授权 RFC([#4063](https://github.com/bytedance/deer-flow/issues/4063))
|
||
的持续实施记忆。它用于补充设计 RFC,记录已经实际合并的内容、review 中确认的契约,
|
||
以及每个后续 PR 必须验证的事项。
|
||
|
||
## 每个 RFC PR 的必读要求
|
||
|
||
修改任何后续阶段前,必须阅读:
|
||
|
||
1. [设计 RFC](2026-07-10-pluggable-authorization-rfc.md)。
|
||
2. 本实施记录。
|
||
3. 前一阶段已经合并的代码和测试。如果它们与旧 RFC 示例不一致,以已合并契约为准。
|
||
|
||
每个 PR 描述中必须复制并确认以下内容:
|
||
|
||
```markdown
|
||
## Authorization RFC 连续性确认
|
||
|
||
- [ ] 已阅读 `docs/plans/2026-07-10-pluggable-authorization-rfc.md`。
|
||
- [ ] 已阅读 `docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md`。
|
||
- [ ] 已核对所有前置阶段的决策和延期事项。
|
||
- [ ] 已用本 PR 的新决策和后续事项更新实施记录。
|
||
```
|
||
|
||
## 信息优先级
|
||
|
||
不同来源发生冲突时,按以下顺序判断:
|
||
|
||
1. 已合并代码和回归测试。
|
||
2. 已接受的 review 决策和最终合并的 PR 描述。
|
||
3. 本实施记录。
|
||
4. 设计 RFC 中较早的示例或阶段划分。
|
||
|
||
不能静默修改或重新解释冲突。必须写入决策日志;涉及架构或安全行为时,还要在
|
||
issue #4063 中确认。
|
||
|
||
## Phase 0:已合并基线
|
||
|
||
PR [#4127](https://github.com/bytedance/deer-flow/pull/4127) 于 2026-07-15
|
||
以提交 `1300c6d3` 合并,确立了以下契约:
|
||
|
||
- `AuthorizationProvider` 是可在运行时检查的 Protocol,包含同步授权、异步授权和
|
||
`filter_resources`。
|
||
- `filter_resources` 是必需方法。Protocol 中方法体为 `...` 不代表存在默认实现。
|
||
没有静态映射的 provider 必须自行实现逐项授权。
|
||
- `GuardrailAuthorizationAdapter` 有意让 provider 异常向上传播。
|
||
`GuardrailMiddleware` 统一负责异常处理、审计以及 fail-open/fail-closed 执行。
|
||
- 不能通过 `user_role == "internal"` 推导 `Principal.is_internal`。权威信号是内部
|
||
认证状态 `auth_source`,必须从 Gateway 上下文传递。
|
||
- Adapter 上下文保留 `thread_id`、`run_id`、`tool_call_id`、`tool_input`、
|
||
`is_subagent`、`agent_id` 和 `timestamp`。
|
||
- `AuthorizationConfig` 已加入 AppConfig,默认 `enabled: false`,并参与 singleton
|
||
加载;Phase 0 尚无运行时代码读取它。
|
||
- Adapter 在结构上符合 `GuardrailProvider`;同步和异步异常传播均由测试固定。
|
||
|
||
以下原 RFC Phase 0 项目没有在 PR #4127 中落地,仍属于后续工作:Principal 构建器、
|
||
内置 RBAC provider、Layer 1 过滤、Layer 2 自动装配和内部 Principal 填充。
|
||
|
||
## PR #4127 多轮修改的原因
|
||
|
||
实现方向获得认可,但首版没有完整验证后续阶段将继承的关键契约:
|
||
|
||
- 直接沿用了 RFC 关于 `filter_resources` 默认行为的假设,没有先验证 Python
|
||
Protocol 的真实语义。willem-bd 和 zhfeng **独立**指出了同一个问题——多个 reviewer
|
||
从不同角度指向同一处,说明该缺陷在 review 中非常显眼。后续阶段中如果再次出现
|
||
多人独立指出同一处,应当视为最高优先级,不再需要多方确认。
|
||
- 身份字段按照表面数据结构映射,没有追踪到运行时权威来源。
|
||
- 配置测试验证了 Pydantic 对象构建,但最初绕过了真实 singleton 加载生命周期。
|
||
- schema 变化最初遗漏 `config_version`;同时主线发生版本竞争,需要 rebase 后重新
|
||
选择版本号。
|
||
- Helm 中的配置版本镜像和仓库内 RFC 文档较晚才在 review 中被发现。
|
||
- 代码、测试、注释和 PR 描述没有在同一次 push 中同步,导致旧描述让已修问题再次
|
||
被提出。
|
||
- 对 `GuardrailMiddleware` 已有的 fail-closed 机制理解不足,在 adapter 中写了
|
||
"Phase 1 加 try/except" 的 TODO,暗示 adapter 应当自行处理异常。实际上 fail-closed
|
||
是 middleware 的职责,adapter 刻意不 catch 异常。描述与架构意图不一致导致了额外的
|
||
review 轮次。
|
||
|
||
这些问题主要是实施前检查和可追踪性不足,并非两层授权设计被否定。
|
||
|
||
## 所有后续阶段必须保持的不变量
|
||
|
||
- `authorization.enabled: false` 必须保持现有行为不变。
|
||
- Layer 1 和 Layer 2 必须使用同一个 provider 和同一个 Principal。
|
||
- Layer 1 必须在 `assemble_deferred_tools` 之前过滤;被移除的工具不能进入
|
||
`DeferredToolCatalog`,也不能被 `tool_search` 再次提升。
|
||
- Layer 1 必须覆盖 lead agent、native subagent 和 `DeerFlowClient` 三条装配路径。
|
||
- Layer 2 复用 `GuardrailMiddleware`;不能在 adapter 中重复实现异常处理、审计、
|
||
deny 消息或 fail-closed 逻辑。
|
||
- ownership 检查和现有 `require_admin_user()` 管理端点保护必须保留。细粒度授权只能
|
||
增加策略,不能削弱现有保护。
|
||
- deny 优先于 allow。身份缺失、未知角色、provider 故障和 provider 返回值格式错误
|
||
都必须具有明确且经过测试的行为。
|
||
- 同步和异步路径必须具有一致的授权决策和失败语义。
|
||
- internal、Web、关闭认证、已绑定频道、未绑定频道、scheduler 和 subagent 的身份
|
||
都必须从真实来源追踪。
|
||
- 新增 resource 或 action 时,必须检查所有消费者、allowlist、配置示例、文档和测试。
|
||
- 新组件嵌入现有中间件前,必须先完整理解宿主中间件已有的机制(异常处理、审计、
|
||
fail-closed 等)。新组件不重复实现宿主已有的逻辑;如果看似缺少某功能,先确认是
|
||
否由宿主在上游或下游统一处理。
|
||
|
||
## Phase 1 实施前确认
|
||
|
||
Phase 1 是工具授权。编码前必须先确定:
|
||
|
||
- Principal 在哪里构建,以及如何进入三条工具装配路径。
|
||
- `auth_source`、owner role、`default_role` 和 subagent 继承如何组合。
|
||
- provider 如何实例化,以及配置热更新后如何刷新。
|
||
- authorization 与显式配置的 guardrail 如何共存,二者都不能静默替换或绕过对方。
|
||
- provider 构造异常和授权决策异常是否使用同一 fail-closed 策略,以及各自由哪层处理。
|
||
- 内置 RBAC provider 对 allow、deny 和通配符的精确定义。
|
||
|
||
Phase 1 最低验证要求:
|
||
|
||
- 每角色 allow、deny、通配符、deny 优先、未知角色和默认角色测试。
|
||
- lead agent、subagent 和 embedded client 的工具可见性测试。
|
||
- 证明被拒绝工具不会进入 deferred catalog。
|
||
- Layer 2 的 allow、deny、provider 异常、审计、同步和异步测试。
|
||
- prompt injection 回归测试,证明装配阶段被过滤的工具无法执行。
|
||
- internal、未绑定频道、关闭认证和 subagent 的 Principal 测试。
|
||
- 通过真实生命周期执行 AppConfig 加载与热更新测试。
|
||
- 证明关闭 authorization 时现有工具集合完全不变。
|
||
|
||
## 每次更新 PR 前的固定清单
|
||
|
||
- [ ] 选择配置版本号前,已 fetch 并 rebase 最新 `upstream/main`。不能使用本地缓存的
|
||
旧版本号——主线可能在此期间已被其他 PR bump 过。先 fetch、读最新值、+1,再在
|
||
`config.example.yaml` + `deploy/helm/deer-flow/values.yaml` + `deploy/helm/deer-flow/README.md`
|
||
三处同步。
|
||
- [ ] 已搜索 issue #4063 和当前阶段是否存在并行工作。
|
||
- [ ] 每个新字段都已追踪到权威生产者,而不只是确认类型。
|
||
- [ ] 测试经过公开运行时生命周期,而不只是直接构造配置模型。
|
||
- [ ] 已按需覆盖 lead、subagent、embedded、同步和异步路径。
|
||
- [ ] 已执行负向变异检查:删除新增 wiring 后,至少一个回归测试必须失败。
|
||
- [ ] 已搜索配置的所有镜像,包括 Helm values 和相关文档。
|
||
- [ ] 代码注释、测试、RFC 记录和 PR 描述已在同一次 push 中更新。
|
||
- [ ] 已明确列出延期阶段,且没有把延期功能带入当前范围。
|
||
- [ ] 已在下方记录新决策和未解决问题。
|
||
- [ ] 如果多个 reviewer 独立指出同一处问题,视为高置信信号,立即修复,不再等待
|
||
进一步确认。
|
||
|
||
## 决策日志
|
||
|
||
只追加新记录。需要推翻旧决策时,必须新增一条“替代决策”,不能直接重写历史。
|
||
|
||
### 2026-07-15 — Phase 0 / PR #4127
|
||
|
||
- **决策:** `filter_resources` 为必需方法,不提供 Protocol fallback。
|
||
- **决策:** provider 异常穿过 adapter,由 `GuardrailMiddleware` 处理。
|
||
- **决策:** internal 身份来自认证上下文,不使用角色名称约定推导。
|
||
- **决策:** Phase 0 默认保持运行时行为不变。
|
||
- **延期:** Principal 构建、RBAC provider、两层执行接入和 internal Principal 传递
|
||
移至 Phase 1。
|
||
|
||
### 2026-07-15 — Phase 1A-1 / 可信 Principal 链路
|
||
|
||
- **背景:** Phase 0 建立了 `AuthorizationProvider` Protocol 和 adapter,但
|
||
`Principal.is_internal` 无可信来源,adapter 手工构造 Principal(与未来 Layer 1 的
|
||
builder 不一致),客户端可伪造身份字段。
|
||
- **决策:** `build_principal_from_context()` 是唯一 Principal builder,Layer 1 和
|
||
Layer 2(adapter)必须共用。
|
||
- **决策:** `is_internal` 来自 `request.state.auth_source == AUTH_SOURCE_INTERNAL`,
|
||
在 `inject_authenticated_user_context` 最顶部(所有 early return 之前)用直接赋值
|
||
写入 runtime context,不用 `setdefault`。
|
||
- **决策:** `is_internal`、`authz_attributes` 和 `channel_user_id` 列为
|
||
`_SERVER_OWNED_AUTHZ_CONTEXT_KEYS`,
|
||
从 `config["context"]` 和 `config["configurable"]` 清除客户端值。
|
||
- **决策:** `channel_user_id` 只接受内部认证 IM 调用方的顶层 `body.context` 值;普通
|
||
session 调用和 `body.config` 两个 section 均不能提供该授权身份字段。
|
||
- **决策:** Phase 1A-1 没有 Gateway 侧 `authz_attributes` 权威生产者;Gateway 请求中
|
||
的 `authz_attributes` 一律删除(默认 `{}`)。
|
||
- **决策:** adapter 的 `evaluate`/`aevaluate` 通过 `build_principal_from_context()`
|
||
构造 Principal,接收 `default_role` 参数。
|
||
- **决策:** `authz_attributes` 在所有进程内消费边界统一使用 `isinstance(x, Mapping)`
|
||
+ `dict()` 复制;非 Mapping 抛 `TypeError`。
|
||
- **决策:** subagent 的 `is_internal` 无条件写回 context(包括 `False`)。
|
||
- **证据:** 323 个目标与边界测试通过,覆盖 Gateway 防伪、channel sender 信任边界、subagent 继承、
|
||
`GuardrailMiddleware` runtime 字段映射、adapter builder 复用和 harness/app 边界。
|
||
- **兼容性:** `authorization.enabled: false` 时工具集合和执行决策不变;runtime context
|
||
新增 `is_internal` 字段是有意的可观察变化。
|
||
- **延期:** RBAC provider、provider factory、Layer 1 过滤、Layer 2 自动接线移至
|
||
Phase 1A-2 / Phase 1B。
|
||
|
||
### 2026-07-17 — Phase 1A-2 / 内置 RBAC provider 与 provider factory
|
||
|
||
- **背景:** Phase 1A-1 建立了可信 Principal 链路,但没有策略引擎。
|
||
Phase 1A-2 实现内置 RBAC provider 和统一 provider factory。
|
||
- **决策:** `RbacAuthorizationProvider` 在构造时完成全部配置校验并编译为
|
||
不可变结构(`frozenset` / sentinel `_ALL`)。请求路径只做 O(1) membership 检查。
|
||
- **决策:** deny 永远优先于 allow,无论 allow 是 `"*"`、`True`、列表还是缺失。
|
||
- **决策:** 未知角色和缺失角色抛 `ValueError`(不返回 allow),由执行层
|
||
根据 `fail_closed` 决定。
|
||
- **决策:** 资源名使用显式映射(`tool → tools`,`model → models` 等),
|
||
不通过加 `s` 猜测。配置中的保留请求别名(如 `tool`)在构造期拒绝,并提示使用
|
||
对应配置键(如 `tools`),防止策略被存储在永远无法命中的键下。未知 resource
|
||
使用原名查找;未配置时视为"不受限"。
|
||
- **决策:** `resolve_authorization_provider()` 是唯一 provider 解析入口。
|
||
disabled 时返回 `None`(不 import provider 模块);enabled 但缺少 provider
|
||
时抛 `ValueError`。不缓存实例。不注入 `fail_closed` 或 `default_role`。
|
||
- **决策:** 内置和自定义 provider 使用完全相同的 `resolve_variable` class-path
|
||
解析路径,无特殊分支。
|
||
- **证据:** 66 tests passed(51 RBAC + 15 factory,其中 5 条为 malformed-policy
|
||
回归测试)。
|
||
- **兼容性:** 无运行时行为变化(`authorization.enabled: false`)。不修改
|
||
`config.example.yaml`,不 bump `config_version`。
|
||
- **延期:** Layer 1 工具过滤、Layer 2 自动接线、DeerFlowClient、RBAC 配置示例
|
||
移至 Phase 1B。
|
||
- **Phase 1B 注意:** 已知角色缺少某个 resource policy 时语义是“不受限”,不是
|
||
fail-closed。配置示例必须明确提醒,并枚举部署方希望限制的每种 resource。
|
||
- **Phase 1B 注意:** 内置 RBAC 当前按 role + resource + target 决策,不区分
|
||
`AuthzRequest.action`;`policy_id` 也是稳定但粗粒度的
|
||
`rbac:allow` / `rbac:deny` / `rbac:unrestricted`。接入审计日志前应决定是否通过
|
||
更具体的 policy id 或 decision metadata 记录 role / resource / target。
|
||
|
||
### 2026-07-20 — Phase 1A-2 / PR #4260 请求边界收口
|
||
|
||
- **背景:** review 发现 `request.target` 未经运行时校验;通配符策略会允许
|
||
`None` 或空字符串,而列表策略会拒绝,形成依赖策略形态的不一致结果。进一步审查
|
||
发现无效 resource 和批量过滤候选项也存在相同的“不受限/通配符路径放行”风险。
|
||
- **决策:** 内置 RBAC 在请求边界要求 resource、resource type、target 和每个
|
||
candidate 都是非空字符串;`filter_resources()` 还要求 candidates 是 list。
|
||
非法输入统一抛 `ValueError`,不能进入 `rbac:unrestricted` 或通配符 allow 路径。
|
||
- **决策:** `filter_resources()` 对缺失/未知角色继续与 `authorize()` 一致地抛
|
||
`ValueError`,不在 provider 内静默返回空列表。Phase 1B 集成层负责按
|
||
`fail_closed` 处理 provider 异常,避免隐藏身份或部署配置错误。
|
||
- **证据:** 90 tests passed(75 RBAC + 15 factory),覆盖 unrestricted、
|
||
wildcard、allow-list、同步/异步、非法 resource/target/candidates,以及
|
||
`filter_resources()` 的缺失/未知角色错误语义。
|
||
- **兼容性:** 只拒绝不符合 `AuthzRequest` / `filter_resources` 类型契约的运行时
|
||
输入;Phase 1A-2 仍未接入运行时,`authorization.enabled: false` 行为不变。
|
||
|
||
### 2026-07-22 — Phase 1B / 工具授权执行接入
|
||
|
||
- **背景:** Phase 1A 完成了 Principal 链路、RBAC provider 和 factory。
|
||
Phase 1B 将 provider 接入 Layer 1(组装时过滤)和 Layer 2(执行时拦截)。
|
||
- **决策:** `apply_tool_authorization()` 是 Layer 1 的统一入口,组合 provider
|
||
解析、Principal 构建和 `filter_tools_by_authorization` 过滤。disabled 时返回
|
||
原始工具和 `None`。
|
||
- **决策:** Layer 1 过滤在 `assemble_deferred_tools` 之前执行,覆盖三条路径:
|
||
lead agent(bootstrap + default)、subagent、embedded client。
|
||
- **决策:** Layer 2 通过 `GuardrailAuthorizationAdapter` 复用 `GuardrailMiddleware`,
|
||
authorization middleware 在显式 guardrail 之前(外层),两者独立运行。
|
||
- **决策:** Layer 1 和 Layer 2 尝试共享同一个 provider 实例("resolve once per
|
||
build")。subagent 通过 `_authz_provider` 属性传递。
|
||
- **决策:** Embedded client `_agent_config_key` 加入 `user_role` 和 `is_internal`
|
||
作为 cache key,角色变化时强制重建 agent。
|
||
- **兼容性:** `authorization.enabled: false` 时工具集合和执行决策完全不变。
|
||
- **延期:** Models/Skills/Sandbox 权限(Phase 2+);route-level 迁移。
|
||
|
||
#### Phase 1B review 收口
|
||
|
||
- Layer 1 的候选集合必须包含本次 build 最终可能暴露给模型的全部业务工具;
|
||
`describe_skill` 和 memory tools 因此在授权过滤前加入,过滤后才进行 deferred
|
||
assembly。框架生成的 `tool_search` 仍是受已过滤 catalog 约束的基础设施工具。
|
||
- lead、bootstrap、native subagent 和 embedded client 都把 Layer 1 解析出的同一
|
||
provider 实例传给 Layer 2,禁止在 middleware 构建时再次解析 provider。
|
||
- `tool_search` 仅在当前 build 确实生成了 deferred catalog 时作为基础设施工具跳过
|
||
authorization adapter 的第二次 provider 调用;catalog 已由 Layer 1 过滤。显式配置的
|
||
guardrail 仍会检查它,没有 deferred setup 的普通同名工具也不获得豁免。
|
||
- 内置 RBAC provider 在解析时校验 `authorization.default_role` 属于已配置角色,配置
|
||
错误直接阻止 agent 构建,不再表现为难以诊断的空工具集合。
|
||
- `DeerFlowClient.stream()` 的调用方属于可信进程内边界,可通过关键字参数传入与
|
||
Gateway runtime context 相同的授权身份字段;这些字段同时进入真实执行 context。
|
||
agent cache key 使用完整 Principal(包括 user/channel/oauth/internal/attributes),
|
||
并深拷贝嵌套 attributes,防止调用方原地修改身份数据后复用旧工具集合。
|
||
- disabled 模式仍在 Layer 1 候选阶段包含 `describe_skill` / memory tools,但 deferred
|
||
assembly 后恢复原有顺序(业务工具、`tool_search`、late framework tools)。
|
||
- 回归测试必须经过真实 lead/bootstrap、subagent 和 embedded 组装函数,不能只测试
|
||
`filter_tools_by_authorization()` helper;同时断言被拒绝工具不在最终 bound tools 中,
|
||
且 Layer 2 收到的 provider 与 Layer 1 为同一对象。
|
||
|
||
### 2026-07-24 — Phase 2A / Gateway route permissions
|
||
|
||
- **背景:** `@require_permission` 已覆盖 Gateway 的 threads/runs 普通路由,但
|
||
`AuthMiddleware` 和 decorator-only `_authenticate()` 都向每个已认证用户写入固定
|
||
`_ALL_PERMISSIONS`,所以 Phase 1 的 provider 还不能限制 HTTP route。
|
||
- **决策:** `resolve_route_permissions()` 是唯一 route provider 入口;两条认证路径
|
||
都调用它,并把结果缓存到 request-scoped `AuthContext`。每个已注册 permission
|
||
生成独立 `AuthzRequest(resource="route", action=<action>,
|
||
target="<resource>:<action>")`,通过 `aauthorize()` 求值。
|
||
- **决策:** 单项 decision 异常只按 `authorization.fail_closed` 影响对应 permission,
|
||
不能因为求值其他五项的 incidental failure 扩大当前 route 的拒绝范围。provider
|
||
解析失败按同一配置返回空权限或 legacy 全权限。
|
||
- **兼容性:** `authorization.enabled: false` 时不解析 provider,继续返回原有六项
|
||
threads/runs 权限。`owner_check` 与 `require_admin_user()` 保持独立且不变。
|
||
- **证据:** 新增 route policy、trusted principal、async provider、fail-open /
|
||
fail-closed、middleware/decorator 共用和 built-in RBAC 覆盖;既有 auth 与 middleware
|
||
回归测试一并执行。
|
||
- **延期:** Models、Skills、Sandbox 权限;前端 effective-permissions 展示;
|
||
management route 的 provider 迁移。
|
||
|
||
### 2026-07-28 — Phase 3 / Models authorization (list / use)
|
||
|
||
- **背景:** Phase 2A 合并后,route-level 权限已由 provider 派生,但模型仍然对所有
|
||
已认证用户开放——`list_models` 返回全部模型,`_resolve_model_name` 不检查角色。
|
||
RFC §9 Phase 3 要求覆盖 Models/Skills/Sandbox 三个资源类型。
|
||
- **决策(Gateway 路由层):** 新增 `resolve_model_authorization(user, *, is_internal)`
|
||
返回 `(provider, principal)`,复用 Phase 2A 的 `_get_cached_route_provider` 和
|
||
`build_principal_from_context`,包括 `INTERNAL_SYSTEM_ROLE → None` pop。
|
||
`list_models` 使用 `provider.filter_resources(principal, "model", names)` 批量过滤;
|
||
`get_model` 使用 `provider.authorize(AuthzRequest(resource="model", action="use",
|
||
target=model_name))`。deny → 403(模型存在但角色无权使用),provider 解析失败 →
|
||
`_AuthorizationUnavailable`(携带 `fail_closed` 标志)。
|
||
- **决策(运行时解析层):** 新增 `_authorize_model_name(model_name, *, context,
|
||
app_config)` 在 `_resolve_model_name` 之后执行。deny 时按 RFC §9 优雅降级:回退到
|
||
`filter_resources` 返回的第一个允许模型并记录 warning,而不是崩溃。全部模型被拒 +
|
||
`fail_closed` → `ValueError`(与现有"无模型配置"契约一致);fail-open 返回原名。
|
||
fallback 阶段对每个候选重新调 `authorize("model", "use", candidate)` 验证,避免
|
||
custom provider 在 `filter_resources`(action-agnostic)里可见但 `use` 被拒的模型被
|
||
静默选中。
|
||
- **决策(embedded/library 路径):** `_authorize_model_name` 同样接入
|
||
`DeerFlowClient._ensure_agent`(client.py),与 Gateway runtime 路径 `_make_lead_agent`
|
||
对称。否则 library/embedded 消费者启用 `authorization` + role-scoped model policy 时,
|
||
tools 会被过滤但模型仍可绕过 `model:use`。调用前先把 `None` 默认解析为第一个配置模型
|
||
(与 `create_chat_model(name=None)` 的语义一致),确保隐式默认模型也经过授权。
|
||
- **否决方案:** 不为 `get_model` 引入 `"read"` action——RFC §9 将 `get_model` 映射到
|
||
`model:use`,引入第三个 action 会增加 RBAC 配置面而无实际收益。不在 `_resolve_model_name`
|
||
内部做授权——该函数是纯解析(request → config → default fallback),授权检查放在调用
|
||
点之后,保持单一职责。
|
||
- **兼容性:** `authorization.enabled: false` 时两条路径均为 no-op(路由返回全部模型,
|
||
解析返回原名)。匿名请求(user=None)不触发过滤。RBAC provider 的 `_RESOURCE_POLICY_KEYS`
|
||
已包含 `"model": "models"`,无需 schema 变更。
|
||
- **证据:** `tests/test_models_authorization.py`(26 tests)覆盖 disabled/anonymous/
|
||
RBAC allow/deny/wildcard/fail-closed/fail-open 路由场景(含 provider-resolution-error
|
||
fail-closed/fail-open),disabled/allowed/
|
||
graceful-fallback/all-denied-fail-closed/all-denied-fail-open/custom-provider-list-vs-use/
|
||
no-usable-fallback 运行时场景,以及 `DeerFlowClient._ensure_agent` 的 model:use 强制 +
|
||
None 默认解析 + disabled no-op 集成场景;
|
||
`test_authorization_*.py` + `test_lead_agent_model_resolution.py` +
|
||
`test_auth_middleware.py` 共 318 tests 全部通过。
|
||
- **延期:** Skills、Sandbox 权限(Phase 3 后续 PR);前端 effective-permissions 展示;
|
||
management route 的 provider 迁移。
|
||
|
||
### 2026-07-28 — Phase 3 / Skills authorization (activate)
|
||
|
||
- **背景:** Phase 2A 合并后,技能仍然对所有已认证用户开放——`available_skills`
|
||
仅由 agent config 白名单控制,不检查角色。RFC §9 Phase 3 要求覆盖 Skills 资源类型。
|
||
技能的 enforcement 与 Models 不同:技能没有 Gateway route,而是在 harness 层
|
||
(lead agent assembly + subagent executor + slash-activation middleware)执行。
|
||
- **决策(Layer 1 — 装配阶段):** 新增 `filter_available_skills_by_authorization()`
|
||
过滤技能名称集合(`set[str] | None`),而非 `Skill` 对象列表。在 lead agent
|
||
`_make_lead_agent` 中,该函数在 `_available_skill_names()` 之后、`_load_enabled_available_skills()`
|
||
之前执行,使得 catalog(`describe_skill`)和 `SkillActivationMiddleware`(slash 激活)
|
||
共享同一个已过滤的 allowlist。在 subagent `executor.py` 的 `_load_skills()` 中同样过滤
|
||
`config.skills` 白名单。
|
||
- **决策(Layer 2 — 运行时 slash 激活,2026-09-03 修订):** 成员检查之外,显式
|
||
slash 激活还执行动作级 `authorize("skill", "activate")` 检查。`SkillActivationMiddleware`
|
||
新增 `skill_authorization` 参数(`ResolvedSkillAuthorization`:装配时解析一次的
|
||
provider + principal + fail_closed),在 `_resolve_activation` 的成员检查之后调用
|
||
`_activation_allowed()`;deny 返回与成员拒绝相同的用户文案(不泄露原因),provider
|
||
异常按 fail_closed 拒绝 / fail_open 放行。三条链路(lead `build_middlewares`、
|
||
`DeerFlowClient._ensure_agent`、subagent `build_subagent_runtime_middlewares`)均接线。
|
||
修订原因:Layer 1 的 `filter_resources` 是动作无关的可见性层,动作感知的自定义
|
||
provider 可以"可见但拒绝激活"——原设计(仅成员检查)对该类 provider 不闭合。
|
||
~~`describe_skill` 与 in-context 秘密绑定保持在可见性层~~(2026-09-04 再修订:
|
||
`describe_skill` 与 skill-file-load 路径也执行动作检查,见下方 2026-09-04 日志;
|
||
in-context 秘密绑定经由"拒绝的读取不进 `skill_context`"传递性覆盖)。
|
||
- **决策(候选集解析的 fail-closed):** 当 `available_skills=None`(无 agent 级白名单)
|
||
且 `_all_configured_skill_names` 抛错(storage I/O 错误)时,不静默返回 `None`
|
||
(会让所有技能绕过授权)。改为让 `_all_configured_skill_names` 抛异常,调用方按
|
||
`fail_closed` 决策:fail-closed → `set()`(拒绝全部),fail-open → `None`(无限制)。
|
||
与 provider 错误的 fail-closed/open 处理对称。
|
||
- **决策(通用过滤器):** 新增 `filter_resources_by_authorization()` 在 `enforcement.py`,
|
||
是 `filter_tools_by_authorization` 的泛化版本,适用于任何有 `name` 属性的资源(技能、模型等)。
|
||
- **否决方案(2026-09-03 部分反转):** 原方案否决在 `_resolve_activation` 中添加
|
||
运行时 `authorize("skill", "activate")` 检查,理由是需要将 provider + principal 传入
|
||
middleware 构造函数并改变 `build_middlewares` 签名,且认为 Layer 1 过滤已覆盖激活路径。
|
||
该理由对动作无关的 RBAC provider 成立,但对动作感知的自定义 provider 不成立(可见 ≠
|
||
可激活,评审 P1 发现并复现)——现按上文 Layer 2 修订落地,"穿 provider/principal 进
|
||
构造函数"的成本被接受,与 `_authorize_model_name` 的第二重 `authorize("model", "use")`
|
||
检查模式一致。仍然成立的部分:不为技能新增独立 Gateway route(技能管理路由保持
|
||
`require_admin_user`,per §12 Q6);~~`describe_skill` 不做动作检查(见 Layer 2 修订)~~
|
||
(2026-09-04 再修订:describe 与 skill-file-load 均已加动作检查,见下方日志)。
|
||
- **兼容性:** `authorization.enabled: false` 时 `filter_available_skills_by_authorization`
|
||
是 no-op(返回原始 allowlist)。`available_skills=None`(无 agent 级白名单)+ 启用授权时,
|
||
从 config 解析全部技能名并过滤。RBAC provider 的 `_RESOURCE_POLICY_KEYS` 已包含
|
||
`"skill": "skills"`,无需 schema 变更。对 test mock(SimpleNamespace app_config)安全:
|
||
使用 `getattr` + `is not True` 防御。
|
||
- **证据:** `tests/test_skills_authorization.py`(45 tests,2026-09-04 两轮扩充)覆盖 disabled/RBAC allow/deny/
|
||
wildcard/empty/provider-error-fail-closed-fail-open/internal-caller 场景,generic filter,
|
||
候选集解析错误的 fail-closed/fail-open + 空配置无 bypass 回归,以及 Layer 2:动作级
|
||
deny 阻断激活(lead 中间件单测 + client 装配接线 + subagent 链接线)、provider 异常
|
||
fail-closed/fail-open、候选集复用 catalog loader(不再二次扫描)、effective user
|
||
三处统一。既有 authz + skills 测试全部通过。
|
||
- **延期:** Sandbox 权限(Phase 3 后续 PR,已由 #4911 落地);前端 effective-permissions 展示;
|
||
management route 的 provider 迁移。Models 权限由并行 PR(#4540)处理。
|
||
|
||
### 2026-08-02 — Phase 3 / Sandbox authorization (execute)
|
||
|
||
- **背景:** Phase 3 Models 合并后,sandbox 仍只由 config presence
|
||
(`feat.sandbox is not False`)控制,任何已认证用户都能获得完整 sandbox 执行
|
||
(bash、文件 I/O)。RFC §9 要求 `SandboxMiddleware gates on
|
||
authorize("sandbox","execute")`,deny 时返回友好错误消息而非崩溃。
|
||
- **决策(gate 位置):** 与 Models/Skills 不同,sandbox 不是具名资源,而是一个
|
||
**执行环境** —— 多个工具(bash、read_file、write_file、glob、grep 等)都依赖它,
|
||
全部经过 `ensure_sandbox_initialized` / `ensure_sandbox_initialized_async`
|
||
(sandbox/tools.py)。选择在 **sandbox 获取的唯一入口** gate,而非在 middleware 里
|
||
维护"sandbox 工具名集合"(Shotgun Surgery,每加一个 sandbox 工具都要改 middleware)。
|
||
具体在两个 acquire 点之前调用共享的 `authorize_sandbox_execution` helper:
|
||
- lazy 路径:`ensure_sandbox_initialized` + async(覆盖所有 sandbox 工具)
|
||
- eager 路径:`SandboxMiddleware.before_agent` / `abefore_agent`(`lazy_init=False`)
|
||
- **决策(授权语义):** `authorize("sandbox", "execute", target="*")` —— **二元判断**
|
||
("这个角色能否用 sandbox"),target 用 `"*"` 表示"sandbox 资源整体"。RBAC
|
||
`allow: ["*"]` / `allow: true` 允许,`allow: []` / `allow: false` 拒绝。
|
||
- **决策(deny 行为):** 新增 `SandboxAuthorizationError(SandboxError)`,deny 时抛出,
|
||
沿工具执行链传播 → agent 的 tool-error 处理转成友好 `ToolMessage`
|
||
("sandbox execution is not permitted for your role"),符合 RFC §9 的"not a crash"。
|
||
- **否决方案:** 不在 `SandboxMiddleware.wrap_tool_call` 里 per-tool gate —— middleware
|
||
无法区分哪些工具需要 sandbox,要么误伤非 sandbox 工具,要么维护硬编码工具名集合。
|
||
不为 sandbox 引入独立的 `SandboxMiddleware` 构造参数接收 provider —— lazy 路径不经过
|
||
middleware,gate 放在工具侧的 `ensure_sandbox_initialized` 才是 single source of truth。
|
||
- **决策(Gateway 辅助同步路径):** 多路径覆盖自审(pr-review 检查点 14)发现 4 个
|
||
绕过 `ensure_sandbox_initialized` 的直接 acquire:uploads.py(上传文件同步进 sandbox)、
|
||
artifacts.py(artifact 编辑后同步)、feishu.py / dingtalk.py(IM 下载文件同步)。
|
||
- uploads / artifacts(Gateway 路由,身份齐全):加共享 helper `try_acquire_sandbox_for_request`(内部经 `authorize_sandbox_for_request` gate)
|
||
(gateway/authz.py,从 `request.state.user` 构造 Principal,含 INTERNAL_SYSTEM_ROLE pop)。
|
||
deny 时**跳过 sandbox 同步**(上传/artifact 编辑本身仍成功——deny 的 role 反正无法
|
||
通过 sandbox 消费这些文件);provider 解析失败按 `fail_closed` 降级,不让 route 500。
|
||
- feishu / dingtalk(channel worker 路径):**本 PR 不 gate**。理由:channel 文件下载路径
|
||
无法拿到完整授权身份(owner-user 解析依赖 run 启动时的 `inject_authenticated_user_context`,
|
||
文件下载时不可得);且 deny 时同步的文件无法被 agent 消费,仅浪费一次幂等 acquire。
|
||
留作 follow-up(若维护者要求,可从 channel worker 的 run context 传递身份)。
|
||
- **兼容性:** `authorization.enabled: false` 时 `authorize_sandbox_execution` 是 no-op
|
||
(直接返回)。RBAC provider 的 `_RESOURCE_POLICY_KEYS` 已包含 `"sandbox": "sandbox"`,
|
||
`provider.py` 已声明 `"sandbox"` 为有效 resource,无需 schema 变更。对 test mock
|
||
(SimpleNamespace app_config)安全:使用 `getattr` + `is not True` 防御。
|
||
- **证据:** `tests/test_sandbox_authorization.py`(23 tests)覆盖 disabled/RBAC allow/deny/
|
||
deny-via-bool/no-policy-unrestricted/provider-error-fail-closed-fail-open/
|
||
internal-caller/default-role 场景,deny 错误携带 role,provider 收到正确的
|
||
resource/action/target;`ensure_sandbox_initialized` sync+async 的 deny(不 acquire)
|
||
+ allow(acquire)集成场景;eager 路径(`before_agent` + `abefore_agent`)deny 跳过
|
||
acquire 而非 run 级报错;provider 解析错误的 fail-closed/fail-open(含 fail-open 语义
|
||
反转回归);uploads/artifacts 路由 deny(acquire 不被调用、主操作仍成功)+ allow
|
||
(acquire 被调用)集成场景;request=None 容忍(直调测试路径);无 config.yaml 时
|
||
gate no-op(CI 环境);mock app_config(SimpleNamespace)防御回归;既有 `test_sandbox_middleware.py`(22 tests)、
|
||
`test_artifacts_router.py`、`test_uploads_manager.py` 全部通过
|
||
(authorization 禁用时 gate 是 no-op,不破坏现有行为)。
|
||
- **延期:** Phase 3(Models/Skills/Sandbox)三资源类型完成;Phase 4 前端
|
||
effective-permissions 展示;management route 的 provider 迁移;
|
||
feishu/dingtalk 文件同步路径的 sandbox gate(身份传递机制待定)。
|
||
|
||
### 2026-08-27 — Phase 3 / PR #5006 组合调用单次决策与异步阻塞收口
|
||
|
||
- **背景:** review 在默认启用的 `ReadBeforeWriteMiddleware` 组合路径复现了一次工具
|
||
调用产生两次 provider 决策:读工具在 tool body 后重新读取以写 mark,写工具在
|
||
tool body 前读取以检查 gate。异步路径还在 event loop 上同步加载配置并解析 provider。
|
||
- **决策(调用作用域):** `sandbox_authorization_scope` / async counterpart 用 task-local
|
||
`ContextVar` 覆盖完整的组合工具调用,而不只覆盖 offload 的同步 tool body。读写 gate、
|
||
tool body 和 mark stamping 共用一次实时授权决策;下一个独立工具调用仍重新授权。
|
||
- **决策(deny 语义):** `ReadBeforeWriteMiddleware` 在作用域入口把
|
||
`SandboxAuthorizationError` 转成标准 error `ToolMessage`,并在 `_check_write_gate` 与
|
||
`_attach_read_mark` 中显式重新抛出该异常,禁止通用 fail-open 分支吞掉授权拒绝。
|
||
- **决策(event-loop 边界):** async config 加载通过 `safe_app_config_async()` offload;
|
||
`_resolve_authorization_inputs()` 也在线程中执行,避免每次复用 sandbox 时在 event loop
|
||
上 stat/hash 配置文件或 import/构造自定义 provider。只有 provider 的 `aauthorize()`
|
||
在异步调用路径上直接 await。
|
||
- **证据:** `tests/test_sandbox_authorization.py` 新增 sync/async `read_file` 与
|
||
`write_file` 组合覆盖,断言每次调用恰好一个 provider 决策并验证 deny 不被 fail-open;
|
||
`tests/blocking_io/test_sandbox_authorization.py` 用真实阻塞文件探针固定配置与 provider
|
||
解析均不在 event loop 上执行。
|
||
- **兼容性:** `authorization.enabled: false` 仍为 no-op;未启用
|
||
`ReadBeforeWriteMiddleware` 的普通 sandbox 工具继续在各自调用入口重新授权;同步与异步
|
||
deny 均保持工具级错误而非 run 级异常。
|
||
|
||
#### PR #5006 review 补充:异步 provider 的构造线程
|
||
|
||
- 自定义 provider 的模块发现可能触发阻塞 import,但 provider 构造函数也可能创建
|
||
asyncio loop-affine 客户端。`runtime.py` 因此把解析拆成两阶段:
|
||
`resolve_authorization_provider_spec()` 在线程池完成 class-path 发现,
|
||
`construct_authorization_provider()` 在调用方事件循环构造并校验实例。
|
||
- 同步 `resolve_authorization_provider()` 继续组合这两个阶段,保持原有调用契约与错误语义。
|
||
async sandbox gate 的发现和构造任一失败仍统一遵循 `fail_closed` / `fail_open`。
|
||
- 回归覆盖同时固定两个边界:阻塞文件探针证明 config hash 与 class discovery 不占用
|
||
event loop;loop-affine provider 在 `__init__` 调用 `asyncio.get_running_loop()` 并在
|
||
`aauthorize()` 验证仍是同一个 loop。
|
||
|
||
### 2026-09-04 — Phase 3 / PR #4541 自主加载路径动作门控、bootstrap 装配收口与 subagent provider 复用
|
||
|
||
- **背景:** review(CHANGES_REQUESTED)发现三个缺口:① 动作级 `skill:activate` 只在
|
||
显式 slash 路径执行——动作感知 provider 可以让技能"可见"(`filter_resources` 放行)
|
||
但拒绝激活,模型仍可 `describe_skill` → `read_file`,`DurableContextMiddleware` 把文件
|
||
记入 `skill_context` 后 allowed-tools 策略与自主秘密绑定全部生效,被拒的动作从未检查;
|
||
② bootstrap 分支把未过滤的 `set(_BOOTSTRAP_SKILL_NAMES)` 传给 `build_middlewares` /
|
||
`apply_prompt_template`,且 `skill_setup.skill_names or None` 在策略拒绝 `bootstrap` 时
|
||
把空集转回 `None`,legacy 全量 prompt 会重新加载并宣传被拒技能;③ subagent
|
||
`_load_skills()` 与 `_create_agent()` 各自解析一次 provider——provider 明确不缓存,
|
||
两层可能拿到不同实例/策略快照,违反"Layer 1/Layer 2 同实例"契约。
|
||
- **决策(共享动作检查):** `skill_filter.skill_activation_allowed(authorization, name)`
|
||
成为唯一的动作检查实现(provider + principal + fail_closed 语义,异常按配置
|
||
fail-closed/open)。slash 路径(`SkillActivationMiddleware._activation_allowed` 委托)、
|
||
`describe_skill`(匹配集过滤,全部被拒时与"无匹配"不可区分以避免泄露原因)、
|
||
skill-file-load 盖章路径三处共用,防止语义漂移。
|
||
- **决策(自主加载路径的 gate 位置):** 盖章生产者 `ToolErrorHandlingMiddleware
|
||
._stamp_skill_read_metadata` 是单一收口点——`skill_context` 条目、`SkillToolPolicyMiddleware`
|
||
的 allowed-tools、`_in_context_secret_sources` 的秘密绑定全部以该盖章为源头。动作被拒的
|
||
读取改盖 `SKILL_CONTEXT_DENIED_KEY` 拒绝标记(而非条目元数据),`extract_skills` 见标记
|
||
即跳过且不产生 "missing skill read metadata" 告警。`skill_authorization` 经
|
||
`_build_runtime_middlewares` → `build_lead_runtime_middlewares` /
|
||
`build_subagent_runtime_middlewares` 穿入。文件内容本身仍可读——可见性是 Layer 1 的
|
||
决定(与 sandbox projection 同语义),被拒的是"激活"(策略/秘密/持久上下文)。
|
||
- **决策(bootstrap 装配):** bootstrap 分支全程使用过滤后的 `available_skills`
|
||
(拒绝时为空集:激活中间件成员门 = 不可激活任何技能;legacy prompt 路径的空 allowlist
|
||
分支返回空);`skill_names` 仅在 deferred discovery 开启时保留(含空集),关闭时保持
|
||
`None`(legacy 渲染)——允许路径行为不变。
|
||
- **决策(subagent provider 复用):** `SubagentExecutor._resolve_skill_authorization()`
|
||
按执行器实例记忆化(executor 每次任务派发新建),Layer 1 filter(`authorization=`)、
|
||
激活中间件、describe 盖章、read 盖章四点共用同一 `ResolvedSkillAuthorization`。
|
||
- **否决方案:** 不在 `read_file` 工具本体 gate(需要路径→技能名映射且影响非技能读取);
|
||
不在 `DurableContextMiddleware` gate(时序上晚于 `SkillToolPolicyMiddleware` 读 state,
|
||
依赖 hook 顺序);不在装配期过滤 catalog(会把 `<skill_index>` 可见性折叠进激活语义,
|
||
且需 N 次 authorize 调用;运行期过滤每次 ≤ MAX_RESULTS 次)。
|
||
- **证据:** `tests/test_skills_authorization.py` 45 tests(describe 动作门控 + provider
|
||
错误 fail-closed/open + 禁用时全量;read 盖章拒绝/放行 + `extract_skills` 无告警跳过;
|
||
lead/subagent 链 stamping 接线;distinct-instance 工厂回归——`_load_skills` 与
|
||
`_create_agent` 共享一个实例且仅解析一次)+ `test_authorization_enforcement.py`
|
||
bootstrap 三参数回归(允许/拒绝×deferred 开关)。突变验证:逐项还原六处修复,
|
||
对应测试均失败。
|
||
- **兼容性:** `authorization.enabled: false` 时三处 gate 均 no-op;`build_skill_search_setup` /
|
||
`build_describe_skill_tool` / `ToolErrorHandlingMiddleware` / 两个 runtime builder 的新
|
||
参数均默认 `None`;bootstrap 允许路径与 deferred 关闭路径行为不变。
|
||
|
||
### 2026-09-04(第二轮)— PR #4541 持久化条目复授权与异步 provider API
|
||
|
||
- **背景:** review 第五轮发现两个缺口:① `skill_context` 跨 run 持久——条目在盖章时
|
||
授权过,但下一轮被 `SkillToolPolicyMiddleware`(allowed-tools)与
|
||
`_in_context_secret_sources()`(秘密绑定)直接消费,不再查 `skill:activate`;provider
|
||
从允许翻转为拒绝后,旧条目仍在施加策略与绑定秘密。② `skill_activation_allowed()`
|
||
只调同步 `provider.authorize()`——异步执行路径因此用错 API:盖章在 event loop 上
|
||
同步调用、slash 激活与 describe 在 worker 线程同步调用;loop-affine provider 永远
|
||
收不到 `aauthorize()`,同步调用可能阻塞或失败,把允许错成 fail-closed 拒绝、把拒绝
|
||
错成 fail-open 放行。
|
||
- **决策(异步助手):** `skill_filter.skill_activation_allowed_async()` 与同步版同语义
|
||
(同 fail-closed/open),经 `await provider.aauthorize()`,镜像
|
||
`authorize_sandbox_execution_async` 的既有模式。
|
||
- **决策(决策预计算模式):** 异步 hook 在 event loop 上预计算
|
||
`activation_decisions: dict[name, bool]`(slash 目标名 + 持久化条目名,一次批量
|
||
aauthorize),传入 to_thread 中的阻塞处理函数;处理函数查映射,未覆盖的名字回退
|
||
同步检查(同步路径的正确 API)。三处落地:`SkillActivationMiddleware.awrap_model_call`
|
||
(决策贯穿激活与秘密绑定)、`ToolErrorHandlingMiddleware.awrap_tool_call`(`_amaybe_stamp`
|
||
先 await 决策再盖章)、`SkillToolPolicyMiddleware.awrap_model_call` / `awrap_tool_call`
|
||
(条目名 + slash 来源名预计算)。
|
||
- **决策(describe 双实现):** `describe_skill` 用 `StructuredTool.from_function`
|
||
同时挂同步函数与 coroutine——`invoke`(测试/同步链)走 `authorize()`,
|
||
`ainvoke`(异步 agent 执行)在 loop 上 `await aauthorize()`。
|
||
- **决策(持久化条目复授权):** 两个消费点在使用前复授权:
|
||
`_in_context_secret_sources` 对每个解析后的条目查动作决策,拒绝则跳过(不绑定秘密);
|
||
`SkillToolPolicyMiddleware._active_skills_for_paths` 对每个解析后的技能查动作决策,
|
||
拒绝则 `continue`(与 disabled / 出 allowlist 的技能同等跳过;全部被拒时沿用既有
|
||
"no active reference authorized → fail-closed builtins" 语义)。`skill_authorization`
|
||
新传入 `SkillToolPolicyMiddleware`(lead `build_middlewares` 与 subagent builder 两处)。
|
||
- **否决方案:** 不在 `DurableContextMiddleware` 渲染时复授权(消费点直接读 state,
|
||
渲染层过滤不覆盖 policy/secret 读取);不在条目上缓存决策(持久化条目的正确缓存
|
||
粒度是"每次消费时重查",与 live registry 复验同一模式)。
|
||
- **证据:** `tests/test_skills_authorization.py` 45 tests:allow→deny-next-run 双回归
|
||
(秘密绑定断源、allowed-tools 部分拒绝不株连、全拒 fail-closed builtins)、
|
||
`_AsyncOnlyProvider`(同步 API 恒失败、aauthorize 可用)驱动四条异步路径断言
|
||
(激活、盖章允许/拒绝、describe、tool policy——允许场景下正确接线保留技能声明,
|
||
错回退同步 API 则 fail-closed 丢声明)。六项突变(两个复授权 gate + 四处异步预计算)
|
||
逐一还原均有测试失败。
|
||
- **兼容性:** 同步 hook 路径行为不变(决策映射仅异步构造);`SkillToolPolicyMiddleware`
|
||
新参数默认 `None`;`describe_skill` 对 `invoke` 调用方(既有测试)保持同步语义。
|
||
|
||
### 2026-09-17 — Phase 4 / PR #5489 Skills listing visibility (list / detail)
|
||
|
||
- **背景:** Phase 4 PR 1(#5228 `/me` route permissions)与 PR 2(#5294 前端权限门控)
|
||
合并后,模型已有 per-caller listing/use 授权(#4540),sandbox 已有 execute 授权
|
||
(#4911),但 skill 列表表面仍对全部已认证用户开放——`GET /api/skills`、
|
||
`GET /api/skills/custom`、`GET /api/skills/{name}` 不检查角色,是 Phase 3 资源类型
|
||
清单里最后未收口的 listing 面。
|
||
- **决策(表面清单):** 恰好三个非管理 GET 表面接入 per-caller 可见过滤:`list_skills`、
|
||
`list_custom_skills`、`get_skill`,共享 `_filter_visible_skills(request, config, skills)`
|
||
helper,语义镜像 `list_models`:`provider.filter_resources(principal, "skill", names)`
|
||
批量过滤;provider 解析失败 → `_AuthorizationUnavailable`(携带 `fail_closed` 标志);
|
||
provider 抛错或返回非 `list[str]` → 空(fail-closed)或全量(fail-open)。skills.py
|
||
其余全部路由维持 `require_admin_user` 门控,不在本层重复过滤。
|
||
- **决策(detail 404 而非 403):** `get_skill` 对被过滤 skill 返回与真实缺失逐字一致的
|
||
404,而非 `get_model` 的 403。理由:`get_model` 执行的是 `authorize("model", "use")`
|
||
使用决策(模型存在但角色无权使用 → 403 合理);本层只有 listing visibility,没有
|
||
skill 执行决策的对应物,403 会让 detail 端点变成过滤清单刚关掉的 existence oracle。
|
||
- **决策(resolver 结构):** 从 `resolve_model_authorization` 提取共享核心
|
||
`_resolve_route_scoped_authorization(user, *, is_internal)`,
|
||
`resolve_skill_authorization` 与 model 版本互为薄封装(含 `INTERNAL_SYSTEM_ROLE → None`
|
||
pop 与 internal-caller 语义)。RBAC `_RESOURCE_POLICY_KEYS` 已含 `"skill": "skills"`
|
||
(rbac.py),roles 的 `skills: {allow: [...]}` 直接生效,无 schema 变更。
|
||
- **否决方案:** 不为 skill 引入 `authorize("skill", "read")` 逐名授权——listing 表面
|
||
用批量 `filter_resources` 一次往返即可,逐名决策增加配置面且与 `list_models` 不对称。
|
||
不只过滤 `/skills` 主列表——自审发现 `/skills/custom` 与 `/skills/{name}` 会原样
|
||
泄露主列表隐藏的名字,三个表面必须同批收口。
|
||
- **兼容性:** `authorization.enabled: false` 时三表面均 no-op(返回全量)。匿名请求
|
||
(user=None)不过滤——生产 auth 开启时 `AuthMiddleware` 先行 401,该分支实际只覆盖
|
||
auth-disabled 本地模式,与 `list_models` 对齐。skill 管理端点(install/edit/export/
|
||
delete 等)保持 `require_admin_user`,不受本过滤影响。RBAC 缺 `skills` 键 = 放行,
|
||
`allow: []` = 全拒(与 `models` 键同语义)。
|
||
- **证据:** `tests/test_skills_listing_authorization.py` 覆盖 disabled/anonymous/RBAC
|
||
allow/deny/wildcard/absent-policy、custom+public 一致过滤、provider error/unavailable/
|
||
坏返回类型 × fail-closed/fail-open、`("skill", [...])` 契约、custom 列表绕过封闭、
|
||
detail 404 与真实缺失逐字一致;`test_skills_router_authz.py` 与
|
||
`test_skills_custom_router.py` 的 fake config 补 `AuthorizationConfig`(含
|
||
`_make_test_app` 回填 shim)。review(willem-bd)在 head tree 执行验证:4 套件
|
||
78 tests 通过,且移除 custom-listing 过滤的突变使
|
||
`test_list_custom_skills_rbac_filters_by_deny` 变红,守护测试真实。
|
||
- **延期:** #4541(Phase 3 执行层:assembly 过滤 + slash-activation 授权)与本 PR
|
||
互补(本 PR 管 listing visibility,#4541 管 runtime use),其 rebase 时需双向调和:
|
||
`config.example.yaml` roles 注释段两 PR 均改;本文件决策日志两 PR 也在同一插入点
|
||
各追加条目。前端 effective-permissions 展示剩余项;management route 的 provider
|
||
迁移(沿袭前阶段延期项)。
|
||
|
||
### 2026-09-24 — Phase 5 / PR1 — 插件资源授权管道(targets、决策层、action 检查、management 守卫)
|
||
|
||
- **背景:** 设计文档 `2026-09-23-extension-tool-authorization-coverage-gaps.md` 与实施规格
|
||
`2026-09-24-extension-tool-authorization-spec.md`(rev 5)把「插件工具/页面授权覆盖缺口」
|
||
切成三个 PR:PR1 = 插件管道(target 编码、provenance 展示、`plugin_authz`、action 检查、
|
||
management 守卫、provider 生命周期、公开 helper + 一次契约版本提升),PR2 = 工具链路
|
||
(middleware 声明工具、identity-bound exemption),PR3 = 页面切片。本条记录 PR1。
|
||
- **决策(target 编码):** 新增 `deerflow/authz/plugin_targets.py`,复合 target 一律
|
||
`"{namespace}/{part}"`,由唯一 `_join` 校验器编码:namespace 字符集与
|
||
`config/plugin_settings.py` 一致,action / surface id / management part 分别镜像 registry、
|
||
浏览器 surface 规则和两个模块常量。三个构造器各带 kind 检查,调用点禁止字符串拼接;
|
||
非法输入抛 `PluginTargetError`(`ValueError` 子类),绝不返回 best-effort 字符串。
|
||
- **决策(RBAC 别名):** `_RESOURCE_POLICY_KEYS` 新增 `"plugin_action" → "plugin_actions"`
|
||
与 `"plugin_management" → "plugin_management"`(自映射,合法,同 `sandbox`)。左值仍是请求
|
||
`resource`,右值是 `config.yaml` 键;把 `plugin_action` 当作 config 键会在构造期被拒
|
||
(reserved request alias)。`plugin_page → plugin_pages` 随 PR3 落地——该 PR 才产生
|
||
`plugin_page` 资源,缺 key = 不受限仍是不变的语义。
|
||
- **决策(决策层):** 新增 `deerflow/authz/plugin_authz.py`:六个决策函数 +
|
||
`PluginAuthorizationError`(携带 resource / target / reason_code / fail_closed)。disabled →
|
||
no-op;显式 deny 抛错;provider 异常、解析失败、malformed decision、无 principal 一律按
|
||
`fail_closed` 抛错或 warning 放行(镜像 sandbox gate 的语义,而不是工具过滤器的静默集合
|
||
语义)。异步批量决策用 `asyncio.to_thread(filter_resources)`,因此
|
||
`AuthorizationProvider.filter_resources` 的 docstring 增加一句线程安全要求(仅文档,
|
||
三个方法和签名不变)。
|
||
- **决策(provider 生命周期):** 插件请求路径**不**复用既有 route provider cache(同步、
|
||
在事件循环上构造、仅按 config identity 命中,且无 blocking-IO anchor)。新增按
|
||
`(config signature, loop_key)` 键的缓存:同步调用者用私有 `_SYNC_SLOT`,异步按
|
||
`id(asyncio.get_running_loop())`;两者互不串用,也不跨 loop 复用;config 读取与
|
||
`resolve_authorization_provider_spec` 在线程中执行,`construct_authorization_provider`
|
||
保持在调用方 loop 上;无 single-flight 锁(允许并发冷启动重复构造,已文档化);
|
||
写入时清理已关闭 loop 的条目。
|
||
- **决策(公开 helper + 契约版本):** `deerflow_extension_api.auth` 新增
|
||
`EXTENSION_PLUGIN_AUTHZ_RESOLVER_KEY`、`EXTENSION_PLUGIN_AUTHZ_RESOLVER_ASYNC_KEY` 与
|
||
`require_plugin_management` / `arequire_plugin_management`(只读 `request.app.state`,保持
|
||
契约包不依赖 `deerflow`)。两者 fail closed:解析器缺失/失败/返回 `None`、未知 namespace、
|
||
非 `True` 答案都抛 `PermissionError`;`authorization.enabled: false` 时宿主回答 `True`
|
||
(no-op),因此不会开始拒绝企业路由,需要无条件底线的企业仍叠加 `require_admin`。同步形式
|
||
供 FastAPI `def` 端点(线程池)使用,异步端点使用 `a` 版本。契约版本 `0.2.3 → 0.2.4`
|
||
(`API_VERSION`、包 version、harness 精确 pin、lockfile、契约测试四处同步)。
|
||
- **决策(action 检查):** `POST /api/plugins/{ns}/actions/{name}` 在 action 解析之后
|
||
(未知 action 仍是 404,不改变存在性预言)、读取请求体之前插入一次 `plugin_action` 决策;
|
||
deny → `403 "Plugin action not permitted for your role."`,handler 不被调用,被拒调用者
|
||
无法占用 256 KiB 输入预算。
|
||
- **证据:** 新增 `tests/test_plugin_targets.py`(字符集、跨 kind 同形 target、
|
||
management part、非法输入)、`tests/test_tool_provenance.py`(plugin tag 往返、错配 tag 被丢弃、
|
||
MCP 优先级、`deerflow_tool_source` 与模块回退标签保持)、
|
||
`tests/test_plugin_action_authorization.py`(allow/deny/未授权先于 body、
|
||
fail_closed/fail_open、disabled no-op、未知 action 404、internal 身份)、
|
||
`tests/test_plugin_management_guard.py`(helper 的 fail-closed 矩阵、宿主解析器安装与
|
||
未知 namespace、disabled 放行、loop 生命周期与只读投影前置)、
|
||
`tests/blocking_io/test_plugin_authorization.py`(config/discovery 不在事件循环上,
|
||
含 red→green teeth);扩展 `test_rbac_authorization_provider.py`、`test_plugin_tools.py`、
|
||
`test_extension_api_contracts.py`。
|
||
- **兼容性:** `authorization.enabled: false` 时 action 路由与 management helper 均为 no-op;
|
||
既有工具 resource 名与 `AuthorizationProvider` Protocol 未变;`config_version` 47 → 49
|
||
(与主线的并发 bump 竞争后按「每次更新 PR 前的固定清单」重取下一个可用号,见下方合并记录;
|
||
`config.example.yaml` + Helm values + Helm README 三处同步);`config.example.yaml`
|
||
roles 新增 `plugin_actions` / `plugin_management` 两行并注明「省略 key = 该资源不受限」。
|
||
- **否决方案:** 不为节省一次构造而复用 route cache(会把一个 loop 上的 loop-affine provider
|
||
交给另一个 loop);不在请求边界拼接 target(必须走 `plugin_targets`);不在 PR1 引入
|
||
`plugin_page` 别名(没有生产者,属 PR3);不把插件检查做成第二个常开 gate。
|
||
- **延期:** PR2(工具链路:middleware 声明工具、`LayerOneOutcome` 种子、build-local view、
|
||
identity-bound infrastructure exemption、`GuardrailRequest.tool_provenance`/`tool_identity`)、
|
||
PR3(`pages`/`shared_operation` 声明门、`extensions/catalog.py`、`/api/plugins` 只读投影、
|
||
前端页面 gate、企业示例与剩余文档)。
|
||
|
||
### 2026-09-24 — Phase 5 / PR1 review round 1(PR #5842,willem-bd)
|
||
|
||
- **背景:** 静态审查对 PR1 提出两条授权缺陷:P1 —— 决策层为了判断 `enabled`/`fail_closed`
|
||
会**第二次**读取配置,而 Gateway 已经用可读配置解析出 provider/principal;这次重读若在
|
||
热重载窗口内失败(文件缺失/暂时非法),`_enabled_config(None)` 会把「配置不可用」当作
|
||
「授权已关闭」,于是受保护的插件 action/management 请求在未询问 `fail_closed` 的情况下被放行。
|
||
P2 —— `AuthzDecision` 是普通 dataclass,自定义 provider 可以返回
|
||
`AuthzDecision(allow="false")`;`isinstance` 校验通过后真值字符串被当作 allow。
|
||
- **决策(P1,采纳审查建议的第一种修法):** 决策层不再自行读取配置。`plugin_authz` 的六个函数
|
||
把 `app_config` 改为**必填**,语义是「调用方用于解析 provider 的那个请求级快照」;
|
||
§5.3-C 的两个 Gateway 解析辅助函数改为返回 `(provider, principal, app_config)` 三元组,
|
||
并把同一快照交给执行层,因此一次请求只有一次配置读取,也不存在两次读取之间的状态漂移。
|
||
快照为 `None`(调用方完全读不到配置)时,视为**不可用**而非「已关闭」:
|
||
以 `authz.config_unavailable` + `fail_closed=True` 失败关闭(能放行的那个开关本身读不到)。
|
||
同时 Gateway 侧新增 `_plugin_app_config[_async]`,把「不存在 config.yaml」(`FileNotFoundError`
|
||
→ 今日行为,无门禁) 与「配置存在但当前读不了/解析不了」(异常上抛 → 拒绝)区分开;
|
||
后者不再被 `safe_app_config` 的宽泛 `except Exception` 吞掉。
|
||
- **决策(P2):** 新增 `_validated_decision`,要求返回值是 `AuthzDecision` 且
|
||
`type(decision.allow) is bool`(同步 `authorize` 与异步 `aauthorize` 两条路径都走它);
|
||
非 bool 的 `allow` 与 `AuthzDecision` 类型错误一样按 provider 失败处理,遵循 `fail_closed`。
|
||
- **证据:** 新增回归测试并做了反证(revert 修复后必须变红)——
|
||
`test_unreadable_config_denies_the_action`(200 vs 403)、
|
||
`test_installed_resolver_denies_when_the_config_cannot_be_read`(True vs False)、
|
||
`test_a_non_bool_allow_is_a_malformed_decision[...]`(12 项红)与对应异步/路由用例、
|
||
`test_installed_resolver_allows_when_no_config_exists`(不存在 config.yaml 仍为放行)、
|
||
`test_no_config_at_all_keeps_todays_behavior`。全部 95 项插件套件在修复后通过。
|
||
- **兼容性:** `authorization.enabled: false` 与「没有 config.yaml」两种情形行为不变;
|
||
harness 内部函数签名由「可选 app_config」变为「必填快照」,属 PR1 内的内部契约调整,
|
||
不涉及 `deerflow_extension_api` 公开面(公开 helper 的 fail-closed 语义只在“宿主读不到配置”
|
||
时更严格)。
|
||
- **延期:** 不变(工具链路 PR2、页面切片 PR3)。
|
||
|
||
### 2026-09-25 — Phase 5 / PR1 review round 2(PR #5842,willem-bd)
|
||
|
||
- **背景:** 静态审查对当前 head(`62197d96`)再提两条:P1 —— `_plugin_app_config` 仍把
|
||
`FileNotFoundError`(请求时刻文件不在)映射成「授权从未启用」并返回 `None`,调用方据此放行
|
||
受保护的插件 action 与 management 路由;但真实 Gateway 不可能在没有可读 `config.yaml` 的情况下
|
||
启动(`app/gateway/app.py` 的 `lifespan` 用同一个访问器读配置,失败即 `RuntimeError`),
|
||
因此请求期的「缺失」只意味着这份配置在运行中变得不可用(热重载 / 原子替换窗口)。
|
||
P2 —— `_deny_reason_code` 在 provider 校验的 `try` 之外遍历 `decision.reasons`,
|
||
自定义 provider 返回 `AuthzDecision(allow=False, reasons=None)`(或任何不可迭代对象)时抛
|
||
`TypeError`,把一次拒绝变成未捕获错误,而不是配置好的授权失败(403)。
|
||
- **决策(P1,采纳审查建议的第一种修法):** 用「这个进程是否在跑一份配置」区分「不可用」与「未配置」,
|
||
而不是用异常类型或无条件失败关闭:
|
||
- 读取失败且进程**从未加载过配置**(`peek_loaded_app_config() is None`;`deerflow.config.app_config`
|
||
新增只读访问器,覆盖 `get_app_config()` 成功缓存与 `set_app_config()` 注入两种来源):授权只能由
|
||
配置开启,故**无门禁**放行——与同一文件里的 `_get_route_authorization_config()`
|
||
(`(FileNotFoundError, RuntimeError)` → disabled)和 `sandbox_authz.safe_app_config()`
|
||
(“no readable config ⇒ the sandbox gate is a no-op … CI runners and direct-call tests”)同一条规则。
|
||
挂载 plugins router 的 e2e 宿主(`backend/extension_test_fixtures/bookmark_plugin_gateway.py`)
|
||
正属此类。
|
||
- 读取失败且进程**正在跑一份配置**:这是「可用但当前读不到」,异常上抛,
|
||
`resolve_/aresolve_plugin_authorization` 以 `_PluginAuthorizationUnavailable(fail_closed=…)`
|
||
失败处理,标志由**那份配置自己**给出(`enabled` 非 `True` → 无门禁;enabled → 取该策略的
|
||
`fail_closed`,默认 `true`)。于是启用授权且 fail-closed 的宿主在窗口内返回 403 / `False`,
|
||
不再像以前那样被当成「授权从未开启」放行。
|
||
- 文件存在但读不了/解析不了(非 `FileNotFoundError`):无已加载配置时沿用 round 1 的 fail-closed
|
||
(能放行的那个开关读不到);有已加载配置时同样取该策略的 `fail_closed`。
|
||
*被取代的规则:* round 1 记录的「不存在 `config.yaml` → 今日行为,无门禁」,以及本轮中间版本的
|
||
「任何读取失败都无条件失败关闭(`fail_closed=True`)」。
|
||
- **修正记录:** 本轮先按审查字面实现「缺失即无条件失败关闭」,本地与 CI e2e 立刻变红
|
||
(`frontend/tests/e2e/bookmark-plugin.spec.ts` 的 save action 由 200 变 403——该宿主本来就
|
||
没有 `config.yaml`),且与 `_get_route_authorization_config()`、`safe_app_config()` 的既有规则
|
||
冲突。随后改为上面的「按进程已加载的配置」判定:没有配置的宿主行为不变,丢失配置的宿主不再被
|
||
当成未配置。
|
||
- **决策(P2):** 拒绝码提取改为全函数:`reasons` 不可迭代时直接退化为 `authz.denied`,
|
||
元素校验额外要求 `reason.code` 是非空 `str`。刻意**不**并入 `_validated_decision` 的「畸形判决」
|
||
判定:`allow` 已是真正的 `bool`,这条判决本身是合法拒绝;若按畸形判决走 `fail_closed`,
|
||
在 `fail_closed: false` 下会把明确拒绝翻成放行。
|
||
- **证据:** 新增/改写回归测试并做了反证(修复前必须变红)——
|
||
`test_installed_resolver_allows_when_no_config_exists`(无配置宿主:True)、
|
||
`test_installed_resolver_applies_the_running_policy_when_the_config_disappears[True/False]`、
|
||
`test_installed_async_resolver_denies_when_the_config_disappears`、
|
||
`test_installed_resolver_stays_noop_when_the_lost_config_had_authorization_off`、
|
||
`test_a_lost_config_follows_the_running_policy[403/200]`、`test_no_config_at_all_keeps_todays_behavior`、
|
||
`test_peek_loaded_app_config_survives_a_missing_file`(文件删除后 `peek_loaded_app_config()`
|
||
仍返回已加载配置),以及 round 1 的 `test_unreadable_config_denies_the_action` /
|
||
`test_installed_resolver_denies_when_the_config_cannot_be_read`(非 `FileNotFoundError` 仍然 fail-closed);
|
||
P2 侧 `test_malformed_reasons_keep_the_denial[...]` / `test_async_malformed_reasons_keep_the_denial[...]`
|
||
(`TypeError: 'NoneType' object is not iterable`,`plugin_authz.py:113`)。
|
||
e2e 层面复现了 spec 的后端一半:拉起 `extension_test_fixtures.bookmark_plugin_gateway` 后
|
||
POST `/api/plugins/community.bookmarks/actions/save`,中间版本 `403`(与 CI 的 3 个 bookmark 用例
|
||
失败一致),最终版本 `200`。验证:配置模块测试 41 项、插件/extension + blocking-IO 429 项、
|
||
`ruff check` 与 `format --check` 全绿。
|
||
- **否决方案:** ①「缺失即无条件失败关闭」:与既有 `safe_app_config` / `_get_route_authorization_config`
|
||
规则冲突,并打断 e2e 宿主的插件 action(见上)。②「Gateway 侧发布 startup 快照」:状态要手动接线、
|
||
且只反映启动那一刻的策略(热重载后的新策略不会生效),改用配置模块自己缓存的「最后一次成功加载」
|
||
更准确且无需额外生命周期钩子。
|
||
- **兼容性:** 没有配置的宿主(CI runner、直接调用、只挂 plugins router 的宿主)行为完全不变;
|
||
配置可读且 `authorization.enabled: false` 时不变(no-op);只有「运行中配置消失/读不了」由
|
||
静默放行改为按该策略的 `fail_closed` 处理(默认拒绝)。
|
||
- **延期:** 不变(工具链路 PR2、页面切片 PR3)。相邻风险已记录但未改:同一文件里的
|
||
`_get_route_authorization_config()`(model / skill / sandbox 路由门)对读取失败仍一律回退到
|
||
disabled,存在同类窗口;如需同样收紧应另开一条(影响面覆盖全部路由门,超出本轮范围)。
|
||
|
||
### 2026-09-25 — Phase 5 / PR1 review round 3(PR #5842,willem-bd)
|
||
|
||
- **背景:** 静态审查对当前 head(`a891e704`)追加一条 P3 —— round 2 的 P2 修复只挡住了
|
||
「`reasons` 不是 `Iterable`」,遍历本身仍在 provider 校验的 `try` 之外:生成器或自定义可迭代
|
||
对象可以满足 `isinstance(reasons, Iterable)`,却在 `__iter__`/`__next__` 中抛错,于是一次明确
|
||
拒绝仍会以未捕获错误(500)逃逸,而不是按配置的授权失败退化为 `authz.denied`。
|
||
- **决策(采纳审查建议的第一种修法):** `_deny_reason_code` 的**整段提取**(取属性、`Iterable`
|
||
判定、遍历)包进一个窄 `try/except Exception`,任何异常都降级为 `authz.denied`,并记一条
|
||
`logger.debug(..., exc_info=True)`;函数签名、返回值域与「拒绝仍是拒绝」的语义不变。
|
||
不采纳第二种修法(把 `reasons` 校验成声明的具体 list 形状):合法 provider 返回 tuple 或生成器
|
||
形态的 `AuthzReason` 时,那会把**本来可读**的拒绝码一并降级为 `authz.denied`,属无谓的行为收窄;
|
||
「`Iterable` 判定 + 全函数提取」既保留合法可迭代对象,又对抛错对象收敛。
|
||
- **证据:** 红先验证(修复前必须变红)——管理器层
|
||
`test_malformed_reasons_keep_the_denial[True|False-reasons2|reasons3]`(4 项)与异步
|
||
`test_async_malformed_reasons_keep_the_denial[reasons2|reasons3]`(2 项)修复前抛
|
||
`RuntimeError: reasons.__iter__ failed` / `RuntimeError: reasons.__next__ failed`
|
||
(`plugin_authz.py:127`),修复后降级为
|
||
`PluginAuthorizationError(reason_code="authz.denied")`;新增的 `_ExplodingReasons` 覆盖两种形态:
|
||
`__iter__` 直接抛,以及先产出一个非 `AuthzReason` 再从 `__next__` 抛。路由层
|
||
`test_unreadable_reasons_deny_instead_of_erroring[True|False]` 修复前让异常穿出路由(未捕获),
|
||
修复后 `403` 且 handler 未被调用——两种 `fail_closed` 取值下都保持拒绝
|
||
(`fail_closed: false` 不得把明确拒绝翻成放行)。验证:两个插件测试文件 115 项、
|
||
`tests/blocking_io` 163 项、`ruff check` 与 `ruff format --check` 全绿。
|
||
- **兼容性:** 拒绝码提取的返回值域不变(`str`,无法读取时为 `authz.denied`);只有「可迭代对象在
|
||
遍历时抛错」这一种输入从 500 变为降级后的 403。
|
||
- **延期:** 不变(工具链路 PR2、页面切片 PR3)。相邻同类风险已记录但未改:
|
||
`deerflow/authz/adapter.py` 的 `_to_guardrail` 仍直接遍历 `d.reasons`,而消费它的
|
||
`GuardrailMiddleware` 把 provider 异常按 `fail_closed` 处理,因此 `fail_closed: false` 时
|
||
「显式拒绝 + reasons 遍历抛错」可能被翻成放行;该文件不在本 PR 面内(工具链路),
|
||
如需同样收敛应随 PR2 一并处理。
|
||
|
||
### 2026-09-25 — Phase 5 / PR1 合并主线 `3a862780`(config_version 竞争)
|
||
|
||
- **背景:** 合并最新 `upstream/main`(`3a862780`)时只有 `config.example.yaml` 一处文本冲突:
|
||
冲突块本身是主线新增的 `lead_prompt_overlay` 注释段(本 PR 该位置为空),取主线即可。
|
||
真正的语义问题是 `config_version`:merge-base 为 47,本 PR 与主线**各自** bump 到同一个 48
|
||
(本 PR 为 `authorization` / roles 新增字段,主线为 prompt overlay 等)。该字段只驱动
|
||
`AppConfig._check_config_version()` 的过期提示(`backend/docs/CONFIGURATION.md`),两边同号会让
|
||
「已从主线 48 升级过的 `config.yaml`」收不到缺 `authorization` 字段的提示。
|
||
- **决策:** 按本文件「每次更新 PR 前的固定清单」执行:先 fetch 最新 `upstream/main`、读最新值
|
||
(48),取下一个可用号 **49**,并在三处镜像同步(`config.example.yaml`、
|
||
`deploy/helm/deer-flow/values.yaml`、`deploy/helm/deer-flow/README.md`);
|
||
`frontend/src/content/{en,zh}/harness/checkpoints/reference.mdx` 里「current `config_version`」
|
||
也一并改为 49(该文档由主线新增,写成时值为 47)。冲突块取主线;本 PR 的 `authorization:`
|
||
段与 `peek_loaded_app_config()` 均在自动合并结果中保留。
|
||
- **证据:** `scripts/check_config_version.sh` 通过(example=49 / chart=49);
|
||
`backend/tests/test_config_version.py` 全绿(含用真实 `scripts/config-upgrade.sh` 跑
|
||
v26 → 当前版本的升级用例,其 `expected_version` 直接读 `config.example.yaml`)。
|
||
- **兼容性:** 仅提示语义变化,运行时行为不变。已按主线 48 升级的 `config.yaml` 会收到一次
|
||
“outdated” 提示,可 `make config-upgrade` 合并 `authorization` 默认段——这正是版本号存在的目的。
|
||
- **延期:** 不变(工具链路 PR2、页面切片 PR3)。相邻且非本轮产生的文档漂移未改:上述
|
||
checkpoints 文档里的 `appcfg:531-575` / `appcfg:570-575` 行号引用在主线自己新增 prompt overlay
|
||
后已失准(当前 `_check_config_version` 位于 `appcfg:534-576`),不属本 PR 面内。
|
||
|
||
### 2026-09-25 — PR #4541 规范技能名:授权目标统一为注册表 `Skill.name`
|
||
|
||
- **背景:** review(ShenAC-SAC,P2)发现路径推导名与声明名不一致:bundled 技能目录名
|
||
可不同于 SKILL.md 声明的 `name`(`skills/public/vercel-deploy-claimable` 声明
|
||
`vercel-deploy`),而 Layer 1、slash 激活、`describe_skill` 授权的都是声明名。
|
||
① read 盖章路径(同步 `_stamp_skill_read_metadata` / 异步 `_amaybe_stamp`)用
|
||
`_skill_name_from_path()`(目录 basename)做 `skill:activate` 目标——RBAC
|
||
`allow: ["vercel-deploy"]` 下读取被误盖 `skill_context_denied`;② 异步决策预计算 map
|
||
的键同样用路径推导名(slash 来源 + 持久化 `entry["name"]`,后者本身由
|
||
`extract_skills` 按 path 盖章、必然是路径名),而消费点 `_active_skills_for_paths` /
|
||
`_in_context_secret_sources` 查的是注册表解析出的 `skill.name`——map miss 回退
|
||
worker 线程里的同步 `authorize()`(`_AsyncOnlyProvider` 下直接 fail-closed 丢工具,
|
||
且破坏上一轮消除同步回落的成果)。
|
||
- **决策(共享注册表解析):** 新增 `deerflow/skills/container_registry.py`:
|
||
`build_container_path_registry(storage)`(规范化容器 SKILL.md 路径 → live `Skill`,
|
||
`enabled_only=False`)与 `canonical_skill_name(registry, path)`。三个中间件
|
||
(activation / tool-policy / tool-error-handling)统一经它把路径解析为声明名。
|
||
- **决策(盖章路径):** `ToolErrorHandlingMiddleware` 新增 `user_id`(经
|
||
`_build_runtime_middlewares` → lead `build_middlewares` / subagent builder 穿入,
|
||
与另两个技能中间件同一 storage 解析顺序——user-scoped 优先)。同步路径在
|
||
`_canonical_skill_name()` 中解析(registry 失败或路径未解析 → 回退路径名,未解析路径
|
||
的下游消费本就按同一 registry 跳过);异步路径 `asyncio.to_thread` 解析后于 loop 上
|
||
`await skill_activation_allowed_async(...)`。
|
||
- **决策(决策 map 键):** `SkillToolPolicyMiddleware` 异步 hook 改三段式:
|
||
to_thread 加载 registry 并把策略路径规范名化(`_resolve_policy_registry`)→ loop 上
|
||
`aauthorize()` 批量预计算(`_collect_activation_decisions(names)` 只收规范名)→
|
||
to_thread 过滤并复用同一 registry(整 hook 一次 skill-tree 扫描)。slash 来源不再
|
||
basename 推导,持久化条目一律取 `entry["path"]` 解析(不信任 `entry["name"]`),
|
||
`_entry_names` 删除。`SkillActivationMiddleware` 同理:候选收集拆为
|
||
`_candidate_activation_targets()`(slash 名——本就是注册表名 + 条目路径,loop-safe),
|
||
`awrap_model_call` 先 to_thread 规范名化(`_canonical_names_for_paths`)再预计算。
|
||
- **否决方案:** 不在 `extract_skills` 盖章时把 `entry["name"]` 改写为声明名(渲染层
|
||
展示名与路径一致有其意义,且历史条目仍需注册表解析,改写只修新条目不修存量);
|
||
不在 map 里同时预计算"路径名 + 声明名"两个键(掩盖键错配而非消除,同步回落仍可能
|
||
命中错误键)。
|
||
- **证据:** 新增 5 个回归(`tests/test_skills_authorization.py` 45→50):同步盖章断言
|
||
provider 目标为声明名(`sync_calls == ["vercel-deploy"]`);RBAC allow 声明名 → 激活 /
|
||
allow 目录名 → 拒绝(双向钉死经注册表解析);异步盖章 `_AsyncOnlyProvider` 下
|
||
`async_calls == ["vercel-deploy"]` 且条目盖章(同步回退会 fail-closed 出拒绝标记);
|
||
组合路径(async slash 激活 → tool-policy)`async_calls` 恰为声明名×2、`bash` 保留
|
||
(回退同步 API 则 fail-closed 丢 `bash`);持久化条目(路径名盖章的历史形态)经
|
||
注册表规范名化后秘密绑定仍生效(路径名键 → map miss → 同步回退 → 绑定丢失)。
|
||
三处突变(盖章路径名、tool-policy map 键、activation 条目名)逐一还原均有测试失败。
|
||
- **兼容性:** 授权禁用时零新增开销(`skill_authorization is None` 不触达 registry);
|
||
`ToolErrorHandlingMiddleware.user_id` 与两个 builder 的新参数默认 `None`;
|
||
未解析路径回退路径名(与旧行为一致);同步 hook 路径语义不变。
|
||
|
||
### 2026-09-26 — PR #4541 review(willem-bd,P2×2):预扫描失败传递与空路径零扫描
|
||
|
||
- **背景:** 合入主线后的新一轮 review 指出规范名修复引入的两个缺口:①
|
||
`_resolve_policy_registry` 瞬时失败返回 `(None, [])`,worker 侧
|
||
`registry is None` 会重试存储——重试若成功,恢复出的技能名不在(空的)决策 map
|
||
里 → 回退 worker 线程的同步 `authorize()`:loop-affine provider 抛异常后
|
||
`fail_closed=false` 变成放行(被拒技能的 allowed-tools 保留),
|
||
`fail_closed=true` 变成误拒;② 授权开启时每个异步模型步都调用
|
||
`_canonical_names_for_paths`,空 `entry_paths` 也触发全量技能树扫描——普通请求
|
||
(无 slash、无条目、无 secrets)此前零注册表 I/O,大目录/NFS 下每步 LLM 前多一次
|
||
未缓存扫描。
|
||
- **决策(失败传递):** `_REGISTRY_LOAD_FAILED = object()` 三态哨兵(对齐同文件
|
||
`_MISSING_POLICY_DECISION` 先例):`_resolve_policy_registry` 失败时返回哨兵,
|
||
`_active_skills_for_paths` 见哨兵直接 `([], True)`(与首次加载失败同一 fail-closed
|
||
builtins 处置),不再静默重试。失败步的 builtins 决策经 refresh_decision 缓存
|
||
仅作用于本步;下一步模型调用预扫描重试存储即可恢复,无粘性状态。哨兵分支先于
|
||
"No active skill references could be authorized" 告警返回——存储失败不产生该误导性
|
||
日志,异常日志(`_resolve_policy_registry` 内)是唯一信号。
|
||
- **决策(空路径零扫描):** `_canonical_names_for_paths` 开头 `if not paths:
|
||
return []`(单一 choke point,未来调用方自动受益)。普通异步模型步的存储调用数
|
||
归零(slash 解析在触达 storage 前返回 None;secrets 不存在则
|
||
`_resolve_secret_bindings` 不加载注册表)。
|
||
- **否决方案:** 不在失败后立即重试恢复(reviewer 给的第二选项)——多一次全量扫描
|
||
且复杂度更高,保留失败与既有"首次加载失败"语义逐字一致;不在调用点条件跳过
|
||
`to_thread`(choke point 单点守卫才可被单点突变钉住)。
|
||
- **证据:** `tests/test_skills_authorization.py` 50→52:
|
||
`test_async_policy_preserves_prepass_registry_failure`(首读失败、次读会成功的
|
||
flaky storage + `_AsyncOnlyProvider(denied)` + `fail_closed=False`:恰一次
|
||
storage 调用、零 provider 调用、builtins-only——旧行为下 worker 重试成功 →
|
||
同步回退 → fail-open 保留 `bash`);`test_async_model_call_without_skill_refs_
|
||
skips_registry_scan`(storage 调用计数 == 0)。两项突变(哨兵还原为 None、
|
||
删除空路径守卫)逐一还原均有测试失败。
|
||
- **兼容性:** 同步 hook(`registry is None` 自行加载)语义不变;`awrap_tool_call`
|
||
缓存命中路径不触达哨兵;授权禁用时两条新路径均不存在。
|
||
|
||
### 2026-09-26(第二轮)— 第 4 实例收口:activation 秘密绑定改快照传递,类成员 grep 封闭
|
||
|
||
- **背景:** 上一节修复落地后的严格自查复现出同一 bug 类的第 4 个实例:
|
||
`SkillActivationMiddleware.awrap_model_call` 的条目预扫描瞬时失败返回 `[]` 并继续,
|
||
线程内 `_resolve_secret_bindings` 在有 request secrets 时经
|
||
`_load_skill_registry_by_path` **另一次独立加载**恢复——条目解析出的名字不在(空)
|
||
决策 map → 回退 worker 线程同步 `authorize()` → `fail_closed=false` 时
|
||
aauthorize 拒绝的技能秘密被 fail-open 绑定(复现:两次加载、零 aauthorize、
|
||
被拒秘密已注入)。slash 路径经复现实证免疫(名字来自消息解析,不经存储,
|
||
预扫描失败也在 map 内)。
|
||
- **决策(快照传递,对齐 policy 侧哨兵修复的形状):**
|
||
`_canonical_names_for_paths` 返回 `(names, registry)`——名字与其来源快照一起返回;
|
||
快照经 `_handle_model_request` 穿到 `_resolve_secret_bindings`,条目来源解析**复用该
|
||
快照**:决策 map 的键 = 快照解析的名字,map miss 在构造上不可能(连"同一步两次
|
||
加载不一致"的理论窗口——预扫描成功后存储被并发改名——也一并关闭)。加载失败返回
|
||
`_REGISTRY_LOAD_FAILED` 哨兵:条目绑定归零(空注册表解析不出任何路径,构造保证),
|
||
**slash 来源不受株连**(它不查决策 map,在激活时已验证,属 run 级用户承诺,照常
|
||
走新鲜加载)。同步链不传快照、行为不变。附带收益:有 secrets 的异步步从两次
|
||
注册表扫描降为一次(快照复用),`_load_skill_registry_by_path` 的新鲜度契约
|
||
("下一次模型调用即吊销")仍然满足——快照取自本步开头。
|
||
- **决策(类成员封闭 + 延期加固锚点):** 全仓 grep 实证
|
||
`skill_activation_allowed(` 同步调用点恰 4 处:stamping 同步路径(合法)、
|
||
describe 同步实现(合法)、两个 `_activation_allowed`(activation / policy 中间件)
|
||
——**异步可达的同步回退点有且仅有 2 个,本轮修复后全部关闭**。结构性加固
|
||
(决策 map 类型化为 async-batch 语义对象,"异步路径 map miss = fail-closed + 响亮
|
||
日志,绝不静默同步回退"由构造保证;per-step 注册表快照经 run context 全链共享)
|
||
作为后续工作延期——四实例证明调用方自觉已失效四次,但结构性重构会再开 N 轮
|
||
review,先以实例收口 + 成员封闭 + 本锚点记录推进合并。
|
||
- **否决方案:** 不用"失败标志只关门条目"(上一轮初步规格)——标志只堵失败扇窗,
|
||
堵不住"预扫描成功但两次加载不一致"的窗口;快照传递同成本下把两类窗口都关成
|
||
构造不可能。不为空 `entry_paths` 跳过 `to_thread`(微秒级线程跳 vs 单点守卫的
|
||
突变可钉性,取后者;P3 有意放弃并记录)。
|
||
- **证据:** `tests/test_skills_authorization.py` 52→54:
|
||
`test_async_secret_binding_preserves_prepass_failure`(两技能两调用:调用 2 预扫描
|
||
第 3 次加载失败——条目绑定归零且 slash 绑定存活;旧行为同步回退 fail-open 绑定
|
||
被拒的 ENTRY_KEY)、`test_async_secret_binding_resolves_entries_against_prepass_snapshot`
|
||
(第二次加载返回改名后的同路径技能:条目必须按快照名(allow)而非新鲜名(deny)
|
||
解析)。三突变逐一还原必红:M1 哨兵改新鲜恢复(prepass failure 测试红)、M2 哨兵
|
||
株连 slash(同测试红)、M3 条目绕过快照(snapshot 测试红)。
|
||
- **兼容性:** 授权禁用或无条目路径时 `awrap_model_call` 传 `None`,
|
||
`_resolve_secret_bindings` 走历史新鲜加载,行为不变;同步链完全不变;
|
||
policy 中间件注册表参数以 `_RegistryArg` 别名收编退化联合类型。
|
||
|
||
### 2026-09-27 — PR #4541 review(willem-bd R9):slash 绑定参照点纠正与来源独立性
|
||
|
||
- **背景:** R8 的快照设计把 slash 来源也统一到了预扫描快照上——快照在
|
||
`aauthorize()` await **之前**拍摄,slash 激活在其后重新加载当前技能;窗口内
|
||
技能声明秘密 OLD_KEY→NEW_KEY 变化时,激活展示 NEW 内容而绑定注入 OLD(刚激活的
|
||
技能拿不到它声明的凭据、反而拿到已不声明的)。R8 的"一步一载"附送优化在此翻车。
|
||
- **决策(两个来源、两个参照点):** slash 来源永远走**激活后的新鲜加载**
|
||
(`_load_skill_registry_by_path()`)——绑定必须与激活刚读到的内容一致;slash
|
||
来源不查决策 map,快照一致性对它无价值。条目来源保留快照(map 键 = 快照名,
|
||
miss 构造不可能);哨兵失败条目归零;`None`(同步链)回退同一新鲜注册表(同步
|
||
authorize 在同步链是正确 API,无分叉)。两来源解析**相互独立**——新鲜加载瞬时
|
||
失败只归零 slash,条目继续按自己的快照绑定(审查自查发现初版修复把条目嵌在
|
||
slash 守卫下,该场景会误杀条目绑定,已独立成行并由回归钉住)。
|
||
- **复盘(为什么多轮自查仍漏):** 五轮 review 中 reviewer 找的几乎全是"上一轮
|
||
修复新创造的面";R9 的根源是快照统一这个**未被点名的附送优化**逃过了新面计价,
|
||
以及"新鲜度"论证用了错误参照点(对照文档契约"下一步调用"而非"同调用内的激活
|
||
读")且被写进文档后视为已封闭。已入 review-lessons 清单第 36–38 条(读取对×
|
||
变化窗口×参照点;附送优化单独计价;已记录论证可再攻击)。
|
||
- **四轴构造审计(举一反四,模拟 reviewer 方法)**:① 读取对×窗口——
|
||
prepass↔activation 有意分离(各有参照点,代码注释记录);activation↔binding
|
||
为两次新鲜加载、窗口为文件读+哈希(无 await,µs 级),reviewer 措辞
|
||
"fresh/activation-era metadata" 明示 fresh 可接受,且该窗口 PR 之前即存在,
|
||
完全封闭需将 Skill 对象穿透 `_Activation`,记录为已知窗口不扩面;跨中间件
|
||
双快照(activation 与 policy 各自预扫描)无共享决策消费,良性。② 返回值×
|
||
消费者——`(names, dict|sentinel|None)` 三值 × 唯一调用链全部处理,空 dict
|
||
快照构造性绑定归零。③ I/O 失败文法——异步有密步加载 {#1 prepass, #2 激活,
|
||
#3 slash 新鲜}×失败:#1 失败钉(既有)、#3 失败钉(本轮新增)、#2 失败
|
||
`_resolve_activation` 未捕获存储异常直接打断 run——**PR 之前既有**、不在本
|
||
diff,记录不搭车。④ 边界——快照 dict 跨线程只读共享,无变异点。
|
||
- **证据:** `tests/test_skills_authorization.py` 54→56:
|
||
`test_slash_secret_binding_uses_post_activation_registry`(预扫描成功+窗口内
|
||
slash 声明 OLD→NEW:注入 NEW、不注入 OLD);`test_snapshot_entries_bind_while_
|
||
slash_fresh_load_fails`(新鲜加载瞬时失败:slash 归零、条目仍按快照绑定)。
|
||
突变 M4(slash 回退快照)、M5(条目重嵌 slash 守卫)逐一还原必红;上一轮双调用
|
||
测试加载计数按新序列更新(#5)。
|
||
- **兼容性:** 同步链与授权禁用路径不变;"一步一载"附送收益放弃(回到 PR 前
|
||
加载计数),一致性优先;无新增 rider。
|
||
|
||
### 2026-09-27(第二轮自查)— 同技能双来源的"两个时代并集"与 slash 支配规则
|
||
|
||
- **背景:** 修复 R9 后按 reviewer 的构造审计法再读最终代码,构造出下一个场景并实证:
|
||
同一技能既是 slash 来源(新鲜注册表,v2/NEW_KEY)又有持久条目(预扫描快照,
|
||
v1/OLD_KEY)时,两来源并集同时注入两个时代的钥匙(复现输出
|
||
`{OLD_KEY, NEW_KEY}`)——OLD_KEY 正是"技能已不再声明却仍被注入"的形态,
|
||
与 R9 同型、经条目路径到达。工具策略中间件已有先例语言"Explicit slash
|
||
activation dominates for the rest of that run",秘密绑定对同一技能未对齐。
|
||
- **决策(对齐先例):** `_in_context_secret_sources` 新增 `exclude_names`——
|
||
已由 slash 来源绑定(激活时代)的技能名不再贡献条目视图。slash 是显式仪式且
|
||
解析自更新的注册表,同名条目视图只能使其过期或加宽;不同技能的条目照常
|
||
叠加(秘密是加法语义,与工具策略的排他语义不同——该不对称是既有设计,不动)。
|
||
- **证据:** `test_slash_era_dominates_entry_source_for_same_skill`(快照 v1 +
|
||
激活/新鲜 v2:仅注入 NEW_KEY);突变 M6(移除排除)必红(并集
|
||
`{OLD_KEY, NEW_KEY}` 回归)。套件 56→57。
|
||
- **兼容性:** 仅同名支配;异名 slash+条目叠加行为不变(既有双调用回归覆盖)。
|
||
|
||
### 2026-09-27(双角色审查轮)— 同步链授权分支的覆盖缺口
|
||
|
||
- **背景:** 双角色(找问题/解决问题)审查第 1 轮:异步快照路径有 8 个回归,但
|
||
**同步链 + 授权开启**(`wrap_model_call` + `skill_authorization` + 持久条目 +
|
||
secrets,走 `entry_registry=None` 新鲜回退分支)零覆盖——重构该分支的回归不会
|
||
红任何测试。行为先实证正确(规范名经同步 `authorize()`、绑定成功、恰一次
|
||
加载——同步 API 在同步链是正确契约)。
|
||
- **决策:** 补 `test_sync_chain_secret_binding_uses_sync_api_and_canonical_names`
|
||
(`sync_calls == ["vercel-deploy"]`、`async_calls == []`、绑定生效、loads == 1);
|
||
突变 M7(`entry_registry=None` 回退改空 dict)必红。
|
||
- **第 2 轮(换角度重扫)**:声明↔代码对齐逐条核(slash 不查 map、条目 map miss
|
||
构造不可能、支配排除先于激活检查);`tool_search` 确认在
|
||
`ALWAYS_AVAILABLE_BUILTIN_TOOL_NAMES` 中(skill 策略层不破坏 deferral,既有
|
||
设计已覆盖);秘密日志仅记录名字。无新发现,枚举空间记为已穷尽。
|
||
|
||
### 2026-09-27(第三轮自查,willem-bd 视角扫未审面)— user_id 接线回归缺口
|
||
|
||
- **背景:** 换到他从未审查的面:`container_registry.py` 新模块本身、`user_id`
|
||
穿线的接线回归、Command 路径细节。代码接线正确(`_build_runtime_middlewares`
|
||
把 `user_id` 传给 `ToolErrorHandlingMiddleware`,L514),但唯一接线测试
|
||
(`test_lead_runtime_chain_forwards_skill_authorization_to_stamp_gate`)只断言
|
||
`_skill_authorization`——**删掉 user_id 传递不会红任何测试**:stamp 门会静默
|
||
解析进程全局注册表,per-user 自定义技能的读路径回退路径推导名 → 错误授权
|
||
目标。subagent builder 同型缺口。
|
||
- **决策:** lead 接线测试补 `user_id="user-123"` 断言;新增
|
||
`test_subagent_runtime_chain_forwards_user_id_to_stamp_gate`。突变 M8(构造
|
||
调用去 user_id)双测试必红。
|
||
- **其他面:** `container_registry.py` 全文精读(40 行)——键规范化双向、
|
||
thread 纪律入档、`enabled_only=False` 理由明确,无发现。Command 路径 N-scan
|
||
与 activation↔binding µs 窗口维持既有记录。
|
||
- **证据:** 套件 58→59;ruff 干净。
|
||
|
||
### 2026-09-27(第四轮自查,willem-bd 视角扫未审面)— 持久条目的模型可见渲染不复授权
|
||
|
||
- **背景:** 未审面清单再推进:`DurableContextMiddleware` 渲染的 "Active skills"
|
||
提醒直接来自 `state["skill_context"]`,**无任何 `skill:activate` 复授权**(grep
|
||
实证)。provider 翻转为 deny 后:工具与秘密的消费点正确跳过(R6/R7 修复),
|
||
`describe_skill` 也过滤被拒技能(R5 修复)——但模型可见的持久提醒仍宣传该技能的
|
||
名字与路径,直到条目离开 `skill_context`。
|
||
- **定性:** 执行面无洞(模型重读 → stamp 门拒 → 不建新条目;声明工具/秘密均被
|
||
policy/secret 门拦下)——这是**声明精度问题 + 表面不一致**:早期记录中"被拒的是
|
||
激活(策略/秘密/持久上下文)"的"持久上下文"一词过度声明,精确表述应为"被拒读取
|
||
不产生新持久条目;既有条目的**消费**(工具/秘密)复授权,但其**模型可见渲染**
|
||
不过滤"。`DurableContextMiddleware` 属上游模块,渲染过滤记为 follow-up,不在本
|
||
PR 扩面(对齐清单第 35 条)。
|
||
- **证据:** grep 复授权关键词在该文件为空;渲染调用链
|
||
`render_skill_context(state.skill_context)` 无条件透传。
|
||
- **同轮其他未审面:** `release_policy_parameters` 契约(assembly_descriptor 的
|
||
可选鸭子类型;激活中间件发布的 `available_skills` 已是授权过滤后集合)——无发现。
|
||
|
||
### 2026-09-27(第五轮)— 渲染过滤落地:持久条目决策发布与 durable 消费
|
||
|
||
- **背景:** 第四轮将渲染过滤记为 follow-up 后,用户指令授权扩面实现。
|
||
- **决策(发布/消费分离):** `SkillActivationMiddleware` 每步发布**路径键**的
|
||
``skill:activate`` 决策到 run context(`__skill_entry_activation_decisions`,
|
||
复用 slash source 的 owner-token 认证契约;已加入 `REDACTED_CONTEXT_KEYS`):
|
||
异步 hook 复用预扫描快照(零新增 I/O,失败/不可解析发布 False=隐藏);同步
|
||
hook 仅在有条件目时一次扫描 + 同步 authorize(被动步零 I/O,对齐 P2-B 先例)。
|
||
`DurableContextMiddleware` 纯消费——按发布决策过滤**渲染副本**(state 不动),
|
||
自身不做任何 provider/storage 工作;未发布(授权禁用/无条目/非法载体)= 照旧
|
||
渲染(absent-is-permissive,与其他 run-context 载体一致)。lead 与 subagent
|
||
两链均接线 `skill_authorization` + 共享 token。
|
||
- **证据:** 3 个新测试(异步组合断言过滤生效且 `load_calls == 1`(渲染复用发布、
|
||
不自扫);同步组合同断言;授权禁用渲染不变)。突变 M9(消费端不过滤)、
|
||
M10(异步不发布)、M11(同步不发布)逐一必红。同步链既有测试的 sync_calls /
|
||
load 计数按新的合法双调用更新。
|
||
- **兼容性:** 授权禁用路径零变化;`release_policy_parameters` 新增
|
||
`skill_entry_render_filter` 布尔;state 不被渲染过滤触碰。
|
||
|
||
### 2026-09-27(第六轮自查)— 渲染过滤的链级接线回归
|
||
|
||
- **背景:** 渲染过滤落地后的新面上再执行"他式"检查:链级 wiring(token 配对、
|
||
`skill_authorization` 传递、activation 先于 durable 的构造顺序——发布必须先于
|
||
消费)无任何测试;任一被静默删除,过滤失效且无测试变红(与 M8 同类)。
|
||
- **决策:** 两个链级接线测试(lead `build_middlewares` / subagent
|
||
`build_subagent_runtime_middlewares`):断言 durable 拿到同一 resolved 实例、
|
||
token 与链上 activation 中间件相等、`index(activation) < index(durable)`。
|
||
突变 M12(lead 去 token)/ M13(subagent 去 authz)各自必红。
|
||
- **证据:** 套件 62→64;受影响面 302 passed;ruff 干净。
|
||
|
||
### 2026-09-27(第二次 38 条全清单重跑)— 新面增验
|
||
|
||
- **重跑范围:** 渲染过滤落地后的 7 文件改动面。新增取证:新符号出现点枚举
|
||
(`_publish_entry_decisions` 双调用点=同步/异步 hook;`_renderable_skills`
|
||
单消费;write/read 单写单读;key 三用途=写/读/redaction 清单);上游漂移
|
||
**累计 21 个新提交**(`827acf51d..52a3e2564`)——提交前合并义务加重。
|
||
- **发现并补齐 [第 5/24 条]:** 新载体 key 无 redaction 断言——补
|
||
`test_entry_decisions_carrier_is_redaction_listed`(断言剥离 + "redacted,
|
||
not suppressed":源载体保持完整)。
|
||
- **维持:** M9–M13 突变全红记录、发布/消费构造保证、接线/顺序双测试、
|
||
fail_closed=True(生产默认)方向覆盖。套件 64→65。
|
||
|
||
### 2026-09-28 — PR #4541 review R10(willem-bd,P2):无秘密时代的支配排除
|
||
|
||
- **背景:** R9 修复推送后的新发现:同技能排除集此前从"成功绑定的 slash 来源"
|
||
推导(`slash_bound = sources 名`)。当预扫描快照为旧声明(OLD_KEY)、operator
|
||
在 await 窗口内把技能改为**不声明任何秘密**时:激活与新鲜 slash 查询看到新版,
|
||
`_resolve_registry_skill` 因 `required_secrets` 为空返回 None → slash 来源不进
|
||
sources → 排除集为空 → 旧快照条目视图照旧把 OLD_KEY 注入——刚激活的技能已
|
||
不声明任何秘密却仍被注入。
|
||
- **决策(身份推导):** 排除集改为从**已认证的 slash 激活身份**(run context 里的
|
||
slash source path,token 认证)推导:身份路径先在新鲜注册表、后在条目快照中
|
||
解析出名字(名字不因声明变化而失锚),加入排除集——与该技能当前是否绑定成功
|
||
无关。slash 身份在两处注册表都不可解析(技能已卸载)时不排除——沿用条目的
|
||
快照参照点语义。
|
||
- **证据:** `test_slash_dominance_holds_when_activation_era_declares_no_secrets`
|
||
(快照 OLD、激活/新鲜=无秘密:`ACTIVE_SECRETS_CONTEXT_KEY is None`);突变 M14
|
||
(身份推导禁用,退回 sources 推导)必红。套件 65→66。
|
||
- **兼容性:** 有秘密时代的支配行为不变(既有 dominance 回归覆盖);无 slash
|
||
身份时排除集来源不变。
|
||
|
||
### 2026-09-28(R10 修复自查)— 改名交错:身份锚定从名字升级为路径
|
||
|
||
- **背景:** 对 R10 修复本身执行清单第 39–41 条(用户点名"当前修改也要做设计/
|
||
方向处理")。第 40 条(值→门→下游)命中:只枚举了 `required_secrets` 的 ∅,
|
||
没枚举**声明 `name` 本身的变化**。复现坐实:operator 在 await 窗口内改名
|
||
(同路径 foo→bar),用户按新名 `/bar` 激活——名字锚定的排除集只含 "bar",
|
||
快照条目把同一路径解析为旧名 "foo" → 不被排除 → `{OLD_KEY, NEW_KEY}` 两
|
||
时代并集,R10 的洞经改名存活。根源:条目与 slash 来源共享的稳定身份是
|
||
**路径**;名字是每注册表版本可变的派生属性。
|
||
- **决策(路径锚定 + 名字补充):** `_in_context_secret_sources` 新增
|
||
`exclude_paths`——条目规范化路径 == 已认证 slash 身份路径即排除(先于注册表
|
||
解析,不依赖任一版本叫它什么);名字排除保留,覆盖同名异径遮蔽(custom
|
||
shadow public)。身份路径不可解析(已卸载)时路径排除仍然生效——身份是
|
||
run 级承诺。
|
||
- **证据:** `test_slash_dominance_anchors_on_path_across_midrun_rename`
|
||
(快照 foo/OLD_KEY,激活/新鲜 bar/NEW_KEY,按新名激活:仅 NEW 绑定);
|
||
突变 M16(仅路径排除禁用)必红。套件 66→67;受影响面 242 passed。
|
||
- **复盘:** 本洞由"对刚写的修复立即执行新清单条目"抓出——第 41 条
|
||
(重攻自身修复)的直接收益。
|
||
|
||
### 2026-09-28 — 决策 map 构造性加固(#4541 review 线程预告的 follow-up)
|
||
|
||
- **背景:** 四轮 review(R5–R9 前 #4541 合并前后)追踪到同一失败类的四个实例:
|
||
异步执行路径上决策 map 的 miss 静默回退 worker 线程里的同步 `authorize()`——
|
||
对 loop-affine provider 是错误 API,`fail_closed=false` 时拒绝翻成放行。根因是
|
||
载体契约(`dict | None`)由调用方自觉维持:`None` = 同步链,miss = 回退同步。
|
||
- **决策(类型化载体):** `deerflow/authz/activation_decisions.py` 的
|
||
`ActivationDecisions`:覆盖名返回批量决策;**miss 按配置的 provider-error 策略
|
||
解析(fail-closed 拒 / fail-open 放)并以 WARNING 响亮记日志,从不触达
|
||
provider**——miss 是构造 bug 不是策略问题。`None` 保留为同步链信号(同步
|
||
`authorize()` 在同步链是正确 API)。空候选集返回空批而非 `None`:即便无候选,
|
||
消费者的意外名字也必须按策略失败。`get_or_none` 供发布者区分"未覆盖"与 False。
|
||
`fail_closed` 随载体携带(取自 resolved 授权上下文)。
|
||
- **证据:** `tests/test_activation_decisions.py` 5 个类型单测(覆盖返回/miss
|
||
双方向 + 响亮日志断言/get_or_none/容器协议)+ 中间件级 3 测试(miss 在
|
||
fail-open 允许且 `provider.sync_calls == []`、生产默认 fail-closed 拒绝、
|
||
`None` 同步链仍走同步 API)。突变 M18(恢复 miss 的同步回退)必红。
|
||
迁移后两中间件 + 既有套件 252 passed。
|
||
- **兼容性:** 同步链(`None`)行为逐字不变;批量内覆盖名的语义不变;仅 miss
|
||
的处置从"静默同步回退"变为"按策略失败 + 响亮日志"——正是四轮 review 追逐
|
||
的行为面。
|
||
|
||
### 新记录模板
|
||
|
||
```markdown
|
||
### YYYY-MM-DD — Phase N / PR #NNNN
|
||
|
||
- **背景:** 本次变化或 review 发现了什么?
|
||
- **决策:** 新的正式契约是什么?
|
||
- **证据:** 相关代码路径、测试、issue 评论或 benchmark。
|
||
- **否决方案:** 考虑过哪些方案,为什么不采用?
|
||
- **兼容性:** 如何保持现有部署和关闭功能时的行为?
|
||
- **延期:** 哪些工作留给下一阶段?
|
||
```
|
||
|
||
## 当前连续性风险
|
||
|
||
- 原始 RFC 仍包含“`filter_resources` 存在默认实现”的草案示例;本文件记录的已合并
|
||
Phase 0 契约优先于该示例。
|
||
- RFC 原始 Phase 0 范围大于 PR #4127 的实际落地范围。后续实现必须依据已合并基线
|
||
和明确延期清单,不能假设这些功能已经存在。
|
||
- 主线配置版本可能并发变化。不能提前占用版本号;必须先 rebase,再在所有镜像文件中
|
||
使用下一个有效版本。
|