做货代单证数字员工的第二周,我在开发一个纯"只读"的功能:把业务系统里生成的 EDI 报文抓下来留底,
方便事后对账。功能本身不该发出任何东西。结果那天中午,船司的报文系统给我们回了三封
S/I ACKNOWLEDGEMENT——一个带测试前缀的提单号,被真实递交进了船司系统。
不可逆操作的安全,不能靠"记得别点"。它得写进代码,而且缺一个条件就直接崩掉。
01 · 发生了什么
业务系统的「数据交换 → EDI 导出」弹窗里有一个按钮,文字是「生成」。
在主船司模板下,这个模板带着 sendonly=true,按钮的实际行为是发送:
点下去不弹确认,直接跳到"xxx.xml 发送成功",报文已经进了船司。
为了做留底功能,我反复进入过这个弹窗探查模板;后来一次授权测试里,
一段一次性脚本点了 #generate,用的却是 AI 前缀的测试单。
更难受的是时间线里还有一封更早半小时的回执,触发源没能百分之百追溯。 无论它来自哪次交互,结论是一样的:只要进了"发送可点"的弹窗,就有被触发的风险。
02 · 复盘出的三层原因
第一层是命名陷阱:「生成」名不副实,看起来像只出报文,实际是递交。 第二层是没有确认环节:可逆性为零、可能产生改单费,却连一个"确定要发送吗"都没有。 第三层最关键:没有代码级护栏。所有安全都靠"人记得别点",而人在反复探查同一个弹窗时, 注意力是会耗尽的。
03 · 先立规矩,然后发现规矩会被现实推翻
复盘当天定了五条发送前提:逐票书面放行、永不发测试单、人工在场、接受计费、 以及"发送只能走一次性脚本,绝不写进任何 skill"。听起来很安全。 第二天需求就变了:留底之后确实需要一个稳定的发送能力,一次性脚本每次手写既慢又更容易出错。 于是"绝不写进 skill"这条被推翻——发送代码进了 skill。
这时候安全不能再靠约定,只能靠代码。我把前四条前提的精神改写成了多重硬闸:
每一道闸独立判断,任一不满足就 raise 终止,连报文都不会生成。
04 · 三道硬闸
def real_send(bl_no: str, xml: str) -> None:
# G1:测试单永远发不出去
if bl_no.startswith("AI"):
raise RuntimeError("refuse: test prefix")
# G2:逐票放行,泛化授权不算数
if os.environ.get("EDI_REAL_SEND") != bl_no:
raise RuntimeError("refuse: EDI_REAL_SEND must equal this BL")
# G4:把完整报文打给人看,人敲入提单号才继续
print(xml)
if input("type the BL number to confirm: ").strip() != bl_no:
raise RuntimeError("refuse: not confirmed")
click_generate() # 只有走到这一行才会触发发送
write_receipt(bl_no) # 发送后自动落盘的确认回执
细心的话会发现编号跳过了 G3。它原本是"人手动预放一个放行文件",上线一天就废掉了: 实际流程里没人会中途去建一个文件,这种闸只会被绕开。 取而代之的是 G4 的交互确认,而放行文件改成发送后自动写出的回执——留痕而不是设卡。
05 · 残留风险要写在明面上
G4 是唯一的人闸。它有一个明确的盲区:在无人值守的 Agent 环境里,
"请输入提单号确认"这句话会被 Agent 自己回答。所以这套发送能力被限定为只能在用户始终在回路的交互场景使用,
严禁接进任何自动流程;skill 的默认行为仍然是只读留底,sent 与 clicked_generate 恒为 false。
这一条被写进了项目记忆,而不是只存在于某个人的脑子里。
06 · 我学到的
一是"不写发送代码"不如"发送代码有闸":能力总会被需要,藏起来只会让它以更糙的形式出现。 二是测试数据必须和真实系统物理隔离:前缀校验只有一行,却是所有闸里最便宜、最有效的一道。 三是复盘要写残留风险:说清楚哪些场景还是不安全,比宣称"已经彻底解决"更有用。 那三封回执后来被人工撤销了。而这三道闸,到现在还在每一次真实发送前拦着。