Skip to main content
Glama
mrvgao

parallight-lecture-9-agentify

by mrvgao

lecture-9-agentify —— 把一个已有工程项目 Agent 化(ERPNext 实战靶场)

一句话主线

Agent 化 ≠ 重写,是"包裹"(wrap)。 一个成熟系统(这里是 ERPNext)已经自带四样护栏—— 权限内核、业务校验、状态机(draft→submit)、审计流水。天真的 fork 把这四样全绕过,得到一个 能干活但会闯祸的 agent。正确的做法是让 agent 降权继承这四样护栏,再在其上加一层 agent 专属安全中间件:身份透传(层2)、风险分级 + 人审批闸(层3)、隔离边界 + 幂等/ 爆炸半径(层4),能力映射本身(层1)只留 5 个窄工具,不管宿主有多少种单据类型。六个现场 demo(A–F)分别把这条主线的一个失效模式讲透;第三份东西(gates/)把"学生 fork 出来的东西 是不是真的做了这件事"变成一套可核对的证据要求,而不是"读起来像"就算数。

Related MCP server: ERPNext MCP Server

§6.0 真实事故锚点表

每个 demo 开头都要先讲这一行的真实事故——学员先在这个 ERPNext 靶场上复现同一类故障, 再修掉,最后在 trace/代码行里验证到底是哪一层挡住的。

Demo

真实锚点

学员要"感受到"的事

A

Supabase + Cursor agent 事故(2025 中):service-role 特权 + 支持工单里的用户输入被当指令 → 集成 token 外泄到公开工单。"lethal trifecta":特权访问 × 不可信输入 × 对外信道

admin key 让 agent 无所不能,而"无所不能"本身就是事故

B

GitHub MCP 事故(2025-05,Invariant Labs):公共仓一条恶意 issue 就能让接了 GitHub MCP 的 agent 把私有仓内容、个人数据、工资信息吐出去

权限不是 prompt 里一句"你不许看工资",是宿主系统里可验证的拒绝路径

C

agent 重试风暴(生产案例):90 秒内重试 17 次,每次措辞微调 → 幂等键跟着漂移 → 数百条重复记录;及"回执 200 但其实没落库 / 超时后其实已落库"

幂等键必须来自业务语义,不能来自 LLM 生成的请求体;回执不可信,必须回查宿主状态

D

同 A 的 lethal trifecta 收口:发票 remarks 里埋注入 → naive 版既能付款又能外传工资数据

防注入不是让模型"自觉",是信道隔离 + 能力最小化 + 人闸三层叠加,trace 里能指出挡住它的确切代码行

E

这门课自己的问题(无外部事故):一份把安全声明写得很周全、却一行代码都没强制的 fork,读起来和真正做了架构工作的 fork 很像

评审不能只读 README/系统提示词,要问"如果把这一行删掉/顺序换一下,会不会有一个独立信号告诉我出事了"

F

工具膨胀 / 上下文轰炸(企业 MCP 普遍病):开满 93 工具的 GitHub MCP 一启动吃约 55,000 token;企业级把多个 MCP server 接在一起,用户还没打第一个字,上下文就可能已经烧掉六七成

"把宿主全部 DocType 自动生成工具"是最天真的 agent 化;能力映射的第一动作是删,不是加

来源与逐条展开见各 demo 文档自己的"现实锚点"小节(Demo F 的行业数字额外标了出处,不与本仓 实测数字混在一起)。

环境准备

先说两件读者容易搞错的事:

  1. ERPNext + MariaDB 很重。 这套栈是在一台 6 GiB / 4 CPU 的 Docker VM(本机用 Colima:colima start --cpus 4 --memory 6 --vm-type vz --vz-rosetta,或等价配置;本机 colima list 实测确认当前就是 4 CPUs / 6GiB)上 构建并验证的——9 个容器(backend/db/redis-cache/redis-queue/websocket/queue-short/ queue-long/scheduler/frontend)。给少了会更慢,不代表跑不起来,但不要指望在 2 GiB 的虚机上顺畅跑。

  2. 建站要等,不要碰见报错就重试。 make create-site 内部有一个真正的就绪轮询(等 backend 能跑 bench --version 才继续,最多等 40×3 秒),第一次跑还要现拉 HRMS 源码、 跑 DocType migration——诚实的指令是"跑 make create-site 然后等",不是"报错了就重跑"。 已实测(docker/Makefile 的注释里写明了原因):首次冷启动(第一次拉镜像 + 首次 bench get-app clone HRMS)约 8 分钟;镜像已在本机缓存后的干净重建(make nuke && make up && make create-site)约 2 分钟2:07.07 实测)。给学生的预算:第一次 跑留 5–10 分钟。(这两个数字来自这个仓库自己 Task 2 的干净态实测记录,不是编的估算; 写这份 README 时栈已经在跑,没有为了重新计时而 make nuke 重建。)

git clone <this-repo> lecture-9-agentify
cd lecture-9-agentify
npm install                       # 装 connector + MCP server 依赖(node >=20)

cd docker
cp .env.example .env              # SITE_NAME / DB_ROOT_PASSWORD / ADMIN_PASSWORD /
                                   # AGENT_SVC_PASSWORD 都是本地沙箱占位符,不要在课堂外复用
make up                           # 起 9 个容器,几秒钟(镜像已缓存的前提下)
make create-site                  # 建站 + 装 erpnext + 拉装 hrms + developer_mode;等它
                                   # 自己的就绪轮询走完,预算 5–10 分钟(首次)/ ~2 分钟(缓存后)
make seed                         # 灌种子:4 个角色化用户、10 个商品/7 个客户/7 个供应商、
                                   # 8 名员工+工资单、期初库存、销售/采购单据(草稿+已提交都
                                   # 有)、两条双语投毒 draft 采购发票;幂等,可重复跑

种子数据现在长什么样(seed/seed.py,2026-08-01 从"能跑通"扩到"能逛",细节见 .superpowers/sdd/2026-07-31-lecture-9-agentify-erpnext/demo-data-report.md):Company 1 个 (Agentify Demo Co);Fiscal Year 5 个(当前年份 ±2,从 frappe.utils.today() 派生,不再 是硬编码的字面量 2024——这正是这次改动修的真事故:讲师现场撞过"当天日期落在任何财年之外, docstatus 0 的草稿都插不进去");Item 10 个(跨 Standard Items/Consumables 两个分组,8 个 库存 + 2 个非库存);Customer 7 个、Supplier 7 个(中文公司名居多;ACME/Globex 这两个被 投毒单和 Demo C 幂等用例点名的老面孔原样保留,一个字段没改);Employee 8 个(Alice 的 Salary Slip,net_pay 10000,原样保留——加 7 个新员工,各自挂了部门/职位,三档底薪 8000/10000/15000,共 8 张 Salary Slip);Bin 8 条(Material Receipt 灌的期初库存); Sales Order 8 / Sales Invoice 6 / Purchase Order 6 / Purchase Invoice 8(含种子自带的 2 条 投毒 draft)——草稿和已提交混着来,draft→submit 状态机在列表页肉眼可见。

逛 UI 请用 Administrator 登录,不要用四个种子用户sales_user/accounts_user/ hr_user/agent_svc 是刻意收窄权限的角色化身份(Demo B 的跨域拒绝就是靠这个拦的),拿它们 逛 desk 会到处撞权限墙——这是这门课的设计意图,不是 bug,讲师现场就踩过这一下。要看"这 是个真实系统"的完整效果(列表页、看板、报表都有数据可看),登录页填 Administrator / docker/.env 里的 ADMIN_PASSWORD

跑完之后:http://localhost:8080(或 http://erp.localhost:8080,两个 host 都路由到同一 站点)应该是 ERPNext 登录页;docker ps 能看到 9 个 l9-erpnext-* 容器 Updb 那条带 (healthy))。不要 make seed 第二次去"确认一下"——种子数据是全部 6 个 demo 共用的固定 靶子,幂等归幂等,但没有必要重复跑;也不要在 demo 之间 make down/make nuke,容器会一直 跑到课程结束。

一处没打通的开关,先别踩:不要单独去改 .envAGENT_SVC_PASSWORDseed/seed.py:104agent_svc@example.com 这个用户时读的是 ERPNext 站点配置里的 agent_svc_password 键, 而 docker/Makefilecreate-site 从来没有把这个键写进站点配置——所以不管 .env 里写 什么,种出来的真实密码永远是硬编码的 agent-svc-pw。改了 .env 的这个值(哪怕连带改了 MCP server 侧的 env)只会让 MCP server 用一个新密码去登录一个密码没变的用户,登录会静默 失败。细节和"这一步该怎么补"见 agent/claude_code.md 的对应小节。

目标语区现在是简体中文:make seed 现在还会把 System Settings.language 和四个种子 用户的 User.language 都设成 "zh"(seed/seed.py"1.5) 目标语言"一节)——纯配置,镜像本身 自带 84 种语言,不装任何新东西。desk UI(/app/...)、Frappe 报错文本(权限拒绝/校验失败) 现在都是中文,但 connector 五个工具的 docType 参数(connector/src/types.ts)依然是英文 字面量,完全不受影响——这条"label 会变、identity 不会变"的对照,和"host 报错现在是中文、 quarantine 边界依然扛住"这两条,分别写在 experiments/demo_b_permission_passthrough.md Step 6 和 experiments/demo_d_prompt_injection.md Finding 3 的现场印证里。切换前留了一份 完整数据库备份:sites/erp.localhost/private/backups/20260801_105602-erp_localhost- database.sql.gz(还原整站,不是只回退语言这一个字段)。

双前端接入

同一个 MCP server(npm run --silent server,stdio 传输,5 个 erp.* 工具)可以接两种 前端——--silent 不是可选项:裸 npm run server 会把 npm 自己的 banner 打到 stdout,而 stdout 就是 MCP 的 JSON-RPC 传输通道,这会污染协议流(细节和真实报错见 agent/claude_code.md):

  • Claude Code —— 已验证跑通,见 agent/claude_code.md.mcp.json 配置 + 真实 JSON-RPC 握手记录)。

  • OpenWorker(Andrew Ng,MIT,本机跑的开源 agent)—— 见 agent/openworker.md2026-08-01 已在本仓做了两轮端到端联调:第一轮跑 OpenWorker 自己的 MCP 客户端代码, 第二轮跑它的无头 server REST API(openworker-server)——这条 API 也正是桌面 GUI 的 Manage → Integrations 粘贴框在代码层面调用的同一条路径,所以 GUI 背后那条数据通道 也已经验证过,但 Tauri 桌面应用本身的界面/点击交互没有打开验证过。文档里逐条标了 哪些是查得到出处的公开事实、哪些是实测确认的、哪些仍未验证——现场用之前先看文档末尾 "这次任务验证的是什么、没验证的是什么"一节。

六个 demo 怎么跑

Demo

文件

要不要 ERPNext 起着

怎么跑

A —— 天真世界:admin key + 裸工具

experiments/demo_a_naive_admin_key.md

要(讲师现场 curl/docker exec,只读不写)

照 runbook 里的 curl 命令一步步跑,docker exec 打开 frappe/permissions.py 现场看 Administrator 怎么短路权限检查

B —— 权限透传:四个身份,宿主自己拦

experiments/demo_b_permission_passthrough.md

要(同上)

同一批任务分别以 sales/accounts/hr/agent_svc 四个种子用户登录跑,看 ERPNext has_permission()/permlevel 现场拒

C —— 重试风暴 / 幂等 / 回执不可信

experiments/demo_c_draft_gate_idempotency.md

不要——全程离线,FakeErpClient

npm test -- demoC(已在默认 npm test 里)

D —— 压轴:注入双弹头(付款+外泄),双语

experiments/demo_d_prompt_injection.md

不要——全程离线;种子库里确实种了这条 payload 的真身,但断言不依赖它在不在线

npm test -- demoD(已在默认 npm test 里)

E —— anti-shallow-fork 三道闸对照

experiments/demo_e_anti_shallow_fork/README.md

不要——不需要 docker/npm test,证据全部来自本仓已验证过的源码文件

照 runbook 现场 cat/sed -n 打开引用的文件核对,走三道闸表格

F —— 工具膨胀:全量映射 vs 窄工具

experiments/demo_f_tool_sprawl.md

不要——全程离线,纯函数

npm test -- demoF(已在默认 npm test 里)

Demo A/B 需要真栈时,experiments/scripts/demoAB.int.test.ts 有对应的可跑对照用例(走真实 HttpErpClient/Dispatcher),默认是 describe.skip + 命中 vitest.config.tsexclude: ['**/*.int.test.ts'] 双重挡住,讲师现场演示前手动把 describe.skip 改回 describe 再跑,跑完记得改回去——不是学生日常 npm test 会碰到的路径。

npm test 说明:离线单测 vs 需要真栈的集成测试

vitest.config.ts 定义了默认 npm test 的范围:

test: { include: ['connector/test/**/*.test.ts', 'experiments/**/*.test.ts'], exclude: ['**/*.int.test.ts'] }
  • 默认 npm test:跑 connector/test/**/*.test.ts(10 个文件:identity/risk/ limits/approval/quarantine 五个源码文件各自的单测——这五个文件把层1–层4 的安全 中间件落地成了五份代码,"四层"是教学模型,"五个文件"是实现,不是两套不同的东西——外加 fakeErpClient/naiveTool/smoke/tools/server-read-quarantine 五个 dispatcher/ 基础设施测试)加上 experiments/**/*.test.ts(Demo C/D/F 的离线脚本,共 3 个文件,都基于 FakeErpClient)。全程不碰真实 ERPNext,不需要 docker ps。本次任务实测:13 个 文件,79 个测试,全绿(栈当前正跑着,未受影响)。

  • 被排除的 *.int.test.tsconnector/test/erpClient.int.test.tsHttpErpClient 对 真实 ERPNext)和 experiments/scripts/demoAB.int.test.ts(Demo A/B 的真栈对照)都命中 .int.test.ts 后缀,被 exclude 挡在默认 npm test 之外;两个文件内部还各自套了一层 describe.skip 作为第二道保险。需要真的跑它们,走上面"六个 demo 怎么跑"里 Demo A/B 那 一行的手动解 skip 流程,不是切个 config 就完事。

  • 一处已经修好的坑,写在这里省得学生去翻旧报告package.json 曾经有一条 npm run test:int 脚本,指向一个仓库里从未存在过的 vitest.integration.config.ts,跑起来 直接报 Could not resolve .../vitest.integration.config.ts 退出——这条死脚本已经被移除 (commit 8673d5a),package.json 里现在只有 test/test:watch/typecheck/server 四条。如果你看到的教学材料还提这条命令,那是过时的;上面描述的手动解 describe.skip 流程才是当前唯一验证过、真的能跑通 .int.test.ts 的路径。

npm test              # 默认:离线,13 文件 / 79 测试
npm run typecheck      # tsc --noEmit
npm run --silent server # 启动 MCP server(stdio)——--silent 不可省,见 agent/claude_code.md

三道 anti-shallow-fork 质量闸

gates/ 下三份文件是喂给一个 evaluator(subagent 或人)的判据文件,各管一段,互不重复, 但引用同一批本仓源码作证据:

  • gates/architecture_gate.md —— AG-1~AG-4:授权是不是委托给宿主判、身份是不是真的 降权、检查是否结构性地先于执行(要求学生把顺序对调,粘一份变红的测试输出)、层与层是否 真的独立可测。AG-1/AG-2/AG-3 任一 FAIL,整个闸 FAIL。

  • gates/safety_gate.md —— SG-1~SG-4:拦截是否发生在代码里(不是"模型这次没上当")、 转义堵的是一整类字符还是几个已知字符串、测试是不是能在 revert 之后真的变红、host 数据 能到模型面前的每一条通道是否都被枚举过(含错误通道,不只是正常数据)。四条全过才算 PASS。

  • gates/correctness_gate.md —— CG-1~CG-3:幂等键是否只来自业务语义字段、写后回查是不是 真的在跑而不是死代码、"调用失败"是否被误判成"什么都没发生"。三条全过才算 PASS,且 CG-1 过不代表 CG-3 自动过,两个是独立失败模式。

三道闸的共同要求:不接受没有 file:line 引用的 PASS,证据必须可重跑(测试输出、 trace、revert-and-fail 对比),不接受"我们人工试了几次都没问题"这种采样当证明。

experiments/demo_e_anti_shallow_fork/README.md 是把三道闸走一遍的 worked example—— 拿本仓自己的反面教材(connector/src/naiveTool.ts + 一段专门写来示范"说得好听但没代码"的 系统提示词)和本仓自己的架构级实现做对照,11 条子判据逐条打分,浅 fork 全 FAIL,本仓全 PASS。用它当模板去评一份真实的学生 fork。

仓库结构

docker/          ERPNext v15 + HRMS 靶场(Makefile: up / create-site / seed / down / nuke / logs)
seed/            角色化用户 + 主数据 + 工资 fixture + 双语投毒 draft 采购发票
connector/src/   agent 化层:identity/risk/limits/approval/quarantine 五个源码文件(对应
                 教学模型里的层1–层4)+ tools.ts 的 Dispatcher + server.ts(MCP server 接线)
                 + naiveTool.ts(Demo A 反面教材)
connector/test/  上面每一层各自的单测(+ 2 个 *.int.test.ts,见"npm test 说明")
experiments/     六个 demo 的 runbook(A–F)+ 离线可跑脚本(experiments/scripts/)
gates/           三道 anti-shallow-fork 质量闸的判据文件
agent/           两个前端的接入说明:claude_code.md(已验证)/ openworker.md(已验证,
                 2026-08-01 两轮:MCP 客户端代码 + 无头二进制 REST API(含 GUI 背后同一
                 条路径),不含 Tauri 桌面 GUI 本身的界面/点击交互)

快速核对清单(从干净 checkout 到能跑第一个 demo)

  1. npm install(根目录)。

  2. cd docker && cp .env.example .env

  3. make up(几秒)→ make create-site(等,见上)→ make seed

  4. npm test(根目录,离线,应为全绿)—— 到这一步 Demo C/D/E/F 已经能跑。

  5. 想跑 Demo A/B:装 curl/jq/python3,照 experiments/demo_a_naive_admin_key.md 的 "前提"小节确认 9 个 l9-erpnext-* 容器 Up

  6. 想接 Claude Code:照 agent/claude_code.md.mcp.jsonclaude/mcp 确认 5 个 erp.* 工具在列。

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    A secure, audit-logged MCP server for Frappe that exposes specific DocTypes and operations to AI agents with granular permissions and field-level control.
    4
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables ERPNext management, file operations, read-only database access, and ERPNext API integration through a standardized MCP server.
    4
    MIT
  • F
    license
    Not graded
    quality
    D
    maintenance
    A governed MCP server for integrating AI agents with customer data, featuring role-based access control, field redaction, and human-in-the-loop approval for secure support operations.
    1
    -
  • A
    license
    Not graded
    quality
    C
    maintenance
    A comprehensive MCP server for ERPNext providing generic, doctype-agnostic access to any ERPNext document type with robust permission controls, audit logging, and enterprise-grade security.
    MIT