Codex 做了不安全的假设:先引述再写 + 只读模式接地

Codex 用了没读过的列、环境变量或函数签名。强制 quote-before-write 规则,并让 Codex 跑只读模式,把每一行都锚到真实文件上。

Codex 在 SELECT 里写了 user.handle,但列名其实是 user.username。或者调了 sendEmail(to, subject, body),可签名是 sendEmail({ to, subject, html })。或者硬编码了 https://api.example.com,而你项目里这个 URL 走的是 process.env.API_URL。代码看着对,但要么不编译、要么跑不起来,更糟的是跑到了错的目标上。

最快的修法: 在 prompt(或 AGENTS.md)开头加一条规则:「写任何涉及外部函数、schema 列或环境变量的代码前,先读它所在的源文件,并在回答里原样引述那一行。引述不出来就停下来问。」 这一条就能逮住绝大多数不安全假设,因为它逼着 Codex 去开文件,而不是从训练先验里猜。

底层问题是「自信幻觉」:Codex 没读你的真实文件,而是从见过的形状里推断。没有 grounding,能力再强的模型也会猜。下面是完整的诊断、按目标分类的 prompt(schema、签名、环境变量),以及怎么验证结果。

你属于哪一类

把症状对到最可能的原因,然后直接跳到对应修法。

生成代码里的症状最可能的原因先查什么修法
列名 / 表名错Codex 根本没开 schema记录里有没有读 schema.prismaStep 2
参数顺序 / 形状错从 import 或网红库推断签名在记录里搜有没有读定义文件Step 1、Step 3
环境变量名或类型错编了名字;把 string 当 number对照 .env.example 比对Step 4
用了根本不存在的符号纯幻觉grep 找定义Step 5
看着对但实际是历史旧值AGENTS.md 或 JSDoc 过期git log -1 比 doc 和源Step 6

常见原因

按命中率从高到低排。

1. Codex 没开文件,从 import 推断

Prompt 里点了文件名,但 Codex 根本没打开过。它从这个函数在别处被 import 的样子、或训练先验里猜了形状,参数细节就错了。

如何判断:在 Codex TUI 里跑 /status 看哪些目录在范围内,再翻记录找有没有读相关文件的 tool 调用。如果没有读到那个文件,就是 Codex 凭想象。(在 workspace-write 模式下 Codex 可以 自动读你的文件,但它得自己选择去读,而且只能在 workspace 边界内。在 prompt 里点了文件名,不等于强制读了。)

2. AGENTS.md 描述的是过去的现实

你的 AGENTS.mdusers 表有 handle,6 个月前你改名成了 username,文档没更新,Codex 信了它。这件事现在比以前更要命:Codex 会把多个 AGENTS.md 按 root 在前、leaf 在后拼接,直到 project_doc_max_bytes(截至 2026 年 6 月默认 32 KiB)为止,所以一个过期的根文件会在每一轮都喂给 Codex 错的事实。

如何判断:在 AGENTS.md 里 grep Codex 用的那个错符号。在里面就是文档过期。

3. Codex 套了「网红」泛模式

sendEmail(to, subject, body) 是无数 Node.js 博客里的经典写法,但你项目用的是 options-object 变体。Codex 默认走了网红那个。

如何判断:错签名匹配某个明显的开源库形状,说明 Codex 套了常见模式,没套你的代码。

4. Schema 在 Codex 没读的生成文件里

Prisma 的 schema.prisma 是源,但 TypeScript 类型在生成的 node_modules/.prisma/client/index.d.ts 里。Codex 两个都没读,退回去猜。

如何判断:错类型或错列出现在本该用 Prisma 生成类型的地方。看 Codex 的 read 列表里有没有 schema.prisma 或生成的 .d.ts

5. 环境变量名 / 形状是编的

你写 STRIPE_KEY=sk_test_...,Codex 用了 STRIPE_API_KEY(多了个词)。或者把 process.env.PORT 直接当数字用,但环境变量永远是 string,你的代码得用 parseIntNumber()

如何判断:Codex 的环境变量名和形状对照 .env.example,对不上的基本都是编的。

6. 过度相信过期的注释 / JSDoc

函数有 JSDoc 写 @param userId: string,但实现现在其实接受 number | string。Codex 信了 doc,传了 string,跑起来也没事,直到别处某个 caller 传了 number,而类型系统又没拦住。

如何判断:实现签名和 JSDoc 对不上。JSDoc 是谎言,实现才是真相。

最短修复路径

按收益从高到低,Step 1 一步就能逮住绝大多数不安全假设。开始前有个好用的小技巧:在规划阶段用 /permissions 把 Codex 切到只读(或启动时加 --sandbox read-only),这样它没得选,必须先读、先引述,才可能写出任何东西。

Step 1:先引述再写

每个生成代码的 prompt 里都加上:

写任何要调用外部函数 / schema / 环境变量的代码前:

1. 读它定义所在的文件。
2. 在回答里原样引述相关行(签名 / 列 / export)。
3. 然后才写调用代码。

如果引述不出来(文件没找到、不编译),停下来问。
不要从 import 推断,也不要靠训练先验。

多了一步,但根除了「我以为长这样」这种失败。

Step 2:schema 强制先读

你要对 `users` 写查询。
先:读 `prisma/schema.prisma`,把 `User` 模型原样引述。
然后:只用引述里出现过的列写查询。
缺列就建议加(不要假设有)。

没用 ORM 就指向 migration 或原始 DDL 文件。如果用 Prisma,也告诉 Codex 它可以读生成的 node_modules/.prisma/client/index.d.ts 拿精确类型。

Step 3:函数签名强制先读

你要调 `sendEmail`。
先:读 `src/lib/email.ts`,把 export 签名引述出来。
然后:按引述的签名写调用。

引述不出来,文件可能根本不存在,这种不一致最好在任何代码落地前就显出来。

Step 4:环境变量锚到 .env.example

你要用一个环境变量。
1. 读 `.env.example`,引述相关行。
2. 用 `.env.example` 里出现过的精确名字——不要编变体。
3. 记住环境变量值都是 string,需要数字就用 parseInt / Number 转换。
4. 没有这个变量的话建议加(不要假设有)。

Step 5:写完用 grep 验证

不要信汇报。核实引用的符号真的存在:

# 新代码里每个独立标识符都 grep 一下定义
grep -rn "function sendEmail\|export const sendEmail\|export function sendEmail" src/
grep -rn "model User\|interface User" prisma/ src/

grep 不到,就说明 Codex 用了不存在的符号。然后让编译器和真实运行收尾:npx tsc --noEmit 能查出 grep 查不到的错误形状,跑一遍快速测试能逮到那些存在但取值错了的环境变量。

Step 6:靠 AGENTS.md 之前先刷新

如果你的 AGENTS.md 比它描述的代码还旧,而代码又变得很快,它就在骗 Codex。查时间戳:

# 关键 doc 和关键文件的最后更新时间
git log -1 --format="%ai" AGENTS.md
git log -1 --format="%ai" prisma/schema.prisma
git log -1 --format="%ai" src/lib/email.ts

如果 schema 或 email 文件比 AGENTS.md 新,就刷新 AGENTS.md 或删掉过期段,让 Codex 直接读源。由于 Codex 把拼接后的 AGENTS.md 链截到 32 KiB、且 root 在前,根文件要保持精简——臃肿的全局文件会挤掉那些其实准确的目录级说明。

怎么确认修好了

  1. 回答里引述了真实的行。 一个接地的回答会在生成代码前先贴出你文件里真实的签名 / 列 / 环境变量行。没有引述,就是又猜了。
  2. grep 能找到每个被引用的符号(Step 5)。哪里都搜不到,就说明有个幻觉标识符溜进去了。
  3. 类型检查干净。 npx tsc --noEmit(或你语言的等价命令)通过,没有「property does not exist」或参数个数错误。
  4. 它跑在对的目标上。 确认代码里用的 URL 或 key 解析到的是环境变量,而不是硬编码的字面量。

预防建议

  • 把「先引述再写」写进 AGENTS.md 当永久规则,而不是每个 prompt 重复一遍。
  • AGENTS.md 里直接点名 schema、环境变量、函数签名的源文件。
  • 把生成类型(Prisma、GraphQL codegen)当真相,让 Codex 去看它们。
  • AGENTS.md 保持精简,别撞上 32 KiB 上限挤掉准确的嵌套文件;季度审一次,删掉与代码不符的段落。
  • 风险高的规划阶段用 /permissions 让 Codex 跑 read-only 模式,逼它先读后写。
  • Codex 写完后 grep 引用的符号,缺任何一个就是幻觉。
  • AI 的自信和正确率没关系。用编译器和 grep 验证,别信回答的语气。

常见问题

在 prompt 里点了文件名,Codex 就会去读它吗? 不会。在 workspace-write 模式(默认的「Auto」沙箱)下 Codex 可以 读 workspace 里的文件,但读不读由它自己定。点名只是个提示,不是保证。「先引述再写」规则把提示变成硬要求,因为没有引述出来的回答就是不完整的。

只读模式能彻底让 Codex 不再幻觉吗? 不能,但它把失败模式换成了一个有用的形态。read-only 模式下 Codex 不能改文件,所以它会先产出一个计划让你审,然后才落地代码。它仍然得先读、先引述才能接地这个计划,而这正是你要的 grounding。要应用改动就切回 workspace-write

Codex 坚持说某个列存在,但我明明删掉了,为什么? 几乎都是文档过期。在 AGENTS.md 和各处 JSDoc 里 grep 那个旧名字,再用 git log -1 比 doc 和 schema。如果 schema 后来才改过,那 doc 就在每一轮都喂给 Codex 一个过时事实,直到你修掉它。

环境变量名对了,但运行时取的值不对,怎么办? 这不是假设的 bug,变量是存在的。检查你有没有做类型转换(parseInt(process.env.PORT))、.env.env.example 的名字是否一致、以及进程是不是真的加载了 .env(Codex 的沙箱默认不会注入你 shell 里的环境变量)。

怎么看 Codex 到底读了什么? 翻 TUI 记录找文件读取的 tool 调用,再跑 /status 确认哪些目录在范围内。如果你关心的那个文件从没被读到、也不在范围里,那就是你的根因。

相关阅读

外部参考:Codex agent approvals & securityCodex configuration referenceproject_doc_max_bytes 与沙箱模式说明)。

标签: #Codex #Coding Agent #排查 #排查 #不安全假设