fix(acp): classify a kindless mcp tool call by its transport - #8800
fix(acp): classify a kindless mcp tool call by its transport#8800vishalvignesh wants to merge 1 commit into
Conversation
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All base-file checks confirm the description's claims: the base First-Principles-Verdict: PASS Verify the widest item: kindless MCP child calls now auto-approve under trust-all/YOLO/parent_policy=auto on dashboard, Slack, and subagent manager — not only the reported CLI defect. What this change shipsIntent: stop a backend that omits Inventory (7 items)
[FIRST-PRINCIPLES-REVIEWED] 5dff2ba |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of Design-Verdict: PASS Root-cause fix confined to the identity-only lane: the pin it rewrites is refuted by an in-repo-documented stronger signal, and every adjacent boundary gains a new pin. The rewritten assertion ( Suggestions
[DESIGN-REVIEWED] 5dff2ba |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
c090834 to
14f0b1f
Compare
The finding held: writing The rework follows the suggested fix — the transport-only proof is now tracked separately and kindless calls stay ineligible for content-matching auto-approval:
|
f4ae37d to
a711e75
Compare
The finding held and the suggested fix is what shipped, both halves:
|
|
a711e75 to
7802f51
Compare
Confirmed and exactly as described — the insertion consumed the neighboring test's decorator and |
7802f51 to
28b1dd0
Compare
The finding held: the opt-in made the handle deliver EVERY low-fidelity child event, not just the MCP-served ones the CLI can present honestly, and Locked by two tests: |
28b1dd0 to
f94546d
Compare
Held: the opt-in comment described the pre-existing pipeline and was not updated when the child fail-close landed ahead of it. The comment now reads "child-context fail-close (rejecting a low-fidelity child request without a verified identity) -> hook gate -> |
|
|
@vishalvignesh Thanks for this. Nothing here has landed on One coordination item before it can merge: #9053 (@karraghu) rewrites the same guard line. Both PRs start from the identical The two escapes are complementary, not duplicated. Yours covers a kindless MCP Suggested path: agree with @karraghu which lands first, then rebase (this branch is 272 commits behind) and re-express the escape on top. Expect smaller textual conflicts with #6307 in Posted from the 2026-09-08 open-PR relationship audit (read-only, one auditor per PR); reply here if any of this is wrong. |
…d classification A tool_call frame that reports no `kind` leaves the shell cache unwritten, so the following permission_request reads shell_classified=False. Three consumers turn that into a refusal for a tool that never ran a command: the CLI's fail-closed _unverifiable_shell backstop denies it outright, the session handle's fidelity-unaware fail-close gate auto-rejects the child case before the CLI ever sees it, and child_mcp_identity_trusted - the escape hatch - required a resolved classification, so child_unconditional_grant_eligible was false and no session, global, or parent-policy grant could rescue it. Confine the transport proof to the identity-only lane. The frame's `_meta.kiro.mcpServerName` is backend-authored and a host shell or builtin can never carry one, so a cache-provenance server/tool pair is itself proof the call is MCP-served and not a host shell command: child_mcp_identity_trusted no longer requires shell_classified, the CLI's _unverifiable_shell honors the same escape, and the CLI opts into the child-fidelity contract (its approval path runs no content-matching auto-approve - hook gate, fail-closed backstop, then a prompt built from non-model-authored fields only), so the handle delivers the child event instead of rejecting it. The shell cache is deliberately NOT written from the transport signal - a cached False reads back as a RESOLVED non-shell classification, which would flip child_low_fidelity to False and un-gate the title-keyed auto_approve_tools content matching for a kindless mutating call with a read-looking, agent-authored title. A kind that resolves to execute still caches True whatever its _meta says, which keeps the identity split closed for shell calls, and a frame carrying neither signal still earns nothing.
f94546d to
5dff2ba
Compare
|
Rebased onto main Conflicts: none, clean rebase (the branch was 272 commits behind; Gates run locally on the changed files only: Please review the rebased result. Note this PR rewrites the same A maintainer push makes the maintainer the last pusher, so under the repo's last-push rule a second approver is needed. Reply here if anything looks wrong. |
Problem / Motivation
An MCP tool invoked from a backend-routed child sub-session is auto-denied
before the user ever sees a prompt. The observed symptom in
kirocrew chatis atool that never ran a command being refused outright, reported as
⛔ permission auto-rejected (missing security context)or silently denied bythe CLI's fail-closed shell backstop.
The refusal is not reproducible from a top-level session, which is what makes it
look intermittent: a crew-spawned subagent gets its own ACP session and is
top-level there, so the child gates never apply and the same tool succeeds. Only
a child sub-session — one with
sub_session_idset — hits the gates.Why it matters
Any MCP server a user has configured is unreachable from a child sub-session
whenever the backend omits
kindon thetool_callframe, and there is no wayto opt out.
child_mcp_identity_trustedis the designed escape hatch forexactly this case, but it itself requires a resolved classification, so
child_unconditional_grant_eligibleis false and no session grant, globalgrant, or parent policy can rescue the call. The user's only recourse is to
disable permission checks wholesale, which trades a false deny for a real loss
of protection.
What changed (motivation → approach → change)
Symptom. A
permission_requestfor an MCP tool is refused with no prompt.Root cause. In
_build_tool_call_event, the shell-classification cachewrite was guarded by a resolved
kindalone:A
tool_callframe carrying nokindtherefore wrote nothing. The followingpermission_request— which carries nokindof its own and depends on thecache — read
shell_classified=False. Three independent consumers turn thatinto a refusal:
cli_chat.py::_unverifiable_shell— the fail-closed backstop — denies it.session_handle.pyauto-rejects it,audited as
child_low_fidelity_unaware_consumer.types.py::child_mcp_identity_trusted, the escape hatch, requiresshell_classified, so it cannot fire either.Declining to cache an unresolved classification is correct — caching
Falsewould let a later event read a resolved non-shell verdict that no
classification ever produced. The defect is that a kindless frame is treated as
carrying no classification signal at all, when an MCP frame carries a second,
stronger one.
Approach. Confine the transport proof to the identity-only lane, and mint no
classification. An earlier revision of this change wrote the shell cache from
the transport signal; review surfaced that a cached
Falsereads back as aRESOLVED non-shell classification (
shell_classified=True), which flipschild_low_fidelitytoFalseand un-gates the title-keyedauto_approve_toolscontent matching — a kindless mutating call with aread-looking, agent-authored title could then auto-approve without a prompt.
The reworked change leaves
_dispatch.pyuntouched and instead lets the tworefusing consumers honor the identity proof directly:
types.py::child_mcp_identity_trustedno longer requiresshell_classified. The cache-provenance_meta.kiroserver/tool pair isitself proof the call is MCP-served and therefore not a host shell command —
a host shell or builtin can never carry a server name. The property already
feeds only UNCONDITIONAL grant paths (session trust-all, global YOLO,
parent_policy=auto), whose approve decision reads no agent-authored eventdata, so honoring identity without a classification widens nothing that
matches on content.
cli_chat.py::_unverifiable_shellgains the same narrow escape: an eventwhose trusted identity fields were populated by the tool_call cache hit is
proven MCP-served, so there are no command bytes for the backstop to demand,
and the request proceeds to the normal interactive prompt. The escape is not
child-gated, so a top-level kindless MCP frame also moves from auto-deny to
a prompt -- a deliberate widening in the safe direction (a human decides
instead of a silent refusal).
cli_chat.py::_chatopts the CLI into the child-fidelity contract(
provider.child_fidelity_aware = True). Without this the third refusingconsumer -- the session handle's fail-close gate
(
child_low_fidelity_unaware_consumer) -- rejects every low-fidelity childpermission request before the CLI's escape can run. The CLI qualifies for
the contract because its approval path runs no content-matching
auto-approve: the child-context fail-close first, then the hook gate, then
the
_unverifiable_shellfail-close, then an interactive prompt that showsonly non-model-authored context (the cached command, the
_meta.kiroMCPidentity, the target path); the
non-interactive
-mpath stays fail-closed and denies rather than prompts.Because the opt-in admits every low-fidelity child event -- not just
MCP-served ones -- the CLI's own first check re-applies the boundary: a
low-fidelity child event is rejected (
child_unverified_context) unlessits identity is verified -- consumed as
not event.child_unconditional_grant_eligible, the same hoisted boundary thedashboard runner, Slack gateway, and subagent manager already use, rather
than a fourth hand-spelling of it. A child edit whose params never reached the
cache would otherwise prompt without a Path line, and approving it would
execute an undisclosed write; the trusted transport identity is the one
context that survives an empty params cache and is shown to the human --
the same args-blind consent contract the dashboard's interactive card
provides.
The split is safe in both directions by construction:
child_low_fidelitystaysTruefor a kindless frame, so everycontent-matching auto-approve path (trusted patterns, trust-reads, title-keyed
auto_approve_tools) stays gated. Interactive approvers still receive the⚠️ UNVERIFIED child requestannotation.kindresolved to execute cachesis_shell=True, which keepsboth the identity property and the CLI escape closed for shell calls — the
transport identity never waives a shell check.
_meta.kiroonthe tool_call frame, guarded by the explicit
mcp_identity_trustedprovenance flag; inline
_metaon the agent-reachable permission payloadearns nothing.
identity trust, and deny-by-default everywhere it applied before.
Scope.
client.py::_extract_tool_eventwrites the shell cacheunconditionally, which is a hole in the opposite direction (a wrong
classification rather than a false deny). It is deliberately left alone here and
is worth its own issue.
Tests
test/test_acp_runtime.py— four tests around the split:test_trusted_mcp_transport_earns_identity_trust_without_a_classification—drives
_build_tool_call_eventwith a kindless frame carrying_meta.kiro.mcpServerName, thenbuild_permission_event, and asserts bothhalves of the split: no shell-cache write,
shell_classifiedFalse,child_mcp_identity_trustedTrue,child_unconditional_grant_eligibleTrue, and — the security lock —child_low_fidelitystillTrue, sotitle matching stays gated.
test_trusted_mcp_transport_never_waives_a_reported_shell_kind— a framewith
kind: "execute"and a server name still cachesTrue, and theresulting permission event's
child_mcp_identity_trustedisFalse. Locksthe never-waive direction.
test_inline_mcp_server_name_on_a_permission_frame_earns_no_classification—inline
_metaon the agent-reachable permission payload manufacturesnothing: no cache write, no identity trust, no grant eligibility. Locks
non-forgeability.
test_child_mcp_identity_trusted_isolates_verified_identity(existing) —updated: an unresolved classification with trusted identity now reads
identity-trusted while the composite stays low-fidelity.
test/test_acp_runtime.py—test_aware_consumer_receives_kindless_mcp_child_permissiondrives thehandle's dispatch loop end-to-end (subagent registration, kindless
_meta-carrying tool_call, permission frame) and asserts the opted-inconsumer receives the event -- still low-fidelity, identity trusted -- and
the
child_low_fidelity_unaware_consumerfail-close never fires.test/test_cli.py—test_chat_opts_into_the_child_fidelity_contract_before_startpins theCLI's opt-in, and that it happens before
start()so an early child framecannot race the gate.
test_low_fidelity_child_without_identity_is_rejected_not_promptedpins theadmission boundary (a param-less child edit is rejected with
child_unverified_context, the human never asked), andtest_identity_trusted_low_fidelity_child_still_reaches_the_promptpins theone admission through it (the prompt shows the non-forgeable server/tool
pair).
test_kindless_mcp_tool_reaches_the_prompt_instead_of_auto_denyingdrives theshared-runtime parser end-to-end and asserts the user-visible outcome: the
prompt is shown (one read), the tool is approved, the audited outcome is
allowedrather than a silent deny — andshell_classifiedstaysFalsethroughout, so the escape demonstrably rides the identity fields alone.
Manual verification
The child-path claim is verified by driving the handle's own dispatch loop
rather than by hand-built events:
test_aware_consumer_receives_kindless_mcp_child_permissionfeeds the rawsubagent registration, the kindless
_meta-carryingtool_call, and thepermission frame through
AcpSessionHandleand asserts the event is yieldedto an opted-in consumer with the trusted identity attached (and the
fail-close audit never fires). The originally reported symptom was observed
in
kirocrew chatagainst a live backend that omitskindon MCP frames;the non-reproducing sibling path is explained by the
sub_session_iddiscriminator (a crew-spawned subagent is top-level in its own ACP session,
so the child gates never apply to it).
Reverting the production hunks with the tests kept (the prove-the-test check)
fails the new assertions, confirming they lock the fix. The touched surfaces
run clean;
flake8,mypy,isort,black, the docs-lint and spec-indexchecks pass. The
website/lanes are not exercised — this diff touches nofrontend file.
Related Issues
Adjacent to the provenance-conflation problem described in #6228 (closed) and
#6938. This PR does not restructure the fidelity split; it fixes the narrower
defect that a kindless MCP frame never earns a classification in the first
place.
Pattern harvest
Rule candidate: review-prompt
Pattern: a security decision cached under one provenance signal, read later by a
consumer that cannot recompute it, where a legitimate frame carrying a
different trustworthy signal falls through to the fail-closed branch. The
guard was written as "did we see the signal I expected" rather than "did any
non-agent-authored signal resolve this," so the absence of one signal was
conflated with the absence of all of them. Worth asking of any write to a
cache that a fail-closed gate later reads: is every trustworthy provenance for
this value enumerated here, and does each one only ever resolve in the safe
direction?
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)