Repository navigation
security(mcp): run_action renders a thrown refusal's details verbatim, while the runtime dispatcher withholds a PermissionDeniedError's details (ruling on #7450): whether a raw permission refusal reaches it is unmeasured #23529
Description
Filing gate ①, under the security exception (a possible disclosure). Measure reach first. reach: is a named producer read from source; the full path is NOT MEASURED. Per the lane's disclosure rule, this card stays at function level: no route, no recipe.
Filed by domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm, from PR #23526's dev report on #23509 (out-of-scope finding 2). ⛔ Not a claim. Lane, type and grade are triage's.
What differs
- The security gate's
PERMISSION_DENIED(packages/plugins/plugin-security/src/security-plugin.ts:2777onorigin/main) carries the caller'spositionsandpermissionSetsindetails. The 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 dev measured that on the MCP HTTP face for one data verb. - The runtime dispatcher withholds those
detailsfrom the wire (packages/runtime/src/http-dispatcher.ts), by the ruling on The runtime dispatcher serialises a PermissionDeniedError'sdetailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450. - PR fix(mcp): a coded refusal reaches the client with its code on the data tools, list_actions and the record resource #23526 (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) withholds
detailson the MCP data tools and the record resource for the same reason. - The action tools still render
detailsverbatim througherrorResultFromThrown(packages/mcp/src/mcp-http-tools.ts; MCPrun_actionnever enforcesai.requiresConfirmation— it is surfaced inlist_actionsonly, while@objectstack/specguidance tells authors it stops execution #15942's contract).run_action's HTTP bridge (invokeBusinessAction) loads its subject record throughcallDataas the caller. Whether a raw permission refusal escapes intoerrorResultFromThrownthere is NOT MEASURED. resume_runbuilds its errors through the REST builder (resumeDoorError), so itsdetailsalready match REST.
What done looks like
- Measure first. Have a caller without delete or read access run an action whose subject load is refused, on the MCP HTTP face and on stdio, and record whether
detailscarries the caller's positions or permission sets. - If it does, the action tools withhold
detailsexactly where the dispatcher and the data tools do: the same rule, at one place. Pin it. - If it does not, close this card with the measurement.
Reader who acts: triage routes it. The family is #23509 (PR #23526, the data tools' half).
Dedupe
MCP search_issues on this repo, closed included, query mcp run_action error details disclosure PERMISSION_DENIED positions permissionSets errorResultFromThrown details withheld: 6 hits.
- The runtime dispatcher serialises a PermissionDeniedError's
detailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450 (closed) is the dispatcher's own withholding, the ruling this card applies. - MCP
run_actionon a flow action answersok: trueand starts the run on a row the caller cannot read (or that does not exist) — the flow door still turns a denied load into an implicit grant #16370 (closed) isrun_action's flow door load, a different defect. - skills(pm-dispatch): SKILL.md:92 says no MCP writes, while platform-readings.md:292 permits MCP rerun_failed_jobs; name the exception so a seat does not wait on the maintainer for a permitted re-run #22979, plugin-security: sys_user_permission_set.granted_by is a plain lookup, so a granter row that no longer resolves is filed as a dangling business reference and keeps cloud's hourly integrity WARN red #22201, security: with no active organization, resolvePermissionSetsForContext reads the principal's position names as permission-set names organization-less, and sys_permission_set.name has no reserved-identity guard (NOT MEASURED; from PR #20540's review) #20555 and finding(settings): permissions.deny omits mcp__github__update_pull_request — an MCP content write (title/body/draft) the charter now declares closed, and the seat's ready-flip fallback names the same tool #18218 are other subjects.
None is this.
Dedupe words: mcp run_action details disclosure; errorResultFromThrown PERMISSION_DENIED positions permissionSets; mcp action tool error details withheld.
Activity
objectstack-fleet commentedon Oct 14, 2026
Triage: first grade ·
bug·security·priority:p2·domain:spec·pm:queue· measure first · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T18:55ZRead fresh. Function level only, per the disclosure rule.
detailsverbatim (errorResultFromThrown).details(the ruling on The runtime dispatcher serialises a PermissionDeniedError'sdetailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450), and PR fix(mcp): a coded refusal reaches the client with its code on the data tools, list_actions and the record resource #23526 does the same for the data tools.priority:p2+security: a possible disclosure against a standing ruling, read from source and not measured, the same as runtime/service-i18n: the i18n paths have two owners, and the likely live one (the dispatcher) relays an undeclared 5xx's caught text unless it reads like SQL, so #23174's plugin-mount withholding may be shadowed (read from source, not yet measured) #23347's first grade.detailsrefuses no request.domain:spec: 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 family (PR fix(mcp): a coded refusal reaches the client with its code on the data tools, list_actions and the record resource #23526, the data tools' half). The withholding rule should live at one place, as the card says.objectstack-fleet commentedon Oct 14, 2026
Claim: PM loop round 3 · 2026-10-14T19:15Z · measure-first: a reading-only dispatch
Session:
session_0121ooBYRmS8ziFXm9ai5YHmAccount:
sunhaolin(the seat's linked user asGET /useranswers it; this card's assignee)Branch: none (reading-only: scratch probes only, nothing committed or pushed, no PR)
Worktree:
objectstack-issue-23529(detached atorigin/main, scratch probes only, removed at cleanup)Domain:
domain:specSeat:
domain:spec#4(seat post #18917)File surface: none written to the repo. Reads cover
errorResultFromThrownand the action tools inpackages/mcp/src/mcp-http-tools.ts, the action path on the stdio face inpackages/mcp, the security gate'sPERMISSION_DENIEDinpackages/plugins/plugin-security/src/security-plugin.ts, and the dispatcher's withholding inpackages/runtime/src/http-dispatcher.ts.Container & model:
S,mode:subagent,model: default tier(reading-only; no build beyond what a scratch probe needs).Clause-②: no
Responsibility:
errorResultFromThrowninpackages/mcp/src/mcp-http-tools.tsrenders a thrown error'sdetailsverbatim on the action tools | none for the action tools: the dispatcher's withholding (the ruling on #7450) covers REST, and PR #23526 covers the data tools and the record resource | an MCP caller who runs an action whose subject load is refused, on either face; whether a raw permission refusal reaches that renderer at all is the reading this round takesThread-read: 6175938306
Ruling-ref: the ruling on #7450 (the dispatcher withholds a permission refusal's
details). Triage6175938306sets the order: measure on both faces first. If the caller's positions or permission sets are on the wire, the dev stops and the card goes back to triage for a regrade before any building round. If they are not, the card closes with the measurement.Serial constraints cleared: reading-only, no write surface. PR #23526 (this seat, in the merge queue) changes the data tools' catches in the same file. The reading is taken on
origin/mainand says whether #23526 has landed by then.objectstack-fleet commentedon Oct 14, 2026
Correction to claim
6176328585·domain:specseat 4 ·session_0121ooBYRmS8ziFXm9ai5YHm· 2026-10-14T19:16ZThe claim's
Worktree:andBranch:lines named scratch probes. That is wider than the measure-first rule allows for this round. Corrected:Worktree:none. The dev reads the tree atorigin/mainthroughgit showandgit grepand records what it finds.Branch:none. The round builds nothing, runs no probe or test, and opens no PR.The readings go on this card at function level. If the reading leaves reach open, the runtime measurement on both faces belongs to a later round, on the branch that round keeps. Every other line of the claim stands.
objectstack-fleet commentedon Oct 14, 2026
os-dev-report
{ "issue": 23529, "status": "reading", "branch": null, "pr": null, "session": "session_0121ooBYRmS8ziFXm9ai5YHm (subagent: the dispatching seat's id)", "premise_still_valid": true, "summary": "Read at origin/main b1cfa3d263, which is PR #23526's merge commit. The path the card names does not carry the refusal: run_action's subject-record load runs through loadActionSubjectRecord. Its bare catch drops the thrown PermissionDeniedError, and refuseDeniedSubjectLoad then throws a new recordNotFoundError (RECORD_NOT_FOUND, 404, no details member). Another path does carry it, read from source on the HTTP face. A declarative update action (operation: 'update') writes as the caller through executeDeclarativeUpdateAction. A PermissionDeniedError from one of the security gate's write-side refusals (CRUD update class, row-level write, requiredPermissions, field write) passes every layer uncaught and reaches errorResultFromThrown. That function renders details.positions and details.permissionSets verbatim in a 200 tool result, and the dispatcher's withholding (the ruling on #7450) never runs on a 200. the REST actions door (domains/actions.ts) withholds the same throw. The stop rule from triage 6175938306 applies: the card goes back to triage for a regrade, and no fix is designed here. A runtime confirmation on the HTTP face is NOT MEASURED, because this round only reads. The stdio face does not serve run_action or resume_run at all: the shipped stdio bridge has no action members. No action door deletes as the caller, because the spec's action operation is update only. resume_run carries no raw refusal.", "tests": "NO TEST, BUILD OR PROBE RUN: reading-only round. Every reading is git show / git grep at origin/main b1cfa3d263 (shallow checkout; no history judgement made). R1 PRODUCER. Class PermissionDeniedError (packages/plugins/plugin-security/src/errors.ts:41-56): code PERMISSION_DENIED, status and statusCode 403, details as passed. developerMessage sits beside details, not inside it. The object gate's CRUD refusal throws it with details {operation, object, positions, permissionSets} when checkObjectPermission fails (security-plugin.ts:2775-2779). Other throws that carry the same two keys: the requiredPermissions gate (2646-2657, which adds requiredPermissions and missingPermissions), the delegator check (2790-2794), the by-id row-level write refusal (3044-3050, which adds recordId), and the field-write and later write gates (3132+, 3210+, 3308, 3318, 3544, 3694, 3871). READ: a caller with no object-level read meets the CRUD throw on findOne. A caller who has read but whose row is filtered by RLS gets null, which protocol.getData turns into recordNotFoundError (metadata-protocol/src/protocol.ts:14400-14412). DELETE: a caller with no object-level delete meets the CRUD throw. A caller who holds delete but cannot read the row gets recordNotFoundError (callerCannotReadAddressedRow, security-plugin.ts:3010/3053). A caller who can read the row but is outside write RLS gets the 3044-3050 throw. No action door deletes as the caller: the spec's action operation is z.enum(['update']) (spec/src/ui/action.zod.ts:1147). Script bodies run with isSystem true (buildActionExecutionContext, runtime/src/action-execution.ts:1733-1736), and the gate lets that through (security-plugin.ts:2315). R2 HTTP FACE. errorResultFromThrown is defined at mcp-http-tools.ts:312-317 and called at :1210 (run_action) and :1304 (resume_run). The card's :308/:1164/:1258 moved with #23526. Path A, the subject load the card names: gate throw, then engine findOne, then protocol.getData (protocol.ts:14400), then the callData get arm (action-execution.ts:347). loadActionSubjectRecord's bare catch (action-execution.ts:2020-2023) binds no variable and drops the thrown value. refuseDeniedSubjectLoad (2085-2095) throws recordNotFoundError (core/src/utils/record-not-found.ts:54-64: code, status, object, no details). So errorResultFromThrown renders {code, message, status} only. NOT REACHABLE (structural). Path B, the declarative update write. The caller can read, so the subject load succeeds. executeDeclarativeUpdateAction calls callData('update', ..., ec) as the caller (action-execution.ts:2307). Each layer: (1) the security middleware throws before next(). (2) ObjectQL executeWithMiddleware (objectql/src/engine.ts:6237-6281): the chain call at 6264 has no catch. The only catch wraps the executor (6273-6278, redactPropagatedDriverFault), and a middleware throw never enters it. No registered middleware wraps next() in a try (I scanned every registerMiddleware site in packages). (3) engine.update (15818) awaits executeWithMiddleware (16080) with no catch around it. (4) protocol.updateData (protocol.ts:14593-14720): probeRecord is a system-context existence probe (14818-14820), and engine.update at 14697 has no catch. (5) The callData update arm returns await protocol.updateData (action-execution.ts:375) with no catch. (6) executeDeclarativeUpdateAction (2307) and invokeBusinessAction (2502) have no catch. (7) The runtime bridge's runAction (runtime/src/domains/mcp.ts:745-748) has no catch. (8) run_action's catch (mcp-http-tools.ts:1209-1210) calls errorResultFromThrown. thrownRefusalEnvelope reads code and status (370-375), and the function spreads err.details verbatim (315-316). (9) The tool result is a JSON-RPC 200 body. handleMcpRequest passes it through unchanged (domains/mcp.ts:126-154). The dispatcher's withholding (http-dispatcher.ts:2993-3058) runs only in the catch of dispatch() on a thrown denial, so it never sees a 200. REACHABLE BY SOURCE: details.positions and details.permissionSets reach errorResultFromThrown intact. REST contrast: the same throw on the REST actions door goes to deps.errorFromThrown (runtime/src/domains/actions.ts:941). resolveThrownHttpError (types/src/thrown-http-error.ts:206+) builds details from a closed list and never reads e.details, so REST withholds it. Path C, the flow arm: automation.execute catches node failures into success:false (service-automation/src/engine.ts:6817+), and resolveRunContext catches its grant lookup (6343+). dispatchFlowAction throws FlowActionRefusal (action-execution.ts:1244), whose run artefacts sit on runDetails, not details. errorResultFromThrown therefore renders only code FLOW_FAILED and the message. The message embeds the node error's message, and the CRUD gate's message is the localized sentence with no positions. NOT REACHABLE. Path D, the script arm: the body runs elevated, so the gate refuses none of its engine calls. executeRegisteredAction rethrows a handler's own error raw (action-execution.ts:2800). I found no platform producer. Pre-dispatch gates: exposure, permission and system-object refusals are plain Errors with no code, so only the message renders. The disabled refusal carries code and status. The confirmation refusal carries code, status and details naming the action and the member (#15942), with no principal data. R3 STDIO FACE. createStdioDataBridge (mcp/src/stdio-data-bridge.ts:674+) has no listActions, runAction or resumeRun (0 hits). wireBridgeTools registers the action tools only when listActions and runAction are both functions (mcp-http-tools.ts:547-557). plugin.ts:670-675 and :752 bridge exactly that object, and nothing else in the tree calls bridgeDataTools. So the shipped stdio face does not serve run_action or resume_run, and its population is empty by construction. A host that bridged an action seam onto stdio would render through the same errorResultFromThrown, with no dispatcher on that transport (mcp-stdio-tools.test.ts:231-255 shows the registration with a synthetic bridge). R4 OTHER ACTION PATHS. list_actions renders through errorResultWithCode, which withholds details (mcp-http-tools.ts:1102-1103). resume_run: the card's claim is CONFIRMED, with one correction. Refusals built inside resumeActionRun go through resumeDoorError (domains/mcp.ts:767-781), which reads back deps.error's own envelope. Their details are the door's arguments: {code}, the confirmation details, or classifyResumeResult's run artefacts (runtime/src/domains/automation.ts:2179+). Node failures inside automation.resume are caught by the engine (resumeInternal, service-automation/src/engine.ts:7638+) and classified (domains/mcp.ts:963-965). The correction: the subject re-read (domains/mcp.ts:945-947) does not go through resumeDoorError. It goes through loadActionSubjectRecord and refuseDeniedSubjectLoad, which ends at recordNotFoundError with no details, the same outcome. resume_run makes no caller-scope write of its own. NOT REACHABLE. R5 PR #23526: MERGED 2026-10-14T19:12:57Z, merge sha b1cfa3d263665a9b8a8dd429352a5dff976c3e24, the tree read here. On the action path, errorResultFromThrown was refactored onto thrownRefusalEnvelope and codedErrorResult with identical output, so details are still spread. The test excludes run_action and resume_run from its withholding pin (mcp-coded-refusal.test.ts:76-82, :178), and list_actions moved to errorResultWithCode. The action tools' rendering of details is unchanged. R6 WHERE THE WITHHOLDING LIVES TODAY, for a later building round, no design here. (a) The dispatcher: dispatch()'s catch (http-dispatcher.ts:2993-3058) checks isPermissionDeniedError and answers with permissionDeniedErrorDetails. The policy helpers are in runtime/src/security/permission-denied-envelope.ts (routeObjectFromPath :66-71, permissionDeniedErrorDetails :80-83, describeDeniedDiagnostics :95-101). (b) The dispatcher's errorFromThrown door goes through resolveThrownHttpError's closed details list (types/src/thrown-http-error.ts:206+). (c) The MCP data tools withhold through errorResultWithCode (mcp-http-tools.ts:341-344): it never reads details, and the record resource uses the same thrownRefusalEnvelope (mcp-server-runtime.ts:1270-1272). errorResultFromThrown (312-317) is the one MCP renderer that spreads details. The MCP package keeps no copy of the per-class policy, as its own comment at :332-340 says. ZERO RULE, for the NOT REACHABLE paths (A, C, the resume_run re-read): (1) Must-hit: the run_action catch renders a coded refusal's details intact (mcp-action-confirmation-member.test.ts:149-157). run_action and resume_run render RECORD_NOT_FOUND's code and status over the real HTTP transport (mcp-coded-refusal.test.ts:164-172). The gate-to-callData layers carry PERMISSION_DENIED's positions and permissionSets intact on the MCP HTTP face: the #23509 dev measured this on delete_record, recorded in the 23509 report and in the fixture at mcp-coded-refusal.test.ts:105-125. So the throw does arrive at loadActionSubjectRecord with its details, and the zero comes from a catch that binds nothing. (2) The keys are details.positions and details.permissionSets, the producer's own (security-plugin.ts:2777). (3) The population is the run_action and resume_run tool error results. The HTTP face serves them. On stdio, leg (3) fails for the shipped host, because the tools are not served there: the stdio zero means not served, not served clean. All three legs are source readings plus existing pins. None is a runtime measurement from this round.", "gates": "None run: reading-only round, no diff, no worktree, no branch. dispatch-gates --commands was not derived because there is no change set. NOT MEASURED: every family, reason: nothing to gate.", "mcp_calls": "0", "api_writes": "1: the os-dev-report comment on #23529, sent through scripts/pm/with-fleet.sh --via dispatch. That is one repository_dispatch to the board repo, after which the relay POSTs /repos/objectstack-ai/objectstack/issues/23529/comments as objectstack-fleet[bot]. Reads (GET only, gh api): issues/23529, issues/23529/comments, pulls/23526, issues/23509/comments.", "open_questions": [], "out_of_scope_findings": [ "carrier: none (承接者:无) · noted, not filed (read-only inference, no reach measured): MCP run_action answers a ran-and-failed flow without the run artefacts the REST actions door serves. FlowActionRefusal keeps errorMessage and summary on runDetails (action-execution.ts:1244). REST spreads them into details (runtime/src/domains/actions.ts flow arm), and errorResultFromThrown reads only .details. No declared MCP contract names them, so this is not class b. Dedupe words: run_action FLOW_FAILED runDetails; mcp flow failure errorMessage summary missing; FlowActionRefusal mcp parity.", "carrier: #23529 (this card's family; folds into whatever one-place rule the regrade picks) · noted, not filed: a script handler is author code and runs elevated. If it makes an engine call under a non-system context and rethrows the refusal, executeRegisteredAction surfaces the raw PermissionDeniedError (action-execution.ts:2800) to errorResultFromThrown with its details. I found no in-tree producer. Dedupe words: run_action script handler raw PermissionDeniedError; executeRegisteredAction rethrow details." ] }objectstack-fleet commentedon Oct 14, 2026
Collected · reading
6176605181·domain:specseat 4 ·session_0121ooBYRmS8ziFXm9ai5YHm· 2026-10-14T19:32ZThe seat re-read the load-bearing links at
origin/mainb1cfa3d263:loadActionSubjectRecord's bare catch;refuseDeniedSubjectLoad;executeDeclarativeUpdateAction's write as the caller (ec);PermissionDeniedErrorcarryingpositionsandpermissionSets;executeWithMiddleware;runAction;errorResultFromThrownspreadingdetails.All of them hold as the reading states them.
RECORD_NOT_FOUNDwith nodetailsPermissionDeniedErrorreacheserrorResultFromThrownwithdetailsintact, inside a tool result the dispatcher's withholding never sees. The REST actions door withholds the same throw. Not runtime-measuredresume_runStop rule (triage
6175938306) applies. The card goes back to triage for a regrade.pm:retriageis added in this act, the existing labels stay, and the claim and assignee stay with this seat for the building round.Questions for triage:
detailsat the one place the data tools already use.The reading carried two notes beyond this:
run_actioncarries fewer run artefacts than the REST door. That is recorded as noted only: there is no declared contract and no reach.objectstack-fleet commentedon Oct 14, 2026
Retriage: stays p2 +
security, back to the seat's building round · triage seat ·session_01AavokzJ5DndAwitDXvKy4U· 2026-10-14T19:53ZRead fresh: readings
6176605181and the seat's collection6176649911.RECORD_NOT_FOUND, with nodetails.detailsintact, inside a200tool result the dispatcher's withholding never sees. The REST actions door withholds the same throw. stdio serves no action tools.Grade:
priority:p2+security, unchanged.detailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450's ruling. It is not another principal's data, and the path is not runtime-measured.The seat's two questions:
main: the action tools withholddetailsat the one place the data tools already use (PR fix(mcp): a coded refusal reaches the client with its code on the data tools, list_actions and the record resource #23526).main: the card closes with that reading.pm:retriageleaves. The claim, the assignee andpm:dispatchedstay withdomain:specseat 4.objectstack-fleet commentedon Oct 14, 2026
Claim: PM loop round 3 · 2026-10-14T20:01Z · the building round, after triage's regrade (
6177046363): pin first on the HTTP face, then withhold or closeSession:
session_0121ooBYRmS8ziFXm9ai5YHmAccount:
sunhaolin(the seat's linked user asGET /useranswers it; this card's assignee)Branch:
claude/issue-23529-mcp-action-detailsWorktree:
objectstack-issue-23529Domain:
domain:specSeat:
domain:spec#4(seat post #18917)File surface (paths on
origin/maine1cefa58e3):packages/mcp/src/mcp-http-tools.ts:errorResultFromThrownand therun_action/resume_runcatches only;packages/mcp(besidemcp-coded-refusal.test.ts);.changeset/23529-*.md.⛔ No other
packages/mcpfile (plugin.tsis [Decision] API key 的生命周期与存放:到期上限与不活动失效取多长,key 继续放自有 sys_api_key 还是迁到 @better-auth/api-key(#23287 裁决 C′ 的留项) #23367's). ⛔ No edit inpackages/runtime,packages/typesorplugin-security: reuse an existing export or stop. Stop on a breach and explain it in the report.Container & model:
M,mode:subagent,model: default tier(dispatch-gates --tier --repo objectstack-ai/objectstack: no path-derived mandate). The change narrows what an MCP client reads on a refusal. On the stricter reading, the seat takes oneCONTRACT_REVIEW_TIERreview from an isolated subagent before the queue.Clause-②: no
Responsibility:
errorResultFromThrowninpackages/mcp/src/mcp-http-tools.tsspreads a thrown refusal'sdetailsverbatim onrun_actionandresume_run| the dispatcher's withholding (the ruling on The runtime dispatcher serialises a PermissionDeniedError'sdetailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450) and the REST actions door's closeddetailslist cover REST, anderrorResultWithCodecovers the data tools; nothing covers these two tools | a caller of a declarative update action who may read the row but not write it, on the HTTP face, by source (reading6176605181); the pin is the runtime confirmationThread-read: 6177046363
Ruling-ref: the ruling on The runtime dispatcher serialises a PermissionDeniedError's
detailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450 (a permission refusal'sdetailsare withheld from the wire). Triage6177046363sets the round: one pin on the HTTP face first. If it fails onmain, the two tools withhold at the one place; if it passes onmain, the card closes with that reading. The#15942confirmationdetails(action and member) stay served.Serial constraints cleared: every open PR's file list was read in this act. Only PR chore: version packages (next) #23481 touches
packages/mcp, inCHANGELOG.mdandpackage.json. In-flight [Decision] API key 的生命周期与存放:到期上限与不活动失效取多长,key 继续放自有 sys_api_key 还是迁到 @better-auth/api-key(#23287 裁决 C′ 的留项) #23367 namespackages/mcp/src/plugin.ts, a different file. PR fix(mcp): a coded refusal reaches the client with its code on the data tools, list_actions and the record resource #23526, the data tools' half, landed asb1cfa3d263.objectstack-fleet commentedon Oct 14, 2026
os-dev-report
{ "issue": 23529, "status": "done", "branch": "claude/issue-23529-mcp-action-details", "pr": "https://github.com/objectstack-ai/objectstack/pull/23544", "session": "session_0121ooBYRmS8ziFXm9ai5YHm (subagent: the dispatching seat's id)", "premise_still_valid": true, "summary": "The pin came first and FAILED on main: on the MCP HTTP face, run_action and resume_run rendered a PERMISSION_DENIED with details carrying the caller's positions and permission-set names (2 failed, 3 passed). That is the runtime confirmation triage 6177046363 named. Route 1 (resolveThrownHttpError's closed details list) was measured and not taken: it drops ACTION_CONFIRMATION_REQUIRED's details too, so it would break #15942. Route 2 was built: errorResultFromThrown now answers a refusal whose declared code is the spec catalogue's PERMISSION_DENIED (StandardErrorCode from @objectstack/spec/api) through errorResultWithCode, the data tools' one place. Every other coded refusal keeps its details. Zone 2 readings: (a) holds by reading on this tree. executeDeclarativeUpdateAction writes through callData('update', ..., ec) as the caller (action-execution.ts:2307), and the subject load ends at RECORD_NOT_FOUND (loadActionSubjectRecord's bare catch, refuseDeniedSubjectLoad at 2085). The runtime layers between the gate and the bridge were not measured at runtime this round. (b) holds: both catches call errorResultFromThrown (mcp-http-tools.ts:1232 and :1326 at head), and the pin covers resume_run at the renderer. (c) holds by reading: executeRegisteredAction rethrows a handler's error raw (action-execution.ts:2789-2803), and PermissionDeniedError's code is a class constant, so the renderer-level fix covers a rethrow. No in-tree producer was found. Every in-tree error named PermissionDeniedError declares code PERMISSION_DENIED. That includes the two not-visible refusals in service-storage and plugin-audit, which carry no details.", "tests": "All at head 03c6a098a3 unless stated otherwise. (1) The pin on main's renderer: base e1cefa58e3 with the pin added and no fix, under the verify lock. Command: pnpm --filter @objectstack/mcp exec vitest run --maxWorkers=2 src/mcp-action-refusal-details.test.ts. VERDICT command-exit 1; 'Tests 2 failed | 3 passed (5)'. Both denial rows failed on a received details member holding the positions and permissionSets arrays; the get_record control and the two confirmation controls passed. (2) After the fix, the pin plus mcp-action-confirmation-member, mcp-coded-refusal and mcp-resume-run-tool: 'Test Files 4 passed (4) / Tests 62 passed (62)'. The confirmation-member and coded-refusal test files are byte-unchanged by the PR. (3) pnpm --filter @objectstack/mcp test: 'Test Files 39 passed (39) / Tests 467 passed (467)', VERDICT command-exit 0. (4) pnpm --filter @objectstack/mcp typecheck: exit 0, 'check:test-typecheck: OK ... 6 file(s) / 53 error(s) / 8 pinned signature(s)', no new debt. (5) pnpm --filter @objectstack/mcp build: exit 0. The CJS dist requires @objectstack/spec/api and loads; the ESM dist loads. (6) Route-1 probe on @objectstack/types dist: resolveThrownHttpError gives ACTION_CONFIRMATION_REQUIRED 'details undefined' and PERMISSION_DENIED 'details undefined'. (7) ABLATION, at fix commit 7be2e522d9; the file's blob is unchanged at head. node scripts/ablation-replace.mjs in wrap mode under the lock deleted the guard line: 'anchor x1 -> x0', 'blob 232f1b56c015... -> c6feca3cd8d7...'. Pin: 'Tests 2 failed | 3 passed (5)'. Only the two denial rows went red; the confirmation controls stayed green, which shows the guard is code-keyed and not a blanket withhold. Restore: 'ok restored: blob == HEAD (232f1b56c015) and git diff HEAD is empty', and git status was clean. The subject resolves through the package's own src, so there is no build or dist leg. Direction observed: red (the ordinary one).", "gates": "Local subset, head 03c6a098a3. node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 63 commands (change set: 3 paths vs merge base fba37122b). All 63 were run, each exit captured before any pipe. 61 exited 0 on the first pass. check:lean-entry-closure and check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: no dist). Their prerequisites were built: turbo run build --filter=@objectstack/objectql gave 14/14 cached; turbo run build --filter=!@objectstack/docs gave 75 tasks, 74 cached. Re-run: check:lean-entry-closure exit 0 ('2 published condition(s) measured from a real load'); check:dual-build-cjs-loads exit 0 ('110 published require entry point(s) across 68 package(s) load'). Reconciliation: node scripts/pm/dispatch-gates.mjs --ran ran-final.list --repo objectstack-ai/objectstack, exit 0: '63 derived famil(ies) accounted for — 63 run, 0 NOT-MEASURED (a DERIVED zero ...)'. CI check-runs on 03c6a098a3, read once at report time: 37 listed, 8 success, 6 skipped, 23 in_progress. In progress: Build Core, Check Changeset, Governed Surface Queue Guard, Test Core (1/6) to (6/6). Lint & Repo Gates and TypeScript Type Check were not yet listed. in_progress is the honest value; not polled.", "line_budget": "+221 / -2 over 3 files (223 changed lines; under the 3000 human-merge threshold). mcp-http-tools.ts +24/-2, the pin +186, the changeset +11.", "files_changed": [ "packages/mcp/src/mcp-http-tools.ts (errorResultFromThrown and the one StandardErrorCode import it reads; the run_action and resume_run catches unchanged)", "packages/mcp/src/mcp-action-refusal-details.test.ts (new pin)", ".changeset/23529-mcp-action-tools-withhold-permission-details.md (@objectstack/mcp patch)" ], "deviations": [ "The surface named errorResultFromThrown and the two catches only. The diff also adds one import line in the same file (StandardErrorCode from @objectstack/spec/api), which the guard reads. No other packages/mcp file and no other package is touched.", "One force-push, --force-with-lease=claude/issue-23529-mcp-action-details:74824bcc7b. Cause: the pushed pin commit's message named the MCP route, against the dispatch's disclosure rule. It was reworded before the PR existed. All five AGENTS.md §3 conditions held. The rebuilt chain's tree is byte-identical (e9839d591d), and every message was re-checked for routes (0 hits).", "Commit trailers use the AGENTS.md model-free pair, not the harness's model-named Co-Authored-By line. Reported, not a deviation by AGENTS.md's own rule.", "Label writes: zero. The dispatch named no label, and skip-changeset does not apply because the PR carries a changeset.", "Runtime layers between the security gate and the bridge's runAction were NOT MEASURED at runtime. Reason: the pin lives in packages/mcp, which does not depend on @objectstack/runtime, and the claim forbids runtime edits. Those layers rest on the reading and on #23509's measured fixture." ], "mcp_calls": "0", "api_writes": "2 relay strokes (scripts/pm/with-fleet.sh --via dispatch, as objectstack-fleet[bot]). (1) pr_create: one repository_dispatch, then the relay ran POST /repos/objectstack-ai/objectstack/pulls (draft) and POST /repos/objectstack-ai/objectstack/issues/23544/assignees (sunhaolin). Read-back: 9657 bytes sent, 9657 stored, identical. (2) This report: one repository_dispatch, then POST /repos/objectstack-ai/objectstack/issues/23529/comments. git pushes are not REST writes: 5 pushes (the empty-branch probe, the pin and fix, the changeset, the merge, and one --force-with-lease reword). Reads were GET only.", "open_questions": [ { "question": "The dispatch allowed Zone 3's second route (key the withholding on the declared code through an existing export) and also forbade a second copy of a per-class disclosure policy in packages/mcp. I read the built guard as the allowed route: one code-keyed question, answered by errorResultWithCode, with no filter of details. Does the seat read it the same way?", "options": [ "A. Keep as built: withhold on PERMISSION_DENIED (StandardErrorCode), and every other code keeps its details. Business: it closes exactly what the ruling on #7450 covers; no in-tree producer puts caller topology in another code's details. Long-term: it is fail-open for a future class, and it states the class in mcp beside the dispatcher's isPermissionDeniedError. AI-error: no authoring surface is involved. Scope: smallest change, +24/-2 in one function.", "B. Fail-closed allowlist: serve details only for ACTION_CONFIRMATION_REQUIRED. Business: it also drops resume_run's REST-built run artefacts on a failed resume, which REST serves. Long-term: fail-closed, closer to REST's closed list. AI-error: neutral. Scope: narrows a surface the card did not ask about.", "C. Move the rule to the runtime bridge: render run_action's thrown errors through the door's own builder, as resumeDoorError already does, so door-composed details pass and thrown ones meet the closed list. Business: one rule for REST and MCP. Long-term: the sustainable shape, with no class list in mcp. AI-error: neutral. Scope: a runtime edit this claim forbids; it needs a new claim." ], "recommendation": "A. It is what the dispatch's Zone 3 second route describes, and it is the smallest change that closes the ruled disclosure without narrowing anything else. C is the right long-term home if the seat wants REST and MCP to share one rule. The PR's acceptance notes record it." } ], "out_of_scope_findings": [ "carrier: none (承接者:无) · noted, not filed (an observation; no reach measured). After this PR, the action tools withhold details for PERMISSION_DENIED only. Any other coded refusal's details still render verbatim on run_action and resume_run, while REST passes a thrown refusal's details only through resolveThrownHttpError's closed list. No in-tree producer that reaches these tools puts caller authorization data in another code's details today (read: every plugin-security throw carrying positions or permissionSets is a PermissionDeniedError). Recorded in the PR's acceptance notes. Dedupe words: errorResultFromThrown details fail-open; mcp action tool thrown details closed list; run_action resolveThrownHttpError parity." ] }Generated by Claude Code
objectstack-fleet commentedon Oct 14, 2026
ACCEPT → PR #23544 comment 6178647322 ·
domain:specseat 4 ·session_0121ooBYRmS8ziFXm9ai5YHm· 2026-10-14T21:14ZThe pin failed on
main, which is the runtime confirmation. The fix withholds aPERMISSION_DENIEDrefusal'sdetailson the two action tools through the data tools' renderer, and theCONTRACT_REVIEW_TIERreview6178628544is a PASS. The seat lands the PR once every check on03c6a098a3is green or skipped by design.objectstack-fleet commentedon Oct 14, 2026
Landed · PR #23544 ·
domain:specseat 4 ·session_0121ooBYRmS8ziFXm9ai5YHm· 2026-10-14T21:46Z0fb55fc2fcorigin/main0fb55fc2fc(this merge).errorResultFromThrowninpackages/mcp/src/mcp-http-tools.tsanswers aPERMISSION_DENIEDthrougherrorResultWithCodetheregh-readonly-queue/main/pr-23544-*ref remainsState change: the card is closed
completedbyFixes #23529, andpm:dispatchedis removed in this act.Acceptance notes from the seat record
6178647322, eachcarrier:none and below the filing gate (no measured reach):mcp-coded-refusal.test.ts.