Repository navigation
Commit e1dac19
fix(mcp, runtime)!: MCP describe_object answers the same field-masked object schema the /meta door answers (ADR-0106) (#31042)
security#1 · os-tesla · session_01L9qibvynRbCtMndW8EHNGc
Clause-②: no (narrowing) — the MCP object-schema reads now serve a
caller only the fields the `/meta` object door serves that caller, which
ADR-0106 D1 ("a field the caller cannot read does not exist for that
caller, on any plane") and the MCP security model ("field-level security
applies to the agent exactly as it does to a person") already promise,
so no stated contract is narrowed; what narrows is the answer to a
caller with a `readable: false` field. Nothing is widened: no export,
answer member, error code or status is added.
Metadata-protocol: none
Skills-docs: no move — `skills/**` teaches no field set for
`describe_object`; `content/docs/ai/connect-mcp.mdx` states that schema
discovery is covered by field-level security.
## 维护者速读
- **改了什么**:MCP 的 `describe_object`、`list_objects` 的
`fieldCount`、`validate_expression` 的可用字段,以及 stdio 的
`objectstack://objects` 资源,现在与 `/meta` 对象门走同一套 ADR-0106
字段级遮蔽,同一调用者看到同一组字段。
- **为什么改**:ADR-0106 D1 与 MCP 文档都承诺字段级安全在每个平面一致生效;本 PR 让 MCP
的对象结构读取兑现这一承诺。
- **风险与回滚**:对带 `readable: false` 字段的调用者是收窄(标题带
`!`);字段可见性无法评估、或遮蔽后对象无字段时,按 `/meta` 的方式拒绝(`FIELD_VISIBILITY_UNRESOLVED`
/ 503)。无限制调用者、D4 豁免者与无 security 服务的部署逐字节不变。回滚即 revert 本 PR。
-
**先看**:`packages/runtime/src/domains/mcp.ts`(`servedToCaller`)、`packages/mcp/src/stdio-data-bridge.ts`(`callerObjectView`)、`packages/mcp/src/plugin.ts`(stdio
资源视图接线)。
- **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run
src/domains/mcp-object-schema-mask.test.ts`,预期 `Tests 17 passed (17)`。
- **需要你做的**:无,席位落地。
## What changes
- **HTTP bridge** (`packages/runtime/src/domains/mcp.ts`). Every object
definition the bridge serves to a caller (`describeObject`,
`listObjects`' `fieldCount`, `optionFieldsOf`) now passes the `/meta`
door's own masker: `resolveObjectMasker` / `maskObjectSchema`, newly
exported from `packages/runtime/src/domains/meta.ts` for this purpose. A
refused projection throws a coded error (`FIELD_VISIBILITY_UNRESOLVED`,
503) that the tools render as their usual `{ error: { code, message,
status } }`. The action collection and exposure gates keep their
unmasked reads.
- **stdio bridge** (`packages/mcp/src/stdio-data-bridge.ts`). A new
`callerObjectView` wraps the object view with
`resolveObjectSchemaMaskPosture` → `relateObjectSchemaMaskPosture` →
`applyObjectSchemaMask` (`@objectstack/metadata-core`), asking the
host's `security` service about the API-key principal, resolved on each
read. `listObjects`, `describeObject` and `optionFieldsOf` read through
it. The query verb's sort check keeps the unmasked view.
- **stdio resources** (`packages/mcp/src/plugin.ts`,
`packages/mcp/src/mcp-server-runtime.ts`). When the plugin binds a stdio
principal, the `objectstack://objects` resources read the same masked
view. A coded refusal from the view is answered as `{ error, code,
status }`, and any other throw still propagates.
- **Dependency.** `@objectstack/mcp` now depends on
`@objectstack/metadata-core` at runtime; it was a dev dependency.
`pnpm-lock.yaml` moves the one entry.
- **Docs.** `content/docs/ai/connect-mcp.mdx` adds one sentence to the
security model saying that schema discovery is covered by it.
- **Changeset.** `.changeset/mcp-describe-object-field-mask.md` (patch,
`@objectstack/mcp` and `@objectstack/runtime`).
## Tests
- `packages/runtime/src/domains/mcp-object-schema-mask.test.ts` (17).
The first half runs the shared `OBJECT_SCHEMA_MASK_CASES` table over the
HTTP bridge. The second half runs the real stack (ObjectQL + SQL driver
+ `SecurityPlugin` + the metadata protocol): it covers a member with one
field denied, an unrestricted control, a D4 author, and a member denied
every field, and compares `describe_object` with the
`/meta/object/:name` item field set by field set.
- `packages/mcp/src/stdio-data-bridge.object-schema-mask.test.ts` (16).
It runs the same case table over the stdio `describe_object` /
`list_objects` / `validate_expression` behind the real tool
registration, and an `MCPServerPlugin` composition that covers both
tools and both resources.
-
`packages/qa/dogfood/test/showcase-mcp-describe-object-field-mask.dogfood.test.ts`
(3). On the showcase stack with real keys, the denied field is absent
from REST `/meta`, from HTTP and stdio `describe_object` and from the
stdio resource, all with one field set. A member denied every field is
refused 503 `FIELD_VISIBILITY_UNRESOLVED` on both transports. The
unrestricted control is answered byte for byte.
- **Ablations.** Each was committed first, then mutated through
`scripts/ablation-replace.mjs`, rebuilt, checked with
`ablation-dist-preflight` and restored to the HEAD blob. Removing the
HTTP mask call turns 7 runtime cases and 2 dogfood cases red. Removing
the stdio projection turns 5 mcp cases and 2 dogfood cases red. Both
restore legs are green.
## Acceptance notes
- Field-level masking only: lacking object read is not turned into a
whole-object refusal.
- The runtime dispatcher's own `/meta` object door refuses the same
unresolvable projection with code `SERVICE_UNAVAILABLE` (503), while the
REST `/meta` door and these MCP tools use `FIELD_VISIBILITY_UNRESOLVED`.
That door is not changed here.
- The stdio object reads now resolve the API-key principal on each call,
as the data verbs already do.
## Verification
<details>
<summary>Package suites, typecheck, dogfood</summary>
- `pnpm --filter @objectstack/mcp test`: Test Files 58 passed, Tests 706
passed
- `pnpm --filter @objectstack/runtime test`: Test Files 551 passed,
Tests 8833 passed, 19 skipped
- `pnpm --filter @objectstack/metadata-core test`: Test Files 24 passed,
Tests 928 passed
- `turbo run typecheck` over mcp, runtime, metadata-core and their
direct consumers (example-todo, cli, dogfood, http-conformance, verify,
example-crm, example-showcase, hono, client, cloud-connection,
plugin-dev): 85 tasks; dogfood re-run green after its last test-typing
commit
- Full dogfood battery under the verify lock (`pnpm --filter
@objectstack/dogfood test`): Test Files 429 passed, 3 skipped; Tests
3563 passed, 41 skipped; lock VERDICT command-exit 0
</details>
<details>
<summary>Repo gates</summary>
- `node scripts/pm/dispatch-gates.mjs --ran`: 106 derived, 106 run, 0
NOT-MEASURED, 0 UNRUN
- `node scripts/check-nul-bytes.mjs`: exit 0
- `node scripts/pm/check-governed-merges.mjs --branch
origin/claude/sec-hardening-r13`: NOT governed, 1240 changed lines
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01L9qibvynRbCtMndW8EHNGc)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 115a43a commit e1dac19
14 files changed
Lines changed: 1233 additions & 25 deletions
File tree
- .changeset
- content/docs/ai
- packages
- mcp
- src
- qa/dogfood/test
- runtime/src/domains
- spec/src/api
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
361 | 361 | | |
362 | 362 | | |
363 | 363 | | |
364 | | - | |
365 | | - | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
366 | 370 | | |
367 | 371 | | |
368 | 372 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
30 | 30 | | |
31 | 31 | | |
32 | 32 | | |
| 33 | + | |
33 | 34 | | |
34 | 35 | | |
35 | 36 | | |
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
39 | | - | |
40 | 40 | | |
41 | 41 | | |
42 | 42 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
877 | 877 | | |
878 | 878 | | |
879 | 879 | | |
| 880 | + | |
| 881 | + | |
| 882 | + | |
| 883 | + | |
| 884 | + | |
| 885 | + | |
| 886 | + | |
| 887 | + | |
| 888 | + | |
| 889 | + | |
| 890 | + | |
| 891 | + | |
| 892 | + | |
| 893 | + | |
| 894 | + | |
| 895 | + | |
| 896 | + | |
880 | 897 | | |
881 | 898 | | |
882 | 899 | | |
| |||
906 | 923 | | |
907 | 924 | | |
908 | 925 | | |
909 | | - | |
| 926 | + | |
| 927 | + | |
| 928 | + | |
| 929 | + | |
| 930 | + | |
| 931 | + | |
| 932 | + | |
| 933 | + | |
| 934 | + | |
| 935 | + | |
| 936 | + | |
| 937 | + | |
910 | 938 | | |
911 | 939 | | |
912 | 940 | | |
| |||
997 | 1025 | | |
998 | 1026 | | |
999 | 1027 | | |
1000 | | - | |
| 1028 | + | |
| 1029 | + | |
| 1030 | + | |
| 1031 | + | |
| 1032 | + | |
| 1033 | + | |
| 1034 | + | |
| 1035 | + | |
| 1036 | + | |
| 1037 | + | |
| 1038 | + | |
1001 | 1039 | | |
1002 | 1040 | | |
1003 | 1041 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
36 | 36 | | |
37 | 37 | | |
38 | 38 | | |
| 39 | + | |
39 | 40 | | |
| 41 | + | |
40 | 42 | | |
41 | 43 | | |
42 | 44 | | |
| |||
458 | 460 | | |
459 | 461 | | |
460 | 462 | | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
461 | 478 | | |
462 | 479 | | |
463 | 480 | | |
| |||
729 | 746 | | |
730 | 747 | | |
731 | 748 | | |
| 749 | + | |
732 | 750 | | |
733 | 751 | | |
734 | 752 | | |
| |||
791 | 809 | | |
792 | 810 | | |
793 | 811 | | |
| 812 | + | |
794 | 813 | | |
795 | 814 | | |
796 | 815 | | |
| |||
801 | 820 | | |
802 | 821 | | |
803 | 822 | | |
804 | | - | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
805 | 834 | | |
806 | 835 | | |
807 | 836 | | |
| |||
0 commit comments