Skip to content

fix(runtime): MCP list_actions marks a confirmation-gated action unavailable for an AI service account - #33144

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-bsdrd4
Nov 13, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-bsdrd4

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #33067

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  • 改了什么:MCP list_actions 给 AI 服务账号列出声明了 ai.requiresConfirmation: true 的动作时,在该行标上 unavailable,带 run_action 实际会回的 428 代码与原句。
  • 为什么改:服务账号永远不能替自己确认,run_action 每次都拒;但列表说它可用,无人值守的 agent 据此规划,注定失败。
  • 风险与回滚:只多答一个标记,不新拒任何输入;真人、代理用户的 agent 与系统上下文的列表不变。回滚即 revert 本 PR。
  • 先看:packages/runtime/src/domains/mcp.ts 的 listActions 标记处(新增 confirmation 判定与 ?? 顺序);packages/runtime/src/action-confirmation-gate.test.ts 末尾新 describe。
  • 两分钟验证:pnpm --filter @objectstack/runtime exec vitest run src/action-confirmation-gate.test.ts,预期 18 passed。
  • 需要你做的:无,席位落地。

What changes

  • listActions (packages/runtime/src/domains/mcp.ts) now asks, for a caller isAiServiceAccountCaller is true of, the run door's own actionConfirmationRefusal, and marks the row unavailable: { code, reason } with that refusal's code (ACTION_CONFIRMATION_REQUIRED) and sentence ("… An AI service account does not confirm an action for itself: a person runs this action."). Both predicates were already exported; nothing in action-execution.ts / action-dispatch.ts changed and the rule is not restated.
  • The marker follows the DECLARED ai.requiresConfirmation flag (the predicate reads === true only), never actionLooksDestructive.
  • Order: the run path asks the confirmation gate in the admission sequence (action-dispatch.ts, the CONFIRMATION GATE block before the subject load), and the forward-credential refusal only later, inside forwardApiAction in the dispatch arms. So where both apply the mark is the confirmation refusal (confirmation ?? apiForwardCredentialRefusal(...)).
  • A person, a delegated agent (onBehalfOf set) and the system context are not asked the gate at all: their listing is byte-for-byte as before.
  • McpActionSummary.unavailable JSDoc (packages/mcp/src/mcp-http-tools.ts) named only the endpoint case and UNAUTHENTICATED; it now names the account case and its code. Surface note for the seat: this JSDoc is the exported description of when unavailable appears, the same family as the tool description the order lists; I read it as inside the "description" row. No other text changed.
  • The list_actions tool description ("An action that carries "unavailable" cannot be run with the credential you connected with … unavailable.reason says why and what would let it run") stays true for a service-account key, so it is unchanged and no Skills-docs: line is owed. The served skill (skill-md.ts + Console mirror) does not mention unavailable.
  • Stdio twin: stdio-data-bridge.ts implements no listActions; the stdio server registers list_actions through the same registerActionTools over whatever bridge the host passes, so there is no second listing to align.
  • Ruling: ADR-0151 D3 rule 3, read at docs/adr/0151-ai-service-account-a-headless-agent-is-a-registered-principal-of-its-own.md line 74: "An action that asks for a person's confirmation before it runs (ADR-0011's ai.requiresConfirmation, rule 3's co-sign) is refused for an account, and never confirmed by the account itself". Ruling grep (ruling|Ruling-ref|maintainer|⛔) over the edited spans of listActions, actionConfirmationRefusal / isAiServiceAccountCaller and the McpActionSummary.unavailable JSDoc: 0 hits (file-wide: mcp.ts 17, action-execution.ts 65, mcp-http-tools.ts 39, none in or governing these spans).

Tests

New top-level describe in packages/runtime/src/action-confirmation-gate.test.ts (the confirmation gate's shared harness), each case pairing the listing with run_action on the same bridge (HttpDispatcher.buildMcpBridge):

  1. THE GAP: the account's gated row carries unavailable equal to { code, reason } of the refusal; run_action with and without the confirmation member throws that exact code (428) and message; no handler ran and no record was read.
  2. Declared flag, not heuristic: a mode: 'delete' row (listing still says requiresConfirmation: true), a declared-false danger row and a plain row stay bare for the account, and the destructive-looking one runs.
  3. Both refusals: a gated identity-route api action under an API key: run_action answers ACTION_CONFIRMATION_REQUIRED and the mark equals it. CONTROL: a person on the same key keeps the UNAUTHENTICATED forward-credential mark.
  4. CONTROL: a person, a delegated agent and the system context see the gated row with no marker, and no row marked at all.

Measured first at origin/main (dd4ce50b, test commit f6f2fa19 before the fix): cases 1 and 3 red: the gated row had no unavailable (expected undefined to deeply equal { …(2) }), and for the combined case the listing said UNAUTHENTICATED while run_action answered ACTION_CONFIRMATION_REQUIRED (the run half of case 3 passed, so the 428 was measured).

Ablations (each through `scripts/ablation-replace.mjs`, anchor 1 → 0, restored to the HEAD blob, `git diff HEAD` empty)
  • A, listing ignores the account rule (const refusal = apiForwardCredentialRefusal(...)): 2 failed / 16 passed (cases 1 and 3).
  • B, the gate asked for every caller (const confirmation = true ? …): 2 failed / 16 passed (cases 3 and 4: the person and delegated agent get a marker).
  • C, forward refusal first (apiForwardCredentialRefusal(...) ?? confirmation): 1 failed / 17 passed (case 3).

The suite imports ./http-dispatcher.js from src, so no dist/ rebuild sits between mutation and run.

Acceptance notes

  • requiresConfirmation on the row is still actionLooksDestructive (declared flag, else mode: 'delete' / variant: 'danger'); only the unavailable marker follows the declared flag. A row can therefore say requiresConfirmation: true with no marker for an account: that action runs for it (case 2 pins it).
  • For an object-less action the reason's "on 'OBJECT'" clause reads the same declaration key run_action passes to the gate; not pinned separately here.

Verification

At head 06c6cc6c (fix d2b01d7c + merge of origin/main 02f55c5d), after pnpm install --frozen-lockfile and a rebuild of the @objectstack/runtime closure:

Package suites, typecheck, lint
  • pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2: Test Files 600 passed (600), Tests 11936 passed | 19 skipped (VERDICT command-exit 0).
  • pnpm --filter @objectstack/mcp exec vitest run --maxWorkers=2: Test Files 63 passed (63), Tests 1104 passed (VERDICT command-exit 0).
  • pnpm --filter @objectstack/runtime --filter @objectstack/mcp typecheck: exit 0.
  • pnpm lint: exit 0.
Derived gates (dispatch-gates --commands, 66 families; reconciled with --ran)
  • 63 exit 0.
  • NOT MEASURED, exit 3 PREREQUISITE NOT MET: node scripts/check-plugin-teardown-shape.mjs --self-test (its pinned positive-control commit is not reachable from this clone), pnpm check:dual-build-cjs-loads (needs every package's dist/).
  • pnpm check:dts-closure: exit 1, because the local closure was built with OS_SKIP_DTS=1, so 39 packages have no .d.ts; environmental, not this diff (no export changed). CI's build lanes carry all three.
  • dispatch-gates --ran: 66 derived, 64 run, 2 NOT-MEASURED (derived from recorded exit 3), 0 UNRUN.

Generated by Claude Code

…navailable for an AI service account

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibxBYPPczsnXa8gB52y
…ble for an AI service account

An AI service account never confirms an action for itself, so run_action
refuses it on every action declaring ai.requiresConfirmation: true. The
listing now carries that refusal's code and sentence on the row, ahead of
the forward-credential mark, in the order run_action asks them.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibxBYPPczsnXa8gB52y
@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 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

1 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))
What this run could not see
  • 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 — 36 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 bf02d33bd64f496aa8166e1a235ef10977927947 → packageMentionDocs.

Which tree this was computed on

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

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

⚠️ 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 bf02d33bd64f496aa8166e1a235ef10977927947 → 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#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 06c6cc6c9fd225ff77363c80ac5e66593e576553
Local-runs: none

① Derived judgments

  • Rows and callers (right). packages/runtime/src/domains/mcp.ts:buildMcpBridge.listActions: only a caller isAiServiceAccountCaller(ec) holds (principalKind === 'agent', no onBehalfOf) gets a new mark, and only on a row whose action declares ai.requiresConfirmation: true. The mark is { code: 'ACTION_CONFIRMATION_REQUIRED', reason: <the refusal's account sentence> }. Rows are still listed and summarised as before, apart from the mark.
  • Same predicate, by construction (right). The listing calls the exported action-execution.ts:actionConfirmationRefusal with the bridge's own ec, the same ec that runAction passes to invokeBusinessAction. For an account that predicate never reads request, so undefined at the listing and call.confirmation at the run produce the same refusal. The outer isAiServiceAccountCaller guard only chooses who is asked. It does not restate the rule (the run gate in action-dispatch.ts:admitResolvedAction is selected the same way). action-execution.ts and action-dispatch.ts are untouched.
  • Declared flag, not heuristic (right). actionConfirmationRefusal returns early on action?.ai?.requiresConfirmation !== true. actionLooksDestructive still produces the row's requiresConfirmation and does nothing else. A mode: 'delete' row and a variant: 'danger'/declared-false row stay unmarked, and they run.
  • Person, delegated agent, system (right). The guard is false for all three, so confirmation is undefined and refusal stays exactly apiForwardCredentialRefusal(context, action). Their listing does not change.
  • Order when both refusals apply (right). I read the run path myself. admitResolvedAction puts the CONFIRMATION GATE after the param contract and before the subject load. forwardApiAction asks apiForwardCredentialRefusal only later, in dispatchAdmittedAction's api arm. So confirmation ?? apiForwardCredentialRefusal(...) marks the refusal that run answers first.
  • Other doors (right, nothing divergent). Only the MCP bridge listing carries unavailable. REST has no action listing with this field (GET /automation/actions is a different surface). resume_run (domains/mcp.ts:resumeActionRun) runs actions and lists none. packages/mcp/src/stdio-data-bridge.ts implements no listActions, and the stdio server registers the same registerActionTools over the host's bridge. run_action's face resolver and sys_* pre-screen (mcp-http-tools.ts:resolveActionFace, isListableAction) do not read unavailable, so marked rows still resolve.
  • McpActionSummary.unavailable JSDoc (right). packages/mcp/src/mcp-http-tools.ts now names the account case and its code. This matches the code.

② Semver level

  • Changeset .changeset/33067-list-actions-service-account-unavailable.md: @objectstack/runtime patch (behaviour) and @objectstack/mcp patch (a JSDoc-only change to a published .d.ts). Both moved packages are covered and nothing else is published. Metadata-protocol: none is right, because no authorable key, value or meaning changed, so patch is the right level (AGENTS.md, landing step 3). Check Changeset is success.
  • Clause-②: yes (widening) is right: the listing answers a mark on rows it left bare, and no input is newly refused. The PR body and the changeset body carry identical lines.
  • Every claim in the changeset sentence matches the code (account definition, the declared flag only, confirmation first where both apply, three callers unchanged, JSDoc named).
  • Skills-docs: is not owed. The list_actions tool description (mcp-http-tools.ts:registerActionTools) describes unavailable without listing cases, and that stays true for a service-account key. packages/mcp/src/skill-md.ts never mentions unavailable.

③ Boundary flags

  • Dev flag: JSDoc edit in packages/mcp. Accepted. The file is on the claim's surface (it holds the list_actions description). Card mcp: list_actions marks no action unavailable for an AI service account, though run_action refuses its confirmation-gated actions whatever it sends #33067 cites this JSDoc as the contract, and leaving it alone would make it wrong by omission.
  • Dev flag: requiresConfirmation still heuristic (out of scope). Accepted and correct per the card's acceptance ("computed from the declared flag, not from actionLooksDestructive"). Case 2 pins it. No carrier is owed.
  • Dev flag: gates left to CI. check-plugin-teardown-shape --self-test runs in Lint · CI, release & engine gates, still in progress. check:dual-build-cjs-loads runs in Build Core, still in progress. check:dts-closure passed in Type Check · debt ledger; it also runs in Type Check · consumer gates and Build Core, both still in progress. The local exit 3 and OS_SKIP_DTS=1 results are environmental. No export changed.
  • Pins fail without the change. Yes, by reading. Case 1's toEqual fails on undefined. Case 3 gets UNAUTHENTICATED where it expects ACTION_CONFIRMATION_REQUIRED. Cases 2 and 4 are controls and pass either way. Each claimed ablation maps to the case it reds: A reds 1 and 3, B reds 3's person control and 4, C reds 3.
  • code and shape asserted. Yes. The mark is checked with exact toEqual({ code, reason }) and a literal 'ACTION_CONFIRMATION_REQUIRED'. The run is checked for code, status 428 and the identical message, with and without confirm: true, and executeAction/find are never called.
  • CONTROL is real. Case 4 covers a person, a delegated agent and { isSystem: true }: each asserts the gated row is listed and that no row is marked, and it reds under ablation B. Case 3's control (a person on the same API key keeps UNAUTHENTICATED with the identity-route sentence) shows the mark depends on the caller and the forward mark survives.
  • nit: packages/mcp/src/mcp-http-tools.ts:McpActionUnavailable.code still says "The standard error code". ACTION_CONFIRMATION_REQUIRED is a registered code, not a standard one (action-execution.ts:ACTION_CONFIRMATION_REQUIRED_STATUS docblock). The PR dropped "standard" only one level up.
  • nit (pre-existing class, out of scope): listActions does not ask the admission gates that come before the confirmation gate (disabledActionRefusal, actionHandlerOwnerRefusal). So for a switched-off gated action the mark's code is not the one run_action answers first. The forward mark already behaves this way. The row is still refused either way, and this PR owes no carrier for it.
  • nit (dev-flagged): the "on 'KEY'" clause for an object-less row is not pinned. On the bare call path, the listing and the run pass the same declaration key.

Implemented-by: claude/project-thread-bsdrd4
Reviewed-by: session_01L9qibzuZJBQY4wUQFJbkfx

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#7 · os-tesla · session_01L9qibzuZJBQY4wUQFJbkfx

维护者速读 · PR #33144 @ 06c6cc6c · 2026-11-13T19:20Z

  • 改了什么:用 AI 服务账号密钥调 MCP list_actions 时,声明了 ai.requiresConfirmation: true 的动作现在会带上 unavailable 标记,原因就是 run_action 拒绝时给出的那句话(需要由人来执行)。
  • 为什么改:以前列表说这类动作可用,服务账号去调却每次都被 428 拒绝。客户端是按列表做计划的(mcp: list_actions marks no action unavailable for an AI service account, though run_action refuses its confirmation-gated actions whatever it sends #33067)。
  • 权限与边界:判定规则不变,列表直接复用 run_action 的同一个判定函数。人、带 onBehalfOf 的委托智能体和系统上下文看到的列表都不变。
  • 风险与回滚:patch 级,只是列表多回答了一项。回滚就是 revert。
  • 先看:packages/runtime/src/domains/mcp.ts 的 listActions;测试 action-confirmation-gate.test.ts 新增的四条。
  • 需要你做的:无,席位落地。
  • 席位意见:落地。契约复审(TIER_DEFAULT,declaration 面)PASS,只有 nit。CI 全绿,没有碰受管面,改动 161 行。

席位核对

  • 形态: draft → main,正文 Fixes #33067,合并后应当关卡;正文其余地方没有挨着别的 open 卡号的闭合关键词。Clause-②: yes (widening)、Metadata-protocol: none。工具描述没有列举出现 unavailable 的情形,served skill 也没有提到它,所以不欠 Skills-docs 行,复审 ② 也核过。changeset 33067-list-actions-service-account-unavailable.md 把 runtime 和 mcp 都定为 patch,mcp 那边只改了 JSDoc。没有改 content/docs/releases/。文件面 = 认领 6860809053,McpActionSummary.unavailable 的 JSDoc 属于认领里写的描述面。
  • 前提实测: dev 先在 origin/main 上测到 2 条 pin 是红的(报告 6861939268)。三组消融都转红,已恢复。
  • 门禁 check-runs @ 06c6cc6c: 51 个,35 success、16 skipped、0 失败。dev 本地留给 CI 的三个门禁(teardown self-test、dual-build-cjs-loads、dts-closure)分别在 Lint · CI/release & engine gates、Build Core、Type Check · debt ledger 里跑过,都是 success。本地 runtime 600/600 个文件、mcp 63/63,typecheck 与 lint 都通过。
  • 契约复审: 6862042825 在 06c6cc6c 上 PASS(TIER_DEFAULT):
    • 标记用的是 run_action 的同一个 actionConfirmationRefusal,按声明的标志判断,不走 actionLooksDestructive;
    • 两种拒绝同时成立时,confirmation ?? forward 给出的正是运行路径先回答的那一个;
    • 其他入口都没有自己的 unavailable,没有分叉。
  • 接受的偏差: 三个 nit 都接受:
    • N1 McpActionUnavailable.code 的注释仍写 "standard";
    • N2 列表不问在确认闸门之前跑的 disabled/owner 闸门,这个问题早于本 PR,行照样被拒;
    • N3 无对象行的 "on 'KEY'" 子句没有单独的 pin。
  • 范围外去向: 无。
  • 落地条款: 维护者常设落地令 「按 pm-dispatch 审过的pr你可以自主落地」 (2026-10-30T14:20Z)。非受管面(check-governed-merges --pr 33144:0/4 命中);161 行 ≤ 3000。K = 1(PRBASE bf02d33b,取自 Test Core 计划的 Affected-set diff base;origin/main 932eab54),< 15,无需先 merge main。mcp-http-tools.ts 与刚合并的 fix(mcp,storage): say which upload link a lock refuses; close the upload-link relay's code #33142 同文件不同段,git merge-tree 与当前 main 无冲突,GitHub 显示 clean。没有新的语种包。

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

Development

Successfully merging this pull request may close these issues.

mcp: list_actions marks no action unavailable for an AI service account, though run_action refuses its confirmation-gated actions whatever it sends

2 participants