Skip to content

fix(mcp): reload the target table so a stub change needs no broker restart - #4321

Open
chenmingwei23 wants to merge 1 commit into
mainfrom
fix/mcp-target-table-file
Open

fix(mcp): reload the target table so a stub change needs no broker restart#4321
chenmingwei23 wants to merge 1 commit into
mainfrom
fix/mcp-target-table-file

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Problem / Motivation

On Developer -> MCP Management -> Servers, flipping one server's stub switch freezes the page for seconds to tens of seconds, and every other switch on the page -- including the global sharing switch -- is disabled for the whole time, with no progress indication. It reads as a hung UI.

POST /api/mcp-gateway/servers/stub persists the allowlist and then awaits _apply_mcp_stub(), which stopped the broker, re-ran the rewriter over every agent spec, respawned the daemon, and rebuilt the warm pool -- all inside the request. The stop alone has a 20s upper bound derived from the daemon's own drain budget (DRAIN_SECS 10 + POOL_SHUTDOWN_SECS 5 + SIGNAL_MARGIN_SECS 5), and its real cost scales with the live fleet, because every pooled backend and in-flight connection has to be torn down.

Why it matters

Changing one server's stub bit is a one-bit configuration edit, and it costs a full broker cycle: every pooled backend drained, every in-flight tool call cancelled, the warm pool rebuilt. The respawn also bought nothing for the sessions the operator has open -- a session's MCP toolset is fixed at session/new, so an already-running session cannot pick up a new stub set no matter what the broker does, and a new session connects to the daemon fresh either way. The whole cycle was paid and discarded.

What changed (motivation -> approach -> change)

Root cause. The daemon resolved a stubbed server's real launch command from its own process environment (env_target_resolver reading KIROCREW_MCP_TARGET_<SERVER>). A live process's environment cannot be changed, so the routing table was immutable for the daemon's lifetime and the only way to change it was to respawn.

Approach. The resolution seam was already pluggable -- gatewayd calls resolver(pool_key) and its own error text advertises or pass a target_resolver, so reading the environment was a default implementation, not a constraint. The rewriter already computes the whole mapping. So the mapping is published beside the gateway socket as an owner-only targets.json, and the daemon reads it instead. An apply becomes a rewrite plus one atomic file write, with the broker left serving.

The shipped design, and the two properties that make it simple:

  • The mapping is published on EVERY build, including the one that precedes a broker start. That is what removes freshness reasoning entirely: a published table can never be older than the environment of any daemon that could read it, so the daemon needs no generation, clock or process-start comparison to decide whether its copy is current -- and so nothing for a clock step, a VM restore or a supervisor respawn to skew. The file's (inode, size, mtime) fingerprint is used only to skip re-parsing an unchanged file, not as a freshness gate.
  • The daemon reloads the table immediately before resolving a backend spawn, off the event loop, in _acquire_backend. Before resolving rather than only after a miss, because a stale SUCCESS is the harder case: a server whose target command changed would otherwise keep resolving to the previous command for as long as the cached copy survived. Exactness matters for a miss too, because the stub treats an unknown target as terminal and deliberately does not fall back to a per-session exec (so a broken backend cannot crash-loop per session) -- a server stubbed moments earlier would otherwise be reported unknown and lost for the whole life of the session that asked for it. A spawn already forks a process, so one stat there is not a cost worth trading against correctness.

Precedence is per table, never per key. A table that loads is the whole answer, so a server the operator just unstubbed stops resolving even though the daemon's environment still names it. Merging the two sources key-by-key would have let that entry keep resolving it, making unstubbing a no-op until the next restart.

Failure handling. The environment remains the floor: a missing, foreign-owned, group-writable, unparseable or wrong-version table falls back to it, so the worst case is the previous behaviour rather than a failed spawn. A publish that does not land returns None, so neither a broker start nor an applied: true report proceeds on routing the broker never saw.

Trust. targets.json maps a server name to a command the broker execs, and the broker does not run under the agent's sandbox. The reader refuses a table owned by another account or writable beyond its owner, but those checks cannot refuse a same-uid write -- so the path is also registered in security._WRITE_PROTECTED_HOME_PATHS and security._WRITE_PROTECTED_BASH_LEAVES, the same registry that already covers the browse launch config and the on-call schedule as inputs to a security or authorization decision. Kiro Crew publishes the file directly and does not route through that gate, so its own writes still work. The table carries command lines only; backend environment keeps its own separate assembly path.

Tests

test/test_mcp_target_table.py (new, 21 tests) and test/test_mcp_stub_apply.py (extended). Mutation-verified -- 9 mutants applied across the design, all killed:

  • the published table resolves a server the environment does not name;
  • a table miss does not fall back to the environment (the property that makes unstubbing take effect);
  • an absent table does fall back, and an empty table ({}) is a real "nothing stubbed" state rather than a fallback;
  • an unusable table (unparseable / wrong version / wrong shape / non-string value) degrades to the environment;
  • a group-writable table is refused; the published file is 0o600; the path is write-gated on both the file-edit and shell paths, with the gated path derived from default_target_table_path so renaming the runtime directory cannot silently move the file out from under the gate;
  • the resolver itself performs no filesystem IO (asserted by making Path.stat/Path.read_text raise);
  • a spawn reloads before resolving an unknown target, and reloads even when the cached lookup would have succeeded (the stale-success case);
  • the daemon reads the table beside its own socket;
  • both sources share one lookup order (args-hashed key beats bare, current prefix beats legacy);
  • the mapping is published on every build; a failed publish returns None; a failed rewrite never publishes;
  • a serving broker is not torn down to re-route, and a failed rewrite is not reported as applied.

Two existing tests pinned the old restart contract (test_mcp_stub_apply.py, test_slack_gateway_coverage.py) and were retargeted to the republish contract, keeping the reason each existed -- the rewriter must still re-run.

Manual verification

N/A -- unit coverage sufficient. The change is backend-only with no rendered delta, and each load-bearing property (per-table precedence, reload-before-resolve including the stale-success case, fail-closed publish, the write gate on both paths) is pinned by a mutation-verified test rather than a click-through.

Full local floor green: isort, flake8, mypy (987 files), and pytest at 56456 passed. Nine failures on this host are pre-existing and reproduce identically on a clean checkout of the base (they depend on the host's /tmp layout, a real gh on PATH, and a live flock holder), so they are environmental rather than introduced here.

Why no screenshot: backend-only change; no frontend path is touched and nothing rendered changes. The UI's page-wide disable during the apply is deliberately left alone here and tracked separately.

Related Issues

Closes #4317

Checklist

  • Single commit with a Conventional Commits title
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (CHANGELOG entry added)
  • No secrets, credentials, or internal references in the diff

@chenmingwei23
chenmingwei23 requested a review from a team as a code owner August 18, 2026 11:29
@github-actions github-actions Bot added the readiness: checking Automated validation is still running label Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — 🔴 changes requested (blocking)

GPT 5.6 found at least one blocking issue that must be resolved before merging c845b1631ae123a8a2d739dfc7dab271fdc66344.

This comment is updated in place on each push.

BLOCKING -- src/kiro_crew/slack/gateway.py:6282 -- Target-table write adds an awaited gateway boot step

published = await loop.run_in_executor(
Configured stubs -> GatewayOrchestrator.run() -> _init_mcp_gateway() awaits filesystem publication -> dashboard bind and KIROCREW_READY are delayed.
Anchor: no-new-work-on-gateway-boot-path
Fix: Revert the startup publication hunk or move publication entirely past readiness.

BLOCKING -- src/kiro_crew/mcp_gateway/target_table.py:58 -- Custom socket paths bypass the target-table write gate

return Path(socket_path).parent / TARGET_TABLE_FILENAME
Custom socket in an agent-writable directory -> agent replaces targets.json -> _acquire_backend resolves it -> broker executes the injected command outside the agent sandbox.
Anchor: residual/security
Fix: Derive the table from the fixed write-protected data-home runtime directory, independent of socket_path.

[BLOCK-MERGE] c845b16
[GPT-REVIEWED] c845b16
False positive or not applicable? A repository writer can comment:
/ai-review override gpt c845b1631ae123a8a2d739dfc7dab271fdc66344: <one-sentence reason>

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 18, 2026
@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Advisory design-level review of c845b1631ae123a8a2d739dfc7dab271fdc66344 — updated in place on each push; does not block merge.

Design-Verdict: CONCERNS

Sound root-cause fix; but it converts a spawn-time-only trust anchor into a runtime-mutable exec-command file, and the security spec wasn't updated with it.

Watch

  • Trust-surface shape change a human should sign off. The env was safe "precisely because only whoever spawns the daemon can set it"; targets.json is re-read before every spawn, and the broker "does not run under the agent's sandbox" — so the same-uid write path is now guarded only by the hooks.py PreToolUse registry, a gate this repo's own docs treat as fail-open for pre-authorized tools (the stated reason computer-use refusals avoid it). The rotation.yaml precedent is real, but those inputs gate authorization decisions, not the argv of an unconfined exec. Confirm the sandbox floor actually covers the data home, or consider having the daemon verify the table against something only the publisher holds.
  • Spec-sync miss. docs/system-specs/modules/security.md individually documents each _WRITE_PROTECTED_HOME_PATHS / _WRITE_PROTECTED_BASH_LEAVES entry (marker, config, Ops Mission Control inputs); this PR adds a new entry to both with only a CHANGELOG line. AGENTS.md requires the spec update in the same commit — add the targets.json rationale there, and cover the new publish/reload contract in the MCP gateway docs, which currently still imply restart-to-apply.

[DESIGN-REVIEWED] c845b16

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed c845b1631ae123a8a2d739dfc7dab271fdc66344 — this comment is updated in place on each push.

Review details

Based on my analysis of the diff and the actual code, let me finalize my assessment of each candidate.

Candidate 1 (concurrent refresh() race): The mechanism is real — refresh_targets() is awaited from every connection's _acquire_backend via asyncio.to_thread(target_cache.refresh) (default multi-threaded executor, no serialization), so concurrent TargetTableReader.load() calls on the shared reader do happen, and its _fingerprint/_targets update is non-atomic. But the poisoning interleave (reader ending at fp=v2, targets=v1) requires the file to be rewritten between the two threads' stat() calls — i.e. an operator stub toggle coinciding to a microsecond with two simultaneous backend spawns. That is a "might land in this interleaving," not a condition that occurs in practice, and it self-heals on the next toggle (new fingerprint → re-parse). Below the 80 bar.

Candidate 2 (custom socket_path outside crew-home): Requires a non-default operator config placing the socket outside _CREW_HOME_PREFIXES, mirrors a pre-existing home-anchoring limitation shared by every write-protected leaf, and its own confidence is "low." The fix would touch the untouched static-list mechanism. Below bar / out of scope.

Candidate 3 (respawn + prewarm skip pre-resolve reload): Confirmed both call sites omit refresh_targets= (gatewayd.py:597 and :2891). But respawn intentionally preserves the session's original binding (using the command it started with is defensible, not a defect), and prewarm at startup reads a freshly-published table while top-up passes are best-effort; a stale spawn is transient. "Low" confidence, outcomes are "could." Below bar.

No new grounded defect surfaced in Step 2 — the _apply_mcp_stub applied computation fails closed correctly, and the security.py leaf is paired on both the home-path and bash-leaf registries.

No findings.

[OPUS-REVIEWED] c845b16

Verdict parsed from the review's SHA-scoped output markers for commit c845b1631ae123a8a2d739dfc7dab271fdc66344.

False positive or not applicable? A repository writer can comment:
/ai-review override fable c845b1631ae123a8a2d739dfc7dab271fdc66344: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — ✅ PASS

Advisory premise-level review of c845b1631ae123a8a2d739dfc7dab271fdc66344 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push; does not block merge.

First-Principles-Verdict: PASS

Cause-level fix: the immutable spawn environment was the real constraint, and every piece added — file, reload, gate, fail-closed report — names its harm.

What this change ships

Intent: make flipping one MCP server's stub switch take effect without freezing the dashboard behind a full broker teardown (#4317) — a FIX.

  1. Flipping a stub switch no longer restarts the broker or drains live backends — justified, cause-level (env immutability was the nameable cause).
  2. A targets.json appears beside the gateway socket, rewritten on every apply — justified, declared.
  3. The daemon re-reads that file immediately before each backend spawn — justified (stale-success and terminal-unknown cases both named), declared.
  4. A rewrite or publish that fails now reports the switch as not applied — justified, declared.
  5. Broker start aborts when the publish fails — justified (a stale leftover table would outrank the fresh env, since precedence is per table).
  6. The agent's file and shell tools are refused writes to targets.json — justified by the agent-vs-own-ceiling boundary; sits in the existing registries, not a new mechanism.
  7. Env and table resolution share one lookup order (lookup_target) and one spawn-tuple builder — justified dedup replacing two spellings.
  8. CHANGELOG entry — declared.

Watch

One sibling of the rewrite-requires-restart coupling remains: _apply_mcp_gateway_enabled (src/kiro_crew/slack/gateway.py:6382-6385) still does stop+init for the sharing switch — the description's own symptom names "the global sharing switch". The off-direction has a real drain requirement (already-shared backends must die); the on-direction inherits the restart with no equivalent cause. Count: 1 apply path still restarting. Accepted-and-deferred, not a demand.

[FIRST-PRINCIPLES-REVIEWED] c845b16

@chenmingwei23
chenmingwei23 force-pushed the fix/mcp-target-table-file branch from 9cee014 to 869d99d Compare August 18, 2026 11:55
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 18, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Dispositions for the two blocking findings on 9cee014. New head: 869d99d07251.

  • span=0e21ddd67f91 Watchdog respawn restores the stale startup routing -- fixed

This one is correct and it was mine. The gate keyed on process start, and a respawn is a new process holding an OLD mapping: the supervisor restarts a crashed or wedged daemon with the environment captured when the broker last started, so the gate discarded a table published since and reverted routing to the stub set from before the last toggle.

The invariant is now "newer than the environment the daemon was handed" rather than "newer than the process". Both the spawn environment and the published table carry a generation minted per build of the mapping, and the table wins only when its generation is higher. A respawn therefore keeps the newer table, while a fresh broker start -- which mints a new generation -- correctly ignores a leftover file. Comparing two stamps from the gateway's own clock also removes a filesystem-mtime-versus-wall-clock comparison that a first attempt got wrong, where coarser mtime granularity made a table published moments after startup read as older than it.

Pinned by test_a_supervisor_respawn_keeps_the_newer_table and, separately, by test_the_daemon_wires_the_gate_to_its_env_generation -- the second one exists because the first does not catch this class: the reader's semantics were already right in isolation, and the defect lived in what the daemon handed it. The construction moved into default_target_cache() so that wiring is reachable from a test without booting a daemon. Both are mutation-verified: keying the gate on time.time_ns() again fails the wiring test.

  • span=8f81065d4f9c Same-UID agent can replace the executable routing table -- needs-a-decision, ruled out of scope by the maintainer

The finding holds on its own terms and I am not disputing the mechanism: targets.json is under $HOME, writable by the same uid, and it decides which executable the daemon launches. This repository does elsewhere treat same-user writable inputs that reach execution as needing a guard beyond file ownership.

It was put to the maintainer rather than patched, because the obvious mitigations do not survive the same-uid model: a MAC keyed by a secret in the daemon's spawn environment is forgeable, since a same-uid process can read /proc/<pid>/environ; and relocating the file under an agent-write-protected root has nowhere to go, because the data home is itself under $HOME. The maintainer's ruling is that the exposure is too narrow to hold this change, so owner-only plus not-group-writable is the accepted guard here, and the design stays.

A formal /ai-review override for this span follows on the current head once this lane has run against it -- deliberately after, not before, so the override cannot suppress a first review of the new code.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 18, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/mcp-target-table-file branch from 869d99d to b38a7a6 Compare August 18, 2026 12:21
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 18, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Disposition for the blocking finding on 869d99d0. New head: b38a7a6cb.

  • span=e51cd2330ac2 Publish success is reported before the daemon reloads -- fixed

Correct, including the part that decides the severity. My assumption when writing this was that a target the daemon cannot resolve degrades to a per-session exec, which is the fallback most rejections take. It does not: stub.py treats an unknown target as a TERMINAL rejection and deliberately declines to fall back, so that a genuinely broken backend cannot crash-loop once per session. Its own comment says so -- "Terminal rejection (unknown target / genuine spawn failure) ... Surface the failure instead of exec'ing". A session whose toolset is fixed at session/new therefore loses that server for its whole life, not for the length of the window.

The fix is at the consumer rather than in the request path, because the invariant that was actually broken belongs to the rejection: a terminal "this server cannot run" is only sound if the daemon has checked the freshest published mapping. _acquire_backend now awaits one off-loop reload and re-resolves before raising _TargetUnknown, so a target published since the last periodic reload is found, and a miss after the re-check is genuinely unknown -- which is what the terminal rejection means to assert. The reload stays off the event loop, and it runs only on the miss path, so the ordinary spawn is unchanged.

Waiting for a broker acknowledgement instead -- the literal suggested fix -- would need a channel from the daemon back to the gateway that does not exist today; the re-check closes the same gap without one, and without returning a blocking wait to the request the change exists to speed up.

Pinned by test_a_miss_rechecks_the_table_before_reporting_unknown, which asserts both directions: without the hook the acquisition raises _TargetUnknown, and with it the mapping resolves and the failure that follows is a different one. Mutation-verified -- removing the re-check fails that test.

Also on this head: rebased onto main (c7e2564f), and the earlier Electron Shell Tests red was a CI-side Electron failed to install correctly / fetch failed during dependency install, on a diff that touches no frontend or Electron path -- re-run rather than a code change.

@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 18, 2026
@chenmingwei23
chenmingwei23 force-pushed the fix/mcp-target-table-file branch from b38a7a6 to af54007 Compare August 18, 2026 13:12
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 18, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Dispositions for the two blocking findings on b38a7a6c. New head: af5400738.

Both hold, and taken together they say the mechanism was wrong rather than under-guarded. This is the third round of blocking findings in this span, and each round closed the instance it was handed and received a sibling: a gate keyed on process start, then a publish/reload ordering gap, now a stale-hit path and a non-monotonic stamp. All four share one root -- freshness was being INFERRED from a number, so every patch added another place the inference could be wrong. So this round removes the inference instead of guarding it, and the diff gets smaller rather than larger.

  • span=8f81065d4f9c Wall-clock generations can move backward -- fixed by deletion

Correct: time.time_ns() is not monotonic, so an NTP step, a manual clock set or a VM restore can stamp a newer table with a lower value than the environment it is compared against, and the table is then silently ignored -- a toggle that appears to do nothing until the next broker restart. Note the span id is path-keyed, so this is a different finding from the same-UID one previously raised at this path; no override applies to it.

The generation is gone rather than made monotonic. Startup now publishes the table too, which was the thing being optimised away and the direct cause of needing a comparison at all: with the mapping published on every build, a table that exists cannot be older than the environment of any daemon that could read it, so there is nothing to compare and no clock in the design. mint_generation, env_generation, the GENERATION_ENV_KEY env var, the payload field, the reader's gate and the publish flag are all deleted. The boot-path cost is one atomic small-file write beside the full agent-spec walk that already runs there.

  • span=0e21ddd67f91 Stale successful lookups bypass the reload -- fixed

Correct, and the sharper half of the pair: reloading only when the first lookup returned None meant a server whose target command changed kept resolving -- successfully -- to the previous command for as long as the cached copy survived, so the reload could never repair a stale hit.

The reload now runs before resolving, unconditionally, so a backend is spawned from the mapping on disk at that moment. A spawn already forks a process, so one off-loop stat is not a cost worth trading correctness for, and it subsumes the earlier miss-only re-check: an unknown target is still exact, which matters because the stub treats it as terminal with no per-session fallback. The periodic 2s refresh task and its interval constant are deleted, since the read now happens exactly where the mapping is consumed.

Net effect on the design: no generation, no clock, no process-start gate, no background refresh task, no publish flag. What remains is publish-on-build, read-before-spawn, per-table precedence, environment as the fallback floor, and the owner-only trust check.

Verification on the new head: 7 mutants applied and all killed, including the two that pin these fixes -- reverting to a miss-only reload fails test_a_spawn_reloads_even_when_the_cached_lookup_would_succeed, and skipping the publish fails test_the_mapping_is_published_on_every_build. Full local floor green (isort, flake8, mypy over 987 files, 56427 pytest passed; the nine failures on this host are pre-existing and reproduce on a clean checkout of the base). Rebased onto main at d6ac047e.

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 18, 2026
…start

Toggling one server's stub froze the MCP Management page for seconds to tens
of seconds with every other switch disabled, because the apply tore the
daemon down and respawned it.

The respawn existed for one reason: the daemon resolved a stubbed server's
real launch command from its own environment, which a live process cannot
change. So the routing table was immutable for the daemon's lifetime, and a
one-bit change to one server had to drain every pooled backend and every
in-flight call to take effect -- while buying nothing, since an open
session's MCP toolset is fixed at session/new and cannot pick up a new stub
set either way.

The rewriter already computed that whole mapping. It is now published beside
the gateway socket as an owner-only targets.json whenever it is built, so an
apply is a rewrite plus one atomic file write and the broker keeps serving.

Publishing on EVERY build, including the one that precedes a broker start, is
what keeps this simple. A published table can never be older than the
environment of a daemon that could read it, so the daemon needs no freshness
arithmetic to decide whether its copy is current -- no generation, no clock,
no process-start comparison, and so nothing for a clock step, a VM restore or
a supervisor respawn to skew.

The reload happens off the event loop immediately before a backend is
spawned, so a backend is spawned from the mapping on disk at that moment. It
runs before resolving rather than only after a miss, because a stale SUCCESS
is the harder case: a server whose target command changed would otherwise
keep resolving to the previous command for as long as the cached copy
survived. Exactness also matters for a miss, because the stub treats an
unknown target as terminal and deliberately does not fall back to a
per-session exec, so that a genuinely broken backend cannot crash-loop per
session -- a server stubbed moments earlier would otherwise be reported
unknown and lost for the whole life of the session that asked for it. A spawn
already forks a process, so one stat there is not a cost worth optimising
against correctness.

Precedence is per table, never per key: a table that loads is the whole
answer, so a server the operator just unstubbed stops resolving even though
the daemon's environment still names it. Merging key-by-key would have made
unstubbing a no-op until the next restart. The environment stays as the
floor -- a missing, foreign-owned, group-writable or unparseable table falls
back to it -- and a publish that does not land fails closed rather than
letting a broker serve routing nobody asked for.

Closes #4317
@chenmingwei23
chenmingwei23 force-pushed the fix/mcp-target-table-file branch from af54007 to c845b16 Compare August 18, 2026 13:42
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Disposition for the Design Review concern on af5400738. New head: c845b1631.

  • targets.json is an input to a security decision and was not on the write gate -- fixed

Correct, and it names a mitigation I had concluded did not exist. When this exposure was first raised I looked for a way to make the file unforgeable and found none that survives a same-uid attacker -- a MAC keyed from the daemon's spawn environment is defeated by /proc/<pid>/environ, and relocating under an agent-write-protected root has nowhere to go because the data home is itself under $HOME. What I did not check was whether the repository already refuses the agent's write at the tool layer. It does, and the precedent is an exact match: playwright-cli-config.json is documented in these very terms -- "it holds no secret and the CLI must READ it on every invocation, so it is write-protected rather than sensitive. But it is an INPUT TO A SECURITY DECISION" -- with the same read/write asymmetry and the same note that Kiro Crew's own write does not route through the gate.

mcp-gateway/targets.json is now registered in both security._WRITE_PROTECTED_HOME_PATHS and security._WRITE_PROTECTED_BASH_LEAVES, paired because a leaf protected on one path only is reachable through the other. The reader's owner/mode checks are unchanged and still do what they can do -- refuse a table owned by another account or writable beyond its owner; the gate is what covers the same-uid case they cannot.

Pinned by test_the_table_is_write_gated_against_the_agent_on_both_paths, which derives the gated path from default_target_table_path rather than restating it, so renaming the runtime directory cannot silently move the file out from under the gate while the test still passes. Mutation-verified in both halves independently: breaking either registry entry fails it. The existing parametrized leaf test in test_security.py now also covers the new leaf's native Windows spellings for free.

Worth recording for the maintainer, since it changes a decision made earlier on my analysis: the same-uid exposure had been ruled out of scope on the understanding that no sound mitigation was available. One was, and it costs two registry entries.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Disposition for the First Principles concerns on af5400738. New head: c845b1631.

Both watch items were correct, and both were mine: the design was restructured and the CHANGELOG and commit message were updated with it, but these two were not.

  • The description narrated a superseded design -- fixed

Accurate in every particular. The body still claimed "Startup publishes nothing", "the daemon ignores the table it started with" and a fingerprint freshness gate -- all of which describe the design that the restructure replaced, and it listed two tests that do not exist, one of which is pinned in reverse by test_the_mapping_is_published_on_every_build.

The body is rewritten to the shipped design: published on every build including the pre-start one, which is precisely what removes the freshness comparison; reload before resolving each spawn rather than on a tick; the fingerprint described as what it is, a re-parse skip and not a gate. The test list now matches the file, including the stale-success case. The point that this is "the record a human will trust" is the reason it mattered rather than a nit -- a reviewer reading the old body would have been looking for machinery that is not there.

  • The same stale narrative shipped in a code comment -- fixed

_apply_mcp_stub's docstring said the daemon "reloads that table on its own refresh tick". The tick was deleted in the restructure and the reload moved into _acquire_backend, so the comment described a mechanism that no longer exists -- worse than no comment, since a reader would go looking for the task. It now reads "reloads that table before its next backend spawn". Your grep for refresh tick|_REFRESH|refresh_interval returning zero matches under src/kiro_crew/mcp_gateway is the right check and confirms nothing else survived the deletion.

No code behaviour changed for either item; the first is prose, the second a comment. Full local floor re-run green on the new head (56456 pytest passed, the nine host failures pre-existing).

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: passed Eligible automated validation passed for the current revision readiness: checking Automated validation is still running labels Aug 18, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Stopping to re-decide the premise, not patching round five

Both findings on c845b1631 are legitimate. I am not fixing them, because this is the fifth round of blocking findings in the same span (target_table.py / gatewayd.py resolve-or-publish / now security.py), and the two fixes GPT proposes each reopen a property an earlier round established. That is the signal that the approach, not the instance, is wrong.

The two current findings, and why each is entangled

1. target_table.py:58 -- a custom socket_path puts the table outside the write gate.

Correct, and it is a direct consequence of the mitigation I added last round. I noticed the gap while implementing it and judged it acceptable because every entry in that registry is home-anchored; that judgement was wrong to leave unstated.

The proposed fix -- derive the table from a fixed write-protected runtime directory, independent of socket_path -- conflicts with why the path is socket-derived in the first place. This repo's own records_dir states the rule: a custom socket_path would otherwise have the daemon writing where the reader never looks. Decoupling the table from the socket to satisfy the gate breaks the writer/reader rendezvous under exactly the configuration that motivated socket-derivation. Keeping it socket-derived means a static home-anchored gate can never cover it. Neither side of that is patchable in isolation.

2. gateway.py:6282 -- the publish is awaited on the gateway boot path.

Also correct, and verified rather than taken on faith: _init_mcp_gateway() is awaited at gateway.py:7461 and KIROCREW_READY prints at :7520, so boot does wait on the write. It is one small-file write behind an agent-spec walk that already blocked boot, so the marginal cost is small -- but no-new-work-on-gateway-boot-path is a named convention, and magnitude is not the test.

This one is a recurrence. The same boot-path publish was raised in round one and I dispositioned it as moot. It has come back as blocking, which means my round-one reasoning was not sound and I did not revisit it.

The proposed fix -- move publication past readiness -- reintroduces a window in which a daemon can start against an unpublished table. Publishing on every build, boot included, is precisely what let the round-three restructure delete the generation, the clock and the process-start gate: a published table can never be older than the environment of any daemon that could read it. Moving the publish past readiness gives that reasoning back.

The whole span, for the record

Round Finding Disposition
1 Failed publish reported as applied Fixed
1 Sync IO in the resolver on the event loop Fixed
1 Publish on the boot path Called moot -- wrong, it is finding 2 above
1 Same-uid write to targets.json Ruled out of scope by the maintainer
2 Watchdog respawn reverts routing Fixed
3 Stale successful lookup bypasses the reload Fixed (reload before resolving)
3 Wall-clock generations can move backward Fixed (generation deleted entirely)
4 Table is an ungated input to a security decision Fixed (write gate) -- which produced finding 1 above
4 Description and a comment narrated a superseded design Fixed
5 Custom socket path bypasses the gate Open
5 Awaited publish on the boot path Open

Four rounds of fixes held. What did not hold is the premise underneath them: publish a file next to the socket and have the daemon re-read it. Each round closed a real hole and opened the next one, and the last two cannot both be closed without giving back an earlier guarantee.

What I think is actually right

The reported bug is that the Servers page freezes for up to 20s with every control disabled. It is not that the broker restarts. The restart only had to leave the request path -- and the file table was never load-bearing for the fix, only for making the restart unnecessary.

The smaller shape: keep the broker restart, take it off the request path. _apply_mcp_stub persists the allowlist, returns, and lets convergence happen in the background. The freeze goes away, which is the issue; routing still converges through the mechanism that already exists and is already reviewed. No new file, no trust check, no write-gate entry, no boot-path publish, no freshness reasoning, and none of the eleven findings above are reachable because the surface that generated them does not exist.

What that concedes, stated plainly: a short window after the toggle where a newly created session still gets the old stub set. That window is far less visible than it sounds -- a session's MCP toolset is fixed at session/new, so no already-open session can observe a stub change under any design, including the one in this PR.

The alternative worth considering, if in-place re-routing is genuinely wanted, is a control verb on the daemon's own socket rather than a file: the socket is already owner-only and already the daemon's input surface, so it needs no new trust check, no gate entry and no boot publish. I checked -- no such surface exists today (the socket serves MCP connections only; the signal handlers are shutdown-only), so that is a design round, not an edit.

I would rather ship the small version and file the in-place re-routing separately than take a fifth swing at this one. Flagging for a decision instead of choosing unilaterally, since collapsing the approach is a bigger call than any single finding here.

@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merge conflict Branch has merge conflicts with its base — author must resolve before merge readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MCP Management: toggling one server's stub restarts the whole broker and freezes every control on the page

1 participant