Skip to content

fix(runtime): list_actions marks revoke_api_key unavailable to an API-key caller, with the API-key guard's own refusal (#28943) - #29036

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-jqgfyc
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-jqgfyc

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

Fixes #28943

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:用 API key 登录的调用方调用 MCP list_actions 时,revoke_api_key 不再显示为可运行。它带上 unavailable,内容是 API key 管理守卫自己的 PERMISSION_DENIED 和原句,与 run_action 的拒绝一致。
  • 为什么改:forward 凭据标记只问目标入口能否读取调用方的凭据。数据入口能读 API key,所以 sys_api_key 的 Revoke 被列为可运行;随后该对象的守卫 apiKeyManagementCredentialRefusal 以 403 拒绝写入。这是 18.3.0 RC 跑测 QA run · release-18.3.0-w4-c3a (44/44) · 7fa9873f · 2026-11-02 · 41 PASS / 2 PARTIAL / 1 FAIL / 0 BLOCKED / 0 NOT-RUN #28942 中唯一的 FAIL。
  • 风险与回滚:低。守卫本身、错误码、读 sys_api_key、其他对象,以及 session 调用方的列表和运行都不变。REST 动作入口和 MCP run_action 对同一请求改为在转发前给出同样的 403 和原句,不再转发后才被拒。回滚即还原本 PR。
  • 先看:packages/runtime/src/action-execution.ts 的 apiForwardDataDoorGuardRefusal 和 apiForwardDataDoorOperation。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/domains/api-action-forward.test.ts -t 28943,预期 5 个测试全绿。
  • 需要你做的:无,席位落地。

Measure (step 1, on main f7c545a)

  • Routes the guard refuses to an API-key caller: every non-read data-door operation on sys_api_key: create, update (PATCH and PUT), delete, bulk (the batch routes), import, clone, and any write verb a door adds later. The guard is asked at every per-object gate: the runtime dispatcher's refuseUnexposedDataAction, RestServer.enforceApiAccess (and per op on its cross-object batch), and the stdio MCP write arms. The /api/v1/keys mint is a separate session-only refusal in domains/keys.ts, with its own sentence.
  • Actions whose api target lands on one: sys_api_key.revoke_api_key and sys_api_key.restore_api_key (both PATCH /api/v1/data/sys_api_key/{id}), plus create_api_key on the /keys mint. No action in examples/ or hotcrm targets either (the one examples/app-todo hit is an e2e that runs an undeclared rotate and expects the refusal).
  • Offered as runnable to an osk_ caller on main: revoke_api_key only. It is the one with ai.exposed; restore_api_key and create_api_key declare no ai block, so actionAiExposureError leaves them out of list_actions.

What changes

One source. The guard's predicate, apiKeyManagementCredentialRefusal, is already exported from @objectstack/core, and action-execution.ts already imports it for its per-object gate. So there is no dependency cycle, and no new export on any package root. The marker now calls it directly.

  1. apiForwardDataDoorOperation(method, path) (new, module-private) reads the data-door request a forward makes: the object segment of /api/v1[/environments/:id]/data/:object[/…], and the operation in the doors' own names. GET/HEAD, POST /:object/query and POST /:object/:id/page are reads; PATCH/PUT is update, DELETE is delete, POST to the collection is create, and any other POST is named by its last segment. Only the read-or-write reading is load-bearing, because the guard refuses every operation it does not list as a read.
  2. apiForwardDataDoorGuardRefusal(kind, action, target) (new, module-private) asks the guard about that request when the caller's credential is api_key, and returns the guard's own status (403), code (PERMISSION_DENIED) and message. Nothing is restated.
  3. apiForwardCredentialRefusal: where the target's door accepts the credential, it now returns that answer instead of null. The door table, the identity and OAuth refusals, and the agent forward credential are untouched.

Both readers of apiForwardCredentialRefusal pick this up with no change of their own:

  • the MCP list_actions mark (domains/mcp.ts, comment only), which carries the guard's code and sentence as unavailable;
  • forwardApiAction, behind the REST action door and MCP run_action, which now answers that refusal before forwarding. Before, it forwarded and relayed the data door's 403 with the same code and sentence.

The listing judges the declared target ({id} unfilled) and the run door judges the filled path. Both read action.method, and the object segment is never a token, so they judge the same request.

⛔ The guard's refusal is unchanged, and there is no new error code, no packages/spec/** change and no new gate. Reads of sys_api_key, every other object, and the session caller's listing and run are unchanged.

Changeset: patch on @objectstack/runtime (Metadata-protocol: none gives patch).

Tests

New describe [#28943] in packages/runtime/src/domains/api-action-forward.test.ts. The harness boot gains extraObjects, which serves a sys_api_key carrying revoke_api_key exactly as declared, and a crm_case carrying the same PATCH shape as the control.

Test base f7c545a head
list_actions under an API key: revoke_api_key carries unavailable = the guard's code and sentence red green
MCP run_action and the REST action door under an API key: 403, PERMISSION_DENIED, the listed sentence; nothing forwarded red green
Request by request: each sys_api_key write is refused, its reads are not, a neighbouring object name is not; session and no-credential callers are refused nowhere red green
Control: under a session, revoke_api_key is listed with no mark, and run_action forwards PATCH /api/v1/data/sys_api_key/key_1 with revoked: true green green
Control: the same data-door write on crm_case under an API key is listed with no mark and forwarded, the key carried across green green

The two controls are green on base by design: they pin what must not change.

Ablation: scripts/ablation-replace.mjs replaced the marker's read of the guard with return null. The mutation landed (anchor 1 to 0), and the three pins went red (3 failed, 2 passed). The restore was clean: the blob equals HEAD and git diff HEAD is empty.

Suite, typecheck, lint, gates

On head c3b472f4, after a whole-repo pnpm build under the verify lock (exit 0):

  • the full @objectstack/runtime suite under the lock: 8272 passed, 19 skipped, exit 0. @objectstack/core is not touched;
  • pnpm typecheck in full for runtime: exit 0, including check:test-typecheck (ledger unchanged);
  • eslint --no-inline-config src for runtime: exit 0;
  • dispatch-gates.mjs --commands derived 61 commands (including check:route-ledger-census), each run with its exit captured before any pipe, all exit 0. --ran: 61 derived, 61 run, 0 NOT-MEASURED.

A live MCP probe on a showcase boot was not run; the in-process doors above are the evidence.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5oTRCUNQ4EA3YV84sZ

…n API-key caller, with the guard's own refusal

The forward credential marker now asks the API-key management guard
about the data-door request a forward makes, so revoke_api_key is listed
with the guard's PERMISSION_DENIED and sentence, and the run doors answer
that refusal before forwarding.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oTRCUNQ4EA3YV84sZ
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 10 documentable anchor(s).

25 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json de68479670e751ba13c6f1a10d4d85a772a478b7.

⛔ 5 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: /api/v1/data (route, 48 pages)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 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 — 28 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 de68479670e751ba13c6f1a10d4d85a772a478b7 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a1f31b6f04d1d718b2085d9c6daaa7b4e676707f — the merge of head 98b8895f05510e7247801a5439fecd6f2950c9fa into base de68479670e751ba13c6f1a10d4d85a772a478b7, 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 a1f31b6f04d1d718b2085d9c6daaa7b4e676707f && git checkout a1f31b6f04d1d718b2085d9c6daaa7b4e676707f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin de68479670e751ba13c6f1a10d4d85a772a478b7 98b8895f05510e7247801a5439fecd6f2950c9fa && git checkout -B drift-repro de68479670e751ba13c6f1a10d4d85a772a478b7 && git merge --no-ff 98b8895f05510e7247801a5439fecd6f2950c9fa

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

⚠️ 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 de68479670e751ba13c6f1a10d4d85a772a478b7 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-11-02T06:22Z

Head c3b472f4, report 6570564243 (on #28943), claim 6569706666. Clause-②: no, with no arm: no contract review is owed. The seat read the diff itself.

item reading
Shape Draft, base main, Fixes #28943. 4 files (+242 / −9): apiForwardCredentialRefusal plus two private helpers in runtime/src/action-execution.ts, a comment in domains/mcp.ts, unit pins and a @objectstack/runtime patch changeset. Metadata-protocol: none. No new export, no packages/spec/**, no gate, no new error code.
Rule One source: when the target's door accepts the caller's credential, the marker now also asks @objectstack/core's apiKeyManagementCredentialRefusal (already exported, already imported by this file, so no cycle) about the data-door request the forward makes, and returns that guard's own 403, PERMISSION_DENIED and sentence. list_actions marks revoke_api_key with it for an osk_ caller, and both run doors answer it before forwarding instead of after. The guard is unchanged, and only api_key is ever refused here: sessions, reads of sys_api_key and every other object are unchanged.
Premise On main the guard refuses every non-read data-door op on sys_api_key under an API key; the actions landing there are revoke_api_key and restore_api_key (and the separate /keys mint). list_actions offered only revoke_api_key to an osk_ caller, the one ai.exposed. None in examples/ or hotcrm.
Evidence 3 pins red on base and green on head; the 2 controls (session revoke runs; a crm_case write under a key runs) green on both. Ablation red, restore clean. Full runtime suite (8272), typecheck in full, eslint, 61/61 derived gates, 0 NOT-MEASURED. CI on head is running; auto-merge waits for it. PR #29005 (#28932) edits another hunk of the same file and is ahead in the queue.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, with every check green or an expected skip.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 06:23
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 06:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 2, 2026
…-jqgfyc

# Conflicts:
#	packages/runtime/src/domains/api-action-forward.test.ts
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

席位核对 · ACCEPT r2 (new head) · domain:server seat 1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY · 2026-11-02T08:18Z

Head 98b8895f05, a merge of origin/main (5d3667e2) into the head the predecessor ACCEPTed (c3b472f4, record 6570583249). Takeover claim 6572348542 on #28943. Clause-②: no, no arm: no contract review is owed. The seat read the merge itself.

item reading
Shape Unchanged from 6570583249: 4 files, runtime/src/action-execution.ts, domains/mcp.ts (comment), domains/api-action-forward.test.ts, a @objectstack/runtime patch changeset. Not governed.
Merge git show --remerge-diff 98b8895f has one conflict, in the test harness boot of api-action-forward.test.ts: main added PING_LOCAL to objectDef.actions; this branch added the served map over extraObjects. The resolution keeps both (main's action list, this branch's map). No other hunk differs from a clean merge; the rule in action-execution.ts is the ACCEPTed one.
Evidence CI on 98b8895f: 35 success, 13 skipped, 0 failed; check-expected-skips.mjs --pr 29036: OK, every skip in main's roster. The card thread reported 78/78 in the file and runtime pnpm typecheck exit 0 on this head (amendment 6571831222). mergeable_state clean.
Out of scope none new.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): auto-merge through the relay, every check green or an expected skip.

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.

MCP list_actions offers sys_api_key.revoke_api_key as runnable to an API-key caller, and run_action then refuses it 403

1 participant