Skip to content

feat(runtime): granular MCP taint policy + dashboard tree editor (closes #3050)#3193

Merged
houko merged 13 commits into
mainfrom
feat/issue-3050-mcp-taint-policy
Apr 26, 2026
Merged

feat(runtime): granular MCP taint policy + dashboard tree editor (closes #3050)#3193
houko merged 13 commits into
mainfrom
feat/issue-3050-mcp-taint-policy

Conversation

@houko

@houko houko commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Granular per-tool / per-path / per-rule MCP taint policy plus a dashboard tree editor.

  • McpTaintToolPolicy gains default: McpTaintToolAction (scan | skip) so a single line bypasses scanning for noisy tools (browser tab handles, DB session IDs, etc.).
  • New top-level [[taint_rules]] config table (NamedTaintRuleSet: name, action = block | warn | log, rules). Tool policies reference them via rule_sets; the scanner picks the most permissive action across overlapping sets and emits structured tracing events for warn / log.
  • The registry flows from KernelConfig.taint_rules through every kernel install/reload path into the runtime McpServerConfig.taint_rule_sets snapshot.
  • Path-pattern wildcards ($.foo, $.foo.*, $.foo[*], $.*) are now documented in rustdoc on McpTaintPathPolicy and covered by a dedicated test against the matcher.
  • /api/mcp/servers and /api/mcp/servers/{name} now surface taint_scanning + taint_policy so the dashboard hydrates without a second fetch.
  • New dashboard TaintPolicyEditor (server → tool → path tree, in-place add/edit/remove, default action selector, rule_sets field, per-path rule toggles) hooked into McpServersPage via a "Taint" card action. Saves go through useUpdateMcpTaintPolicy which invalidates mcpKeys.servers / mcpKeys.server / mcpKeys.health — no full config reload needed.

Acceptance criteria

  • McpTaintToolPolicy.default field added (scan | skip)
  • Path-pattern wildcards (.*, [*]) documented in rustdoc + unit tests
  • New [[taint_rules]] table with name, action (block | warn | log), rules
  • McpTaintToolPolicy.rule_sets references named sets
  • Dashboard panel renders server → tool → path tree, supports add/edit/remove without full config reload
  • All new config fields covered by round-trip serde tests (TOML and JSON, plus a legacy-shape test for backwards compat)
  • Targeted cargo clippy -p librefang-types -p librefang-runtime-mcp -- -D warnings clean (full-workspace clippy left to CI)

Test plan

  • cargo test -p librefang-types -- taint — 30 / 30 pass
  • cargo test -p librefang-runtime-mcp — 87 / 87 pass (covers tool-level default = skip, all three rule-set severity tiers, warn precedence over block when sets overlap, wildcard $.metadata.* exemption)
  • cargo test -p librefang-runtime --test mcp_oauth_integration — 9 / 9 pass (verifies the new taint_rule_sets field doesn't break OAuth wiring)
  • cargo check -p librefang-kernel -p librefang-api -p librefang-runtime — clean (no Default impl drift)
  • cargo clippy -p librefang-types -p librefang-runtime-mcp -p librefang-kernel -p librefang-api -- -D warnings — clean
  • Dashboard tsc --noEmit — no new errors introduced (existing pre-existing sessions-stream.test.tsx errors unrelated to this PR)
  • Live integration smoke (PUT /api/mcp/servers/{name} with taint_policy.tools.<tool>.default = "skip" round-trips through TOML and is honoured on the next call) — recommended before merge; CI build of dashboard SPA exercises the new types.

Closes #3050

… tree editor

- McpTaintToolPolicy gains a 'default' field (scan | skip) so a single
  line can bypass scanning for noisy tools (browser tab handles, DB
  session IDs, etc.) without enumerating every argument path.
- Document JSONPath wildcard semantics ($.foo, $.foo.*, $.foo[*], $.*)
  in McpTaintPathPolicy rustdoc and add a dedicated wildcard test that
  asserts each documented form against the in-tree matcher.
- Introduce top-level [[taint_rules]] with named NamedTaintRuleSet
  entries (action: block | warn | log, plus a list of TaintRuleId).
  Tool policies can reference them via 'rule_sets'; the scanner picks
  the most permissive action across overlapping sets and emits
  structured tracing events for warn/log so operators can build an
  exemption baseline before flipping a set to block.
- Wire the registry from KernelConfig.taint_rules through every
  install/reload path in the kernel into the runtime McpServerConfig
  (taint_rule_sets snapshot) so live taint scans apply the latest
  rule-set actions.
- Surface taint_scanning + taint_policy in /api/mcp/servers and
  /api/mcp/servers/{name} so the dashboard can hydrate without a
  second fetch.
- Dashboard: add TaintPolicyEditor (server -> tool -> path tree with
  in-place add/edit/remove, default action selector, rule_sets
  reference, per-path rule toggles), wire a 'Taint' button into
  ServerCard, and route saves through useUpdateMcpTaintPolicy which
  invalidates mcpKeys.servers / mcpKeys.server / mcpKeys.health so
  the card refreshes without a full config reload.
- Add TaintRuleId / McpTaintToolAction / McpTaintRuleSetAction TS
  types mirroring the snake_case serde tags.
- Round-trip serde tests cover legacy configs (no 'default', no
  'rule_sets'), new tool-level skip, wildcard paths, and all three
  rule-set severity tiers.

Closes #3050
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions github-actions Bot added size/XL 1000+ lines changed ready-for-review PR is ready for maintainer review labels Apr 25, 2026
@github-actions github-actions Bot added area/runtime Agent loop, LLM drivers, WASM sandbox area/kernel Core kernel (scheduling, RBAC, workflows) labels Apr 25, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Apr 26, 2026

Copy link
Copy Markdown

@houko

houko commented Apr 26, 2026

Copy link
Copy Markdown
Contributor Author

Code Review: PR #3193

Overview

Closes #3050 by layering three granular controls onto the existing MCP taint scanner: a per-tool kill switch (default = "skip"), reusable named [[taint_rules]] sets with severity actions (block / warn / log), and a dashboard tree editor backed by API surfacing of taint_scanning + taint_policy. The taint scanner is refactored to thread a rule-set registry through walk_taint, and a new detect_outbound_text_violation_with_skip returns the specific TaintRuleId that fired so the caller can apply rule-set actions.


🔴 Correctness: multi-rule downgrade can silently mask unrelated violations

detect_outbound_text_violation_with_skip (taint.rs +1923) short-circuits on the first rule that matches:

if let Some(rule) = detect_secret_rule_with_skip(payload, skip_rules) {
    // build labels = { Secret }, check sink, return Some(rule)
}
if sink.blocked_labels.contains(&TaintLabel::Pii) {
    if let Some(rule) = detect_pii_rule_with_skip(...) { ... }
}

But the OLD check_outbound_text_violation_with_skip builds a combined label set and runs the sink check once. The two functions differ in observable behaviour for payloads that trip both Secret AND PII rules.

Concrete failure mode: a payload like "contact [email protected] with sk-realtoken1234567890abcdef" triggers WellKnownPrefix AND PiiEmail. With a rule set

[[taint_rules]]
name = "browser_handles"
action = "warn"
rules = ["well_known_prefix"]

referenced by the tool, walk_taint calls detect_outbound_text_violation_with_skip → gets Some(WellKnownPrefix)apply_rule_set_action finds well_known_prefix in the set with action warn → returns falsethe PII violation is silently allowed through, even though the operator never authorized PII downgrade.

Suggested fix: either return all matching rules (Vec<TaintRuleId>) and require every fired rule to be downgrade-authorized before allowing the call, or run secret + PII detection independently in walk_taint and && their downgrade decisions. The current design lets one allowlisted rule mask any number of unauthorized ones.

No test in the new suite covers this case — adding one would be a good guard.

🟡 Snapshot semantics for taint_rule_sets are operationally surprising

McpServerConfig.taint_rule_sets is set from self.config.load().taint_rules.clone() at five distinct kernel sites (mod.rs +843, +11653, +11803, +11928, +12075). The rustdoc says "snapshot … at boot" but doesn't call out the consequence: operators editing [[taint_rules]] and reloading config will not see the new rules apply to already-connected MCP servers until those servers themselves are reloaded.

Given that everywhere else in CLAUDE.md/docs the kernel claims hot-reload semantics, this divergence is worth either (a) fixing — pull taint_rules from self.config.load() at scan time instead of snapshotting — or (b) documenting prominently on McpServerConfig.taint_rule_sets and in the operator-facing docs.

If the snapshot is intentional (consistency-during-call argument), at least add a note.

🟡 Five-fold duplication of the snapshot expression

taint_rule_sets: self.config.load().taint_rules.clone(), appears verbatim five times in kernel/mod.rs. The five other surrounding fields (oauth_config, taint_scanning, taint_policy, roots) are also boilerplate-y, but the new line repeats the cross-field lookup. Suggest extracting a LibreFangKernel::build_mcp_runtime_config(&server_config) -> McpServerConfig helper. Not blocking, but five copies is the threshold where future drift gets expensive.

🟡 Dashboard mutation couples GET and PUT shapes

useUpdateMcpTaintPolicy (mutations/mcp.ts +85) sends { ...existing, taint_scanning, taint_policy } to PUT /api/mcp/servers/{name} because the PUT requires a full McpServerConfigEntry. The mutation's own comment flags this. Implication: any future required field added to McpServerConfigEntry that isn't surfaced in list_mcp_servers / get_mcp_server (or that gets renamed in the GET shape) will silently break the taint editor with a 400.

A safer alternative would be a dedicated PATCH /api/mcp/servers/{name}/taint endpoint that accepts just { taint_scanning?, taint_policy? }. Server already has the entry; client doesn't need to round-trip everything else. Worth considering as a follow-up.

🟢 Strengths

  • Backward compat is well-protecteddefault = Scan and empty rule_sets match the old shape; legacy_taint_policy_without_new_fields_still_loads covers exactly this.
  • Tests for the priority logic (test_rule_set_warn_takes_precedence_over_block) and key-name path (test_rule_set_warn_downgrades_sensitive_key_name) are good.
  • Wildcard rustdoc + matcher tests (test_documented_wildcards_match_expected_paths) — the doc and the implementation are pinned together.
  • Violation string still doesn't echo payload — the security-critical invariant from the old function is preserved in the new one (taint.rs +263–268).
  • schemars::JsonSchema and #[serde(skip_serializing_if = "Vec::is_empty")] derives match project convention for new optional collections.

🟢 Minor nits

  • lookup_rule_set_action does O(refs × registry) linear scans per rule fire. Fine for current scale (handful of sets) but if [[taint_rules]] ever grows large, a HashMap<&str, &NamedTaintRuleSet> cached on McpServerConfig would be cheap.
  • action_priority returns u8 — readable, but a derive(PartialOrd) on a properly ordered enum would be even clearer (the current ordering Block=0, Warn=1, Log=2 works because cmp on u8 matches the intent).
  • The PR description has the "Live integration smoke" item unchecked. CLAUDE.md mandates live tests for new endpoint behaviour — recommend running through PUT /api/mcp/servers/{name} with a default = "skip" payload and verifying the round-trip before merge.

Verdict

Architecturally sound and well-tested for the single-rule case. The multi-rule masking issue (🔴) is the one blocker — once that's resolved (or proven to be a non-issue with a test), this is good to merge. The snapshot-semantics concern (🟡) should at minimum get an explicit doc note.

@github-actions github-actions Bot added needs-changes Changes requested by reviewer and removed ready-for-review PR is ready for maintainer review labels Apr 26, 2026
The taint scanner returned only the first rule that fired, so a rule
set authorized to downgrade rule A could silently allow rule B
through when both fired on the same payload (e.g. a Secret-rule warn
downgrade masking a PII-rule fire on `[email protected]`).

- Add `detect_outbound_text_violation_rules_with_skip` returning every
  rule that fires; old `_with_skip` keeps the first-only signature for
  backward-compat callers.
- `walk_taint` now iterates every fired rule and blocks on the first
  one not downgraded by a rule set.
- Regression test asserts the masking case blocks.
- Fix pre-existing `tool_policy_rule_sets_reference_round_trips` that
  used the wrong toml prefix `[mcp_servers.tools.*]` (the policy struct
  has no `mcp_servers` field — test was masked by a `-- taint` filter).
- Extract `LibreFangKernel::snapshot_taint_rules` helper; replace the
  five inline `self.config.load().taint_rules.clone()` call sites.
- Document the snapshot semantics on `KernelConfig.taint_rules`: edits
  do not propagate to already-connected MCP servers without reload.
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed needs-changes Changes requested by reviewer labels Apr 26, 2026
@houko

houko commented Apr 26, 2026

Copy link
Copy Markdown
Contributor Author

Pushed ef54effb addressing the review:

🔴 Multi-rule masking fixed

  • Added librefang_types::taint::detect_outbound_text_violation_rules_with_skip returning every fired rule (Vec<TaintRuleId>).
  • walk_taint now iterates every fired rule and blocks on the first one not authorized by a rule_set, so a Secret-family downgrade can't mask an unrelated PII fire on the same payload.
  • The original first-only detect_outbound_text_violation_with_skip is kept as a thin wrapper (with a docstring directing rule-set callers to the plural variant) so existing callers don't break.
  • New regression test test_rule_set_downgrade_does_not_mask_unrelated_rule exercises [email protected] (KeyValueSecret + PiiEmail co-fire) under a rule_set that warns Secret rules only — must still block.

🟡 Kernel helper extracted

  • New LibreFangKernel::snapshot_taint_rules() helper; replaced the five inline self.config.load().taint_rules.clone() call sites.

🟡 Snapshot semantics documented

  • Added a "Hot-reload caveat" block on KernelConfig.taint_rules rustdoc: edits don't propagate to already-connected MCP servers until those servers themselves are reloaded.

Pre-existing test fix

  • config::types::tests::tool_policy_rule_sets_reference_round_trips was using [mcp_servers.tools.*] toml shape, but McpTaintPolicy has no mcp_servers field. The test was being silently masked by the -- taint name filter (it doesn't contain "taint"). Fixed the toml prefix to [tools.*].

Verification

  • cargo test -p librefang-runtime-mcp -p librefang-types -p librefang-kernel --test-threads=1 — all 559+ kernel tests + 87+ runtime-mcp + 30+ types pass.
  • cargo clippy -p librefang-runtime-mcp -p librefang-types -p librefang-kernel --all-targets -- -D warnings — clean.
  • Multi-threaded cargo test showed an unrelated SIGABRT in kernel tests (flaky; passes single-threaded) — not introduced by this commit.

The earlier dashboard-coupling concern about useUpdateMcpTaintPolicy round-tripping the full server config is left as a possible follow-up — could be addressed by a dedicated PATCH /api/mcp/servers/{name}/taint endpoint, but that's a separate API surface change.

@github-actions github-actions Bot added needs-changes Changes requested by reviewer and removed ready-for-review PR is ready for maintainer review labels Apr 26, 2026
…dpoint

Addresses the two remaining review concerns from #3193:

**Hot-reload `[[taint_rules]]`**
- `McpServerConfig.taint_rule_sets` is now an `Arc<ArcSwap<Vec<...>>>`
  shared across every connected MCP server (was: per-server `Vec`
  snapshot taken at install time). Operators editing
  `[[taint_rules]]` and reloading config now see the new rules
  applied to in-flight tool calls without restarting servers.
- Kernel owns the master swap; `LibreFangKernel.taint_rules_swap`
  is initialised at boot and `.store(...)`-updated by `reload_config`
  (before swapping `self.config` so no scanner observes a window
  where the two disagree).
- Scanner takes a single `.load()` per scan call so config edits
  during an in-flight argument-tree walk can't mutate rules under
  the scanner.
- `empty_taint_rule_sets_handle()` and `static_taint_rule_sets_handle()`
  helpers exposed for tests and out-of-kernel callers.

**Dedicated PATCH endpoint**
- New `PATCH /api/mcp/servers/{name}/taint` accepts only
  `{ taint_scanning?, taint_policy? }`, removing the dashboard's
  need to round-trip every other server field through PUT. Closes
  the silent-drop risk if a future required field gets added without
  dashboard support.
- `useUpdateMcpTaintPolicy` rewritten to call the new endpoint;
  `TaintPolicyEditor` updated to pass `id` instead of the full
  `existing` server config.

Verified the multi-threaded kernel-test SIGABRT reproduces on
`02eb92af` (the original PR head before any fixes), confirming
it is pre-existing and not introduced by this change.
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed needs-changes Changes requested by reviewer labels Apr 26, 2026
@houko

houko commented Apr 26, 2026

Copy link
Copy Markdown
Contributor Author

Pushed e4b40588 finishing the remaining 🟡 items from the review:

Hot-reload [[taint_rules]] — was previously only documented as a snapshot, now actually live:

  • McpServerConfig.taint_rule_sets changed from Vec<NamedTaintRuleSet> to Arc<ArcSwap<Vec<NamedTaintRuleSet>>> shared across every connected MCP server.
  • LibreFangKernel.taint_rules_swap owns the master swap, initialised from config.taint_rules at boot and updated via .store(Arc::new(new_rules)) inside reload_config (executed before self.config.store(...) so no scanner observes a window where the two disagree).
  • The scanner takes a single .load() per scan call, so a mid-walk reload can't mutate rules under an in-flight tool invocation.
  • New helpers empty_taint_rule_sets_handle() / static_taint_rule_sets_handle() for tests + non-kernel callers.
  • The hot-reload caveat in the rustdoc on KernelConfig.taint_rules is gone — operators no longer need to reload servers to pick up rule-set edits.

Dedicated PATCH /api/mcp/servers/{name}/taint — closes the GET/PUT coupling concern:

  • New endpoint accepts { taint_scanning?, taint_policy? }, no other server fields required.
  • useUpdateMcpTaintPolicy rewritten to call PATCH; TaintPolicyEditor passes only { id, taint_scanning, taint_policy } instead of spreading the entire server config.
  • The "future required field added → dashboard silently drops it" risk is gone.

Pre-existing kernel-test SIGABRT verified pre-existing

  • Reproduced multi-threaded cargo test -p librefang-kernel --lib on 02eb92af (original PR head before any of my fixes) — same SIGABRT under default thread parallelism, passes single-threaded. Not introduced by this change.

Going to run a live integration smoke (PATCH round-trip → config.toml persistence → reload visible) before declaring complete.

@github-actions github-actions Bot added needs-changes Changes requested by reviewer and removed ready-for-review PR is ready for maintainer review labels Apr 26, 2026
Live integration smoke surfaced a gap in the previous hot-reload
commit: appending or editing `[[taint_rules]]` returned
`status: no_changes` because `build_reload_plan` did not diff that
field, so `should_apply_hot` returned false and the swap-store was
never reached.

- New `HotAction::ReloadTaintRules` variant; pushed when
  `old.taint_rules != new.taint_rules`.
- Logged from `apply_hot_actions_inner` for audit-trail visibility.
- Derive `PartialEq + Eq` on `NamedTaintRuleSet` so the diff compiles
  (other rule types in the chain — `McpTaintRuleSetAction` and
  `TaintRuleId` — already had it).
- Reverted the temporary `else if` in `reload_config` that performed
  a partial swap when the plan was empty; the diff now correctly
  drives the canonical `should_apply_hot` path so no fallback is
  needed.

Live smoke (`PATCH /api/mcp/servers/{name}/taint` + reload-config
flows on an isolated `LIBREFANG_HOME`) now reports:
- first reload after adding `[[taint_rules]]` → `hot_actions_applied:
  ["UpdateDefaultModel", "ReloadTaintRules"]`
- idempotent second reload → `status: no_changes`
- editing only `action = "warn"` → `action = "log"` → `hot_actions_applied:
  ["ReloadTaintRules"]`
@houko

houko commented Apr 26, 2026

Copy link
Copy Markdown
Contributor Author

Live integration smoke done — pushed 0abf3848 after smoke surfaced one more gap.

Smoke setup

Isolated LIBREFANG_HOME=/tmp/librefang-smoke-3193, fresh release binary, single MCP server stub in config.

PATCH endpoint round-trip ✅

PATCH /api/mcp/servers/stub-test/taint  {"taint_scanning": false}
  → {"reload":"applied","status":"updated","name":"stub-test"}
GET /api/mcp/servers/stub-test
  → taint_scanning: false   ✓ live
config.toml on disk
  → taint_scanning = false  ✓ persisted
PATCH /api/mcp/servers/stub-test/taint  with full taint_policy
  → {"reload":"applied","status":"updated"}
GET /api/mcp/servers/stub-test
  → taint_policy.tools.navigate.default = "skip"
                       .rule_sets        = ["browser_handles"]   ✓ live
config.toml
  → [mcp_servers.taint_policy.tools.navigate]
    default = "skip"
    rule_sets = ["browser_handles"]                              ✓ persisted

Hot-reload of [[taint_rules]] ⚠️ → ✅ (caught a gap)

First smoke iteration showed status: no_changes when only [[taint_rules]] changed — build_reload_plan wasn't diffing that field, so should_apply_hot returned false and the swap-store never ran. Pushed 0abf3848:

  • New HotAction::ReloadTaintRules variant.
  • Diff in build_reload_plan checks old.taint_rules != new.taint_rules and pushes the variant.
  • apply_hot_actions_inner logs the action.
  • Derived PartialEq + Eq on NamedTaintRuleSet (other types in the chain already had them).
  • Reverted the temporary else if fallback in reload_config — now the canonical should_apply_hot path correctly drives the swap.

After fix:

append [[taint_rules]] then POST /api/config/reload
  → hot_actions_applied: ["UpdateDefaultModel", "ReloadTaintRules"]
     status: applied                                              ✓
second reload (no edits)
  → hot_actions_applied: []
     status: no_changes                                           ✓ idempotent
edit action: "warn" → "log", reload
  → hot_actions_applied: ["ReloadTaintRules"]
     status: applied                                              ✓ targeted

Status

All four review concerns now fixed and live-verified:

  • 🔴 multi-rule masking — fixed in ef54effb, regression test added.
  • 🟡 snapshot semantics — replaced with live Arc<ArcSwap> shared registry in e4b40588; reload-plan integration in 0abf3848.
  • 🟡 5x duplicated snapshot expr — snapshot_taint_rules() helper in ef54effb.
  • 🟡 dashboard PUT coupling — dedicated PATCH /api/mcp/servers/{name}/taint endpoint in e4b40588.

Plus the pre-existing test toml bug (mcp_servers.tools.* shape) fixed in ef54effb. The multi-threaded kernel-test SIGABRT was confirmed pre-existing on 02eb92af (the original PR head before any fixes).

@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed needs-changes Changes requested by reviewer ready-for-review PR is ready for maintainer review labels Apr 26, 2026
@github-actions github-actions Bot added needs-changes Changes requested by reviewer ready-for-review PR is ready for maintainer review has-conflicts PR has merge conflicts that need resolution and removed needs-changes Changes requested by reviewer ready-for-review PR is ready for maintainer review labels Apr 26, 2026
houko added 2 commits April 26, 2026 11:02
…aint-policy

# Conflicts:
#	crates/librefang-kernel/src/config_reload.rs
#	crates/librefang-kernel/src/kernel/mod.rs
Two follow-ups from PR review:

1. The TaintPolicyEditor doc comment said the editor saves through the
   "existing PUT /api/mcp/servers/{id} endpoint" but actually goes through
   the new dedicated PATCH /api/mcp/servers/{id}/taint added in this PR.
   Updated the comment to match and noted why PATCH is preferred (no
   revalidation of unrelated McpServerConfig fields on every taint edit).

2. When a tool's `rule_sets` list references multiple sets that overlap
   on the same TaintRuleId, the scanner picks the *most permissive*
   action (Log > Warn > Block). This is intentional but counter-intuitive
   — adding an `audit_only` set with `action = log` will silently
   neutralise any `block` set that overlaps on the same rule.

   Added a prominent rustdoc paragraph on `McpTaintRuleSetAction` and an
   inline UI hint next to the dashboard `rule_sets` field (en + zh
   locales) so operators authoring config aren't surprised.
@github-actions github-actions Bot added ready-for-review PR is ready for maintainer review and removed has-conflicts PR has merge conflicts that need resolution labels Apr 26, 2026
houko and others added 6 commits April 26, 2026 13:53
…ffers

`HotAction::ReloadMcpServers` previously only refreshed
`effective_mcp_servers` and the health registry; existing connections
held their per-server `McpServerConfig` clone (including `taint_policy`,
`taint_scanning`, `headers`, `env`, `transport`), so edits via
PUT `/api/mcp/servers/{name}`, direct `config.toml` edits, or any
non-PATCH reload path silently kept the old policy alive on
already-connected servers — a security drift hazard for operators
tightening rules and assuming the change took effect.

`taint_rule_sets` was already shared via `Arc<ArcSwap>` in the previous
fix commit; this change covers the rest of the per-server snapshot.

- Snapshot the prior effective list, diff each entry by JSON-serialized
  content (robust against future field additions, no `PartialEq`
  required on `McpOAuthConfig` / transport variants / `McpTaintPolicy`).
- Servers that are removed or whose entry differs go into `to_reconnect`.
- After updating `effective_mcp_servers` and bumping `mcp_generation`,
  fire-and-forget a task that disconnects the stale slots and calls
  `connect_mcp_servers()` (idempotent — re-adds servers missing from
  `mcp_connections` using the freshly-installed effective list).
- The PATCH `/api/mcp/servers/{name}/taint` endpoint kept its explicit
  disconnect+reconnect; this change makes the reload-config path behave
  the same.
The MCP taint scanner short-circuits when a tool's `default = "skip"`
and returns before the rule-set walker runs, so any `rule_sets` on the
same tool — including audit-only `Log` sets — never fire. The dashboard
editor still let operators fill `rule_sets` on a `default = skip` tool,
which silently became dead config and could give a false sense of "I
have audit logging on this noisy tool".

- Disable the `rule_sets` Input when `policy.default === "skip"` so new
  values can't be added via the UI.
- Show a warning hint when the saved policy already has `rule_sets`
  alongside `default = skip`, telling the operator to switch back to
  `default = scan` if they want audit-only behaviour.
- Existing `rule_sets` values are preserved in state (not silently
  dropped on save) so toggling default back to `scan` restores them.

Adds `mcp.taint_skip_rule_sets_hint` in en/zh locales.
Two follow-up fixes for the granular MCP taint policy review:

**Low #3 — JSONPath dotted-key limitation**
The pattern parser splits on `.` and treats `[` as the start of array
notation, so a JSON object key containing `.` or `[` (e.g. `"a.b"`,
`"items[0]"`) cannot be addressed precisely. Quoted-segment syntax
(`$.headers."content-type"`) is also not parsed. In practice MCP tool
schemas rarely use such keys, but the matcher fails closed (no false
positive skip) when it bites.

- Add a "Limitation" section to `jsonpath_matches` rustdoc.
- New regression test `test_jsonpath_dotted_keys_are_known_limitation`
  pins the current behaviour so a future change has to opt in.

**Low #4 — silent typos in `tool_policy.rule_sets`**
`lookup_rule_set_action` previously returned `None` when a referenced
name was missing from the registry, so an operator typo (e.g.
`"audit_typo"` instead of `"audit"`) became a silent no-op. Now:

- New `warn_unknown_rule_set_once(name, tool)` emits a one-shot WARN
  on first scan that observes a missing name, deduped per process via
  a static `OnceLock<Mutex<HashSet<String>>>`.
- Wired into `lookup_rule_set_action` at the existing skip site.
- New `GET /api/mcp/taint-rules` exposes the registered
  `[[taint_rules]]` names + actions so the dashboard can validate
  inputs *before* the operator hits Save (separate commit handles the
  frontend wiring; the endpoint is read-only and reuses the existing
  auth chain).
- Regression test verifies the unknown-name path returns `None` and
  doesn't panic / corrupt the dedup set on repeated calls.
The handler was added to the router in the previous commit but I
forgot to list it in `openapi.rs::ApiDoc`, so `openapi.json` was
silently re-generated without the new path and the dashboard's typed
client wouldn't see the route in the contract.

Trivial follow-up — adds one line under the MCP block.
Closes the dashboard half of Low #4 from the #3193 review. Backend
already emits a one-shot WARN for unknown names; this wires the
new `GET /api/mcp/taint-rules` endpoint into the editor so typos
surface *before* the operator hits Save.

- `listMcpTaintRules` (api.ts) + `useMcpTaintRules` (queries/mcp.ts)
  wrap the registry endpoint with a 5-minute staleTime — operators
  don't expect rule-set renames to land instantly and there's no
  hot churn here.
- `mcpKeys.taintRules()` factory follows the existing hierarchy
  (`mcpKeys.all` invalidation still nukes it).
- `TaintPolicyEditor` derives `knownRuleSetNames: Set<string>` once
  and threads it down to each `ToolPolicyRow`.
- Each row computes `unknownRuleSetNames` from the comma-typed input
  and renders a red inline hint listing the offenders. Falls back
  silently when the registry is empty (`knownRuleSetNames.size === 0`)
  so a fresh kernel without `[[taint_rules]]` doesn't false-positive.
- New `mcp.taint_rule_sets_unknown_hint` in en/zh locales.
- Regenerated `openapi.json` to include the new path (the pre-commit
  hook missed the previous backend-only commit because the registration
  in `openapi.rs` was added in the immediately following commit).
@github-actions github-actions Bot added the area/sdk JavaScript and Python SDKs label Apr 26, 2026
@houko
houko merged commit 95afdda into main Apr 26, 2026
20 checks passed
@houko
houko deleted the feat/issue-3050-mcp-taint-policy branch April 26, 2026 05:59
@github-actions github-actions Bot removed the ready-for-review PR is ready for maintainer review label Apr 26, 2026
@houko houko mentioned this pull request Apr 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/kernel Core kernel (scheduling, RBAC, workflows) area/runtime Agent loop, LLM drivers, WASM sandbox area/sdk JavaScript and Python SDKs size/XL 1000+ lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(runtime): granular per-tool/per-path/per-rule MCP taint policy + dashboard tree editor

1 participant