怎样在上线前抓住 AI 悄悄改掉的权限?访问矩阵快照门禁,在一张 7 条记录的 CRM 矩阵上实测
在 @objectstack/cli 18.2.0 上,os validate 对 Agent 把 allowDelete 从 false 改成 true 毫无发现;在选择加入的应用里,os compile 会让构建失败,并用一行话说清谁多了什么权限,直到有人重新生成快照。
先说答案。 os validate 不读取 allowDelete 的值,所以 Agent 把某个权限集上的这个键从 false 改成 true,验证照样通过,没有任何发现。这就是那篇评审清单说的「没有规则被触发」,而且这句话现在依然成立。但有一道门禁会被触发——前提是应用选择加入:一份提交进仓库的访问矩阵,每个(权限集 × 对象)一行,记录有效的 create、read、edit、delete 位;os compile 每次构建都会从元数据重新推导这张矩阵,再与快照比对。只要有漂移,构建就失败,并打印一行语义化的说明——'crm_sales_user' gains delete on 'crm_opportunity'——直到有人重新生成快照;而快照的 git diff,就是评审人要签字的东西。全部在已发布的 @objectstack/cli 18.2.0 上实测,也就是 npm create objectstack@latest 装上的那个版本,对象是自带的 CRM 示例,它的矩阵共有七条记录:一处改动,一行漂移,一行 JSON 变化。
这篇文章值得记住的概念是:有效能力,不是原始标志位。矩阵报告的是把超级用户位折叠进去之后,一个权限集到底能做什么,并把对象的记录基线写在同一行。改动仍然需要人来判断,但它再也无法悄无声息。
这处改动, 和两条 命令
对象是 ObjectStack 仓库里 @objectstack/[email protected] 标签下的 examples/app-crm,装的是已发布的 18.2.0 包,而不是 monorepo。它已经选择加入:objectstack.config.ts 旁边放着一份 access-matrix.json。改动就是评审那篇文章走过的那一处,src/security/sales-positions.ts 第 50 行,一个词:
export const SalesUserPermissionSet = definePermissionSet({
name: 'crm_sales_user',
label: 'CRM Sales User',
objects: {
crm_account: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
crm_contact: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
- crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: false },
+ crm_opportunity: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
第一道门,npx os validate。输出只保留要紧的几行;中间被省略的部分里,没有任何一处提到 allowDelete、crm_sales_user 或矩阵。
$ npx os --version
@objectstack/cli/18.2.0 linux-x64 node-v22.22.0
$ npx os validate
◆ Validate
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (53)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...
✓ Validation passed (824ms)
Security: 3 Positions 2 Permissions
$ echo $?
0
五十三条作者期规则跑完了。没有一条对「销售代表该不该删除商机」有看法,因为那是关于你公司的事实,不是关于元数据的事实。评审那篇文章的句子一字不差地成立:两个值都合法,两个值都可执行,你合并哪个,运行时就执行哪个。
第二道门,npx os compile。同一份配置,同样的 53 条规则,然后多了一步 validate 没有的:
$ npx os compile
◆ Compile
→ Loading configuration...
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (53)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking access-matrix snapshot (ADR-0090 D6)...
✗ Access matrix drift (1 change) — capability changes must be reviewed
• 'crm_sales_user' gains delete on 'crm_opportunity'
If intended, re-run with --update-access-matrix and commit the snapshot — its diff IS the review artifact.
$ echo $?
1
os build 是同一条命令的另一个名字(在 18.2.0 的 CLI 源码里,Build extends Compile),它以同一行失败。加上 --json,失败就是一份 CI 步骤能读的载荷:
{ "success": false, "error": "access matrix drift",
"changes": ["'crm_sales_user' gains delete on 'crm_opportunity'"],
"warnings": [ … ], "conversions": [] }
机制就这么多。文章接下来讲这一行是什么意思、源码里写的明明是 allowDelete 为什么它说 delete,以及这道门禁不做什么。
这道门禁 是什么
ObjectStack 的文档用一段话说清了它,值得原文引用而不是转述:
AI drafts permission metadata on this platform; the one thing it must never do is silently change who can do what. The access-matrix gate makes every capability change a visible, reviewable build event: the (permission set × object) matrix is derived purely from metadata on every os compile, diffed against a committed snapshot, and any drift fails the build with a semantic explanation until a human regenerates the snapshot.
(AI 在这个平台上起草权限元数据;它唯一绝不能做的事,是悄悄改变谁能做什么。访问矩阵门禁把每一次能力变化都变成看得见、可评审的构建事件:(权限集 × 对象)矩阵在每次 os compile 时纯粹由元数据推导,与已提交的快照比对,任何漂移都会让构建失败并给出语义化解释,直到有人重新生成快照。)
关于它的三个事实,每一条都在 18.2.0 上核对过:
- 按应用、选择加入。 在
objectstack.config.ts旁边提交一份access-matrix.json;第一份用os compile --update-access-matrix生成。没有文件,就没有门禁:CLI 只读一次这个路径,文件不存在就当作「未选择加入」。自带的两个示例app-crm和app-showcase都已加入。而一个新的npm create objectstack@latest脚手架没有:18.2.0 的模板里不含access-matrix.json,所以加入的第一条命令得由你来跑。 - 它不是那 53 条规则之一。 CLI 源码把它归为「不是注册表规则:它读取(带上标志时还会写入)配置旁边的一个文件,而不是回答一个关于栈的问题」。这正是
validate和lint永远看不到它的原因:它们跑的是注册表,而快照门禁住在compile里。 - 它很小。 CRM 已提交的整张矩阵只有七条记录,而七行就是这个应用声明的全部授权:
crm_sales_user的六个对象,加上公开 web-to-lead 表单背后匿名guest_portal配置那一条只能新建的记录。
| 权限集 | 对象 | create | read | edit | delete | View All | Modify All | 记录基线(OWD) |
|---|---|---|---|---|---|---|---|---|
crm_sales_user | crm_account | ✓ | ✓ | ✓ | – | – | – | public_read_write |
crm_sales_user | crm_activity | ✓ | ✓ | ✓ | – | – | – | public_read_write |
crm_sales_user | crm_contact | ✓ | ✓ | ✓ | – | – | – | public_read_write |
crm_sales_user | crm_lead | ✓ | ✓ | ✓ | – | – | – | public_read_write |
crm_sales_user | crm_opportunity | ✓ | ✓ | ✓ | – | – | – | public_read_write |
crm_sales_user | crm_opportunity_line_item | ✓ | ✓ | ✓ | – | – | – | controlled_by_parent |
guest_portal | crm_lead | ✓ | – | – | – | – | – | public_read_write |
一个从没打开过 CRM 源码的评审人,不到一分钟就能读完这张表,并说出这个应用让销售用户做什么。把它作为一份提交进仓库的文件来维护,意义就在这里:应用的能力模型是仓库里的一张七行表,任何改动这张表的 pull request,都必须在它的 diff 里把这件事说出来。
落在磁盘上,每一行就是一个 JSON 对象。改动之前的 crm_opportunity 那一行:
{
"permissionSet": "crm_sales_user",
"object": "crm_opportunity",
"create": true,
"read": true,
"edit": true,
"delete": false,
"viewAllRecords": false,
"modifyAllRecords": false,
"sharingModel": "public_read_write"
}
漂移行由两张这样的表做 diff 生成,而且行的形态恰好只有五种。它们是评审人将要阅读的词汇,所以按 18.2.0 里 diffAccessMatrix 的源码列在这里:
| 发生了什么 | 打印的行 |
|---|---|
| 一行消失了 | 'SET' loses ALL access to 'OBJECT' (entry removed) |
| 一行出现了 | 'SET' gains access to 'OBJECT' (create, read, …) |
| 已有的一行上某一位翻转 | 'SET' gains delete on 'OBJECT' 或 'SET' loses read on 'OBJECT';View All 与 Modify All 用同样的形式 |
| 读或写的深度变了 | 'SET' read depth on 'OBJECT': own → org |
| 对象的记录基线变了 | 'OBJECT' record baseline (OWD): private → public_read_write (affects every principal) |
我们的改动是第三种。这一行说的是 gains delete,而不是引用那个键名,这正是下一节的主题。
有效 能力, 不是 原始 标志位
再看一眼上面那一行。它有一列 delete,而不是 allowDelete,这个差别是有意为之:矩阵报告的是有效能力。在 18.2.0 的规范里,由一个函数决定一条对象权限是否授予某个动作,而运行时的执行门在回答 403 之前问的也是同一个函数。它的折叠规则,出自 @objectstack/spec 里的 objectPermissionGrants:
read为真,当allowRead为真,或viewAllRecords为真,或modifyAllRecords为真。edit、delete、transfer为真,当各自的位为真或modifyAllRecords为真。create没有超级用户旁路:Modify All 扩大 edit 和 delete,但不会凭空制造一个 create 授权。export是一个合取,allowExport且read,所以一个权限集若对自己读不了的对象授予导出,等于什么都没授予。(矩阵只带四个 CRUD 列;transfer和export是折叠里的另外两个动作,列在这里是因为折叠本身才是重点。)
规范自己对「为什么不是 permission[target] === true」的注释,说的是声明与执行之间的缺口「指向危险的那一边:一个谓词把某个动作藏起来,不让唯一有权使用它的管理员看见」。一张用原始 allow* 位拼出来的矩阵会有同一个盲点。所以矩阵去问折叠,而两个超级用户列作为原始位留在旁边,因为它们是评审人阅读 CRUD 列时的上下文。
可以测量的后果是:对同一行做另一处只改一行的改动,把 allowDelete: false 换成 modifyAllRecords: true,得到的是两行而不是一行,而其中第一行就是谁也没有敲过的那个 delete:
$ npx os compile
✗ Access matrix drift (2 changes) — capability changes must be reviewed
• 'crm_sales_user' gains delete on 'crm_opportunity'
• 'crm_sales_user' gains Modify All Data on 'crm_opportunity'
对这处改动跑 npx os validate:✓ Validation passed (732ms)。源码那一行已经不再提 delete;矩阵说的是 true,因为运行时会放这个删除过去,而矩阵的职责是告诉评审人 403 会怎么说,不是源文件怎么写。
有一种写法规范已经不再接受,而它恰恰是最先会想到的改法:allowDelete: false 原样不动,在旁边加上 modifyAllRecords: true。18.2.0 之前这个组合能加载,而报告它所授予的那个 delete 的,只有矩阵这一道门。从 18.2.0 起,规范在任何规则或门禁跑起来之前就拒绝它,validate 退出码 1,compile 退出码 2,消息相同:
$ npx os validate
✗ [ { "code": "custom", "path": ["objects", "crm_opportunity", "allowDelete"],
"message": "allowDelete: false is refused beside modifyAllRecords: true since @objectstack/spec 18 (ADR-0049): Modify All Records is the write bypass for edit, delete and transfer, and the permission evaluator grants delete on the bypass bit before it reads allowDelete, so this false is never enforced. …" } ]
$ echo $?
1
这就是上面那个折叠,提前一道门执行:一个运行时本来会忽略的 false,现在是 schema 错误而不是无声的谎言;被接受的那种写法意味着什么,由矩阵的那一行来说。
最后一列重要的理由也一样。评审那篇文章的第二遍要求你即使 diff 没碰到某个对象,也要去读它的 sharingModel,因为一个照抄来的演示对象上的 public_read_write,意味着全组织可读可写,而所有门禁一路绿灯。在这里,基线就写在授权旁边的同一行上,基线的变化是一条独立的漂移行,还标着 affects every principal(影响每一个主体),因为 OWD 一摆动,该对象的每一行的含义都同时变了。
重新 生成, 然后读 diff
失败信息已经说了改动若属有意该怎么办,闭环是三条命令。
$ npx os compile --update-access-matrix
→ Checking that every required capability has a provider installable in this edition...
→ Access matrix snapshot written to access-matrix.json (ADR-0090 D6) — review the diff.
→ Writing artifact...
✓ Build complete (1121ms)
$ git diff -U8 access-matrix.json
@@ -46,17 +46,17 @@
"sharingModel": "public_read_write"
},
{
"permissionSet": "crm_sales_user",
"object": "crm_opportunity",
"create": true,
"read": true,
"edit": true,
- "delete": false,
+ "delete": true,
"viewAllRecords": false,
"modifyAllRecords": false,
"sharingModel": "public_read_write"
},
$ npx os compile
→ Checking access-matrix snapshot (ADR-0090 D6)...
✓ Build complete (702ms)
跑下来有两条实用的提醒。这个标志会无条件重写整个文件,所以重新生成之后,把 access-matrix.json 里的 hunk 数和失败那次 compile 打印的语义行数对一对:一行对一个 hunk,多出来的就是你没要求的变化。另外,读 hunk 时用 -U8,别用默认的三行上下文。这一行的 permissionSet 和 object 键在被改的那一位上方五、六行处,默认的 git diff 给你看的是 "delete": false → true,却把这一行的名字截掉了,这时能告诉你那是谁的 delete 的,就只剩 compile 打印的那条语义行。
现在这个 pull request 带着两个用两种词汇说同一件事的 hunk:Agent 写下的源码翻转,和人重新生成的能力行。文档对后者的说法是对的,也正是这篇文章要实测的那句话:「its diff IS the review artifact」——它的 diff 就是评审物。
它在整套 防线里的 位置
这道门禁是建立在同一批结构化授权之上的三种机制之一,每一种都能抓住另外两种抓不住的东西。
| 机制 | 抓什么 | 何时运行 | 做什么 |
|---|---|---|---|
| 安全姿态 linter(ADR-0090 D7) | 单独就危险的声明:未设置的 OWD、高权限的锚点建议、已退役的别名 | validate、build、lint,作为注册表规则 | 以规则自己的信息让构建失败 |
| 访问矩阵快照门禁(ADR-0090 D6) | 看起来合理、但影响面仍该由人签字的改动 | compile 与 build,在应用选择加入时 | 以语义行让构建失败,直到快照被重新生成 |
| 解释引擎 | 某个主体的某一次请求为什么是那个结果 | 运行时,逐次决策 | 回答问题,不新增任何执行 |
文档用一句话说明了后两者的关系:矩阵「is the same evaluation run over representative (permission set × object) pairs; explain is the per-decision zoom lens」——是同一套评估跑在有代表性的(权限集 × 对象)对上;解释引擎则是逐次决策的放大镜。三者之所以存在,原因也是同一个:授权是词汇封闭的结构化数据,所以构建步骤能从中推导出一张表。把权限模型写成应用代码里自由形式的谓词,就没有表可比。
这道门禁 不做 什么
五条限制,直说,因为「构建会抓住权限变化」这种句子,最容易被去掉条件之后转述。
- 它是选择加入的,默认关闭。 一个新的 18.2.0 脚手架没有快照,也就没有门禁。加入只需一条命令和一份提交的文件,而添加这份文件的那个 pull request,正是最该仔细评审的:那份快照是之后每一次 diff 的比对基线。
- 它跑在
compile和build里,从不跑在validate或lint里。 上面测过了:同一处改动,validateexit 0 且完全不提矩阵,compileexit 1。一条只跑os validate就宣布应用已检查的 CI 流水线,并没有跑过这道门禁。 - 它让改动变得可见,而不是变得不可能。
--update-access-matrix只是一个标志,Agent 自己也能跑它、自己提交新快照。Agent 做不到的是让能力变化从 pull request 里消失:不管是人还是模型重新生成的,那一行都在 diff 里。评审阶梯的第三级——这样对吗——仍然归评审人,门禁的贡献在于,这个问题现在带着答案的形状,用一行话送到你面前。 - 它的范围是(权限集 × 对象)。 create、read、edit、delete,两个超级用户位,读写深度,以及对象的基线。不包括字段级安全,不包括共享规则,不包括行级谓词,不包括哪些用户持有这个权限集(那是运行时数据,评审那篇的限制里已经说过),也不包括一个动作体跑起来之后做了什么。
- 对一个词的翻转,源码 diff 本来就看得见。 这是公平的反对意见:任何读 diff 的人都能在
sales-positions.ts里看到allowDelete: false → true。矩阵的价值,在于源码变化和能力变化形状不一样的地方。一个modifyAllRecords: true是一行源码、两行能力。对象文件上一处sharingModel改动是一行源码,却落在每个权限集关于该对象的那一行上。分散在好几个文件里拼出来的权限模型,最终仍然落成一张有序的表,评审人不必打开任何一个文件就能读。门禁的价值不在于它看见了翻转,而在于它用运行时执行的那套词汇看见了翻转。
加到 清单里的 两行
评审那篇文章以一份清单收尾。这道门禁给它的第二遍加一行,给第一遍加一个问题。
[ ] Which door? If the pipeline ran only os validate, the access-matrix gate did not run.
[ ] Pass 2 — permissions:
[ ] access-matrix.json is committed (opt in once: npx os compile --update-access-matrix)
[ ] on drift: one semantic line per hunk in access-matrix.json, and the PR says why
[ ] read each hunk with -U8, so the permissionSet and object are in it
[ ] for every "gains" line, say who gains what, and read the OWD on the same row
把 Agent 指向 一个 授权 本身 就是 一张 表的 目标
Agent 在任何技术栈里都能生成一套权限模型。它在大多数栈里做不到的,是交给你一张七行的表,由构建每次重新推导、每次比对——因为在大多数栈里,权限模型散落在中间件、查询过滤器和一两个模板之间,根本没有表可比。上面这道门禁之所以存在,是因为 ObjectStack 里的授权是词汇封闭的类型化元数据,而正是这个性质让 diff 可评审,也让一条声明出来的权限不至于只是一句注释。如果你想看执行本身发生在哪里的论证,它在AI Agent 的权限究竟在哪一层被执行。
所以,把你的 Agent 指向这个目标——用一份规则文件,或直接用规范和 MCP 接口——并把快照作为你提交的第一样东西:
npm create objectstack@latest my-app && cd my-app
# 在你的编码 Agent 里打开它,描述业务需要什么
npx os compile --update-access-matrix # 第一份快照:读一遍,提交
npx os compile # CI 里每次都跑;只跑 os validate 不会经过这道门禁
从此之后,Agent 可以自由起草授权,而它唯一做不到的,是在构建输出里没有一行、diff 里没有一行的情况下,改变谁能做什么。
为了运行它,ObjectStack 仓库提供 ObjectOS Community —— ghcr.io/objectstack-ai/objectstack 镜像、os start 和 Console,免费且自托管 —— 而 ObjectOS Cloud 是托管版本,ObjectOS Enterprise 是自管理、需许可的版本。