Skip to content

fix(spec,runtime): 槽契约账本越过 CoreServiceName/security 不再把未校验输入交给安全服务 (#4127) - #4202

Merged
os-zhuang merged 2 commits into
mainfrom
claude/dispatcher-storage-upload-typeerror-0exbcb-b3
Jul 30, 2026
Merged

fix(spec,runtime): 槽契约账本越过 CoreServiceName/security 不再把未校验输入交给安全服务 (#4127)#4202
os-zhuang merged 2 commits into
mainfrom
claude/dispatcher-storage-upload-typeerror-0exbcb-b3

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Refs #4127#4127 gate 的第三批(前两批 #4168 getService#4176 resolveService)。

securityshareLinksobjectql 三个槽,契约写了、提供方注册了、调用点也在契约内 —— 唯一断掉的环节是槽名不是 CoreServiceName 成员,于是没有任何东西能把它们连起来,三处全部躺在 as any 后面。

1. 让账本越过枚举,而不是让枚举变大

这是本 PR 的核心决定。两者回答的是不同问题,混为一谈正是这些槽没类型的原因:

回答什么 改动代价
CoreServiceName 「boot 时这个槽是空的会怎样?」—— 它紧挨着 ServiceCriticality,驱动启动编排与 discovery 改变运行时行为,且枚举成员一旦加入实际上永久
契约账本 「占这个槽的东西长什么形状?」—— 纯类型 零运行时影响

这三个槽只需要第二种。所以 ServiceSlotContracts extends CoreServiceContracts 在账本侧追加,resolveService 改用 keyof ServiceSlotContracts 作键。枚举一字未动。

这不是永久分裂:将来若真要把其中之一升为核心服务(连同 criticality 语义),条目上移进 CoreServiceContracts 即可,其余不变 —— 测试里有一条断言专门守着这个迁移点。

每条条目仍然只在有证据处写:

  • security —— plugin-security 注册,且 ISecurityService 自己的注释就写着 ctx.registerService('security', …)
  • shareLinks —— plugin-sharing 注册 ShareLinkService,后者显式 implements IShareLinkService
  • objectql —— data 的别名packages/objectql 的 plugin 把同一个实例注册在两个名字下,相隔两行。同一个对象,经 data 解析为 IDataEngine,经 objectql 解析为 any

protocol(22 处)和 mcp 没有写过契约,保持未映射。

2. 开机后抓出四处,全在 /security 管理面

① 请求输入未经校验进入安全服务

?status=String(query.status) —— 任意字符串 —— 交给一个契约声明恰好三个值的方法,再从那里进入查询的 where 子句。

不是注入(where 是结构化的,从不拼接字符串),但 ?status=garbage 匹配不到任何行、返回空列表 —— 这读起来是「没有建议」,而不是「你这个过滤器不是一个状态」。现在返回 400。

② 一个测试把这个 bug 钉成了预期行为

现有用例断言 status: 'open'(不在三个合法值里)会抵达服务并返回 200:

expect.objectContaining({ status: 'open' })

它证明的是 delegate 传递了某个 filter,对「这个 filter 是不是一个 status」一无所证。和 batch 1 的 auth.handler mock 同一形状 —— 外观是覆盖,实质是错误契约。这处更糟一点:它锁定的是「未校验输入进入安全服务」。

③④ 两个写操作无法证明自己有调用者

confirmAudienceBindingSuggestion / dismissAudienceBindingSuggestioncallerContext: SecurityContext非可选的 —— 这是刻意的,旁边那个读操作声明的就是可选 —— 而 domain 传的是可能为 undefined 的 execution context。

⚠️ 这不是线上漏洞,这个区分很重要。 execution context 缺失时 shouldDenyAnonymous 已经拒绝了:它看不到 userId/isSystem,而它的 allowlist 分支需要一个非空 path(本 seam 从不传),于是落到 return true。它没做的是收窄 ec 本身 —— 它只读了 ec?.userId。所以契约要求成立、却无法被看见成立。

改成直接检查 ec行为等价的,只是让这个不变量对编译器和下一个读者都可见。

⚠️ 唯一的行为变化

?status= 的拒绝:未知状态从「静默空列表」变成「400 并列出接受的值」。

接受集合是一个以契约类型为键的 Record,所以契约新增状态会导致这里缺键、重命名会导致多键 —— 两种情况都编译失败。换成普通数组就会静默漂移,而那正是这条工作线要消灭的失效模式。

验证

结果
@objectstack/runtime 945 tests / 66 files(+8)
@objectstack/spec 7141 / 274(+29)
plugin-security 677
plugin-sharing 225
tsc --noEmit spec、runtime、downstream-contract、4 个 examples 全清
pnpm lint clean
9 个 check:* 门禁 全 OK

下一步

只剩真正没有契约的槽:protocol(22 处)、mcpkernel-resolverscope-manager。给它们写契约是实打实的设计工作,不适合顺手做 —— 建议单独立项,逐个按「实现是什么形状」而非「调用点碰巧调了什么」来写。

🤖 Generated with Claude Code

https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw


Generated by Claude Code

…eName`, and `/security` stops passing unvalidated input to the security service (#4127)

Batch 3 of the #4127 gate, after #4168 (`getService`) and #4176
(`resolveService`).

Three slots — `security`, `shareLinks`, `objectql` — each had a written
contract, a provider registering them, and call sites already inside the
contract. The only missing link was that the slot name was not a
`CoreServiceName` member, so nothing could connect them and all three sat
behind `as any`.

The ledger extends past the enum rather than the enum growing. The two answer
different questions, and conflating them is what left these untyped:
`CoreServiceName` answers "what happens at boot when this slot is empty?" — it
sits beside `ServiceCriticality` and drives startup orchestration and
discovery, so adding a member changes runtime behaviour and is effectively
permanent. The ledger answers "what shape occupies this slot?" — pure type
information. These three need only the second, so `ServiceSlotContracts extends
CoreServiceContracts` adds them there and `resolveService` keys on `keyof
ServiceSlotContracts`. Zero runtime effect. If one is later promoted to a
genuine core service, its entry moves up and nothing else changes.

Evidence before an entry, as always: `plugin-security` registers `security` and
`ISecurityService`'s own doc names that registration; `plugin-sharing`
registers `ShareLinkService`, which declares `implements IShareLinkService`;
and `objectql` is an ALIAS of `data` — `packages/objectql`'s plugin registers
the same instance under both names two lines apart, so one object was resolving
as `IDataEngine` through one name and `any` through the other. `protocol` (22
call sites) and `mcp` have no written contract and stay unmapped.

Turning it on found four things, all on the `/security` admin surface:

1. Request input reached the security service unvalidated. `?status=` was
   `String(query.status)` — any string — handed to a method whose contract
   declares exactly three values, and from there into the query's `where`
   clause. Not an injection (the `where` is structured, never interpolated),
   but `?status=garbage` matched no row and returned an empty list, which reads
   as "there are no suggestions" rather than "that is not a status". Now a 400.

2. A test pinned that bug as expected behaviour. The existing case asserted
   `status: 'open'` — not one of the three declared values — reached the
   service and returned 200. It proved the delegate carried A FILTER and
   nothing about that filter being a status. Same shape as batch 1's
   `auth.handler` mocks: coverage in appearance, a wrong contract in substance.

3. and 4. Two writes could not prove they had a caller.
   `confirmAudienceBindingSuggestion`/`dismissAudienceBindingSuggestion`
   declare `callerContext: SecurityContext` non-optionally — deliberately,
   since the read beside them declares it optional — and the domain passed a
   possibly-`undefined` execution context.

   This was NOT a live hole, and the distinction matters: with no execution
   context `shouldDenyAnonymous` already denied, because it sees no
   `userId`/`isSystem` and its allowlist arm needs a non-empty `path` this seam
   never passes, so it fell through to `return true`. What it never did was
   narrow `ec` itself — it only read `ec?.userId`. Checking `ec` directly is
   behaviour-preserving and makes the invariant legible to the compiler and the
   next reader.

The `?status=` rejection is the one BEHAVIOUR CHANGE: an unknown status was a
silent empty list and is now a 400 naming the accepted values. The accepted set
is a `Record` keyed on the contract type, so adding a status to the contract
leaves a key missing and renaming one leaves a key excess — either fails to
compile, where a plain array would have drifted silently.

Refs #4127

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw
@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 30, 2026 3:10pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/spec.

113 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via packages/runtime, @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime, packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/runtime, packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime, @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…ecurity/suggested-bindings (#4127)

The 400 introduced alongside the slot-contract ledger is observable API
behaviour, and this page is where the endpoint is documented. Both existing
examples already used valid statuses, so nothing here was invalidated — what was
missing is that the filter has a fixed set at all, and what an unknown value now
does instead of quietly returning an empty list.

Refs #4127

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015T5CeitwV1jXLvsL1A84tw
@os-zhuang
os-zhuang marked this pull request as ready for review July 30, 2026 15:24
@os-zhuang
os-zhuang merged commit bca935b into main Jul 30, 2026
18 checks passed
@os-zhuang
os-zhuang deleted the claude/dispatcher-storage-upload-typeerror-0exbcb-b3 branch July 30, 2026 15:25
os-zhuang added a commit that referenced this pull request Jul 30, 2026
… which found the project-membership gate not gating (#4127) (#4214)

Batch 4 of the #4127 gate. #4168/#4176/#4202 made a slot lookup return the
slot's contract. Nothing protected that: an `any` annotation on the RESULT
switches the checking back off for that call site, silently, with no test
failing and no visual difference from code that has it. Three such sites already
existed and were found by grep — the same unrepeatable sweep this work replaced.

The rule bans `: any` / `as any` on a `resolveService` / `getService` /
`getRequestKernelService` result. Slots with no written contract (`protocol`,
`mcp`, `kernel-resolver`, `scope-manager`) are exempted BY NAME, CENTRALLY, in
eslint.config.mjs — not by inline disables, because `pnpm lint` runs
`--no-inline-config` and ignores those on purpose. The effect is the one worth
having: a deliberate gap is a reviewed line in one file, a careless one is a
build failure, and they stop looking identical in the code.

ITS FIRST RUN FOUND A LIVE FAIL-OPEN. `enforceProjectMembership` read the
session as `authService?.api?.getSession?.(...)` with no `getApi()` fallback —
the only one of the codebase's three `.api` readers without it. `plugin-auth`
registers `AuthManager`, which has NO `.api` member at all. So the read yielded
`undefined`, `userId` stayed unset, and the function returned at its "anonymous
— upstream auth will decide" line BEFORE ever querying `sys_environment_member`.
A signed-in non-member passed the gate, on every deployment with project scoping
on — which is where the flag defaults to true. Anonymous callers were still
denied elsewhere (#2567/#3963), so this was specifically the signed-in
non-member case.

The existing test for that gate mocked auth as `{ api: { getSession } }` — the
legacy shape the shipped provider does not have — so it was green throughout.
That is the FOURTH test in this work line found encoding a contract nobody
implements, after batch 1's three `auth.handler` mocks and batch 3's
`status: 'open'`. The new test uses the `getApi()` shape and fails against the
pre-fix code.

Also found by the rule, all the same #4127 shape (implemented, called,
undeclared) and all now declared: IAuthService gains `api`, `getApi`,
`isAuthGateActive` and `verifyMcpAccessToken`; IMetadataService gains `load` and
`loadDiagnosed`. `getApi`'s return type is the EVIDENCED SUBSET —
`getSession({headers})` and the three fields callers read — not a
re-declaration of better-auth's handle, which belongs to that library.

And the pattern's real root: the lookup facade returning `any` was re-declared
in THREE places. Batches 1-3 typed `DomainHandlerDeps` and left
`ActionExecutionDeps` and resolve-execution-context's `ResolveOptions` still
saying `any` — so the copy that stayed untyped was the way around all the
others, and it is where the auth reads lived. All three are typed now.

Completing the interface: `getRequestKernelService` gets the same overload split
(its one caller resolves the same `objectql` slot the `resolveService` fallback
beside it does, so the two arms of one expression had different types), and
share-links' `getEngine` loses a `Promise<any>` return annotation — a THIRD
erasure syntax after `: any` and `as any`, and one this AST rule cannot see.
That residual is documented in the config.

`getObjectQL` STAYS `any`, deliberately, with the reason recorded: it exists to
reach ObjectQL's surface beyond IDataEngine (`registry`, `executeAction`), which
has no contract. Typing it IDataEngine would be the comfortable-looking lie.

The auth and metadata contract doc pages are brought back in step with the
interfaces — the auth page still called itself "intentionally minimal" while
missing six members, two of them from batch 2.

Refs #4127
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants