feat(runtime): granular MCP taint policy + dashboard tree editor (closes #3050)#3193
Conversation
… 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
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Deploying librefang-docs with
|
| Latest commit: |
a04fd8f
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://bb63fcac.librefang-docs-web.pages.dev |
| Branch Preview URL: | https://feat-issue-3050-mcp-taint-po.librefang-docs-web.pages.dev |
Code Review: PR #3193OverviewCloses #3050 by layering three granular controls onto the existing MCP taint scanner: a per-tool kill switch ( 🔴 Correctness: multi-rule downgrade can silently mask unrelated violations
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 Concrete failure mode: a payload like [[taint_rules]]
name = "browser_handles"
action = "warn"
rules = ["well_known_prefix"]referenced by the tool, Suggested fix: either return all matching rules ( No test in the new suite covers this case — adding one would be a good guard. 🟡 Snapshot semantics for
|
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.
|
Pushed 🔴 Multi-rule masking fixed
🟡 Kernel helper extracted
🟡 Snapshot semantics documented
Pre-existing test fix
Verification
The earlier dashboard-coupling concern about |
…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.
|
Pushed Hot-reload
Dedicated
Pre-existing kernel-test SIGABRT verified pre-existing
Going to run a live integration smoke (PATCH round-trip → config.toml persistence → reload visible) before declaring complete. |
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"]`
|
Live integration smoke done — pushed Smoke setupIsolated PATCH endpoint round-trip ✅Hot-reload of
|
…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.
…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).
Summary
Granular per-tool / per-path / per-rule MCP taint policy plus a dashboard tree editor.
McpTaintToolPolicygainsdefault: McpTaintToolAction(scan|skip) so a single line bypasses scanning for noisy tools (browser tab handles, DB session IDs, etc.).[[taint_rules]]config table (NamedTaintRuleSet:name,action=block|warn|log,rules). Tool policies reference them viarule_sets; the scanner picks the most permissive action across overlapping sets and emits structured tracing events forwarn/log.KernelConfig.taint_rulesthrough every kernel install/reload path into the runtimeMcpServerConfig.taint_rule_setssnapshot.$.foo,$.foo.*,$.foo[*],$.*) are now documented in rustdoc onMcpTaintPathPolicyand covered by a dedicated test against the matcher./api/mcp/serversand/api/mcp/servers/{name}now surfacetaint_scanning+taint_policyso the dashboard hydrates without a second fetch.TaintPolicyEditor(server → tool → path tree, in-place add/edit/remove, default action selector,rule_setsfield, per-path rule toggles) hooked intoMcpServersPagevia a "Taint" card action. Saves go throughuseUpdateMcpTaintPolicywhich invalidatesmcpKeys.servers/mcpKeys.server/mcpKeys.health— no full config reload needed.Acceptance criteria
McpTaintToolPolicy.defaultfield added (scan|skip).*,[*]) documented in rustdoc + unit tests[[taint_rules]]table withname,action(block|warn|log),rulesMcpTaintToolPolicy.rule_setsreferences named setscargo clippy -p librefang-types -p librefang-runtime-mcp -- -D warningsclean (full-workspace clippy left to CI)Test plan
cargo test -p librefang-types -- taint— 30 / 30 passcargo test -p librefang-runtime-mcp— 87 / 87 pass (covers tool-leveldefault = skip, all three rule-set severity tiers,warnprecedence overblockwhen sets overlap, wildcard$.metadata.*exemption)cargo test -p librefang-runtime --test mcp_oauth_integration— 9 / 9 pass (verifies the newtaint_rule_setsfield doesn't break OAuth wiring)cargo check -p librefang-kernel -p librefang-api -p librefang-runtime— clean (noDefaultimpl drift)cargo clippy -p librefang-types -p librefang-runtime-mcp -p librefang-kernel -p librefang-api -- -D warnings— cleantsc --noEmit— no new errors introduced (existing pre-existingsessions-stream.test.tsxerrors unrelated to this PR)/api/mcp/servers/{name}withtaint_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