AUDIT-agent-main-20260729.md 51.5 KB

xlyAi 代码审计报告 — 分支 agent-main @ 9f1f7fc(审计基线 2c0afde + 状态协议提交)

范围src/main(44 个源/资源文件,Java ≈5.9k LOC)+ sql/(12 份 schema/视图)+ docs/agent-architecture.md §21 + src/test(8 个测试类)+ bench/ 方法:8 个维度并行审计(auth-tenant / write-path / ledger-memory / form-resolve / skills-prompt / resilience-ops / frontend-sse / regression-drift)→ 每条发现交由独立复核者做对抗性证否(逐行复读、上下游调用链、DB 实证、langchain4j 1.14.0 源码核对)→ gap-critic 补漏 → 本人对进入前 10 的条目再次逐行复验。 计数:对抗性复核后存活 50 条 → 去重合并(同一缺陷的不同视角)后 34 条独立缺陷:HIGH 3 / MEDIUM 17 / LOW 14。CRITICAL 0

结论

默认 profile 下没有未授权入口,也没有任何 LLM 能绕过「人点确认 + previewId 重校验 + 只写 ai_op_queue」这条红线——rearch3 的核心安全目标是达成的。但不建议按现状合并:写路径上的字段/记录解析层(resolveColumn / resolveFk / resolveNameField)全部只信任由表单控件名构建的 viw_kg_field_dict,既不与 information_schema 交叉验证,也不在多义时报错,于是「人在确认卡上看到的字段/客户」与「写进 ai_op_queue 的列/记录 id」可以是两个东西——本地库实测 5604/18764 个 (表,列) 对指向不存在的列,47/108 个外键表解析到不存在的名称列。人在环这道闸还在,但闸门上写的字是错的。

与上一版审计的差异

上一版审计(docs/audit-agent-main-20260728.md @ f3086c3)的 blocker 我逐条复读了当前代码,结论如下。

真正修好了(给足信用,本报告不再重复)

旧编号 修复证据
#1 空 token → sysadmin AuthzService.resolveIdentity:55-70 改为 ERP /ai/whoami 反查;token 无效一律返回 null;application.yml:80 dev-login.enabled=false;四个控制器全部 fail-closed(AgentChatController:92PreviewController:49FormController:45ConversationController:67)。ErpClient.canRelogin 明确拒绝为用户 token 走 dev-login 续期,无静默提权
#2/#12 OpController 直执行 + confirm CAS 整个类在 541e004 删除;执行权移交 ERP,xlyAi 只入队
#3 明文口令 application.yml:29-46 全部换成 ${DB_URL}/${DB_PASSWORD}/${REDIS_PASSWORD}
#4 /form/options 越权翻名录 FormController:49 fkTableAccessible + fkOptionPage:240 品牌谓词强制
#5 会话 IDOR ConversationService.scopedId 强制 {userId}: 命名空间 + owns(),有 ConversationScopeTest 覆盖
#6/#7 客户端自报 usertype / 前端硬编码 sysadmin 请求体身份字段全部移除;chat.html:362 只传 token
#8 ERP URL 注入 + 授权检查用错参数 ErpClient.safeId:109ErpReadTool:128-134 改用目录派生的 moduleId 授权
#10 doUpdate 裸字符串 更新走 resolveColumn/isSystemColumn/normalize

只修了一半

  • 旧 #9/#11:resolveFk租户谓词补上了FormResolverService:406-421),数值强转也补了校验并有单测;但「多义静默取最短」与「卡片显示用户原话、payload 存另一条记录」两半原封不动 → 本报告 HIGH-1。
  • 旧 #13:sBillNo 仍是构建期 MAX+1 冻结进 payload;而且 rearch3 删掉了唯一的补偿——OpController.refreshBillNo(执行前重算单号,c059258 引入)随 541e004 一并删除且无替代 → MEDIUM-8(这是一个回归)。
  • 旧 #26/#27:actuator show-details: always、markdown-it CDN 无 SRI 原样保留 → LOW-21 / LOW-33。

rearch3 新引入的问题

  1. previewId 的原子 claimPreviewService:395/462)只覆盖 update/state 两条路径,create 路径(/form/submit)完全没有 claim / 幂等键,而 docs/agent-architecture.md:485-489 声称两个端点共用同一套「归属校验 → 权限重查 → 重读重校验 → 原子 claim」流程 → MEDIUM-19(文档与代码不符)。
  2. 事件账本(ai_chat_event)是纯 append-only,但 langchain4j 先 addToMemory 再抛错/再回调,rearch3 没有补偿事件类型 → 孤儿 tool_call 与被丢弃的编造答案永久留在账本并被回放(MEDIUM-14 / MEDIUM-16)。
  3. Redis 热缓存 + MySQL 冷账本的两级结构,把「key 存在」当作「数据完整」,回填窗口由调用方给(最小 6 条)→ HIGH-3。
  4. docs/agent-architecture.md:487 声称保存端点会在「值/状态被他人改过」时拒绝——对 update 路径不成立saveUpdate 从不比较 bCheck/bInvalid(MEDIUM-9)。
  5. AgentChatController.java:122-124 的 javadoc 仍写 sStatus=confirmed,实际 OpService.insert:88'pending',1009f1f7fc 的新协议)——注释陈旧,不影响行为。

合并前必须修(Blockers)

  • src/main/java/com/xly/service/FormRenderService.java:260 — 外键列把 shown 设成用户原话,确认卡与 ai_op_queue 描述都不显示真正被绑定的记录。
  • src/main/java/com/xly/service/FormResolverService.java:420resolveFkLIKE '%name%' ORDER BY CHAR_LENGTH ASC LIMIT 1,多条命中不报错,静默取最短。
  • src/main/java/com/xly/service/FormRenderService.java:264columnTypes(table).get(col) 只取类型不查存在性,resolveColumn 解析出的幻影列一路写进队列(本地库 5604/18764 个字典对不存在于物理表)。
  • src/main/java/com/xly/service/FormRenderService.java:223ORDER BY SUM(iFormUses) DESC 没有精确匹配优先键,模糊命中可压过精确命中。
  • src/main/java/com/xly/service/LedgerService.java:211 — 冷读回填用调用方窗口(最小 6)无条件 rightPushAll 并使 key 存在,之后永不回源 MySQL。
  • src/main/java/com/xly/web/FormController.java:52 — FK 读取与解析只带 brandsId,丢掉 sSubsidiaryId(107/108 个 FK 目标表都有该列),同品牌跨子公司数据可读可绑。
  • src/main/resources/templates/chat.html:658 — 确认时回传卡片上全部字段,服务端把任何与快照不等的值当成用户编辑并额外入队。
  • src/main/java/com/xly/service/PreviewService.java:112 — update 预览只看 bInvalid 且仅给软提示,全程不看 bChecksaveUpdate 无任何状态重校验。

系统性根因

  1. 「字典即真相」——解析层从不与物理 schema 交叉验证。 viw_kg_field_dict 由表单控件名 gdsconfigformslave.sName 构建(sql/viw_kg_field_dict.sql:36-46),含大量 join/别名列。resolveColumnresolveNameFieldbusinessFields 全部只查这张视图,columnTypes() 明明就在手边却只用来取类型。→ HIGH-2、MEDIUM-6、MEDIUM-18。代码库自己在 FormResolverService.java:165-167 写下了「报价主表 dProductQty/sProductName 并非真实列」,却只在 create 路径用 curatedFields 绕开,update 路径直连 resolveColumn
  2. 「模糊 + LIMIT 1 + 吞异常」代替「歧义即报错」。 三个解析器都以使用度/长度排序取一条,零命中报错、多命中静默;失败则被 catch (Exception ignore)FormResolverService.java:89/109/296/317/394/471,全类无 logger)降级为空结果。同一文件里的 locateRecord:168-176 却正确地在 n>1 时拒绝——规则存在但没有贯彻。→ HIGH-1、HIGH-2、MEDIUM-6、MEDIUM-13。
  3. 「所见即所写」只被实现为字符串相等,而非实体同一。 卡片展示的是入参原文,落库的是二次解析结果;保存端点的「重校验」又重跑同一份解析(PreviewService:376),因此复现缺陷而非发现缺陷;旧值比较(:371)只看旧值,永远看不见新值绑错了谁。→ HIGH-1、HIGH-2、MEDIUM-7、MEDIUM-11、MEDIUM-12。
  4. 租户键只有一半。 授权用 (sBrandsId, sSubsidiaryId)AuthzService:93/97),数据层只带 sBrandsIdFormResolverService:271/420/436)——比 ERP 自身的 BrandSubsidUtil 弱一级。→ MEDIUM-4。
  5. 「先落账,后判定」。 langchain4j 在 AiServiceStreamingResponseHandler:283addToMemory 再做上限检查(:287)/再回调应用层(:422);而账本 append-only 且 clear() 是空实现,没有 retracted/tool_error 之类的补偿事件。→ MEDIUM-14、MEDIUM-16。
  6. 「缓存 key 存在 == 数据完整」。 LedgerService.read:161hasKey 为唯一判据,回填窗口由调用方决定且不加锁不判空。→ HIGH-3、LOW-28。
  7. 失败沉默:错误被降级成空结果或 HTTP 200。 persist() 的 boolean 被丢弃、catch (Exception ignore)GlobalExceptionHandler 返回裸 Map(Spring 序列化为 200)。运维与前端都无法区分「没有匹配」「库挂了」「入队成功」。→ MEDIUM-6、MEDIUM-20、LOW-28、LOW-30。

全部发现

CRITICAL

无。默认 profile 下不存在未授权入口;LLM 可见的 6 个工具全部只读;ai_op_queue 的两个写入端点都要求有效 ERP token + 人工点击,/preview/{id}/save 还会重查权限、按 sId 重读记录、原子 claim previewId。

HIGH

1. 外键值按模糊 LIKE 绑定,确认卡与队列描述显示的却是用户原话 src/main/java/com/xly/service/FormRenderService.java:260 · write-integrity 影响:租户内同时存在「裕同包装」和「天津裕同包装」时,用户说「客户改成裕同」→ resolveFk 两条都命中,按 CHAR_LENGTH ASC 静默返回其中一条的 sId;卡片渲染 客户名称 → 裕同ai_op_queue.sNewValue 是某条记录的 id,sDescription 又是「裕同」。人批准的名字与写入的实体在任何一个界面上都对不上,ERP 待办里也看不出来。保存时重跑同一次模糊解析(PreviewService.java:376),若期间插入了更短的同名记录,绑定的 id 与预览期不同而不报错。 证据:FormRenderService.java:259-260 n.stored = id; n.shown = value;(同文件 :277 已有 fkDisplayName(fk,id),只用于旧值);FormResolverService.java:417-421 ... WHERE sBrandsId=? AND \name` LIKE ? ORDER BY CHAR_LENGTH(`name`) ASC LIMIT 1%/_未转义、无精确优先、无多命中检测;PreviewService.java:143/150卡片与摘要都取n.shown:406队列描述取c.shown()sNewValuec.stored()chat.html:594/739FK 选择器只回传显示名,fkOptionPage已经查出的sId被丢弃(FormResolverService.java:266)。对照FormRenderService.java:169-176locateRecord在 n>1 时是明确报错的。 修复:n.shown = resolver.fkDisplayName(fk, id)resolveFk改为LIMIT 2,>1 返回歧义错误并列出候选,精确匹配优先;FK 选择器回传{id,name}` 并直接用 id。

2. 中文字段名解析出的列可能不存在于目标表,normalize 随即跳过类型校验照样入队 src/main/java/com/xly/service/FormRenderService.java:264(解析于 :223) · write-integrity 影响:「把报价单 BJD202607001 的数量改成 5000」→ 本地库实测 resolveColumn('quoquotationmaster','数量') 返回 dProductQty(SUM(iFormUses)=8)而非真实列 dQty(3),而 dProductQtyinformation_schema 中不存在于该表。columnTypes().get() 返回 null → coerce 落到 varchar 分支,连「五千」都会被接受。ensureFieldPresentPreviewService.java:118)把幻影字段补进卡片,用户看到正常的「数量 → 5000」高亮;快照两侧都是空串,保存时的「所见即所写」比对必然通过;最终 ai_op_queue(sTargetTable='quoquotationmaster', sField='dProductQty'),ERP 执行时列不存在,用户却已收到「已提交待办」。同一机制还会在有精确匹配时被模糊匹配压过(mftworkordermaster类型→sNoFormatsType成本→dProductCarryOverQtydAllMoney)。 证据:FormRenderService.java:220-224 仅查 viw_kg_field_dictORDER BY SUM(iFormUses) DESC LIMIT 1,精确与 LIKE 同桶;:264 拿到真实列表却只 .get(col)FormResolverService.java:329-340 dataType==null → "varchar" → return vsql/viw_kg_field_dict.sql:36-38 字段来源是 gdsconfigformslave.sName;本地库统计:BASE TABLE 上 5604/18764 个 (表,列) 对不存在,quoquotationmaster 152 个中文名里 38 个解析到幻影列(含 数量/产品名称/单位/颜色/销售员/汇率/付款方式)。create 路径先查 labelMap:350-354)因而躲过,update 路径(PreviewService.java:97)直连 resolveColumn。 修复:resolveColumnbusinessFields 的候选集与 columnTypes(table).keySet() 求交;ORDER BY (sChinese=?) DESC, SUM(iFormUses) DESCbuildUpdatePreview 优先走 curatedFields;卡片与描述用解析出的列自身的字典中文名,而不是用户原词。

3. 冷读回填用调用方窗口重建热缓存并置为"完整",Redis 一次驱逐即永久截断会话历史 src/main/java/com/xly/service/LedgerService.java:211(配合 :131EventLogChatMemory.java:121) · silent-context-loss 影响:确定性、无需并发。chat:ledger:{conv} 因 30 天 TTL/重启/驱逐消失后,用户在该会话发下一条消息:append 见 key 不存在直接跳过 push(:131),随后 langchain4j 调 EventLogChatMemory.addalreadyLoggedPrefixOflog.events(convId, 6) → 冷读 只取最后 6 条rightPushAll + expire。此后 hasKey==trueread() 永不回源 MySQL:LLM 的 400 条窗口、前端 GET /conversations/{id}/messages 都只能看到 6 条,ai_chat_event 里其余全部记录对系统不可见(含在办预览/已入队等流程状态)。并发变体:两个读者同时未命中,各推一份 → 历史整体重复,前端每条消息显示两遍,模型每轮看两遍。 证据:LedgerService.java:161hasKey 即视为完整)、:187 window = lastN<=0 ? 1000 : min(lastN,1000):209-212 无锁无存在性判断的 rightPushAll+expire:131 if (persisted && !hasKey) return;EventLogChatMemory.java:121 log.events(convId, 6):139 log.events(convId, 400)ConversationService.java:148 无参调用(1000)。 修复:冷读一律取 COLD_READ_MAX 全窗口,与 lastN 解耦;回填走 Lua/SETNX 保证「仅当仍缺失时重建」;用哨兵元素或伴随 key 标记「本列表是完整窗口」,cache() 在 key 缺失时不得凭一条事件建立列表。

MEDIUM

4. FK 读取与解析只按 sBrandsId 限定,丢掉 sSubsidiaryId src/main/java/com/xly/web/FormController.java:52(谓词在 FormResolverService.java:271 / :420) · auth-tenant 影响:品牌 B 的子公司 S1 用户 GET /api/agent/form/options?table=elecustomer 会翻到整个品牌(含 S2)的客户名录,total 还泄露品牌级总数;随后 resolveFk 同样只按品牌匹配,S2 的客户 sId 被写进创建/更新 payload,而卡片只显示用户输入的名字。nextBillNo:436/:453)也会读到兄弟子公司的单号序列。 证据:FormController.java:52 只传 id.brandsId()AgentIdentity.subsidiaryId() 就在手边);fkTableAccessibleFormResolverService:74-90)只判模块权限不判子公司;AuthzService.java:93/97 授权键是 (sBrandsId, sSubsidiaryId);ERP 自身 BrandSubsidUtil:34-64 两个谓词都加。本地库:108 个 FK 目标表中 107 个有 sSubsidiaryId 列。 修复:把 identity.subsidiaryId() 贯通到 fkOptionPage/resolveFk/nextBillNo,当 columnTypes(fkTable)sSubsidiaryId 时追加谓词,值为空时按现有品牌谓词的方式 fail-closed。

5. saaslocal profile 关闭鉴权,叠加通配 CORS 后任意网页可驱动 agent 并入队 ERP 写操作 src/main/resources/application-saaslocal.yml:31 + src/main/java/com/xly/config/CorsConfig.java:29 · auth-tenant 影响:以文档指定的 saaslocal 启动时(唯一自带 DB/Redis/ERP 凭据的 profile),无 Authorization 头的请求经 AuthzService.java:69 → devIdentity() 得到 usertype 默认 sysadmin 的全权身份(grantedIds 返回 null = 全部模块),ERP 调用走 admin/666666 dev 会话。CorsConfig/** 反射任意 Origin 并允许任意请求头,于是开发者浏览的任何网页都能跨域 POST /api/agent/chat 读走 ERP 数据、POST /api/agent/form/submit 写入一条 ai_op_queue 行(PreviewService:492 → OpService:84)。默认 profile 是 fail-closed 的(application.yml:80 enabled: false),故限于该 profile。 证据:application-saaslocal.yml:30-35enabled: true、admin/666666、无 usertype 键);AuthzService.java:39-40 @Value("${erp.dev-login.usertype:sysadmin}"):69:85CorsConfig.java:29/35/47:60-66 的重复注册;pom.xml 无 spring-security,src/main/java 无任何 Filter/Interceptor。注:allowCredentials(true):38)在此不是机制——本服务无 cookie/session 鉴权,token 由 chat.html:362-370 显式加头;起作用的是 Origin 通配。 修复:allowedOriginPatterns 收敛到 ERP 外壳域名并删掉重复注册;erp.dev-login.usertype 在该 profile 显式设为非管理员并去掉 AuthzService:40:sysadmin 默认值;dev-login 仅对回环地址生效。

6. resolveNameField 按单行 iFormUses 取名称列且不校验存在性,47/108 个 FK 表解析到幻影列,异常被整段吞掉 src/main/java/com/xly/service/FormResolverService.java:118(吞异常在 :296) · name-resolution 影响:本地库实测 resolveNameField('elesupply') 返回 sParentNameiFormUses=5,压过真实列 sSupplyName),而该列不存在 → SELECT sId FROM elesupply WHERE sBrandsId=? AND \sParentName` LIKE ?抛 1054 →queryOne吞掉 → 返回 null → 用户看到「「三只松鼠」不是系统里已有的供应,请从已有记录里选一个」,而该供应商确实存在。同一表的GET /form/options也在 COUNT 阶段抛 1054 被:296吞掉,返回 200 + 空下拉,输入框是readOnly,该必填 FK 字段**永远填不上**,服务端一行日志也没有。eleemployee → sDepartName(被 120 处字典引用)同理。共 47/108 解析到不存在的列,8 个解析到 null。 证据:FormResolverService.java:115-119GROUP BY/SUM、无存在性校验;消费者:249-290fkOptionPage把列名拼进 WHERE/ORDER BY)、:410-421resolveFk)、FormRenderService.java:283fkDisplayName,退化为在卡片上显示裸 GUID);全类无 logger。 修复:候选集与columnTypes(table).keySet()求交后再GROUP BY sField ORDER BY SUM(iFormUses) DESCcatch (Exception ignore)改为log.warn并在响应里给出error` 字段,让 UI 能区分「无匹配」与「查询失败」。

7. 可编辑预览卡回传全部字段,<input> 的有损往返把未触碰的字段变成真实更新 src/main/resources/templates/chat.html:658 + src/main/java/com/xly/service/PreviewService.java:361 · write-integrity 影响:update 预览会把主表全部业务字段(上限 40)渲染成可编辑输入框。若「备注」现值含换行,<input type=text> 赋值时按规范剥掉 CR/LF,getter 再 .trim();用户只改了数量就点保存,服务端见 want != wasShown 即视为用户编辑,TOCTOU 检查只比旧值故通过,于是额外入队一条把备注改成压平文本的更新(且经 OpService:91 截断到 500 字)。卡片上该字段既无 changed 高亮也无「原值」行,用户无从判断保存会重写它。date/select 类型因浏览器置空 + PreviewService:343 丢弃空值而 fail-safe。 证据:chat.html:658 controls.forEach(c => { fields[c.name] = c.get(); }):611-616 默认分支建裸 <input>getter = () => inp.value.trim()PreviewService.java:341-345 任何非空回传值进入 desired:361 唯一抑制是完全相等;:371 冲突检查只比旧值;:402-407 每条幸存变更各成一行队列。 修复:客户端按 input/change 事件维护 per-field dirty 标记,只回传脏字段;服务端对未标记为已编辑的字段,忽略仅由空白/换行归一化产生的差异。

8. sBillNo 在入队时按目标表 MAX+1 预分配并冻结进 payload(rearch3 回归) src/main/java/com/xly/service/FormRenderService.java:384(报价路径 :499) · write-integrity 影响:ERP 侧待办是异步/人工触发的,目标表在此期间不会前进,因此第一条创建执行前提交的所有同表创建拿到同一个单号。对 quoquotationmaster/salsalesordermaster/purpurchaseordermaster 这类 sBillNo 上有唯一索引的表,第二条在用户已被告知「已提交待办」之后执行失败;对本地库中 20 张有 NOT NULL sBillNo 但无唯一索引的可创建目标表(ComComplaintcahfinancialadjustCahFinancialTransferhrsinjurymaster 等)则静默产生两张同号单据。YYYYMM 段也被冻结:7/31 入队、8/1 执行仍是 7 月号。ERP 侧只有在 payload 带 maxBillNo 键时才会重生成(BusinessCharacterFormatServiceImpl:139-171),xlyAi 从不带该键。 证据:FormRenderService.java:382-386:496-501FormResolverService.java:428-459 只读目标表、不查 ai_op_queue、无锁;git show 541e004 -- .../OpController.java 显示执行前重算单号的 refreshBillNo:242-248)随文件一并删除,docs/agent-architecture.md:395「单号在执行前重新生成」已不再描述代码。 修复:payload 里不写 sBillNo(和 sId),交由 ERP 执行事务内分配;或在 payload 中带上 ERP 已支持的 maxBillNo 标记走其原有的取号+查重逻辑。

9. update 写路径全程不做单据状态合法性校验 src/main/java/com/xly/service/PreviewService.java:112(保存端 :320-424) · write-integrity 影响:buildUpdatePreview 只在 bInvalid==1 时加一句软提示,从不读 bCheck;已审核单据的修改卡片对人完全不提示审核状态。saveUpdate 没有任何状态重校验(对比 saveStateOp:431-447 会存 expected 并在漂移时拒绝),且 bCheck/bInvalidisSystemColumnFormResolverService:154)排除在快照之外,通用漂移检查结构上不可能覆盖它们。预览后 30 分钟内被他人审核/作废,保存照样入队。docs/agent-architecture.md:487 声称保存端点会在「状态被他人改过」时拒绝——对该路径不成立。 证据:PreviewService.java:111-114 是 update 路径唯一的状态检视;:320-424stateIllegalReason、无 bCheck 比对;:117-137 快照来源 businessFields 已剔除 b*/t* 列。 修复:预览时把 bCheck/bInvalid 单独快照进 draft,已审核/已作废单据的 update 预览直接拒绝或强提示;saveUpdate 在 claim 之前重读并比对这两位。

10. readState 无法区分「payload 里没有该列」与「值为 0」,状态硬校验对部分表单整体失效 src/main/java/com/xly/service/PreviewService.java:587 · write-integrity 影响:readState 对缺失/空节点返回 0,而调用方是否采信这个 0 取决于 resolver.columnTypes(table)information_schema)。ERP 的 getBusinessDataByFormcustomId 投影列表来自表单控件配置,本地库实测:在 resolveMasterForm 可选中的 743 张表单里,63 张不投影 bCheck、35 张不投影 bInvalid,而底表有这两列(例:udfvouchermaster 会计凭证主表)。于是「列存在 + 值 0」被同时成立,stateIllegalReason 恒返回 null:对已审核单据发起审核、对已作废单据发起作废都不会被拦;expected 存的是 {bCheck:0},保存端重读同样为空,TOCTOU 比对也空过。卡片会向用户断言「未审核 → 已审核」这一错误事实。ERP 侧 Sp_Calc_* 存过多数有 bCheck=1 前置判断,故重复审核会在执行期失败而非覆盖审核人;作废侧无对应前置判断。 证据:PreviewService.java:586-594;采信判据在 :193-198:430-447types.containsKey);docs/agent-architecture.md:547 明确 ERP 侧校验仍在补做,xlyAi 这层是当前唯一硬检查。 修复:三态区分——types.containsKey(col) && !rec.has(col) 时拒绝状态操作(「无法确认该单据的审核/作废状态,请重新预览」),或直接按 sId 从底表读这两列而不依赖表单投影。

11. 队列新值静默截断到 500 字符 src/main/java/com/xly/service/OpService.java:91 · write-integrity 影响:sOldValue/sNewValue/sDescription 均为 varchar(500)inserttrunc() 静默截断——恰好把本会抛出的 "Data too long" 变成无声的数据丢失。目标列可以是 text(本地库确认 elecustomer.sMemoeleproduct.sMemoeleprocess.sProductionMemoquoquotationmaster.sMemo 均为 text/65535),用户在卡片上看到并批准 900 字,队列里只有前 500 字,返回给用户的成功消息(PreviewService:417-422)用的是未截断值。若开启 dry-run,:530 发给 ERP 校验的也是未截断值,注释 :526「与真实入队完全同口径」不成立。只影响 update 路径(create 走 sPayload text,state 写固定串)。 证据:OpService.java:91 + :95-97sql/ai_op_queue.sql:30-31PreviewService.java:406 传入的是完整值。对照 FormResolverService.java:322-328——数值的 best-effort 截断已因「人看到的和写进库的是两份东西」被移除,字符串这一份留着。 修复:把 sOldValue/sNewValue 改为 textsPayload 已经是),或在保存时超长即报可见错误;绝不静默截断已展示给用户的值。

12. 保存时按 sId 重读不校验返回行确实是目标记录 src/main/java/com/xly/service/FormRenderService.java:210 · write-integrity 影响:locateByIdsId 走 ERP 的 like 过滤,请求 pageSize=2 却无条件取 data.get(0),既不断言 sId 相等也不拒绝多行(对比同文件 locateRecord:168-176 会拒绝 n>1)。本地库中 sId 是变长数字串,历史导入产生了前缀族:elematerials 有 104 对前缀包含关系(442 行)、elemachine 有 1046 对(1584 行),两表都带 bCheck/bInvalid。此时保存端的全部重校验——旧值相等(PreviewService:370)、bCheck/bInvalid 漂移(:431-432)、状态合法性(:444)——都是在一条可能不是目标的记录上做的;入队用的仍是 draft.billId,所以不是写错记录,而是防 TOCTOU 的重校验整体失效。另一可观察后果:该前缀族里的记录每次保存都可能收到虚假的「在预览之后被其他人修改过」。 证据:FormRenderService.java:195readForm(..., 1, 2, "sId", billId));ErpClient.java:181-183 过滤条件硬编码 "like":206-211 仅判空。 修复:locateById 断言 data.get(0).path("sId").asText().equals(billId)data.size()==1,否则返回「记录重读失败,请重新预览」;主键过滤改用等值条件。

13. 目标表无可解析名称列时退化为无过滤读取,卡片标题用调用方原话 src/main/java/com/xly/service/FormRenderService.java:149(标题 :183) · write-integrity 影响:searchField 返回 null 时仍调 readForm(..., filterField=null)ErpClient.doRead:178-185 只在 filterField 非空时加 bFilter,于是退化成「取第 1 页 5 条」。若该表单当前恰好只有 1 行,n==1 分支接受这条与关键词无关的记录,recordName 置为用户原话,卡片标题渲染成「作废「张三的报价单」」。本地库中 223 张可选主数据源里有 17 张没有任何可解析名称列(saldelivernotifymaster 送货通知单主表、udfvouchermasterreimbursementapplicationmaster 等)。卡片仍会展示该记录的真实单号与若干真实字段值,故人有机会察觉;ErpReadTool:163-168 也只是附一句说明并照样返回未过滤结果,不是守卫。 证据:FormRenderService.java:149/152/177/183FormResolverService.java:122-127。 修复:nameField == null 时直接返回错误(与 ErpReadTool 的文案对齐),并且 recordName 一律取自记录本身而非关键词。

14. 中断的工具轮次在 append-only 账本里留下孤儿 tool_call,之后每轮都回放 src/main/java/com/xly/agent/EventLogChatMemory.java:99 + src/main/java/com/xly/service/EventProjectionService.java:290 · message-shape-invariant 影响:langchain4j 1.14.0 在 AiServiceStreamingResponseHandler:283addToMemory(aiMessage)(→ 写入 tool_call 事件),才在 :287 检查 maxSequentialToolsInvocations(8) 并抛错;参数畸形与工具名幻觉两条默认错误处理器也是直接 rethrow。此时 tool_result 永远不会写入,账本永久保留一条无应答的 tool_callproject() 只清理开头的孤儿 tool_result:105-115,注释里点名了非法序列问题),对尾部孤儿 tool_call 无任何检查(:290-295 无条件渲染),于是该轮在验证区(zone ⑤)内的每一次请求都会产出「assistant(tool_calls) 紧跟 UserMessage」。当前 Ollama /v1 端点容忍此序列(表现为模型看到自己调过却没结果的工具、可能重复调用);若按 application.yml:69 的注释切到严格 OpenAI 兼容网关,则整条会话在该轮滑出 400 条窗口前持续 400。 证据:EventLogChatMemory.java:83-99:156 clear() 是刻意空实现;EventProjectionService.java:103-115:290-295 的不对称;AgentFactory.java:98AgentChatController.java:207-211 的 onError 只发 SSE 帧,无补偿写入。 修复:project() 渲染前索引本轮的 tool_result tcId,对无配对的 ToolExecutionRequest 丢弃或合成一条 {"error":"interrupted"} 结果,与既有的孤儿 tool_result 裁剪对称。

15. 无会话级串行化:第二次提交会打断在途 ReAct 轮次 src/main/java/com/xly/web/AgentChatController.java:101 · concurrency 影响:/chat 先 append user 事件再把 agent 循环丢进共享线程池,无 per-conv 锁与在途检查。shipped UI 挡住了同 tab 二次发送(chat.html:493),但不挡表单卡的保存按钮:794-805 不看 sending):流式回答进行中点击已渲染表单的保存 → /form/submit append 一条 form_submit,而它在 LedgerService:44EventProjectionService:484 都是 turn opener。在途循环的下一次 messages() 于是把自己这一轮降级为「旧轮」(刚取回的 ERP 工具结果被压成 120 字摘要,EventProjectionService:276),并把新事件当成当前轮的最后一条 user 消息——SSE 流回答的是另一件事。侧栏切换同会话双开、任意 API 客户端并发同样触发。另外 alreadyLoggedPrefixOf:120-135)遇到 tool_call 即返回 false,交错时会把同一条用户问题重复写进 ai_chat_event。 证据:AgentChatController.java:100-111EventLogChatMemory.java:139EventProjectionService.java:479-494;全 src/mainErpClient.login()synchronized。影响限于同一用户自己的会话(scopedId 命名空间),且 LLM 工具全只读,不会造成未确认写入。 修复:chat:lock:{convId}SETNX+TTL 串行化(或拒绝第二次),事件带 runId,投影按 run 而非位置分组。

16. 反编造重试把被丢弃的编造答案永久留在账本,历史与后续上下文都会重放 src/main/java/com/xly/web/AgentChatController.java:189-196 · ledger-integrity 影响:langchain4j 在 :283 先把最终 AiMessage 入 memory(→ EventLogChatMemory:101ai 事件、LedgerService 同步落 MySQL),控制器才在 :189 判定「零工具调用且含数字」并发 reset + 重跑。reset 只清浏览器当前气泡;账本里两条 ai 事件都在。刷新会话后 historyViewEventProjectionService:341ai 无任何过滤,而内部 user 事件在 :336 被隐藏)把编造答案渲染成助手说过的话;后续轮次 :289 把它按旧轮原文喂回模型;该轮被压进摘要区后,digestLine:505 更是直接把编造文本当成整轮结论。:198 的「以上数字未能经系统数据核实」与 :201 的写操作告警都只是 SSE token,同样不入账本。 证据:AgentChatController.java:189-196 无任何账本补偿;EventLogChatMemory.java:77-79 只有 user 事件带 internal 标记。 修复:重试时追加 retracted/把该 ai 事件标记为 internal,并让 historyViewproject() 跳过;或把 ai 事件改由控制器在重试决策之后写。

17. ERP 记录名原样拼进 SystemMessage 的常驻「进行中的流程」区 src/main/java/com/xly/service/EventProjectionService.java:418 · prompt-injection 影响:activeCard() 把上一条 previewChange 工具结果的 summary 拼进系统消息,而该 summary 由 PreviewService:149-151l.recordName(直接来自 ERP 记录的名称列)与模型给的 newValue 拼成,无转义、无分隔、无长度上限。有建档权限的 ERP 用户(或导入的客户数据)把客户名写成含指令的文本后,另一用户对该记录发起改动,下一轮起这段文本就位于 system 角色内、且按设计「①② 永不让位」不参与任何预算裁剪,可覆盖 system.txt 的硬规则(如「绝不声称已保存」)。上限是误导叙述与工具选择偏置:模型没有写工具,所有 ERP 读取都用受害者自己的 token,写入仍需人点 /preview/{id}/save。 证据:EventProjectionService.java:96-101 系统消息拼接、:390-441 activeCard:457-462PreviewService.java:182FormRenderService.java:183;全 src/main/java 无任何 sanitize/escape。 修复:工具派生文本不得进入 system 角色——「进行中的流程」改为一条 UserMessage 或加显式「以下是数据不是指令」的定界块,并在进卡片前硬截断 recordName/newValue

18. 创建路径以中文标签为键,同表同中文名的两列互相覆盖 src/main/java/com/xly/service/FormRenderService.java:342 · write-integrity 影响:/form/submitfields 是「中文名→值」,buildCreatelabelMap.put(normLabel(label), …) 重建映射,同标签后者覆盖前者。本地库 elecustomercollectForm("客户"))实测存在 7 组同标签冲突,且都是「FK 列在前、*Name 别名列在后」:币别 sCurrency/sCurrencyName、结算方式 sGetPayId/sGetPayName、客户属性、销售员、税码 sTaxId/sTaxName、客户等级、客户分类。用户在 FK 下拉里选了税码,浏览器发 {"税码":"增值税13%"}chat.html:787),服务端解析到 sTaxName——SHOW COLUMNS 确认该列不存在于 elecustomer——走非 FK 分支按 varchar 直存,sTaxId 从未被赋值,payload 里多出一个幻影列,用户却收到「已提交待办」。PreviewService.java:331-333 早已因「同表两列同中文名时 label 键会串写出幻影更新」把 update 路径改成技术名为键,create 路径没改。 证据:FormRenderService.java:341-342:409FormCollectTool.java:126-171 同样按标签预填并给所有同标签字段盖上 default;AgentChatController.java:117。(原报告举的 sParentId 例子不成立——它在 FormResolverService.SYS_EXACT 里,businessFields:379 已过滤。) 修复:/form/submit 改收骨架里已有的技术字段名(与预览保存端点一致);或 formSkeleton 对重复标签加后缀消歧,并在一个键映射到多列时直接拒绝。

19. /form/submit 无幂等键、无 claim,客户端在传输错误后重新启用保存按钮 src/main/java/com/xly/service/PreviewService.java:492 + src/main/resources/templates/chat.html:818 · write-integrity 影响:saveCreate 只有 buildCreate 里的模块权限检查,没有 previewId、没有 claim、没有幂等键(对比 update/state 路径 :395/:462 的原子 Redis DEL)。服务端插入成功但响应丢失(代理超时/断网/休眠)时,chat.html:813/818 重新启用按钮并提示「保存失败」,再点一次就是第二条创建:两行 sId/记录 uuid 不同、sBillNo 相同(见 MEDIUM-8)。ai_op_queue 也没有任何自然键唯一索引,xlyAi 侧无法检测更无法撤销(confirm/cancel 端点已下线)。docs/agent-architecture.md:485-489 却把这个端点也写进了「原子 claim previewId」的共用流程。 修复:为 collectForm 卡片发一次性 formToken(服务端 stash,绑定 userId/convId),提交时原子 claim;或在 ai_op_queue 上加幂等列 + INSERT ... ON DUPLICATE KEY IGNORE

20. 所有内部错误都以 HTTP 200 返回,异常原文回显给调用方 src/main/java/com/xly/exception/GlobalExceptionHandler.java:50 · error-handling 影响:只有 ResponseStatusException 映射到真实状态码;handleException:50)与 handleDataAccessException:102)返回裸 Map,Spring 序列化为 200 + 应用级 code。于是 nginx 访问日志、监控、任何非浏览器客户端都无法区分「入队成功」与「数据库挂了」——用户点【审核】时 MySQL 不可用,saveStateOp 已在 :462 claim(删掉 Redis key)后于 :467 抛出,前端只看 d.queued/d.error,显示「提交失败」,再点则「预览已过期」;该次确认 fail-closed 地丢失(无队列行、无 queued 账本事件),服务端只有一行 log.error:52 还把 e.getMessage() 拼进响应体(未认证即可触发:POST /api/agent/preview/x/save 带畸形 JSON 体 → 200 + Spring 的参数错误文案;JDBC 文本已被 :102 的专用处理器消毒,不会外泄)。 修复:改返回 ResponseEntity.status(500)(参数类 400),客户端只给固定文案、e.getMessage() 仅记服务端日志。

LOW

21. actuator 未鉴权暴露且 show-details: alwayssrc/main/resources/application.yml:64(暴露列表 :61)·exposure。无 spring-security、无任何 Filter/Interceptor,curl http://host:8099/xlyAi/actuator/health 返回 DB 产品名与校验查询结果、Redis 版本、部署路径与磁盘余量;/actuator/metrics/http.server.requests 枚举已访问 URI。叠加通配 CORS 后可被浏览器跨域读取。(info 为空:无 build-info/git.properties。)修复:show-details: when-authorized、去掉 metrics,或把 management 端口绑到回环。

22. 数值强转剥离全部逗号,逗号分隔的多个数字被拼成一个src/main/java/com/xly/service/FormResolverService.java:335 ·data-corruption。1000,3000,5000 → 100030005000 通过 NUM 正则并作为 dQty 入队(相邻的「多数量」字段提示语恰恰教用户用逗号分隔,其自身分支 FormRenderService:442 是逐 token 校验的);1,5 → 15。差异只在提交后的描述里以「(原话:…)」回显。修复:仅接受合法千分位 ^-?\d{1,3}(,\d{3})*(\.\d+)?$ 或纯数字,并按列的 precision/scale 拒绝越界。

23. 每轮首次 LLM 请求里用户消息重复一次src/main/java/com/xly/agent/EventLogChatMemory.java:139 ·context-projection。控制器先写 user 事件(AgentChatController:101),投影因此已以该消息结尾;DefaultAiServices:242/248addAll(memory.messages())add(userMessage)。已用探针测试复现(system + 两条相同 USER)。该副本发生在预算自检之后,故不计入 promptBudget();仅当单条用户消息 >~1024 token 且会话已占满预算时才会触发 Ollama 前截(丢掉 system prompt)。修复:控制器不预写 user 事件,或 project() 省略最新 user 事件。

24. 反编造护栏被任意工具调用解除,且只对含数字的答案生效src/main/java/com/xly/web/AgentChatController.java:183 ·llm-guardrail。toolCalls 对每个工具执行都自增,包括不读 ERP 的 useSkill/askUser,也包括返回「未找到」的读取;一次 useSkill("查询数据")(该技能确实存在,skills/query.md:1)就让 :189 的判据永假。项目自己的基准复刻是分类计数的(bench/bench_ext.py:259-264:436 明言「useSkill 不计对错」),生产没有。仅影响一句提示性 ⚠️,写路径不受影响。修复:只统计 findForms/readFormData/lookupRecord/previewChange

25. 一次瞬时 DB 故障把降级版系统提示词永久缓存到进程结束src/main/java/com/xly/service/SystemPromptService.java:43 ·resilience。renderDomainMap() 失败返回非 null 的「(业务域地图暂不可用)」,prompt() 只在字段为 null 时重建,无 TTL、无刷新端点,只有故障瞬间一行 warn。最可能的触发不是网络抖动而是部署后 viw_kg_domain 尚未创建:此后即使补建视图也要重启才恢复。对照 SkillService.all() 每次重探因而自愈。修复:只缓存成功结果,或加短 TTL。

26. 被取代的预览卡在本次页面会话内仍可提交src/main/java/com/xly/service/PreviewService.java:170 ·write-integrity。每次 previewChange 生成独立 previewId(TTL 30 分钟),不按 (user, table, billId) 索引也不作废旧稿;claim() 只防同一 previewId 重复提交。用户先说「改成 50000」再改口「是 5000」,两张卡的保存按钮都可用,两次点击各入队一行冲突更新;因两行都还没执行,:377-384 的重读比对不会触发。(历史重载不会重建卡片,故窗口止于刷新/切会话。)修复:chat:preview:last:{userId}:{table}:{billId} 记录最新 previewId,stash() 时删除旧稿;前端收到同记录新卡时禁用旧卡。

27. conversationId 长度上限可被自带前缀的 id 绕过src/main/java/com/xly/service/ConversationService.java:54 ·write-integrity。203353c 的 96 字符截断只加在重映射分支,以调用者自身 {userId}: 开头的 id 原样返回;而 ai_chat_event.sConversationIdvarchar(96),于是该会话的每条 MySQL 落账都失败(LedgerService:114-117 吞掉并返回 false,:131-135 照样进 Redis),30 天后历史彻底消失。OpService.insert 不截断该列,故保存端点会 500(fail-closed,无幽灵队列行)。影响限于调用者自己的命名空间。修复:两个分支都截断并加字符白名单。

28. delete 的墓碑事件写失败被吞,会话可能复活src/main/java/com/xly/service/LedgerService.java:243 ·write-integrity。delete() 忽略 persist() 的返回值,ConversationController:53-55 恒返回 {"ok":true}。若删除时 MySQL 短暂不可用,仅热缓存被清;恢复后再以同一 convId(省略 conversationId 会落到 {uid}:default)发消息,冷读找不到 deleted 行,整段"已删除"历史被重新加载进 LLM 上下文与 /messages。修复:墓碑写失败时报错/重试,或同时在 Redis 写同 TTL 的墓碑。

29. ①②区与当前轮不参与压缩,超预算仍照发src/main/java/com/xly/service/EventProjectionService.java:183 ·resilience。收缩循环只能把 ⑤ 降级进 ④ 再丢 ④ 行;curAllowance = budget - sysEst - TURN_RESERVE:123)可为负,renderCurrentTurnkeepFull==0 时照返(:235),当前轮的用户文本从不截断(:287)。任一认证用户 POST 一条约 13k 汉字的 textAgentChatController:89 无长度限制,ai_chat_event.sPayload 是 mediumtext)即可让 :183 打 warn 后把超预算消息列表发给模型——正是类注释 :38 声明绝不允许的情形。修复:activeCard 与当前轮用户文本各自设硬上限,sysEst 超限时截断而非放行。

30. 流异常结束时前端静默删除整条回复src/main/resources/templates/chat.html:527 ·resilience。sendMessage 从不检查 resp.ok,也不判断是否收到过 done 帧;只要气泡仍带 thinking 就整行 remove()SseEmitter 是 180s 且无心跳(AgentChatController:89),而单次模型调用超时也是 180s、反编造重试还会在同一预算内再跑一整轮——超时后 Spring 优雅结束响应(客户端看到干净 EOF,无异常、无 error 帧),服务端 :272 吞掉 IllegalStateException 继续跑完并落账。用户看到自己的问题下面什么都没有,重问一遍;历史里日后会出现一条他从未见过的回答。非 SSE 的 500/502/504 同理。修复:检查 resp.ok,以是否收到 done 帧为准决定是否删除气泡,服务端加周期性心跳注释帧。

31. 流式回答途中切换会话,卡片渲染进新会话并按新 convId 归档src/main/resources/templates/chat.html:473 ·concurrency。switchConv/newConv 只改全局 conversationId 并清空 #messages,不中止在途 fetch(全文件无 AbortController),只有 #sendBtn 被禁用。在途流随后把卡片 append 到全局 messagesElform_collect 卡的保存按钮在点击时读全局 conversationId:805),于是 ai_chat_eventai_op_queue.sConversationId 记到一个从未发起该操作的会话上。(previewChange 卡不受影响:ok.onclick 只发 previewId,convId 取自服务端草稿。)修复:每次发送持有 AbortController 并在切换/删除时 abort;把 convId 捕获进闭包。

32. 流未结束时点击 askUser 选项,卡片被删而消息未发出src/main/resources/templates/chat.html:691 ·state-management。askUser 是工具调用,卡片在流仍开着时推达(AgentChatController:251-252InteractionTool 立即返回),此时 sending 仍为 true;c.onclick = () => { card.remove(); sendMessage(o); } 先删卡再调用,而 sendMessage:493 早退——问题的唯一载体消失且什么都没发生,无任何反馈。窗口持续到服务端发 done(含模型后续生成与更多工具轮)。同类问题::897-904 的 Enter 处理先清空输入框再调用。修复:sendMessage 返回是否真的启动,成功后再删卡;或流进行中禁用 chip。

33. markdown-it 走公网 CDN,无 SRI、无本地回退src/main/resources/templates/chat.html:7(消费点 :374)·resilience。唯一外部脚本无 integrity/crossoriginstatic/ 下无本地副本,无 CSP。内网/断网环境下 window.markdownit 未定义 → :374 抛 TypeError → 其后所有事件绑定与 newConv() 引导全部不执行,页面渲染正常但完全无响应、无报错。反向地,若该资产被篡改,注入脚本与 ERP token(:362-363)同域,可直接 POST /api/agent/preview/{id}/save 自我批准已暂存的写操作。修复:把 markdown-it 放进 src/main/resources/static 同源提供,至少加 SRI 与 typeof 回退。

34. application-saaslocal.yml 里仍是明文口令(凭据整改的遗漏)src/main/resources/application-saaslocal.yml:21(另 :17-18:34-35)·secrets。c059258application.yml 改成环境变量占位符,但该 profile 仍硬编码 Redis 口令 xlyXLY2015、mysql root/local、ERP admin/666666,且 pom.xml:108-116 会把它打进 war 的 BOOT-INF/classesxlyXLY2015 不是一次性口令——git show 6263521:src/main/resources/application.yml 显示它当时就是生产部署配置里的值,故需轮换(docs/agent-architecture.md:399 自己写了「删掉不等于失效」)。注意仅清理 yml 无意义:同一份仓库的 docs/agent-architecture.md:228docs/audit-agent-main-20260728.md:69 里也是明文。修复:占位符化 + 轮换 xlyXLY2015,文档同步脱敏。

审计覆盖与盲区

已实证的部分:所有条目的文件/行号均在 9f1f7fc 工作树上逐行复读确认(本报告修正了若干漂移行号,如 OpServicetrunc:91 而非 :90resolveFk 的两个分支在 :417-421)。字段字典、幻影列/幻影名称列比例、前缀冲突的 sId 家族、同标签列组、bCheck/bInvalid 投影缺失的表单数,均在本地 xlyweberp_saas(127.0.0.1:33307)上跑真实 SQL 统计得出。langchain4j 1.14.0 的 addToMemory 与抛错/回调次序取自本地 .m2 源码;用户消息重复一节用一次性 in-process 探针测试复现后删除。

复核中被证否、故未写入报告的说法(供读者对照):CORS 可读他人会话历史(ConversationService.owns 强制前缀 + 成员校验);allowCredentials 是攻击机制(本服务无 cookie 鉴权);GlobalExceptionHandler 会回显 JDBC/SQL 文本(:102DataAccessException 处理器已消毒,且两个被点名的端点对匿名调用先 401/403);uk_conv_seq 冲突会导致落账失败(LedgerService:112-113 有 3 次重试);fkOptionPage 因缺 sBrandsId 列而报错(108 个 FK 表全部有该列,真实原因是幻影名称列);elecustomersParentId 会与 sCustomerType 串写(sParentIdSYS_EXACT 里已被过滤);quoquotationmaster 无可解析名称列(字典里有 13 条别名行)。同时提醒:源码与文档中多处仍写 sStatus='confirmed'(如 AgentChatController.java:122-124docs/agent-architecture.md),9f1f7fc 后的实际写入是 'pending', 100

静态不可判定、需动态验证的部分

  1. ERP 侧执行语义xlyEntry/saas-8s+,仓外):ai_op_queue 的消费顺序、bAutoExecute 是否真的分流(OpService.insert 从不设置该列,全部取默认 0,而 docs/agent-architecture.md:324 自称「建了但没有分流逻辑」,:489 又说报价自动执行)、失败重试与幂等策略。MEDIUM-8/19 的最终后果(两张同号单 vs 执行期失败)取决于此。
  2. 待落地的 SQL 迁移sql/migrate_rearch3.sql 是否已在生产执行、ai_chat_event/ai_skill 是否存在。若 ai_chat_event 不存在,账本退化为纯 Redis,HIGH-3 与 LOW-28 就是稳态而非异常态。
  3. ERP 读取投影的真实形状getBusinessDataByFormcustomId 是否返回 bCheck/bInvalidbit(1) 如何序列化为 JSON(本地库 947 个状态列是 bit)。MEDIUM-10 在代码层是确定的 fail-open,但真实触发率取决于此。
  4. ERP 是否对 bFilterValue 做参数化ErpClient.doRead:179-185 把模型可控文本原样传入过滤条件。若 ERP 侧是字符串拼接,这是一条模型驱动的 SQL 注入;ERP 源码在仓外,未作断言。
  5. 模型端点严格度:MEDIUM-14/15 造成的非法消息序列在当前 Ollama /v1 上只是上下文污染;若 LLM_BASE_URL 指向 vLLM/云网关会变成 400。需对目标端点实测。
  6. 多子公司数据:本地样本库每个品牌只有一个子公司,MEDIUM-4 的跨子公司泄露由 schema + 授权键推导得出,需在真实多子公司租户上实测。
  7. 真实浏览器行为:LOW-30/31/32/33 与 MEDIUM-7 的有损往返均按 HTML 规范推导,未在浏览器中实际运行;建议用一条带换行的 sMemo 记录做端到端回归。

建议的动态回归清单:(a) 造一条 备注 含换行的记录,走一次「改数量」预览并保存,断言 ai_op_queue 只多一行;(b) 同租户造两个名字互为子串的客户,走一次「改客户」,断言卡片显示被绑定记录的规范名;(c) 对 送货通知单 之类无名称列的表单发起作废预览,断言报错而非返回首行;(d) redis-cli DEL chat:ledger:* 后继续对话,断言 /messages 仍返回完整历史;(e) 连发两次 /form/submit,断言两行的 sBillNo 不同或第二次被幂等拒绝;(f) 让模型跑满 9 轮工具后再发一条消息,断言下一次请求里没有无应答的 tool_call