deer-flow/docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md
Zeren Wang 82671596f3
feat(authz): add plugin resource targets, provenance, action and management enforcement (#5842)
* feat(authz): add plugin resource targets, provenance, action and management enforcement

PR1 of the extension-tool-authorization series
(docs/plans/2026-09-24-extension-tool-authorization-spec.md): plugin
authorization plumbing, no new author-facing declaration.

- `authz/plugin_targets.py` is the single encoder/validator for composite
  targets (`namespace/part`), with one validating composer behind three
  kind-checked constructors; invalid input raises instead of degrading.
- `authz/plugin_authz.py` is the harness decision layer (`plugin_action`,
  `plugin_management`, plus the batch filters the page slice will consume):
  disabled is a no-op, an explicit deny raises, and provider errors follow
  `fail_closed` like the sandbox gate.
- `POST /api/plugins/{ns}/actions/{name}` decides `plugin_action:invoke`
  after the action resolves and before the body is streamed.
- `deerflow_extension_api.auth` publishes `require_plugin_management` /
  `arequire_plugin_management` with two `app.state` resolver keys; the
  Gateway installs both. Contract version 0.2.3 -> 0.2.4.
- `tools/tool_provenance.py` records host-written plugin provenance for
  display (assembly descriptor, Layer-2 context later); no decision reads a
  label.
- Plugin providers use a `(config signature, loop key)` cache so a
  loop-affine provider is never handed to another loop or to a sync caller.
- RBAC gains `plugin_actions` / `plugin_management` aliases;
  `config_version` 47 -> 48 across the three mirrors.

Tests: new plugin target/provenance/action/management suites plus a
blocking-IO anchor whose offloads were reverted to prove red->green.

* fix(authz): decide plugin resources from the request's config snapshot

Review round 1 on PR #5842 (willem-bd).

P1 — the decision layer no longer reloads the configuration. The six
`authz/plugin_authz.py` functions take a required `app_config` snapshot (the
same request-scoped config the Gateway used to resolve the provider), and
`resolve_/aresolve_plugin_authorization` return it as a third element, so
`enabled`/`fail_closed` can never come from a second read that drifted from
the one that produced the provider. A snapshot the caller could not read at
all is *unavailable*, not disabled: it fails closed as
`authz.config_unavailable`. The Gateway's `_plugin_app_config` now separates
an absent `config.yaml` (no gate, today's behavior) from a config that exists
but cannot be read or validated (deny) instead of swallowing both through
`safe_app_config`.

P2 — `_validated_decision` requires `type(decision.allow) is bool` on both
the sync `authorize` and async `aauthorize` paths, so a provider returning
`AuthzDecision(allow="false")` is a malformed verdict that follows
`fail_closed` instead of being read as an allow.

Both fixes are covered by regression tests that were verified red against the
pre-fix behavior (P1: 200 vs 403 and resolver True vs False; P2: 12 cases
across fail_closed/fail_open and sync/async), plus tests pinning that an
absent config and `authorization.enabled: false` keep today's behavior.

* fix(authz): treat a request-time config absence as unavailable

Review round 2 on PR #5842 (willem-bd).

P1 — `_plugin_app_config[_async]` no longer maps a missing `config.yaml` to
`None` ("no gate"). A Gateway cannot have started without a readable config
(`app/gateway/app.py` reads it through this same accessor in `lifespan`), so
absence *during a request* means the configuration this process was running on
became unavailable — a hot reload, an atomic replace, a ConfigMap remount. It is
unavailable, not disabled: the error propagates,
`resolve_/aresolve_plugin_authorization` raise
`_PluginAuthorizationUnavailable(fail_closed=True)`, the action dispatcher
returns 403 and the management resolvers answer `False`. The dead
`(None, None, None)` allow path is gone, the resolvers return a non-optional
`AppConfig` as their third element, and callers key "authorization disabled" on
`provider is None` alone. This matches the rule the decision layer already
documented (`authz.config_unavailable`) and the public docs.

P2 — `_deny_reason_code` is total: a `reasons` that is not iterable degrades the
code to `authz.denied` instead of raising `TypeError` outside the guarded
provider path, which turned a denial into an unhandled error. It is deliberately
not folded into `_validated_decision`: `allow` was a real `bool`, so the verdict
is a valid denial, and treating it as malformed would flip it to an allow under
`fail_closed: false`.

Both fixes are covered by regression tests verified red against the pre-fix
behavior (P1: resolver True vs False, async `PermissionError` not raised, route
200 vs 403; P2: `TypeError: 'NoneType' object is not iterable` at
`plugin_authz.py:113`). The action-route tests in
`tests/test_plugin_contributions.py` now install a readable
`authorization.enabled: false` config instead of relying on a config-less
process, and `docs/full-stack-plugins.md` plus the PR1 decision log record the
new rule.

* fix(authz): apply the running policy when the plugin config is unreadable

Follow-up to f03d2ea9 (review round 2 on PR #5842, willem-bd).

The first attempt read the P1 finding as "absence is never disabled" and failed
every config read closed unconditionally. That is too broad for this repo: a
config-less host is deliberately treated as having no policy to apply
(`_get_route_authorization_config` falls back to a disabled config,
`sandbox_authz.safe_app_config` documents the same no-op for "CI runners and
direct-call tests"), and the rule turned the config-less e2e plugin host
(`extension_test_fixtures.bookmark_plugin_gateway`) into a 403 — the three
`bookmark-plugin.spec.ts` cases lost the save action. Reproduced locally:
`POST /api/plugins/community.bookmarks/actions/save` against that fixture host
returned 403 before, 200 after this change.

The rule is now "has this process loaded a configuration?", not "was the last
read successful?":

- No loaded config (`peek_loaded_app_config()`, new read-only accessor on
  `deerflow.config.app_config`) and no `config.yaml`: no policy to apply, so the
  gate stays a no-op — unchanged for CI runners, direct-call tests and hosts
  that mount only the plugins router.
- A loaded config whose file disappears or cannot be read: *unavailable*, never
  *disabled*. The resolvers raise `_PluginAuthorizationUnavailable` carrying the
  failure flag of the configuration this process runs on (authorization
  disabled there -> no gate; enabled -> that policy's `fail_closed`, `True` by
  default), so an enabled Gateway answers 403/False in the window instead of
  letting protected plugin actions and management routes through.
- A config file that exists but cannot be read or validated right now: fail
  closed when nothing is loaded (round 1's rule), otherwise the loaded policy
  decides.

`peek_loaded_app_config()` is preferred over a Gateway-side startup snapshot: it
reflects the last successfully loaded config, so a hot-reloaded policy is what
applies during the window, and there is no lifecycle hook to keep in sync.

Evidence: the three no-config/lost-config branches in
`tests/test_plugin_management_guard.py` and
`tests/test_plugin_action_authorization.py`, round 1's unreadable-config tests
unchanged, `test_peek_loaded_app_config_survives_a_missing_file` in
`tests/test_app_config_reload.py`, and the fixture-host probe above. Docs
(`docs/full-stack-plugins.md`, `reference.mdx` en/zh) and the PR1 decision log
state the final rule; the log records the superseded variant.

* fix(authz): keep a denial whose reasons raise while being read

* fix(authz): intersect plugin batch filters with the candidates

The plugin_page/plugin_management batch helpers returned the provider's
`filter_resources` answer verbatim, so a custom provider could hand the
projections a target that was never asked about ("*", another namespace,
a phantom page). Every other filter call site re-projects from its own
candidate list; do the same here, state the intersection on the provider
contract, and pin it with a regression test that fails on the old
pass-through.

* docs(plugins): map a management denial to 403 in the route example

The contributed-route example called `arequire_plugin_management` and let
the documented `PermissionError` escape. The Gateway installs no
`PermissionError` handler, so a route copied from the example answered 500
on a normal policy denial instead of 403. Both example routes now translate
the denial, and the surrounding bullets say why the translation is the
route's job.

* docs(extensions): point the 0.2.4 contract row at the PR that ships it

Every other row in the version-history table links the pull request that
shipped the version; 0.2.4 linked the authorization tracking issue instead.
Both the English and Chinese tables now link this PR.
2026-09-25 14:46:12 +08:00

56 KiB
Raw Blame History

可插拔授权系统实施记录

English summary: This is the cumulative implementation log for the pluggable authorization RFC (#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) 的持续实施记忆。它用于补充设计 RFC,记录已经实际合并的内容、review 中确认的契约, 以及每个后续 PR 必须验证的事项。

每个 RFC PR 的必读要求

修改任何后续阶段前,必须阅读:

  1. 设计 RFC。
  2. 本实施记录。
  3. 前一阶段已经合并的代码和测试。如果它们与旧 RFC 示例不一致,以已合并契约为准。

每个 PR 描述中必须复制并确认以下内容:

## 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 于 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(24 tests)覆盖 disabled/anonymous/ RBAC allow/deny/wildcard/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-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-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 面内。

新记录模板

### YYYY-MM-DD — Phase N / PR #NNNN

- **背景:** 本次变化或 review 发现了什么?
- **决策:** 新的正式契约是什么?
- **证据:** 相关代码路径、测试、issue 评论或 benchmark。
- **否决方案:** 考虑过哪些方案,为什么不采用?
- **兼容性:** 如何保持现有部署和关闭功能时的行为?
- **延期:** 哪些工作留给下一阶段?

当前连续性风险

  • 原始 RFC 仍包含“filter_resources 存在默认实现”的草案示例;本文件记录的已合并 Phase 0 契约优先于该示例。
  • RFC 原始 Phase 0 范围大于 PR #4127 的实际落地范围。后续实现必须依据已合并基线 和明确延期清单,不能假设这些功能已经存在。
  • 主线配置版本可能并发变化。不能提前占用版本号;必须先 rebase,再在所有镜像文件中 使用下一个有效版本。