KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
05 · 模型接入、渠道接入与安全边界 — keel 龙骨
前四章讲的是内核。这一章讲内核的「两个接口面」——往外的模型侧、往人的渠道侧——以及把它们兜住的安全姿态。
前四章讲的是内核。这一章讲内核的「两个接口面」——往外的模型侧、往人的渠道侧——以及把它们兜住的安全姿态。
这一章回答:
ProviderProfile为什么被刻意设计成「只有数据、没有构造逻辑」;- 36 个 provider 插件与 6 个协议适配器如何分工;
- 渠道接入为什么也走插件,
BasePlatformAdapter抽象了什么; - ACP 适配器如何把同一个 agent 塞进编辑器,且不污染 stdout;
SECURITY.md那句「唯一边界是操作系统」到底否定了什么、保留了什么。
一、声明式 ProviderProfile
模型接入的抽象是 providers/base.py:39: class ProviderProfile,一个 @dataclass(装饰器在 :38)。它的第一条注释(providers/base.py:7)就是设计宣言:
Provider profiles are DECLARATIVE — they describe the provider's behavior.
字段可以按四组理解:
name: str
api_mode: str = "chat_completions"
auth_type: str = "api_key" # api_key|oauth_device_code|oauth_external|copilot|aws_sdk
fixed_temperature: Any = None
default_aux_model: str = (...)
def fetch_models(...): ... # providers/base.py:197
- 协议形状:
api_mode决定请求怎么发。取值包括chat_completions、codex_responses、codex_app_server,以及agent/conversation_loop.py里反复出现的anthropic_messages、bedrock_converse。 - 鉴权形状:
auth_type五值覆盖了 API key、OAuth 设备码、外部 OAuth、Copilot、AWS SDK。 - 行为覆盖:
fixed_temperature用于那些不接受任意温度的服务;default_aux_model是辅助模型(压缩、复盘、标题生成都会用到)的默认值——注释providers/base.py:107承认它是「hardcoded id in source, so it rots」,并给出回落策略(:116)。 - 能力查询:
fetch_models()。
「DECLARATIVE」这句强调的其实是否定:profile 不拥有客户端构造。它只描述行为,真正的 HTTP 客户端与协议转换由 agent/ 下的适配器承担。这是把「配置」与「实现」分开的一次刻意切分。
注册表在 providers/__init__.py:_REGISTRY(:45)、register_provider()(:56,写入 _REGISTRY[profile.name])、get_provider_profile()(:70),以及懒发现的 _discover_providers()(:271)——它扫 plugins/model-providers/ 与 entry-points,所以「有哪些 provider」是运行时才知道的。
二、36 个 provider 插件与 6 个协议适配器
plugins/model-providers/ 下共 36 个 含 plugin.yaml 的插件目录,覆盖聚合器与直连两类:openrouter、anthropic、openai-codex、gemini、vertex、bedrock、deepseek、zai、kimi-coding、ollama-cloud、copilot、copilot-acp、xai、qwen-oauth、minimax、nvidia、novita、fireworks、deepinfra、huggingface、upstage、stepfun、xiaomi、arcee、gmi、kilocode、commandcode、meta-ai、opencode-zen、ai-gateway、azure-foundry、alibaba、alibaba-coding-plan、nous、custom、actual。插件清单极简,plugins/model-providers/openrouter/plugin.yaml 全文只有五行 name / kind / version / description / author。
协议转换的实现在 agent/ 下,六个适配器模块:anthropic_adapter.py、gemini_native_adapter.py、bedrock_adapter.py、vertex_adapter.py、azure_identity_adapter.py、codex_responses_adapter.py。另有 agent/transports/ 目录(chat_completions.py、codex.py、anthropic.py、bedrock.py 等)承担发送侧。
这个分工的好处很直接:新增一家「OpenAI 兼容」的供应商,只需一个 plugin.yaml + 一个 ProviderProfile 实例;只有协议真的不同(Anthropic Messages、Bedrock Converse、Codex Responses)才需要写适配器。
三、渠道:一个抽象基类 + 22 个插件
渠道侧的抽象是 gateway/platforms/base.py:2890: class BasePlatformAdapter(ABC)。仓库内直接实现的适配器包括 api_server、signal、weixin、whatsapp_cloud、yuanbao(带 yuanbao_proto / yuanbao_media / yuanbao_sticker 三个配套模块)、qqbot(独立子包)、bluebubbles、msgraph_webhook、webhook;另有一个「中转」实现 gateway/relay/adapter.py:65: class RelayAdapter(BasePlatformAdapter)。
主流 IM 渠道则以插件形式提供:plugins/platforms/ 下共 22 个 频道目录——telegram、discord、slack、feishu、dingtalk、wecom、matrix、mattermost、irc、line、sms、teams、email、google_chat、homeassistant、a2a、buzz、ntfy、raft、simplex、photon、whatsapp。
以 Telegram 为例:plugins/platforms/telegram/adapter.py:638: class TelegramAdapter(BasePlatformAdapter),配套 telegram_ids.py、telegram_network.py(文件头提到 direct-IP failover)。清单 plugins/platforms/telegram/plugin.yaml 里用 requires_env / optional_env 声明依赖:
name: telegram-platform
kind: platform
requires_env:
- name: TELEGRAM_BOT_TOKEN
description: "Telegram bot token from @BotFather"
password: true
optional_env:
- name: TELEGRAM_ALLOWED_USERS
- name: TELEGRAM_HOME_CHANNEL
这份清单同时是「首次配置怎么问用户」的规格:prompt 是提问文案、url 是去哪拿、password: true 表示回显要遮蔽。注册表是 gateway/platform_registry.py:232: class PlatformRegistry。
渠道层的另一个设计是「命令的单一来源」。hermes_cli/commands.py:139 的注释写着「Central registry -- single source of truth」,文件头(:4)声明这套定义同时扇出到 CLI dispatch、Telegram BotCommands、Slack 子命令映射与自动补全。对应函数如 telegram_bot_commands()(:650)、gateway_help_lines()(:599)、resolve_command()(:412)。注释 :684 还记了一条平台硬约束:「Telegram allows up to 100 BotCommands. Hermes ships ~50 built-in commands」。
四、ACP:把 agent 塞进编辑器
ACP 适配器的两个工程细节值得单独看:
- stdout 专供传输。
acp_adapter/entry.py的文件 docstring 与_setup_logging()(:81)把 root logger 的 handler 挂到sys.stderr,并套RedactingFormatter(来自agent/redact)。任何往 stdout 打印的东西都会破坏 JSON-RPC 帧。 - 噪音过滤。客户端会定期发
ping/health这类不在 ACP schema 里的探活方法,acp路由会正确返回-32601,但监督任务会把异常打成 traceback。_BenignProbeMethodFilter(acp_adapter/entry.py:52)专门只压掉这一类噪音(_BENIGN_PROBE_METHODS,:49),其它 background-task 错误照常打印。
Agent 本体是 acp_adapter/server.py:566: class HermesACPAgent(acp.Agent)。宿主能力通过回调桥接:acp_adapter/events.py:114: make_tool_progress_cb、:189: make_thinking_cb、:209: make_step_cb、:266: make_message_cb——正是第 02 章那 19 个回调在 ACP 侧的具体接法。
审批在 ACP 里有两条路径:权限用 acp_adapter/permissions.py:110: make_approval_callback(...);文件编辑另有专门通道 acp_adapter/edit_approval.py:233: maybe_require_edit_approval(tool_name, arguments),它支持 write_file、patch-replace、v4a patch 三种提案形状(_proposal_for_write_file :82、_proposal_for_patch_replace :98、_proposal_for_patch_v4a :155)。文档明确:requester 抛异常时默认拒绝(edit_approval.py:237: "Requester exceptions deny by default")。
五、安全边界:唯一边界是操作系统
SECURITY.md 第 58 节标题是「The Boundary: OS-Level Isolation」,第一句是全书最重的判词(SECURITY.md:60):
The only security boundary against an adversarial LLM is the operating system.
紧接着一段把话说满(:61-65):「Nothing inside the agent process constitutes containment — not the approval gate, not output redaction, not any pattern scanner, not any tool allowlist.」理由是:任何在进程内筛查 LLM 输出的组件,本质上都是「在一个受攻击者影响的字符串上跑的启发式」。
据此它给出两种姿态:
- Terminal-backend isolation(
SECURITY.md:70):非默认终端后端把 LLM 发出的 shell 命令跑在容器/远端/云沙箱里;文件工具(read_file/write_file/patch)也走同一后端,因为它们建立在 shell 契约之上,够不到后端不暴露的路径。但它明确划出不覆盖的范围(:79-84):agent 自己 Python 进程内的一切——code-execution 工具(作为宿主子进程启动)、MCP 子进程、插件加载、hook 派发、技能加载。 - Whole-process wrapping(
SECURITY.md:90):把整棵进程树放进沙箱,所有代码路径受同一套文件系统/网络/进程策略约束。两种实现:Hermes 自己的 Docker 镜像与 Compose(较轻),以及 NVIDIA OpenShell(按会话给沙箱、策略可热重载、凭据从 Provider store 注入而不落沙箱文件系统)。
沙箱后端的实现目录是 tools/environments/,基类 tools/environments/base.py:595: class BaseEnvironment(ABC),后端模块为 local / docker / ssh / singularity / modal / managed_modal / daytona / vercel_sandbox(另有 file_sync.py、modal_utils.py 两个辅助模块)。
六、进程内的纵深(以及它自认不是边界)
把 in-process 的防护说成「不是边界」不等于它们无用。SECURITY.md:137-153 的定位是「useful… not boundaries」——纵深防御,用于降低事故概率而非承担对抗性威胁。这一层由这些组件构成:
- 危险命令拦截:
tools/approval.py的detect_hardline_command()(:601)与用户黑名单_match_user_deny_rule()(:623),见第 03 章。 - 外挂预执行扫描:
tools/tirith_security.py。退出码语义写在文件头:7:0 = allow, 1 = block, 2 = warn;运行期故障(spawn 失败、超时、未知退出码)按注释:10走保守策略。 - 全局急停:
agent/estop.py,is_engaged()(:59)与engage(reason)(:74)。 - 凭据与脱敏:
agent/credential_pool.py管凭据池,agent/credential_sources.py管来源,agent/secret_scope.py管作用域,agent/redact.py管输出脱敏(ACP 的日志格式化器就用它)。 - 子进程环境裁剪:
tools/env_passthrough.py。docstring 说得很清楚:为安全起见会把子进程环境里大部分变量剥掉,只放行「会话级 allowlist」,来源有两处——技能声明与用户配置terminal.env_passthrough;剥变量前必须调is_env_passthrough(:17)。 - 安装前扫描:技能用
tools/skills_guard.py(见第 04 章),插件用tools/plugin_guard.py。后者的 docstring 记了一段动机:Hermes 此前「cloned and executed arbitrary Git repositories unscanned」,而通用的危险模式规则「would flag every legitimate provider plugin」,所以扫描器需要针对插件形态做取舍。
代码地图
| 机制 | 位置 | 要点 |
|---|---|---|
| Profile 声明 | providers/base.py: ProviderProfile |
第 39 行;@dataclass(:38),api_mode 默认 chat_completions(:44) |
| 声明式宣言 | providers/base.py 第 7 行 |
"profiles are DECLARATIVE";不拥有客户端构造 |
| 鉴权五值 | providers/base.py: auth_type |
第 56 行;api_key / oauth_device_code / oauth_external / copilot / aws_sdk |
| 辅助模型默认值 | providers/base.py: default_aux_model |
第 97 行;注释 :107 承认会腐烂,:116 给掉落回策略 |
| 模型列表查询 | providers/base.py: fetch_models |
第 197 行 |
| provider 注册表 | providers/__init__.py: _REGISTRY |
第 45 行;register_provider(:56)、get_provider_profile(:70) |
| provider 懒发现 | providers/__init__.py: _discover_providers |
第 271 行;扫 plugins/model-providers/ + entry-points |
| provider 插件 | plugins/model-providers/openrouter/plugin.yaml |
36 个插件目录;清单仅 5 行(name/kind/version/description/author) |
| 协议适配器 | agent/codex_responses_adapter.py |
六件套之一;另有 anthropic / gemini_native / bedrock / vertex / azure_identity |
| 渠道抽象基类 | gateway/platforms/base.py: BasePlatformAdapter |
第 2890 行;ABC |
| 中转适配器 | gateway/relay/adapter.py: RelayAdapter |
第 65 行;继承 BasePlatformAdapter |
| 渠道插件 | plugins/platforms/telegram/adapter.py: TelegramAdapter |
第 638 行;plugins/platforms/ 共 22 个渠道目录 |
| 渠道依赖声明 | plugins/platforms/telegram/plugin.yaml: requires_env |
第 13 行 TELEGRAM_BOT_TOKEN;optional_env 在 :19 |
| 渠道注册表 | gateway/platform_registry.py: PlatformRegistry |
第 232 行 |
| 命令单一来源 | hermes_cli/commands.py 第 139 行 |
注释「Central registry -- single source of truth」;telegram_bot_commands(:650)、resolve_command(:412) |
| ACP 入口 | acp_adapter/entry.py: main |
第 220 行;_setup_logging(:81)把日志锁到 stderr |
| ACP 探活降噪 | acp_adapter/entry.py: _BenignProbeMethodFilter |
第 52 行;_BENIGN_PROBE_METHODS 在 :49 |
| ACP Agent | acp_adapter/server.py: HermesACPAgent |
第 566 行;继承 acp.Agent |
| ACP 事件桥 | acp_adapter/events.py: make_tool_progress_cb |
第 114 行;另有 make_thinking_cb(:189)、make_step_cb(:209)、make_message_cb(:266) |
| ACP 权限审批 | acp_adapter/permissions.py: make_approval_callback |
第 110 行 |
| ACP 编辑审批 | acp_adapter/edit_approval.py: maybe_require_edit_approval |
第 233 行;requester 抛异常默认拒绝 |
| 安全判词 | SECURITY.md 第 60 行 |
「The only security boundary … is the operating system」 |
| 两种隔离姿态 | SECURITY.md 第 70 / 90 行 |
terminal-backend isolation;whole-process wrapping(含 NVIDIA OpenShell) |
| 沙箱基类 | tools/environments/base.py: BaseEnvironment |
第 595 行;八个后端模块 |
| Tirith 扫描 | tools/tirith_security.py 第 7 行 |
退出码 0 = allow, 1 = block, 2 = warn |
| 全局急停 | agent/estop.py: is_engaged |
第 59 行;engage(reason) 在 :74 |
| 子进程环境裁剪 | tools/env_passthrough.py 第 6 / 17 行 |
会话级 allowlist;剥变量前必须过 is_env_passthrough |
| 插件安装扫描 | tools/plugin_guard.py 第 3 / 11 行 |
针对插件形态取舍,避免误伤全部合法 provider 插件 |
关键取舍
把 profile 做成纯数据、不给它构造客户端的权力,代价是「配置在哪生效」要跨文件追。ProviderProfile 只有字段和 fetch_models(),客户端构造留在 agent/transports/ 与各适配器。收益是新增商家几乎零代码、profile 可以被插件系统安全地装载;代价是排查「这个 provider 的温度为什么没生效」要同时读 fixed_temperature、api_mode 分派点、以及对应 transport——三处都在不同文件。
渠道也走插件、渠道依赖写在 plugin.yaml 里,代价是配置流程变成「数据驱动」。requires_env 的每一项带 prompt / url / password,配置向导可以完全由清单生成。收益是新增渠道不用写配置 UI;代价是「为什么没让我填某个变量」这类问题只能去读 optional_env 与 check_fn 的组合,而不是读一段命令式代码。
ACP 把 stdout 完全让给协议,代价是所有打印都必须走 logger。_setup_logging() 强制 root handler 指向 stderr,且统一套 RedactingFormatter。收益是 JSON-RPC 帧不会被任何 print 污染;代价是调试时不能随意 print,且客户端探活产生的良性异常必须用过滤器(_BenignProbeMethodFilter)单独识别,否则会周期性刷屏。
明确宣布 in-process 防护「不是边界」,代价是用户可能低估它们的作用。SECURITY.md 用整段否定了审批门、脱敏、模式扫描、工具白名单的边界地位,只承认终端后端隔离与整进程包裹两种 OS 级姿态。收益是责任划分清晰:对抗性威胁由 OS 层承担,进程内只做纵深;代价是那些防护(hardline 命令、Tirith 扫描、凭据池、环境裁剪、安装前扫描)真实的实用价值容易被「反正不算边界」一句话带过——它们防的是事故,不是对手。
保守的安装前扫描,代价是规则只能覆盖插件/技能形态的窄集。plugin_guard.py 的注释直说通用危险模式会「flag every legitimate provider plugin」,因此扫描器必须收窄。收益是安装体验不被误报摧毁;代价是这条防线本质上只能拦住「形态明显异常」的包,真正的恶意插件仍需靠 OS 级隔离兜底。
自测题
ProviderProfile的注释说 profile 「do NOT own client construction」。请指出如果让 profile 自己构造客户端,会破坏哪些现有机制(至少两个),并说明api_mode与auth_type分别属于「描述」还是「实现」。auth_type有五个值,其中oauth_external与oauth_device_code的差别是什么?为什么这两者不能合并成一个「OAuth」值,而必须区分?- Telegram 的
plugin.yaml用requires_env/optional_env声明依赖,包括password: true与url。请说明这套声明在「首次配置向导」与「运行时可用性判断」两处各自被谁消费,以及TELEGRAM_ALLOW_ALL_USERS被列为optional_env而非requires_env的安全含义。 - ACP 里权限审批(
permissions.py)与编辑审批(edit_approval.py)是两条独立路径。请说明为什么文件编辑需要一个单独的提案机制,以及「requester 抛异常默认拒绝」这一选择的代价。 SECURITY.md说 terminal-backend isolation 不覆盖 code-execution、MCP 子进程、插件/ hook /技能加载。请针对其中任意两项,说明为什么 whole-process wrapping 才能覆盖它们,以及选择 terminal-backend isolation 的运营者应当接受什么残余风险。