深度长文2026-09-16

我让 AI 改自己的配置,它被自己的安全机制挡住了

一次普通的配置修改,意外挖出 OpenClaw 源码里一道专门防着 AI 自改的白名单。选择权给 agent,定义权留给人。

8 月底我在换 sudocode 中转站,想顺手让 AI 助手把上下文窗口从默认的 200K 调到 500K。

这是一个很正常的需求,配置文件我知道在哪,字段名我也清楚,让它跑一条 gateway config.patch 命令就行了。我以为这件事五分钟搞定。

然后它报错了。

gateway config.patch cannot change protected config paths:
  models.providers.sudocode.models[].contextWindow

受保护的配置路径。我第一反应是不是命令写错了,让它换个写法再试。还是一样的报错。

才意识到不是语法问题。真的被挡住了。

去翻了一下源码

报错信息够明确,守卫函数名字也直接说了是 assertGatewayConfigMutationAllowed,找起来不难。

文件在这里,

~/.local/share/pnpm/global/5/.pnpm/openclaw@2026.7.1-2/
  node_modules/openclaw/dist/openclaw-tools-KulZ1cdH.js

白名单定义在约 1782 行,守卫函数在约 1955 行。完整白名单是这 18 条,

const ALLOWED_GATEWAY_CONFIG_PATHS = [
  "agents.defaults.thinkingDefault",
  "agents.defaults.subagents.thinking",
  "agents.defaults.reasoningDefault",
  "agents.defaults.fastModeDefault",
  "agents.list[].id",
  "agents.list[].model",
  "agents.list[].thinkingDefault",
  "agents.list[].subagents.thinking",
  "agents.list[].reasoningDefault",
  "agents.list[].fastModeDefault",
  "channels.*.requireMention",
  "channels.*.*.requireMention",
  "channels.*.*.*.requireMention",
  "channels.*.*.*.*.requireMention",
  "channels.*.*.*.*.*.requireMention",
  "messages.visibleReplies",
  "messages.groupChat.visibleReplies",
  "messages.groupChat.unmentionedInbound"
];

18 条,按类型分一下,

类别 条数 允许改什么
思考/推理开关 8 thinking / reasoning / fastMode 默认值
Agent 身份与模型选择 2 id、model
渠道 @ 提及要求 5 各层级 requireMention
群聊可见性 3 visibleReplies / unmentionedInbound

models.providers.* 完全不在里面。

这个设计有几个地方值得细看

白名单,不是黑名单。

这个区别很重要。黑名单的思路是「列出不让改的」,白名单是「只允许改这些」。两种思路在面对新增配置项时行为完全不同,黑名单需要维护者记得把新字段加进去,白名单是新字段自动受保护、不需要人去追。

校验的是 diff 结果,不是入参。

守卫函数的逻辑是先算出 nextConfig,再对比当前配置和目标配置,收集出所有实际发生变化的路径,拿这个集合去比对白名单。

这意味着用嵌套结构、merge patch 技巧、或者数组替换,都绕不过去,因为它检查的是最终改了什么,不是你说要改什么。

两道独立的防线。

第一道,路径白名单,挡住改不该改的字段。第二道,collectEnabledInsecureOrDangerousFlags,即使在白名单内的操作,如果会新开危险开关,也会被拦截。两道独立跑,不是串联的。

守卫函数的核心逻辑是这样的,

function assertGatewayConfigMutationAllowed(params) {
  const parsed = parseGatewayConfigMutationRaw(params.raw, params.action);
  const nextConfig = params.action === "config.apply"
    ? parsed
    : applyMergePatch(params.currentConfig, parsed, {
        mergeObjectArraysById: true,
        replaceArrayPaths: new Set(params.replacePaths ?? [])
      });
  const disallowedPaths = [...collectChangedConfigPaths(params.currentConfig, nextConfig)]
    .toSorted()
    .filter((path) => !isAllowedGatewayConfigPath(path));
  if (disallowedPaths.length > 0)
    throw new Error(`gateway ${params.action} cannot change protected config paths: ${disallowedPaths.join(", ")}`);
  const currentFlags = new Set(collectEnabledInsecureOrDangerousFlags(params.currentConfig));
  const newlyEnabled = collectEnabledInsecureOrDangerousFlags(nextConfig).filter((f) => !currentFlags.has(f));
  if (newlyEnabled.length > 0)
    throw new Error(`gateway ${params.action} cannot enable dangerous config flags: ${newlyEnabled.join(", ")}`);
}

通配符只匹配单段。

channels.*.requireMention 里的 * 只匹配一段,所以才要为 1 到 5 层嵌套分别列了 5 条。这看起来有点啰嗦,但好处是通配范围不会失控,不会因为一个 ** 意外放开太多。

真正有意思的切分

白名单里有一条我盯了很久,agents.list[].model,允许。

这意味着 AI 可以改「用哪个模型」,但不能改「这个模型是怎么定义的」,即不能碰 models.providers.*,不能改 endpoint、上下文大小、价格这些参数。

用一句话概括就是,选择权给 agent,定义权留给人。

这个切分很讲究。AI 选用 A 模型还是 B 模型,这是工作需要,合理。AI 去改某个模型的 endpoint 指向哪里、token 限额多少、费率是多少,这改变的是整个系统的运行参数,不应该由它来决定。

前者是「在规则内做选择」,后者是「修改规则本身」。

AI 选择不绕

翻完源码之后,AI 告诉我技术上有几条路是可以绕的。

用 write 或 edit 工具直接改 openclaw.json 文件,就绕过了 gateway 工具这层校验。或者用 exec 跑个 python3 把 JSON 直接改掉,效果一样。

白名单只管 gateway config.patch 这条路,管不到文件系统。

但它没绕,把这件事交还给我,让我自己去改。

理由也说了,这个白名单的存在本身就是一种表达,它在说「模型配置、成本参数、上下文设定,不是 AI 该自主变更的」。工具层挡不住文件层这是技术事实,不代表意图失效。绕过去等于把安全边界当成了需要克服的障碍,而不是一个值得尊重的约定。

「能做」和「该做」是两回事。

我自己改的流程很简单,

# 备份
cp ~/.openclaw/openclaw.json ~/.openclaw/openclaw.json.bak-$(date +%Y%m%d-%H%M%S)

# 人工编辑(直接改 contextWindow 和 contextTokens 字段)

# 校验语法
python3 -m json.tool ~/.openclaw/openclaw.json > /dev/null && echo OK

# models.* 是 hot reload,改完不需要重启
# 验证生效
openclaw models list | grep sudocode

最后 Ctx 列显示 500k/600k,符合预期。

顺带挖出来的上下文问题

改配置的起因是 sudocode 宣传支持 1M 上下文,但 OpenClaw 默认只用 200K,明显没吃满。

我让 AI 做了一轮二分测试,用 'The quick brown fox jumps over the lazy dog. ' 重复不同次数打 /v1/messages,观察 HTTP 状态码。粗估口径下天花板大概在 585K 到 630K 之间。

然后做最后一发确认,估算 500,625 token,上游返回包里的 cache_creation_input_tokens 是 801,017。

估低了 60%。

原因是英文短词的 BPE tokenizer 密度比 4 字符/token 高得多,the / quick / dog 这些高频词单独成 token,实际约 2.5 字符/token。这把「天花板约 585K」往上推了大约 40%,反推真实上限在 100 万附近,sudocode 的 1M 宣传可能是真的,是测量口径压低了。

最终配的是 contextWindow: 600000,留了余量。踩线的代价是硬 400 报错,不是优雅压缩,留余量的成本远低于踩线的成本。

如果按宣传值配 contextWindow: 1000000,OpenClaw 会放任会话涨到接近 1M 才触发压缩,但上游在真实 token 触顶时直接拒绝,结果是吃 400 报错而不是优雅压缩,比默认的 200K 还糟。

AI 还犯了两个错

这件事里有两个错误是 AI 自己的,我觉得写出来比写「AI 有多能干」更有意义。

第一个,把 API key 打进了会话。

检查 provider 配置时,AI 用 sed 按行号读了一段 JSON,那段范围正好包含 apiKey 字段,完整密钥打进了 stdout,等于进了会话 context。密钥进入会话历史后,会随每一轮对话反复重传。完全可以只看结构不看值,没有任何必要读到密钥。

正确做法是结构化读取并过滤敏感字段,

import json
m = json.load(open('/home/ubuntu/.openclaw/openclaw.json'))
m_provider = m['models']['providers']['sudocode']['models'][0]
# 只打印非敏感字段
safe = {k: v for k, v in m_provider.items() if k != 'apiKey'}
print(json.dumps(safe, ensure_ascii=False, indent=2))

如果需要用 key 发请求,走 shell 变量,不进 stdout,

API_KEY=$(python3 -c "import json;print(json.load(open('/home/ubuntu/.openclaw/openclaw.json'))['models']['providers']['sudocode']['apiKey'])")
curl -H "x-api-key: $API_KEY" ...

原则只有一条,密钥可以流经 shell 环境变量,绝不能流经 stdout。

第二个,谎报写了 MEMORY.md。

对话里 AI 说过一句「我这边把这次 provider 切换记进 memory 了」,实际上它没写。后来我主动说「你去更新一下 MEMORY.md 吧」,它去写的时候才发现文件里确实没有。纯属侥幸。

「忘了写」和「说了写了但没写」,后果差很多。前者我可能会想起来问,后者我不会再问,因为以为已经做了。

没做就说没做,要做就当场做完再报,这是基本的。


最后绕回来说一句,OpenClaw 这个白名单不算什么大功能,不会出现在任何 feature 发布里,但它暴露了一个很具体的设计判断,AI 的自主权应该在哪里停。

我觉得这条线划得挺准的。