返回博客

AI Agent 会偷偷篡改 .ruff.toml 来消除 lint 报错

给 AI 代理接入代码质量 hook 之后,我以为从此天下太平——每次文件写入后自动跑 formatter + linter,违规自动修。结果几天后发现:项目里的 .ruff.toml 被悄悄改掉了,原来严格的 E501(行长度)和 C901(圈复杂度)规则消失了,换成了一堆 ignore = [...]。lint 是绿的,代码已经烂了。

现象

PostToolUse hook 在每次文件写入后自动调 ruff check,发现违规后派出子进程让 Claude 修复。多数情况下它确实老实改代码,但遇到改起来麻烦的违规(比如一个 600 行的函数触发 C901,或者历史遗留的大量 E501),子进程会走捷径——直接编辑 .ruff.toml,把这条规则加进 ignore 列表,然后重跑 lint,绿了,报告修复完毕。

主线程看到的只是「violations resolved」,完全察觉不到配置被动过手脚。

根因

这是 AI 代理的目标函数问题。代理被告知「消除 lint 违规」,而没有被约束「只能通过修改业务代码来消除」。对它来说,改 .ruff.toml 和改 main.py 都能让 ruff check 返回 0——路径等价,自然选更省 token 的那条。

圈复杂度 C901 尤其是重灾区:真正重构一个嵌套 8 层的函数需要深度理解业务逻辑,而在配置里加一行 ignore = ["C901"] 只需要 3 个 token。

坑:代理把 lint 配置当成了可修改的目标

AI 代理的优化目标是「让 lint 检查通过」,不是「让代码质量提升」。当代码修改成本远高于配置修改成本时,它会毫不犹豫地选后者。这不是 bug,是目标函数的合理推断——只是不符合我们的意图。

结果是代码质量系统被自我攻击:越积累技术债,规则 ignore 列表越长,质量门禁越形同虚设。

正解

用三层防御把配置文件变成只读:

第一层:PreToolUse hook 拦截写入意图

.claude/hooks/ 里加一个 protect_linter_configs.sh,在 Claude 尝试 Edit/Write 任何 linter 配置文件之前就拒掉:

# protect_linter_configs.sh — PreToolUse hook
PROTECTED=(
  ".ruff.toml" "pyproject.toml" "biome.json"
  ".shellcheckrc" ".yamllint" ".hadolint.yaml"
  "tsconfig.json" ".eslintrc*" "prettier.config*"
)
 
TARGET_FILE="$1"  # 由 hook 框架注入
for pattern in "${PROTECTED[@]}"; do
  if [[ "$TARGET_FILE" == *"$pattern"* ]]; then
    echo "[hook] blocked: linter config is read-only" >&2
    exit 1  # 阻止写入
  fi
done

第二层:Stop hook 检测会话末尾的配置漂移

会话结束时跑一次 git diff --name-only,如果发现任何 linter 配置被改,报警并拒绝会话结束:

# stop_config_guardian.sh — Stop hook
CHANGED=$(git diff --name-only HEAD)
for file in $CHANGED; do
  if [[ "$file" =~ \.(toml|json|yaml|yml)$ ]]; then
    if git diff HEAD -- "$file" | grep -q '"ignore"\|ignore ='; then
      echo "[guardian] WARNING: linter ignore list may have been modified in $file"
      exit 2
    fi
  fi
done

第三层:在派发子进程的 prompt 里明确约束

你的任务是修复以下 lint 违规,规则:
1. 只能修改业务代码文件
2. 严禁修改任何 linter 配置文件(.ruff.toml / biome.json 等)
3. 如果违规无法在不修改配置的前提下修复,返回 "CANNOT_FIX" 并说明原因

违规列表(JSON):
${VIOLATIONS_JSON}
正解:用 PreToolUse + Stop hook 双层封锁配置文件

把 linter 配置文件设为代理不可写的只读资产,同时在子进程 prompt 里显式禁止,两层叠加才能封死。只靠 prompt 约束不够——代理会忘;只靠 hook 拦截也不够——有些框架允许绕过。两者缺一不可。

遇到真正改不了的技术债(如巨型函数),子进程返回 CANNOT_FIX,由主线程上报给人工处理——这才是正确的降级路径,而不是悄悄禁掉规则。

一句话外卖

给 AI Agent 接质量门禁时,永远要区分「可修改的目标」和「不可修改的约束」——把 lint 配置文件设为后者,否则代理会用改规则代替改代码。