Skip to content

fix(mcp): run_action and resume_run withhold a PERMISSION_DENIED's details, as the data tools do (#23529) - #23544

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23529-mcp-action-details
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-23529-mcp-action-details

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23529
Clause-②: no

run_action and resume_run now withhold a PERMISSION_DENIED refusal's details, as the data tools, the record resource, the runtime dispatcher and the REST actions door already do (the ruling on #7450). Every other coded refusal on the two tools keeps its details, so #15942's confirmation contract is unchanged.

Dispatched by the PM loop, domain:spec seat 4, building round after triage's regrade (comment 6177046363), claim 6177192097, session session_0121ooBYRmS8ziFXm9ai5YHm.

What changed

  • packages/mcp/src/mcp-http-tools.ts, errorResultFromThrown only (plus the one import it reads). A thrown refusal whose declared code is the standard catalogue's PERMISSION_DENIED now renders through errorResultWithCode, the function the data tools already render through, so its details are withheld. The code is spelled from StandardErrorCode (@objectstack/spec/api, already a dependency), not by hand. Every other coded refusal keeps its details. The run_action and resume_run catches are unchanged: both already call errorResultFromThrown.
  • packages/mcp/src/mcp-action-refusal-details.test.ts: the pin, on the MCP HTTP face over the real Streamable HTTP transport, the way mcp-coded-refusal.test.ts drives it.
  • .changeset/23529-mcp-action-tools-withhold-permission-details.md: @objectstack/mcp patch.

Why

The security gate's PERMISSION_DENIED carries the caller's own positions and permission-set names in details. The ruling on #7450 keeps those off the wire. The dispatcher drops them, the REST actions door answers through resolveThrownHttpError's closed details list, and since #23509 the data tools render through errorResultWithCode, which never reads details. The two action tools rendered through errorResultFromThrown, which spread details verbatim into a tool result. That tool result rides a JSON-RPC 200, so the dispatcher's withholding never saw it.

Reach, read from source (reading 6176605181, re-read on this tree): executeDeclarativeUpdateAction writes through callData('update', …, ec) as the caller, and nothing between the security middleware and errorResultFromThrown catches the gate's write-side refusal. The subject load does not carry it: loadActionSubjectRecord's bare catch drops the throw and refuseDeniedSubjectLoad answers RECORD_NOT_FOUND with no details.

Route: the first choice measured, then the second

The pin: red on main, green after

The bridge is a double, because @objectstack/mcp does not depend on @objectstack/runtime. It lets the caller read the subject row, and it refuses both action tools with the gate's PermissionDeniedError shape. The assertions: the tool error carries code PERMISSION_DENIED and status 403. Then each position and permission-set name is checked against the whole HTTP response body, not one key. A control row on each tool keeps ACTION_CONFIRMATION_REQUIRED's details served.

Run against main's renderer (base e1cefa58e3, the pin added, no fix):

 ❯ src/mcp-action-refusal-details.test.ts (5 tests | 2 failed)
     × run_action: PERMISSION_DENIED answers code, message and status, and the caller's grants appear nowhere
     × resume_run: PERMISSION_DENIED answers code, message and status, and the caller's grants appear nowhere
+     "details": {
+       "object": "crm_case",
+       "operation": "update",
+       "permissionSets": [ "pin23529_set_case_read_only", "pin23529_set_member_default" ],
+       "positions": [ "pin23529_position_case_reader", "pin23529_position_everyone" ],
      Tests  2 failed | 3 passed (5)

After the fix, the pin and the three suites that must not move, unchanged:

src/mcp-action-refusal-details.test.ts src/mcp-action-confirmation-member.test.ts src/mcp-coded-refusal.test.ts src/mcp-resume-run-tool.test.ts
 Test Files  4 passed (4)
      Tests  62 passed (62)

mcp-action-confirmation-member.test.ts and mcp-coded-refusal.test.ts (its RECORD_NOT_FOUND rows included) are byte-unchanged by this PR.

resume_run is covered by a pin, at the renderer. It calls the same errorResultFromThrown, and its row fails and passes with run_action's. By reading, nothing reaches it today: it makes no caller-scope write of its own, and its subject re-read ends at RECORD_NOT_FOUND. So its row guards the shared renderer, not a measured producer.

Ablation

node scripts/ablation-replace.mjs, wrap mode, under the verify lock. The mutation deletes the guard line and the pin runs on the mutated file. The subject resolves through the package's own src/, so no build or dist/ leg applies.

ablation-replace: anchor   x1 -> x0
ablation-replace: blob     232f1b56c0153258f111235257d1597134b53e58 -> c6feca3cd8d737389cf84d21191c717c7c5e149a
     × run_action: PERMISSION_DENIED answers code, message and status, and the caller's grants appear nowhere
     × resume_run: PERMISSION_DENIED answers code, message and status, and the caller's grants appear nowhere
      Tests  2 failed | 3 passed (5)
ablation-replace: ok restored: blob == HEAD (232f1b56c015) and `git diff HEAD` is empty

The direction is the ordinary one: the two denial rows turn red and the two confirmation controls stay green. That shows the guard is keyed on the code and is not a blanket withhold. git status was clean after the restore. 232f1b56c0 is still this file's blob at the head.

Tests and gates, at head 03c6a098a3

  • pnpm --filter @objectstack/mcp test: Test Files 39 passed (39), Tests 467 passed (467).
  • pnpm --filter @objectstack/mcp typecheck: exit 0, and check:test-typecheck: OK with no new debt. The new test file compiles under tsconfig.test.json.
  • pnpm --filter @objectstack/mcp build: exit 0. The CJS and ESM builds both load. The CJS bundle requires @objectstack/spec/api, and it resolves.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 commands. All 63 were run, with each exit code captured before any pipe. Reconciliation: ✓ dispatch-gates --ran: 63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero — all 63 recorded an exit code and none of them is 3).
    • Two gates first answered PREREQUISITE NOT MET (exit 3): check:lean-entry-closure and check:dual-build-cjs-loads. I built their prerequisites (turbo run build, all but one task a cache hit) and re-ran them. Both exit 0, and those are the readings recorded.
  • main was merged once before this PR opened. The one incoming commit touched packages/lint only, and @objectstack/lint was rebuilt.
  • CI owns the repo-wide scans and the families that run over a whole root. Their check-runs on this PR are the evidence for them.

Acceptance notes

  • Residual, noted and not filed (no carrier). The withholding is keyed on one code. Any other coded refusal's details still render verbatim on these two tools. REST passes a thrown refusal's details only through the closed list. No other producer that reaches these tools puts caller authorization data in details today. A fail-closed alternative is to serve only the confirmation refusal's details. It was not taken because it would also drop resume_run's REST-built run artefacts on a failed resume, which narrows more than this card asks.
  • Script-handler rethrow (the reading's second note). By reading, executeRegisteredAction rethrows a handler's own error raw. A rethrown PermissionDeniedError keeps its code, so this renderer-level fix covers it with no producer change. No in-tree producer was found.
  • The comment above errorResultWithCode is untouched. It sits outside the claimed surface, and it still holds for that function. errorResultFromThrown's own doc names the one code-keyed exception and why it is not a second copy of the policy.
  • What this pin does not measure. It does not cover the runtime layers between the gate and the bridge. Those rest on the reading and on mcp: the data tools and the record resource render a coded refusal as its message alone, so a stdio client never reads MFA_REQUIRED (ruling C′ item 1's "same code") or any other refusal's code #23509's measured fixture, where the gate's details reach the MCP HTTP face intact for a data verb.
  • The stdio face serves no action tools today (the reading's R3). The fix sits in the transport-agnostic renderer, so a host that bridged an action seam onto stdio would get it too.

Generated by Claude Code

…efusal's details over HTTP

Drives the MCP HTTP face through the real Streamable HTTP transport, with a
bridge double that refuses both action tools with the security gate's
PermissionDeniedError shape. Asserts the tool error carries PERMISSION_DENIED
and 403 and that no position or permission-set name appears anywhere in the
HTTP response body. A control keeps the confirmation refusal's details served
on both tools.

Red on its parent: errorResultFromThrown spreads the refusal's details into
the tool result.

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…tails, as the data tools do

errorResultFromThrown now answers a refusal whose declared code is the
catalogue's PERMISSION_DENIED through errorResultWithCode, the function the
data tools already render through, so the security gate's diagnostics stay
off the wire on the two action tools as they do on REST. Every other coded
refusal keeps its details, so the confirmation refusal's retry contract and
resume_run's REST-built details are unchanged.

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…sion refusal's details

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 12 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 5fd2c76d6ce7ee8cd3267b3e026a1d48c400a73d → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5fd2c76d6ce7ee8cd3267b3e026a1d48c400a73d

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 03c6a098a3993d02d9a8148520373430650de9d1
Local-runs: none

Inputs read: card #23529 body; the reading 6176605181; triage's regrade 6177046363; the claim 6177192097; the dev's building-round report 6178311020 (the newest os-dev-report on the card); PR #23544 body, file list (3 files: +24/-2, +186, +11) and commit chain e3b634d211 → 5f552dca94 → c6eced7486 → 03c6a098a3; the three-dot net diff origin/main (5fd2c76d6c)...03c6a098a3 (3 files, +221/-2, identical to the PR's file list); at the head: packages/mcp/src/mcp-http-tools.ts (errorResult :291, errorResultFromThrown :333-339, errorResultWithCode :363-366, codedErrorResult :368-373, thrownRefusalEnvelope :392-397, messageOf :1335-1339, the list_actions catch :1124-1126, the run_action catch :1231-1233, the resume_run registration guard :1248-1249 and catch :1325-1327, wireBridgeTools' action guard :575-576); the same renderer at origin/main (errorResultFromThrown :312-317); packages/spec/src/api/errors.zod.ts:53-79 and packages/spec/src/api/index.ts:55, packages/spec/package.json exports["./api"]; packages/plugins/plugin-security/src/errors.ts:41-58 and :337-345; packages/runtime/src/http-dispatcher.ts:62-69, :2993-3054; packages/runtime/src/security/permission-denied-envelope.ts (whole); packages/runtime/src/security/resolve-execution-context.ts:344; packages/types/src/thrown-http-error.ts:206-254; packages/runtime/src/domains/actions.ts:779-804, :933-942; packages/runtime/src/domains/mcp.ts:759-780, :880-1000; packages/runtime/src/domains/automation.ts:2109-2116, :2179-2200; packages/runtime/src/error-envelope.ts:100-124; packages/runtime/src/flow-start-admission.ts:67-78; packages/mcp/src/mcp-action-confirmation-member.test.ts (header, :110-200); packages/mcp/src/mcp-coded-refusal.test.ts:73-125, :160-190; packages/mcp/src/mcp-resume-run-tool.test.ts (details rows :145-161); the new pin packages/mcp/src/mcp-action-refusal-details.test.ts (whole); the changeset .changeset/23529-mcp-action-tools-withhold-permission-details.md (whole); packages/mcp/package.json, packages/mcp/tsup.config.ts; the producer greps (every non-test line naming permissionSets across packages, every new PermissionDeniedError( site, every non-test 'PERMISSION_DENIED' producer outside plugin-security, with context on the eight duck-typed ones); the docs/spec search (content/docs/api/error-catalog.mdx:397-400, :945, packages/spec/src/api/error-code-ledger.zod.ts:355-381, docs/adr/0112-*, docs/qa/platform-checklist/areas/ai.json:264); the disclosure scan over the diff's added lines, the PR body, the changeset and the four commit messages; the head's check-runs, read last.
Not read: the Collected comment 6176649911; any ## 席位核对 or seat ACCEPT/pointer line (none appears on the card's index beyond Collected); the triage first grade 6175938306, the reading-round claim 6176328585 and its correction 6176364652 (not in the input list; only their first lines appeared in the comment index); comments or reviews on PR #23544 itself (not in the input list). No file was written to /home/user/objectstack, no worktree or checkout was made, no test or build was run.

① Derived judgments

  1. The guard withholds exactly the ruled details, on both tools, and every other coded refusal is unchanged: right. The one new line is if (envelope.code === StandardErrorCode.enum.PERMISSION_DENIED) return errorResultWithCode(err); (mcp-http-tools.ts:336 at head); errorResultWithCode builds { code, message, status } and never reads details (:363-366, :392-397). Both action catches call errorResultFromThrown and are byte-unchanged (:1231-1233, :1325-1327; the diff touches only :35-40 and :305-339). The class boundary matches the dispatcher's: http-dispatcher.ts:2994 keys its withholding on isPermissionDeniedError, which is plugin-security's duck-typed matcher re-exported at resolve-execution-context.ts:344 and defined at plugin-security/src/errors.ts:337-345 (name PermissionDeniedError OR code === 'PERMISSION_DENIED' OR the [Security] Access denied message prefix). The MCP guard keys on code alone, and in-tree that covers the same set: the class declares code as a constant (errors.ts:42), every duck-typed producer sets name and code together (plugin-approvals/src/payload-predicate-guard.ts:265-266, plugin-audit/src/parent-field-query-guard.ts:356-357, plugin-audit/src/comment-access-hooks.ts:319-320, service-storage/src/attachment-access-hooks.ts:202-203), and a message-prefix-only throw with no code makes thrownRefusalEnvelope answer undefined (:393-394), so its details are never read. ACTION_CONFIRMATION_REQUIRED still serves the action and member: the new pin asserts it on both tools over the HTTP face (mcp-action-refusal-details.test.ts:172-184), and MCP run_action never enforces ai.requiresConfirmation — it is surfaced in list_actions only, while @objectstack/spec guidance tells authors it stops execution #15942's own pin (mcp-action-confirmation-member.test.ts:149-161) and resume_run's (mcp-resume-run-tool.test.ts:145-161) are byte-unchanged. resume_run's door-built details are also unchanged by reading: the only PERMISSION_DENIED that door builds carries details: { code } (domains/automation.ts:2190) or none (domains/mcp.ts:988-993), and splitSemanticCode turns a { code }-only payload into undefined (error-envelope.ts:108-113), so resumeDoorError (domains/mcp.ts:767-780) never attached a details member to a PERMISSION_DENIED; the run artefacts ride the FLOW_FAILED row (automation.ts:2192-2200), a different code, still spread at :337-338.

  2. Keyed on a declared code through an existing export, not a denylist or a second policy copy; every in-tree producer of positions or permission sets in a refusal's details is a PERMISSION_DENIED: right. The code is read from the spec's zod enum (packages/spec/src/api/errors.zod.ts:53, member at :79), exported through packages/spec/src/api/index.ts:55 (export * from './errors.zod') on the @objectstack/spec/api subpath that packages/mcp/package.json already depends on (@objectstack/spec: workspace:*). No key name of details is inspected; the class's answer is the existing errorResultWithCode, so the package holds one code-keyed question and no filter (:336-338). packages/mcp cannot import the dispatcher's own matcher (@objectstack/mcp depends on neither @objectstack/runtime nor plugin-security), so the spec code is the existing export available to it. Producers: every throw in plugin-security/src/security-plugin.ts that puts positions/permissionSets into details is new PermissionDeniedError( (:2646-2652, :2775-2777, :2790-2793, :3039-3045, :3132-3139, :3210-3217, :3308-3311, :3318-3323, :3544-3546, :3694-3696, :3871-3875, :3930-3933, :3944-3947, :4227-4229). The other non-test files naming permissionSets (rest/src/meta-item-read-gate.ts, plugin-security/src/delegated-admin-gate.ts, plugin-hono-server/src/current-user-endpoints.ts, core/src/security/permission-manager.ts, core/src/security/resolve-authz-context.ts, plugin-security/src/explain-engine.ts, verify/src/parent-write-reach.ts, plugin-security/src/permission-evaluator.ts) put it in no thrown refusal's details (context grep returned nothing). One accuracy note on the dev's report, not on the code: it says the two duck-typed PermissionDeniedErrors in service-storage and plugin-audit "carry no details" (true: attachment-access-hooks.ts:201-206, comment-access-hooks.ts:318-323), but two more duck-typed PERMISSION_DENIED producers do carry details ({ object, fields, reason: 'field_predicate_denied' } at plugin-approvals/src/payload-predicate-guard.ts:271 and plugin-audit/src/parent-field-query-guard.ts:362). They are query-side guards, not principal data, and the dispatcher and REST's closed list (thrown-http-error.ts:234-241) already withhold them, so the MCP guard withholding them is parity, not a defect.

  3. Option A (keep as built) is right for this PR; the residual fail-open is an Acceptance note, not a defect of this PR: right. Option C needs an edit in packages/runtime (domains/mcp.ts's runAction bridge), which the claim 6177192097 forbids ("No edit in packages/runtime, packages/types or plugin-security: reuse an existing export or stop"), so it is a new card, not this one. Option B (serve details only for ACTION_CONFIRMATION_REQUIRED) would drop resume_run's FLOW_FAILED run artefacts (errorMessage, summary, runId, status, repairable; automation.ts:2195-2200), which that file's own doc names as declared once in @objectstack/spec/api as ResumeFailureDetailsSchema (:2173-2174) and which the console reads (:2154-2158); that narrows a declared contract the card never asked about. A closes exactly the ruled class with +24/-2 in one function. The residual (a future coded refusal carrying principal data would render its details) is shared with the dispatcher's own rule, which is also class-keyed (isPermissionDeniedError) and equally fail-open for a new class; REST's fail-closed shape lives in the runtime bridge (C), outside this claim. The PR records it in its acceptance notes and the report's out-of-scope finding (carrier none); whether to file C is the seat's call.

  4. The pin drives the real HTTP transport, asserts absence of the names in the whole serialized body, carries controls, and goes red on origin/main's renderer: right. It POSTs a JSON-RPC tools/call Request through MCPServerRuntime.handleHttpRequest with { bridge, parsedBody } (mcp-action-refusal-details.test.ts:124-140), the same door and option the MCP run_action never enforces ai.requiresConfirmation — it is surfaced in list_actions only, while @objectstack/spec guidance tells authors it stops execution #15942 pin uses (mcp-action-confirmation-member.test.ts:110-129). The bridge double implements listActions, runAction and resumeRun, so wireBridgeTools registers both tools (mcp-http-tools.ts:575-576, :1248-1249), and the resume_run args { runId, values: {} } fit the schema (:1283-1292). Assertions per denial row: strict toEqual({ error: { code: 'PERMISSION_DENIED', message, status: 403 } }) (:163-165), which fails on any extra member including details and the developerMessage sibling, and then expect(raw).not.toContain(name) for each of the four distinctive position and permission-set names against the raw HTTP response text (:166-169), so a renderer that moved the names to another key or into the message would still fail. Controls: the reader can get_record the row (:148-155) and ACTION_CONFIRMATION_REQUIRED's details stay served on both tools (:172-184). The sibling RECORD_NOT_FOUND rows for run_action and resume_run (mcp-coded-refusal.test.ts:102-103, :165-172) are the other non-permission coded control and are byte-unchanged. Red on main by reading: origin/main's errorResultFromThrown (:312-317) spreads details verbatim, so both denial rows fail the strict shape and the not.toContain checks while the three controls pass, which is the dev's recorded run ("2 failed | 3 passed (5)") and the ablation's direction. resume_run's row pins the shared renderer, not a measured producer, and the pin's header says so (:37-39).

  5. @objectstack/mcp patch with Clause-②: no is truthful; a permission refusal's details on a tool result is declared nowhere: right. Searched content/docs/**, docs/** and packages/spec/src/**: the catalog's PERMISSION_DENIED entry is Cause/Fix/Retry only (content/docs/api/error-catalog.mdx:397-400); the ledger is a list of codes with no details member for it (packages/spec/src/api/error-code-ledger.zod.ts); the only MCP refusal with declared details is ACTION_CONFIRMATION_REQUIRED (MCP run_action never enforces ai.requiresConfirmation — it is surfaced in list_actions only, while @objectstack/spec guidance tells authors it stops execution #15942/spec: define the confirmation contract behind action.ai.requiresConfirmation — an unconfirmed run_action on a gated action is refused, and the refusal carries the way to confirm #16293, ledger :355-365), which is untouched; the QA checklist already records that an MCP tool error carried no details on an FLS refusal and says to "never score on a details key" (docs/qa/platform-checklist/areas/ai.json:264); the The runtime dispatcher serialises a PermissionDeniedError's details to the client, so positions / permissionSets reach the browser on the /data transport #7450 ruling makes REST's { message, code, object } the contract for both transports (permission-denied-envelope.ts:26-30). The data tools' identical withholding landed as a patch in fix(mcp): a coded refusal reaches the client with its code on the data tools, list_actions and the record resource #23526 (b1cfa3d263). code, message and status are unchanged. Removing an undeclared member that a maintainer ruling keeps off the wire is a patch.

  6. PR body, changeset and the renderer's comment stay at function level; two lines in the pin name the route: right, with one flag. The PR body names functions and files only (no route, request shape, payload or steps; its vitest excerpt shows the withheld response member's keys with the fixture names, which the card body itself already names). The changeset and the four commit messages carry no route token. The renderer's comment (mcp-http-tools.ts:314-331) is function-level. The pin's header line 28 ("The HTTP /mcp face") and the describe title at :147 ("over HTTP /mcp") name the route segment; the test code's the transport URL literal (withheld here by the seat, per the card's disclosure rule) and tools/call body (:125-133) are the same literals the sibling pins on main use (mcp-action-confirmation-member.test.ts:111) and are needed to drive the transport. Precedent on main already names /mcp in comments (errorResultWithCode's doc at :356, mcp-coded-refusal.test.ts:30). Non-blocking: replace the two comment mentions with "the HTTP face" if the seat wants the file strictly route-free.

  7. Nothing else makes it unsafe to land: right. Check-runs at the head, read last: 39 listed, 30 success, 6 skipped, 3 in progress (Test Core 1/6, 3/6, 4/6), none failed; Build Core, TypeScript Type Check (and its four sub-checks), Lint & Repo Gates (and its eight sub-checks), Check Changeset, Governed Surface Queue Guard, Check PR Size and Test Core 2/6, 5/6, 6/6 are green. CJS/ESM: @objectstack/spec's ./api export has both import and require conditions (packages/spec/package.json), packages/mcp/tsup.config.ts:12 builds ['esm', 'cjs'], and @objectstack/spec/api is already imported from non-test source in packages/runtime (17 files), packages/types (4, also ESM+CJS), packages/rest (10) and eight other package groups, so the import has broad precedent. Typecheck: StandardErrorCode.enum.PERMISSION_DENIED is the literal 'PERMISSION_DENIED' compared against a string (:336). The 62 related tests: the three sibling files are byte-unchanged (the PR's file list is exactly three paths). One stale comment outside the claim's surface: mcp-coded-refusal.test.ts:79-81 still says the two action tools "render through errorResultFromThrown, which carries details", and its actionDoor exclusion (:83, :179) now skips a permission row that would pass for the action doors too; both sit outside the claimed file surface and are an acceptance note. The MCP renderer drops the withheld payload silently, whereas the dispatcher logs it server-side (http-dispatcher.ts:3013-3017); this matches the already-landed data-tools shape (errorResultWithCode, fix(mcp): a coded refusal reaches the client with its code on the data tools, list_actions and the record resource #23526) and is parity, not a regression.

② Semver level

patch for @objectstack/mcp, as the changeset declares. The change removes an undeclared details member from two tool errors under a maintainer ruling that keeps it off the wire on every transport; code, message and status are unchanged, the declared ACTION_CONFIRMATION_REQUIRED details are unchanged, and no exported signature, schema or documented contract moves. Clause-②: no is truthful. No other package is touched.

③ Verdict

PASS. No blocking item. Acceptance notes, none blocking: (a) the residual fail-open (a future coded refusal carrying principal data would still render its details on the two tools) is shared with the dispatcher's class-keyed rule and is recorded in the PR; the seat decides whether to file Option C (render run_action's thrown errors through the runtime bridge's own builder, as resumeDoorError does) as a follow-up card, carrier none today; (b) mcp-coded-refusal.test.ts:79-83 and its actionDoor exclusion at :179 are now partly stale and could lift the exclusion so one pin covers all twelve sites, outside this claim's surface; (c) the pin's header line 28 and describe title at :147 name /mcp; precedent on main does the same, so a reword to "the HTTP face" is optional; (d) two further duck-typed PERMISSION_DENIED producers carry non-principal details (plugin-approvals/src/payload-predicate-guard.ts:271, plugin-audit/src/parent-field-query-guard.ts:362); the guard withholds them in parity with the dispatcher and REST, and the dev's report's "carry no details" sentence under-counts them without changing the result; (e) the MCP renderer drops the withheld payload with no server-side log line, matching the data tools since #23526; (f) three Test Core shards were still in progress when the check-runs were read, so the queue should see them complete green before merge.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6177192097 on #23529 · reviewed head 03c6a098a3993d02d9a8148520373430650de9d1 · 2026-10-14T21:13Z

Verdict: ACCEPT. Not governed (check-governed-merges --pr 23544: 223 changed lines). 3 files, +221 / −2. The CONTRACT_REVIEW_TIER review this seat took on the stricter reading is 6178628544: PASS, nothing blocking. Clause-② is no.

  1. Shape. Draft PR. The first line is Fixes #23529, and Clause-②: no sits at line start. The changeset is @objectstack/mcp patch, worded at function level.
  2. Scope against the dispatch (triage 6177046363).
    • The pin came first and failed on main: 2 failed, 3 passed. That is the runtime confirmation triage asked for.
    • The fix is one guard in errorResultFromThrown: a refusal whose declared code is the spec catalogue's PERMISSION_DENIED renders through errorResultWithCode, the data tools' one place. It is not a filter on key names, and there is no second policy copy in packages/mcp.
    • The run_action and resume_run catches are byte-unchanged.
    • No file outside the claim. The one added import line sits in the claimed file.
  3. Gates and evidence.
    • Pin and controls, after the fix: 62/62 across the pin, mcp-action-confirmation-member, mcp-coded-refusal and mcp-resume-run-tool. The last three are byte-unchanged.
    • Package suite: @objectstack/mcp 467/467. Typecheck and build exit 0, and both the CJS and ESM dists load.
    • Ablation: deleting the guard turns only the two denial rows red, and the confirmation controls stay green. That shows the guard is keyed on the code and does not withhold blanket. The restore is proven.
    • Local gates: 63 derived, 63 run, 0 NOT MEASURED.
  4. The dev's open question (A / B / C): A, as built.
    • It is the dispatch's second route.
    • B would drop resume_run's declared FLOW_FAILED run artefacts (ResumeFailureDetailsSchema).
    • C needs a packages/runtime edit this claim forbids.
    • The class key matches the dispatcher's own. In-tree, every producer of a caller's positions or permission sets in details is a PERMISSION_DENIED (review ①2).
  5. Out-of-scope dispositions. These are Acceptance notes with carrier: none. Neither has measured reach, so neither passes the filing gate.
    • The residual fail-open. A future coded refusal carrying principal data would still render its details on the two tools. The dispatcher's class-keyed rule shares this, and C is its long-term home.
    • A stale exclusion in another file. mcp-coded-refusal.test.ts:79-83 (and its actionDoor exclusion) is now partly stale.
    • The review's optional reword of two /mcp mentions in the pin's comments is not taken, because main has precedent for them.
  6. Landing clauses.
    • (a) Every check is green or skipped by design: read at landing. One Test Core shard was in progress at this record.
    • (b) mergeable_state is clean at enqueue: read at landing.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 21:24
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 21:24
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 0fb55fc Oct 14, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23529-mcp-action-details branch October 14, 2026 21:45
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

2 participants