Skip to content

fix(runtime,mcp): list_actions marks an organization action the caller's membership role does not reach - #33194

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-dzsngk
Nov 14, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-dzsngk

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #32163

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:普通成员的 AI 通过 MCP list_actions 看到的 15 个组织类身份动作(邀请成员、移除成员、改组织、管团队等)仍然列出,但每行带上 unavailable 标记,写明需要哪种组织角色(如"需要组织 owner、admin 或 delegated admin")。owner、admin(邀请类还有 delegated admin)看到的列表不变。
  • 为什么改:这些动作成员一执行就被组织端点 403 拒绝,AI 却以为能用,白白规划、浪费轮次。按裁决 E「列出并标记,不隐藏」处理。
  • 风险与回滚:只多一个标记;谁能执行完全不变(两个执行门都不读这个标记,实测 main 与本分支执行结果逐一相同)。删 SSO 提供方、删 OAuth 应用、建组织这 3 个按记录所有者或部署形态判定的动作不标记。回滚即还原本 PR。
  • 先看:packages/runtime/src/domains/mcp.ts 的 membershipReachRefusal 与 listActions 里 refusal 那一行的顺序;packages/runtime/src/platform-admin-action-gate.test.ts 末尾新 describe。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/platform-admin-action-gate.test.ts,预期 16 passed。
  • 需要你做的:无,席位落地。

What changes

  • The mark. listActions (packages/runtime/src/domains/mcp.ts) asks a new module function, membershipReachRefusal(action, objectName, ec), after the two existing refusals: confirmation ?? apiForwardCredentialRefusal(...) ?? membershipReachRefusal(...). That is the order run_action meets them: the confirmation gate in the admission sequence, the forward's credential table before the target is called, and the organization endpoint last.
  • Where the reach comes from. The function reads the action's declared target. When it is an organization endpoint under the auth base path (/api/v1/auth/organization/...), the grades that reach it are the union of the grades of every MEMBERSHIP_REACH row naming that endpoint (@objectstack/spec/identity, the table plugin-auth's membership-reach-table.test.ts pins equal to the vendor doors). /organization/update-member-role is two rows, so its reach is their union. No local list, and no new stored key: the reach is derived from what survives on the served action (its target), so ruling A's key is not needed.
  • Whose grades. The caller's positions in the organization the listing reads, by the names mapMembershipRole projects the grades to (org_owner, org_admin, delegated_admin). That is the reading the actions' requiresMembershipReach sugar is lowered to. Only an envelope that carries its own positions is judged: the system context, a delegated agent (its envelope carries the agent ceiling, positions: [], not the person's grades) and an envelope with no positions array are left unmarked.
  • The mark's shape. unavailable: { code: 'PERMISSION_DENIED', reason }. The code is the one run_action answered for a member on every set-A/B action measured on a booted showcase (below). The reason names the action, the roles that reach it and the endpoint, and says who can run it: "Action 'invite_user' on 'sys_member' needs an organization owner, admin or delegated admin: its endpoint (/organization/invite-member) refuses every other membership role, and your role in this organization is none of those. Ask a person who holds one of those roles to run it."
  • Nothing hidden, no run change. Every action stays listed. Neither run door reads the mark: run_action and POST /actions forward the call to the endpoint exactly as before, and the endpoint stays the authority (a call naming another organization is judged there).
  • System-context page. Row 62b on content/docs/permissions/system-context.mdx owns the mark's system-context skip (an elevation read the census counts), and the generated site register names mcp.ts#membershipReachRefusal.
  • Published page. content/docs/ai/actions-as-tools.mdx: the list_actions row names the three codes a listed action can carry (UNAUTHENTICATED, ACTION_CONFIRMATION_REQUIRED, and PERMISSION_DENIED for the membership grade), and the intro and "What gets exposed" sentences no longer say every listed action can be run.
  • Wording. McpActionSummary.unavailable's JSDoc and the list_actions tool description (packages/mcp/src/mcp-http-tools.ts) name the membership-role case and its code, and the description no longer promises that every returned action can be run ("that the caller's permissions allow are returned"; "cannot be run by you as you are connected (your credential, or your role in this organization)"). The served skill (packages/mcp/src/skill-md.ts) no longer says "only those … you are permitted to run" and says what unavailable means. Its Console mirror (ui/apps/console/src/pages/developer/objectstack-skill.ts) carries the same bytes, as its two drift pins require.

Not marked (sets C and D, listed and refused at their door as today): sys_sso_provider.delete_sso_provider and sys_oauth_application.delete_oauth_application (judged on record ownership), and sys_organization.create_organization (judged on the deployment's tenancy posture; measured on the showcase: 403 ORGANIZATION_CREATION_DISABLED). No reach row names their endpoints, so the function answers undefined for them.

Tests

New top-level describe('[ruling E] list_actions marks an organization action the caller's membership role does not reach') in packages/runtime/src/platform-admin-action-gate.test.ts, the identity-action harness: the REAL identity declarations (@objectstack/platform-objects/identity) through the production McpActionBridge, with the forward target faked as the endpoints answer a refused role.

  1. Premise: every pinned action is AI-exposed, api-typed, and declares no capability gate.
  2. A plain member (positions: ['org_member', 'everyone']) is listed every set-A and set-B action, each marked PERMISSION_DENIED, the reason naming "owner or admin" (set A) or "owner, admin or delegated admin" and /organization/invite-member (set B).
  3. Owner and admin get no mark on any of the 15; delegated_admin gets none on set B and keeps it on set A.
  4. CONTROL: the set-C and set-D actions are listed unmarked, for the member and the admin.
  5. CONTROL: a delegated agent and the system context are left unmarked.
  6. CONTROL: run_action unchanged. For each of the 15, the member's call is forwarded (the fake endpoint is called once, so no door refused first) and refused with the endpoint's PERMISSION_DENIED / 403 and its sentence; the admin's call is forwarded once and served. Green at main too.

Red at main (test file at this branch, packages/runtime/src/domains/mcp.ts restored from 28a2f0f5): 2 failed / 14 passed (cases 2 and 3: no mark). Green here: 16 / 16.

Ablations (each through scripts/ablation-replace.mjs, anchor 1 → 0, restored to the HEAD blob, git diff HEAD empty after each; the suite imports ./http-dispatcher.js from src, so no dist/ sits between mutation and run)
Mutation Red
A: the caller's grades never reach (if (false) return undefined;) case 3
B: the mark not asked in listActions (?? undefined) cases 2 and 3
C: a delegated agent and the system context judged too case 5
D: reach from every row, not the rows naming the endpoint cases 2, 3 and 4
E: hide instead of mark (if (!served || membershipReachRefusal(...)) continue;) cases 2, 3 (and the run control at the first test revision)
Live measurement on a booted showcase (temporary dogfood file, not committed; MCP over the session bearer of a signed-up plain member and of the administrator)

At origin/main 28a2f0f5: the member is listed 34 sys_* actions, all 18 of sets A–D among them, none marked; the administrator 44. As the member, run_action answers 403 PERMISSION_DENIED with the endpoint's own sentence on 14 of the 15 set-A/B actions ("You are not allowed to invite users to this organization", "… to delete this member", "… to update this organization", "… to create teams in this organization", …). cancel_invitation answered 400 VALIDATION_ERROR "Member not found": the only invitation row the fixture holds belongs to another organization, so the endpoint stops before its role check. Set C/D: create_organization 403 ORGANIZATION_CREATION_DISABLED; the two deletes 404 (no such provider or client in the fixture).

At this branch (d4df05f4, after the rebuild): the member is listed the same 34, the 15 set-A/B rows marked PERMISSION_DENIED and nothing else; the administrator the same 44, none marked. Every member run_action answer is identical to main's, code, status and sentence.

Records: measure-main-28a2f0f5.json and measure-head-d4df05f4.json in the seat's out/32163/.

Acceptance notes

  • Surface. Ruling grep (ruling|Ruling-ref|maintainer|⛔) over the edited files: mcp.ts 17 hits, mcp-http-tools.ts 39, skill-md.ts 1, none in or governing listActions, the unavailable JSDoc or the list_actions description and skill lines; the one adjacent is the run_action listed-check ("maintainer's ruling on the card: no bypass"), which this change does not touch.
  • What the mark reads is the courtesy reading, not the door's. It reads positions, as the lowered visible predicate does. Two edges where it can differ from the endpoint, both in the safe direction for running (the mark never stops a run): a position granted directly through sys_user_position under a grade name (no mark, the endpoint still refuses, as today); a sys_member row outside its ADR-0091 validity window (no position, so marked, while the vendor may still admit). With no active organization, every membership's grades count, so a caller who is an admin anywhere is not marked.
  • The code. PERMISSION_DENIED is a constant beside the function, typed as the registered vocabulary. It is what run_action answered for a member on the showcase; the reason is this listing's own sentence, not the endpoint's (each endpoint words its refusal differently, and the table does not carry them).
  • REST /actions listing is not changed: the ruling scopes the mark to MCP list_actions.

Verification

Package suites, typecheck, lint (at `056f314d`; later commits are docs-only)

After the last merge of main (056f314d), on a fresh install and a full build (declarations included) of the @objectstack/runtime..., @objectstack/mcp... and @objectstack/service-storage... closures:

Run Result
pnpm --filter @objectstack/runtime exec vitest run 601 files, 11956 passed, 19 skipped
pnpm --filter @objectstack/mcp exec vitest run 64 files, 1145 passed
pnpm --dir ui exec vitest run apps/console/src/pages/developer/objectstack-skill.test.ts 13 passed
pnpm --filter @objectstack/runtime --filter @objectstack/mcp typecheck exit 0
pnpm lint exit 0

3de7508e (system-context row) and f91b2a1d (actions-as-tools page) add only docs on top of 056f314d; the package suites were not rerun for them.

Derived gates (dispatch-gates --commands; reconciled with --ran)

dispatch-gates --ran at f91b2a1d: 100 derived, 100 run, 0 NOT MEASURED, 0 unrun. Record: gates-ran-f91b2a1d.list in the seat's out/32163/. The docs-family gates (31, check:role-word and check:system-context-census among them) were rerun at f91b2a1d; the rest were run at 3de7508e, which differs from it by one docs page.

  • node scripts/check-plugin-teardown-shape.mjs --self-test first exited 3: its positive control is pinned to commit 621a4876, which the shallow clone did not hold. After git fetch --depth=1 origin 621a4876… it passes (48 cases).
  • pnpm check:system-context-census was red at the first gate run: the mark's system-context skip is an elevation read no row owned. Fixed in 3de7508e with row 62b on content/docs/permissions/system-context.mdx and the regenerated register; green after.
  • pnpm check:role-word was red on the first wording of that row ("membership role"); the row now says "membership grade". Green after.
  • The docs edit added 31 docs-family gates to the derivation; all were run at the new tree.

Generated by Claude Code

…r's membership role does not reach

The MCP listing keeps every exposed identity action and marks the ones whose
organization endpoint the caller's role cannot reach as unavailable, with the
code run_action answers (PERMISSION_DENIED) and a sentence naming the roles
that reach it. The reach is read from the action's target against the spec's
MEMBERSHIP_REACH table; neither run door reads the mark.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvdpdhupF2wQf2NUU8
…ip-mark read

The new list_actions mark skips the system context, which is an elevation read the
system-context census counts. Row 62b names it, and the generated site register is
regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvdpdhupF2wQf2NUU8
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 14, 2026
@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/mcp, @objectstack/runtime, touching 8 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/mcp/src/skill-md.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/permissions/authorization.mdx (via buildMcpBridge (symbol, a top-level function))
  • content/docs/permissions/system-context.mdx (via membershipReachRefusal (symbol, a top-level function))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/mcp/src/skill-md.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 53 pages)
  • the SDK route bridge reached 65 of 247 client-bound route-ledger rows — the other 182 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 182: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 59 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 123 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 37 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f45c0125143c766d1d7900590747ad8acded344e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1602ffc8e1ca3058aab9529739d9d11d5fba0114 — the merge of head f91b2a1d69f7bc0dd466089d23ba6aa844ff0042 into base f45c0125143c766d1d7900590747ad8acded344e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1602ffc8e1ca3058aab9529739d9d11d5fba0114 && git checkout 1602ffc8e1ca3058aab9529739d9d11d5fba0114
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f45c0125143c766d1d7900590747ad8acded344e f91b2a1d69f7bc0dd466089d23ba6aa844ff0042 && git checkout -B drift-repro f45c0125143c766d1d7900590747ad8acded344e && git merge --no-ff f91b2a1d69f7bc0dd466089d23ba6aa844ff0042

node scripts/docs-audit/affected-docs.mjs --json f45c0125143c766d1d7900590747ad8acded344e

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f45c0125143c766d1d7900590747ad8acded344e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

The list_actions row named only the credential mark. It now names the three codes a
listed action can carry, the membership-grade mark among them, and the intro and
"What gets exposed" sentences no longer say every listed action can be run.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibvdpdhupF2wQf2NUU8
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

维护者速读 · PR #33194 @ f91b2a1d · 2026-11-14T01:46Z

  • 改了什么:MCP list_actions 照旧列出组织类身份动作(邀请成员、移除成员、编辑组织或团队等),但对成员角色够不到该端点的调用方,这一行带上 unavailable { code: 'PERMISSION_DENIED', reason },原因里写明哪些角色可以执行(如「需要组织 owner、admin 或 delegated admin」)。依据裁决 E(6812625052):列出并标记,不隐藏。
  • 为什么改:普通成员的代理看到这些动作,调用后每次都被 403 拒(MCP list_actions still offers members identity actions they cannot run (invite_user, remove_member, delete_sso_provider, create_organization) after #30771 #32163)。
  • 权限与边界:不变。标记只是说明;两个执行入口都不读它,谁能执行完全照旧(dev 在 showcase 上测过与 main 一致)。可达性从动作的 target 对照 spec 的 MEMBERSHIP_REACH 表推出,没有新的存储键。系统上下文和代人执行的代理(OAuth)不按成员角色标记,后者在这些端点上本来就带 UNAUTHENTICATED 标记。
  • 风险与回滚:只改列表输出、工具描述、served skill 及其 Console 镜像、文档。回滚就是 revert。
  • 先看:runtime/src/domains/mcp.ts 的 membershipReachRefusal 和它在 listActions 里的位置(排在确认与凭据两道标记之后)。
  • 需要你做的:无,席位落地。
  • 席位意见:落地。Clause-② no,席位自审:代码 PASS;请 dev 把 actions-as-tools.mdx 的过时说法在本 PR 内一并改掉,第二轮只改这一页。CI 无失败,没有碰受管面,改动 335 行。

席位核对

  • 形态: draft → main。正文写 Fixes #32163,合并后应当关卡;正文其他地方没有挨着别的 open 卡号的闭合关键词。
    • 申报 Clause-②: no、Metadata-protocol: none;@objectstack/runtime、@objectstack/mcp、@object-ui/console 均 patch。
    • 已发布的 skills/ 里没有「只列出你能执行的」这类说法(skills/objectstack-ai/SKILL.md:204 只讲确认与服务账号),所以不欠 Skills-docs。served skill-md.ts 与 Console 镜像同步更新。
    • 文件面 = 接管认领 6867398485 加修订 6869274659(system-context.mdx 普查行 62b 及其生成的登记行,由 check:system-context-census 强制;actions-as-tools.mdx 三处说法)。
  • 前提实测: dev 报告 6869254122:普通成员在 main 上 list_actions 列出的身份动作及 run_action 的拒绝码已逐一测出;集合 A/B 共 15 个动作被标记,集合 C/D(删除 SSO 提供方、删除 OAuth 应用、创建组织)不标记,PR 正文写明了这 3 个及原因。
  • 门禁 check-runs @ f91b2a1d: 48 success、11 skipped、0 失败、0 进行中。
    • dev 本地 --ran:3de7508e 上 100 推导、99 跑、1 NOT MEASURED(check-plugin-teardown-shape --self-test 因浅克隆缺正向对照提交 621a4876 而 exit 3;补取后 48/48 通过);f91b2a1d 上 100/100,文档族 31 个门禁 exit 0,role-word 与 system-context-census 绿。
  • 契约复审: Clause-② no,席位自审(不另派复审者):
    • 标记排在 actionConfirmationRefusal 与 apiForwardCredentialRefusal 之后,与 run_action 遇到拒绝的顺序一致;
    • 端点从 target 取 /api/v1/auth/organization/...,在 MEMBERSHIP_REACH 里取所有同端点行的角色并集;没有行的端点不标记;
    • 调用方角色用 positions 经 mapMembershipRole 比对;系统上下文、onBehalfOf、无 positions 的信封不标记;
    • 原因措辞与 fix(runtime): MCP list_actions marks a confirmation-gated action unavailable for an AI service account #33144 的服务账号分支同一形状;unavailable 的 JSDoc 与工具描述已列出三种 code;
    • 第二轮 3de7508e..f91b2a1d 只改 actions-as-tools.mdx 一页,三处说法与工具描述、skill 一致。
  • 接受的偏差:
    • 没有 tenantId 的调用方按「你所在的任何组织」措辞判断,实际执行时由端点按请求里的组织判定;这只是标记。
    • 标记读取的是本次列表所在组织里的 positions;run_action 若点名别的组织,仍由端点裁决。
  • 范围外去向: 无。
  • 落地条款: 维护者常设落地令 「按 pm-dispatch 审过的pr你可以自主落地」 (2026-10-30T14:20Z),维护者接手条款按两小时规则(认领 6867398485 已记录)。

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 14, 2026 01:47
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit 66919b6 Nov 14, 2026
60 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-dzsngk branch November 14, 2026 02:16
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

2 participants