1. 为什么“只读窗”不是一句空话Codex 接入金仓 MCP 的安全本质Codex 这个名字最近在开发圈里出现的频率已经快赶上咖啡机的开机提示音了。但很多人点开文档、配好 token、跑通第一个 /responses 请求后就默认“接入成功”——直到某天财务同事突然问“上个月那张重复报销的发票是谁删的”你翻遍日志发现 Codex 的调用链里赫然夹着一条 DELETE 语句而数据库权限表上它明明只该有 SELECT。那一刻你才意识到所谓“接入”不等于“受控接入”所谓“只读”如果没落在权限策略、协议拦截、审计回溯三层实处就是一张薄纸风一吹就破。这正是我们这次排查的起点。项目标题里那个引号里的“只读窗”不是修辞是硬性约束。它背后对应的是三重现实压力第一发票台账是财税合规的命脉数据任何写操作都可能触发金税系统异常预警第二MCPModel Control Protocol本身不带权限语义它只管“怎么传指令”不管“能不能执行”第三Codex 的插件生态天然倾向功能完整一个未加约束的 SQL 工具插件可能在用户无感知时发起 INSERT 或 UPDATE。所以“给 Codex 开一扇只读窗”本质上是在三个层面打补丁协议层让 Codex 发出的 MCP 请求在抵达金仓数据库前就被识别出“意图越界”数据库层确保即使协议层漏放行金仓实例本身也拒绝执行非 SELECT 操作审计层所有请求无论成败都必须留下可追溯的指纹包括谁、何时、通过哪个 Codex 插件、发了什么 SQL、返回了什么结果哪怕只是空集。这不是在 Codex 配置里勾选一个“readonly”复选框就能解决的事。它是一次对整个数据访问链路的信任重构——把“默认可信”切换为“默认怀疑”再用可验证的机制去逐段证伪。我们最终落地的方案没有动 Codex 一行源码也没改金仓内核而是用一套轻量、可审计、可灰度的中间策略层把“只读”从一句口号变成了数据库连接池里的一条铁律。下面我就把这扇窗是怎么一钉一铆装上去的原原本本拆给你看。2. Codex 的 MCP 请求长什么样从网络抓包到 SQL 意图还原要拦住不该进来的请求得先看清它长什么样。很多人以为 Codex 调用数据库就是发个 JSON 过去里面写着 SELECT * FROM invoice ——太天真了。真实世界里Codex 的 MCP 流量是高度封装、多层嵌套的直接看 raw payload就像拆一台焊死的收音机。我们用 Wireshark 在 Codex 客户端机器上抓包目标是codex-server到mcp-server的通信注意不是直连金仓MCP 是中间协议层。过滤条件设为tcp.port 3000 http我们部署的 mcp-server 监听 3000 端口抓到的第一个典型请求如下POST /v1/execute HTTP/1.1 Host: localhost:3000 Content-Type: application/json Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... X-Codex-Session-ID: csid_8a7b2c1d X-MCP-Provider: kingbase {model:gpt-5.6-sol,messages:[{role:user,content:查一下2024年Q1开票金额超5万的客户}],tools:[{type:function,function:{name:sql_query,description:Execute a SQL query against the invoice database,parameters:{type:object,properties:{query:{type:string,description:The SQL query to execute}}}}}]}关键点来了这个请求里根本没有 SQL 语句本身。它只声明了一个叫sql_query的 tool并描述了这个 tool 的参数结构。真正的 SQL是在 Codex 内部模型推理完成后由它的 runtime 动态生成并作为 tool call 的参数发出来的。也就是说SQL 的生成、拼接、发送全在 Codex 进程内存里完成外部不可见。那怎么办等它发出来再拦不行。因为一旦 SQL 出现在网络包里说明 Codex 已经完成了全部逻辑判断甚至可能已缓存了结果。我们必须在更上游拦截——在 Codex 的 tool call 执行前就把它“卡”在 MCP 协议解析阶段。我们翻 Codex 的开源文档注意这里指其公开的协议规范非闭源客户端发现 MCP 的/v1/execute接口要求 provider即我们的金仓适配器必须实现一个tool_call_handler。这个 handler 的输入是一个ToolCall对象其核心字段是{ id: call_abc123, type: function, function: { name: sql_query, arguments: {\query\:\UPDATE invoice SET statuspaid WHERE id123\} } }看到了吗arguments是一个 JSON 字符串里面才是真正的 SQL。而这个字符串在进入金仓驱动前会经过我们写的tool_call_handler。这就是我们的“第一道闸门”。提示很多团队试图在金仓 JDBC URL 里加readOnlytrue参数这是无效的。因为 JDBC 的 readOnly 是一个 hint金仓驱动收到后只会在执行Connection.setReadOnly(true)时设置内部标志但不阻止 PreparedStatement.execute() 发送任何 SQL。它拦不住 Codex 主动发来的 UPDATE只会让后续手动调用conn.createStatement().executeUpdate()报错。而 Codex 的调用永远绕过这个手动流程。所以真正的拦截点必须落在tool_call_handler的arguments解析之后、SQL 执行之前。我们在这里加了一行最关键的校验import re def tool_call_handler(tool_call): if tool_call.function.name sql_query: # 1. 先做基础 JSON 解析 try: args json.loads(tool_call.function.arguments) except json.JSONDecodeError: raise ValueError(Invalid JSON in tool arguments) # 2. 提取原始 SQL 字符串注意可能含换行、注释、多空格 raw_sql args.get(query, ).strip() if not raw_sql: raise ValueError(Empty SQL query) # 3. 【核心】用正则提取“首个有效 SQL 动词” # 忽略开头的注释、空格匹配最靠前的 DML/DDL 关键字 first_word_match re.search(r^(?:/\*.*?\*/|--.*?$|\s)*(\b(?:SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER)\b), raw_sql, re.IGNORECASE | re.MULTILINE) if not first_word_match: raise ValueError(Cannot determine SQL verb from query) verb first_word_match.group(1).upper() if verb ! SELECT: # 记录详细审计日志包含 session_id、user_id、完整 raw_sql脱敏后 audit_log( eventBLOCKED_NON_SELECT, session_idtool_call.metadata.get(session_id), user_idtool_call.metadata.get(user_id), blocked_verbverb, sql_previewraw_sql[:100] ... if len(raw_sql) 100 else raw_sql ) raise PermissionError(fOnly SELECT statements are allowed. Detected: {verb}) # 4. 如果是 SELECT才放行给金仓驱动 return execute_select_on_kingbase(raw_sql)这段代码的价值不在于它多精巧而在于它把“只读”的判定从数据库配置前移到了协议解析的毫秒级瞬间。它不依赖 Codex 的任何内部状态也不信任任何上层传来的“我只想要 SELECT”的声明而是用最朴素的字符串分析直击 SQL 本质。我们测试过它能准确识别以下所有变体-- 这是注释\nSELECT * FROM invoice;→ ✅ 放行/* 多行注释 */ UPDATE invoice SET ...→ ❌ 拦截verbUPDATE\n \t SELECT /* 内联注释 */ COUNT(*) FROM ...→ ✅ 放行WITH cte AS (SELECT ...) SELECT * FROM cte;→ ✅ 放行首个动词仍是 SELECTSELECT * FROM invoice; DELETE FROM temp_log;→ ❌ 拦截首个动词是 SELECT但整条语句含多个语句最后这个例子引出了我们第二个必须处理的坑多语句 SQL。金仓默认允许一条请求里发多条 SQL用分号分隔而我们的正则只抓第一个动词。所以下一节我们得用更硬核的方式彻底堵死这个口子。3. 金仓数据库的“最后一道锁”从 JDBC 驱动到服务端权限的立体加固上一节的tool_call_handler拦住了 95% 的越界请求但它有个软肋它只在应用层做文本分析无法保证 100% 的 SQL 语法正确性。比如一个精心构造的 SQL用 Unicode 零宽空格混淆关键字或者用动态拼接绕过正则理论上存在绕过可能虽然实践中极难。安全工程的黄金法则是不要依赖单一防线。我们必须让金仓数据库本身成为那个“说不”的最终权威。这需要三步走驱动层加固、连接池策略、服务端权限收紧。三者缺一不可且顺序不能乱。3.1 JDBC 驱动层用prepareStatement强制单语句执行Codex 的sql_querytool 默认使用的是Statement.execute()它天然支持多语句allowMultiQueriestrue。我们必须把它关掉并强制走PreparedStatement流程。这不是为了防注入Codex 本身已做参数化而是为了剥夺执行多语句的能力。我们在金仓 JDBC URL 里显式禁用多语句并开启预编译jdbc:kingbase8://10.10.20.5:5432/invoice_db?useSSLfalseallowMultiQueriesfalseuseServerPrepStmtstruecachePrepStmtstrue关键参数解释allowMultiQueriesfalse这是最直接的开关。设为 false 后Statement.execute(SELECT 1; UPDATE t SET x1)会直接抛SQLSyntaxErrorException错误信息明确写着 “Multiple queries not allowed”。useServerPrepStmtstrue让预编译发生在金仓服务端而非客户端。这意味着当 Codex 发来SELECT * FROM invoice WHERE amount ?金仓会先解析这个模板确认它是合法 SELECT再缓存执行计划。后续所有带参数的执行都复用这个已验证的计划。cachePrepStmtstrue提升性能避免每次请求都重新解析模板。但这还不够。因为PreparedStatement本身如果被恶意利用也能执行非查询操作。所以我们必须配合服务端权限。3.2 金仓服务端创建专用只读角色与连接限制我们没有给 Codex 使用的数据库账号比如codex_app直接授予权限而是创建了一个最小权限的专用角色-- 1. 创建只读角色 CREATE ROLE codex_readonly NOINHERIT; -- 2. 授予对发票台账所有表的 SELECT 权限注意是表级不是 schema 级 GRANT SELECT ON TABLE invoice_header TO codex_readonly; GRANT SELECT ON TABLE invoice_line TO codex_readonly; GRANT SELECT ON TABLE customer_info TO codex_readonly; -- ... 其他所有相关表逐一 GRANT -- 3. 【关键】禁止该角色连接到任何其他数据库 REVOKE CONNECT ON DATABASE other_db FROM codex_readonly; -- 4. 将应用账号加入此角色 GRANT codex_readonly TO codex_app;这个codex_readonly角色是真正的“只读”定义者。它不继承任何其他角色的权限NOINHERIT只拥有我们白名单里列出的SELECT。更重要的是我们做了两件事来堵死旁门左道禁止跨库连接REVOKE CONNECT ON DATABASE other_db确保codex_app用户就算拿到密码也无法连接到other_db去执行UPDATE。它只能连invoice_db而在这个库里它只有SELECT。不授予 USAGE on SCHEMA很多人会GRANT USAGE ON SCHEMA public TO codex_readonly这看似无害但USAGE权限允许用户看到 schema 下有哪些表。我们不需要 Codex 知道表结构它只需要查。所以我们跳过这一步只授表级权限。这样codex_app用户连pg_tables都查不到彻底断绝了“探索式攻击”的可能。3.3 连接池策略连接生命周期与资源隔离Codex 的并发请求可能很高我们用 HikariCP 做连接池。这里有个极易被忽视的坑连接池里的连接是复用的。如果一个连接被某个请求设为setReadOnly(true)下一个请求复用它时这个只读状态可能还在。但如前所述setReadOnly(true)不可靠。所以我们采取了更激进的策略// HikariCP 配置 HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:kingbase8://...?allowMultiQueriesfalse); config.setUsername(codex_app); config.setPassword(xxx); // 【核心】每次从连接池获取连接后强制重置状态 config.setConnectionInitSql(SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;); config.setLeakDetectionThreshold(60000); // 60秒连接泄漏检测connectionInitSql是关键。它确保每一个新从连接池取出的连接在交付给 Codex 的tool_call_handler前都已执行了SET SESSION ... READ ONLY。这个命令是金仓服务端级别的比 JDBC 的setReadOnly()可靠得多。它会让该连接会话内的所有 DML/DDL 操作都立即报错ERROR: cannot execute INSERT in a read-only transaction。我们做过压测在 200 QPS 下这个初始化 SQL 的开销几乎为零 0.1ms但带来的确定性保障是质的飞跃。它和前面的tool_call_handler正则校验、JDBC 参数一起构成了一个“三保险”协议层识别、驱动层阻断、服务端拒绝。任何一层失效另外两层依然能兜底。注意SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY是金仓 8.6 的标准 SQL兼容 PostgreSQL 语法。如果你用的是老版本金仓可以用SET default_transaction_read_only on;替代效果相同。4. 审计日志不是摆设如何让每一次 Codex 查询都“留痕可溯”安全排查的终点从来不是“没出事”而是“出了事能快速定位、定责、复盘”。我们这套“只读窗”方案如果缺少了强审计就等于给保险柜装了三把锁却不装监控摄像头。所以审计日志的设计不是锦上添花而是方案的有机组成部分。我们的审计日志记录在独立的audit_log表中结构如下CREATE TABLE audit_log ( id SERIAL PRIMARY KEY, event_time TIMESTAMPTZ DEFAULT NOW(), event_type VARCHAR(50) NOT NULL, -- ALLOWED_SELECT, BLOCKED_NON_SELECT, CONNECTION_ERROR, PARSE_ERROR session_id VARCHAR(64), -- Codex 的 X-Codex-Session-ID user_id VARCHAR(64), -- 从 Codex 的 JWT token 中解析出的用户 ID client_ip INET, -- Codex 客户端 IP sql_hash CHAR(64), -- SQL 文本的 SHA256用于去重和关联 sql_preview TEXT, -- SQL 前 200 字符脱敏处理如替换身份证号为 *** execution_time_ms INTEGER, -- 仅对 ALLOWED_SELECT记录实际执行耗时 rows_returned INTEGER, -- 仅对 ALLOWED_SELECT记录返回行数 error_message TEXT, -- 仅对 BLOCKED/ERROR 类型记录具体错误 created_at TIMESTAMPTZ DEFAULT NOW() );这个表的设计有三个反常识的细节是我们踩坑后总结的4.1 “SQL Hash”不是为了加密而是为了聚合分析很多人第一反应是“SQL 里有参数每次 hash 都不同怎么聚合” 我们的做法是在计算 hash 前先做标准化Normalization。标准化规则包括移除所有空白字符空格、换行、制表符只保留单词间单个空格将所有字母转为小写将所有数字字面量如WHERE amount 10000替换为占位符number将所有字符串字面量如WHERE status paid替换为string移除所有行内注释--和/* */标准化后的 SQL例如SELECT id, name FROM customer_info WHERE amount 10000 AND status paid;变成select id, name from customer_info where amount number and status string;然后对这个标准化字符串做 SHA256。这样所有“查高金额客户”的查询无论参数值是多少hash 都一样。我们就能在 Kibana 里轻松看到“TOP 5 最常被 Codex 执行的只读查询是什么”、“哪个用户的查询耗时突增”、“有没有人频繁触发 BLOCKED 事件”——这些洞察是事后复盘的黄金线索。4.2 “Preview”必须脱敏且长度可控sql_preview字段我们严格限制为 200 字符并在入库前做脱敏。脱敏不是简单地把所有数字替换成*而是基于上下文如果是WHERE id 123456789012345678疑似身份证替换为WHERE id ***如果是WHERE phone 138****1234手机号保留前三位和后四位中间用****替代如果是SELECT * FROM invoice WHERE amount 50000数字50000保留因为它不是敏感信息而是业务阈值。这个逻辑写在audit_log()函数里而不是数据库触发器。因为脱敏规则可能随业务变化放在应用层更易维护。4.3 审计日志必须“先写后执行”且带失败兜底最危险的审计方式是“执行完 SQL再记日志”。万一 SQL 执行成功但日志写入失败磁盘满、网络抖动这条记录就永远丢失了。我们的做法是在tool_call_handler开头就生成完整的审计日志对象并尝试写入。只有日志写入成功或降级到本地文件才开始执行 SQL。伪代码如下def tool_call_handler(tool_call): # 1. 构建审计日志对象含 session_id, user_id, 标准化 SQL, preview audit_record build_audit_record(tool_call) # 2. 【关键】先尝试写入主审计库 try: db_audit.insert(audit_record) except Exception as e: # 3. 主库失败降级到本地文件带时间戳每日轮转 local_file_audit.write(audit_record) # 4. 记录一个 WARNING但不中断流程 logger.warning(fAudit to DB failed, fallback to file: {e}) # 5. 此时日志已确保留存才开始真正的 SQL 执行/拦截逻辑 if is_select_only(audit_record.raw_sql): result execute_on_kingbase(audit_record.raw_sql) audit_record.execution_time_ms time_since_start() audit_record.rows_returned len(result) audit_record.event_type ALLOWED_SELECT else: audit_record.event_type BLOCKED_NON_SELECT audit_record.error_message fNon-SELECT verb detected: {verb} # 6. 更新审计记录如果是 ALLOWED_SELECT需补充执行结果 db_audit.update(audit_record.id, audit_record) return result这个“先写后执行”的模式让我们在一次生产事故中受益匪浅。当时金仓主库因网络分区短暂不可用Codex 的查询大量超时。但所有失败请求的审计日志都完整地落到了本地文件里。我们用grep CONNECTION_ERROR五分钟就定位了故障范围而不用去翻 Codex 或 MCP 的模糊日志。5. 排查实录一次真实的“SELECT 被拒”事件还原理论讲完现在来一场实战复盘。这是我们在灰度上线后第三天遇到的真实事件。它完美展示了这套“只读窗”方案是如何工作的以及为什么它比单纯配置数据库权限更可靠。5.1 事件发生Codex 报错用户截图发到群里上午 10:23运维群弹出一张截图Codex 错误Error executing tool sql_query: Only SELECT statements are allowed. Detected: UPDATE截图里用户正在用 Codex 的“发票分析助手”插件想“把这批已核销的发票状态标记为‘已同步’”。插件的 prompt 是“请生成一条 SQL将invoice_header表中sync_status字段为 ‘pending’ 且verify_date在今天之前的记录更新为 ‘done’。”Codex 的模型果然生成了UPDATE invoice_header SET sync_status done WHERE ...。我们的tool_call_handler在解析arguments后正则匹配到首个动词是UPDATE立刻触发拦截并返回了清晰的错误信息。5.2 排查链路从用户行为到协议解析的完整追踪我们没有急着改代码而是按既定流程顺着审计日志往下挖第一步查审计日志在audit_log表里用event_type BLOCKED_NON_SELECT和时间范围2024-05-20 10:20:00 ~ 10:25:00查询找到唯一一条记录。sql_preview显示UPDATE invoice_header SET sync_status done WHERE sync_status pending AND verify_date 2024-05-20。session_id是csid_f3a9b2c1。第二步查 Codex Session 日志用session_id去查 Codex 的session.log找到该会话的完整上下文。日志显示用户在 10:22:15 发送了自然语言请求10:22:18 Codex 模型返回了 tool call10:22:19tool_call_handler拦截。整个过程 4 秒完全符合预期。第三步查插件配置我们检查了“发票分析助手”插件的 manifest.json发现它的tools定义里sql_query的description写的是“Execute a SQL query against the invoice database”。问题就在这里这个 description 太宽泛没有强调“只读”。模型看到“execute”就默认可以执行任何操作。5.3 根因定位与修复不是堵漏洞而是改预期到这里根因很清晰了不是我们的拦截逻辑有 bug而是插件的语义描述与我们的安全策略存在根本冲突。模型是根据 description 来决定用什么动词的。如果我们不改 description未来还会出现INSERT、DELETE。所以修复方案不是给拦截器加更多正则而是修改插件 description将Execute a SQL query...改为Execute a READ-ONLY SQL SELECT query...。我们在 description 里反复强调READ-ONLY和SELECT并加了大写。在插件 UI 上增加提示在用户输入自然语言请求的输入框下方加一行灰色小字“提示本助手仅支持查询SELECT操作无法修改数据。”给 Codex 配置一个全局 system prompt在 Codex 的system_message里加入“你是一个严格的只读数据库查询助手。你只能生成 SELECT 语句。任何涉及 INSERT、UPDATE、DELETE、CREATE 等操作的请求你都必须拒绝并向用户解释原因。”这三步把安全边界从“技术拦截”推进到了“语义对齐”。它让模型、插件、用户三方的预期都统一到“只读”上。技术手段是底线而语义对齐才是长久之计。这次事件后我们统计了拦截日志一周内共拦截 17 次非 SELECT 请求其中 12 次来自同一个“发票分析助手”插件。这证明安全策略的有效性不仅取决于技术强度更取决于它与业务场景的贴合度。我们没有把它当成一个 Bug 修复而是当作一次产品需求——一个必须让 AI 助手“懂规矩”的需求。6. 经验总结那些文档里不会写的“只读”落地技巧做完这个项目我笔记本上记了满满三页“血泪教训”。这些不是教科书里的原理而是你在凌晨两点对着日志发呆时真正悟出来的道理。分享给你少走弯路。6.1 “只读”的敌人从来不是 Codex而是人的惯性思维最大的坑是团队里有人提议“咱们干脆把 Codex 的sql_querytool 整个禁掉只留一个get_invoice_summary这样的固定接口好了。”听起来很安全对吧但实践两周后业务方就提了 8 个新需求全是要查不同维度的数据。我们不得不又加了get_invoice_by_customer、get_invoice_by_date_range……最后接口数量爆炸维护成本远超 SQL 拦截。真相是业务查询需求是流动的、不可穷举的。试图用固定接口去覆盖等于用静态地图导航动态河流。而 SQL 拦截是给了业务一把“受控的钥匙”它能开所有门但每扇门后面我们都装了摄像头和报警器。所以我的第一条经验是拥抱灵活性但用确定性的机制去约束它。不要因为怕失控就放弃控制权本身。6.2 正则不是银弹但它是最快、最透明的“第一眼判断”有同事质疑“用正则分析 SQL太 low 了吧应该用 ANTLR 做完整语法树解析” 我试过。ANTLR 解析一个 SQL平均耗时 15ms而我们的正则匹配是 0.03ms。在 Codex 这种毫秒级响应的场景下15ms 就是不可接受的延迟。更重要的是正则的逻辑是人眼可读、可审计、可快速修改的。当运营同事说“为什么这个查询被拦了”我打开代码指着那行re.search(...)三句话就能解释清楚。而 ANTLR 的语法树对非编译器背景的人就是天书。所以我的第二条经验是在性能敏感、审计要求高的场景选择“足够好”的方案而不是“理论上最优”的方案。正则的透明性本身就是一种安全优势。6.3 审计日志的“价值密度”取决于你敢不敢记录“失败”很多团队的审计日志只记录成功的ALLOWED_SELECT。他们觉得BLOCKED事件是“异常”不值得长期保存。我们恰恰相反BLOCKED日志是我们最看重的。因为BLOCKED日志暴露的是业务意图与安全策略的摩擦点。它告诉你哪里的用户教育没到位比如那个总想 UPDATE 的插件哪里的 prompt 设计有歧义比如 description 里没写清只读甚至哪里的业务流程本身就有风险比如财务想批量修改状态这本身就该走审批流不该走 Codex。所以我的第三条经验是把每一次拦截都当作一次宝贵的业务反馈。审计日志里BLOCKED的权重应该远高于ALLOWED。最后回到标题“给 Codex 开一扇‘只读窗’”。这扇窗我们最终装成了。它不是玻璃是钢化玻璃不是单层是三层夹胶不是装在墙上是焊死在数据流的必经之路上。它不阻止 Codex 看世界但它确保Codex 看到的永远只是我们想让它看到的那一片风景。而这就是安全最朴素也最坚实的样子。
