Skip to content

Commit 472dca0

Browse files
committed
feat(gateway): Phase 1 — resolver deny by default (Unbound)
Autonomous decisions: - Removed default_fallback() instead of keeping it dead; all three call sites now use unbound(). - Kept feature_set_repo on FeatureSetResolverService for constructor API stability (no longer read at resolve time). Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent 4750935 commit 472dca0

4 files changed

Lines changed: 149 additions & 195 deletions

File tree

crates/mcpmux-gateway/src/services/feature_set_resolver.rs

Lines changed: 65 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,63 @@
11
//! FeatureSet Resolver Service.
22
//!
3-
//! Capability-branched resolution. The branch point is the MCP `roots`
4-
//! capability declared by the client at `initialize`:
3+
//! Binding-canonical resolution. The resolver answers one question: *which
4+
//! [`WorkspaceBinding`](mcpmux_core::WorkspaceBinding) (if any) matches the
5+
//! signals this caller presents?* Signals are ranked; none are mandatory:
56
//!
67
//! ```text
78
//! resolve(session_id, client_id):
8-
//! // Tier 1 — roots-capable session with reported roots
9+
//! // Signal 1 — reported root (deprecated MCP primitive, SEP-2577)
910
//! if session reported roots AND a binding matches:
1011
//! return (binding.space_id, [binding.feature_set_id], WorkspaceBinding)
1112
//!
12-
//! // Tier 1b — roots-capable, roots reported, but no binding yet
13+
//! // Tier 1b — roots reported, no binding matched
1314
//! if session reported roots AND no binding matched:
14-
//! return (default_space, [starter_fs], SpaceDefault) // unmapped folder
15-
//! // (also emits WorkspaceNeedsBinding upstream so the user can still map)
15+
//! return (scoped_space, [], Unbound) // deny by default
16+
//! // (upstream emits WorkspaceNeedsBinding so the user can bind)
1617
//!
1718
//! // Tier 1c — declared `roots` but they haven't arrived yet
1819
//! if session declared `roots` AND none yet in registry:
1920
//! if within the pending-roots grace window:
2021
//! return ([], default_space, PendingRoots) // wait for the root
2122
//! else:
22-
//! return (default_space, [starter_fs], SpaceDefault) // gave up waiting
23+
//! return (default_space, [], Unbound) // gave up waiting
2324
//!
24-
//! // Tier 2 — rootless-by-design (Claude.ai web, ChatGPT, …)
25+
//! // Signal 2 — client identity (rootless-by-design: Claude.ai web, …)
2526
//! if client has grants in the default space:
2627
//! return (default_space, grants, ClientGrant)
2728
//!
2829
//! // Tier 3 — no roots, no grants
29-
//! return (default_space, [starter_fs], SpaceDefault)
30+
//! return (default_space, [], Unbound)
3031
//! ```
3132
//!
32-
//! # Default fallback (the "every folder needs mapping" fix)
33+
//! Signal 3 (machine) scopes binding lookup: client machine → local machine
34+
//! → global. A path bound only on another machine does not match here.
3335
//!
34-
//! When nothing more specific resolves — an unmapped folder (Tier 1b), a
35-
//! rootless client with no grants, or a roots-capable client that never
36-
//! reported a folder (Tier 1c after the grace window) — the resolver falls
37-
//! back to the **default Space's Starter FeatureSet** instead of denying.
38-
//! That makes folders work out of the box: a freshly-opened project gets the
39-
//! Starter tools immediately, and the user only *needs* an explicit
40-
//! [`WorkspaceBinding`](mcpmux_core::WorkspaceBinding) when they want a folder
41-
//! to see something *other* than the default. The Starter FS's membership is
42-
//! the control surface: edit it to change what every unmapped folder sees, or
43-
//! empty it to grant nothing by default. (The Starter is builtin and can't be
44-
//! deleted, so the fallback always has a target.)
36+
//! ## Deny by default
4537
//!
46-
//! ## Grace window — avoid "default then mapped" flips
38+
//! When no binding matches — an unmapped folder (Tier 1b), a rootless client
39+
//! with no grants, or a roots-capable client that never reported a folder
40+
//! (Tier 1c after the grace window) — the resolver returns
41+
//! [`ResolutionSource::Unbound`] with empty `feature_set_ids`. Callers get
42+
//! zero backend tools until they have an explicit binding (or a client grant
43+
//! for rootless clients). Meta management tools are appended unconditionally
44+
//! by the request handler.
45+
//!
46+
//! ## Grace window — avoid "empty then mapped" flips
4747
//!
4848
//! A roots-capable client that's *about* to report a folder must resolve
49-
//! straight to that folder's binding (or the default-for-unmapped), never
50-
//! flash the default tools first and then flip. So while a session has
51-
//! declared (or might declare) `roots` and none have arrived yet, the
52-
//! resolver holds at `PendingRoots` (empty) for a short grace window rather
53-
//! than defaulting immediately. Only once the window lapses with no root in
54-
//! sight does it settle on `SpaceDefault`, so a misbehaving client that never
55-
//! reports isn't stranded on meta-tools forever. A roots-capable session
56-
//! **never** falls through to another client's grants — after the grace it
57-
//! goes straight to the Space default, preserving per-session isolation.
49+
//! straight to that folder's binding (or `Unbound`), never flash tools first
50+
//! and then flip. So while a session has declared (or might declare) `roots`
51+
//! and none have arrived yet, the resolver holds at `PendingRoots` (empty) for
52+
//! a short grace window. Only once the window lapses with no root in sight
53+
//! does it settle on `Unbound`. A roots-capable session **never** falls
54+
//! through to another client's grants — after the grace it goes straight to
55+
//! `Unbound`, preserving per-session isolation.
5856
//!
59-
//! The caller's client identity is used **only** for the rootless fallback —
60-
//! every roots-capable session routes via its own reported roots, regardless
61-
//! of which OAuth client opened it. This is what makes "two VS Code windows
62-
//! sharing one OAuth identity" route independently.
57+
//! The caller's client identity is used **only** for the rootless Tier-2 grant
58+
//! lookup — every roots-capable session routes via its own reported roots,
59+
//! regardless of which OAuth client opened it. This is what makes "two VS Code
60+
//! windows sharing one OAuth identity" route independently.
6361
//!
6462
//! # Trust model (deliberate design decision)
6563
//!
@@ -126,17 +124,18 @@ pub enum ResolutionSource {
126124
/// Rootless-by-design client. The space-default's per-client
127125
/// `client_grants` were applied.
128126
ClientGrant,
129-
/// Fell back to the default Space's Starter FeatureSet because nothing
130-
/// more specific resolved — an unmapped folder, a rootless client with
131-
/// no grants, or a roots-capable client that never reported a folder.
132-
/// For the unmapped-folder subcase the upstream caller still emits
133-
/// `WorkspaceNeedsBinding` so the user can attach an explicit mapping.
127+
/// No binding matched; deny by default (empty `feature_set_ids`). Carries
128+
/// `space_id` for base-dir context and the bind CTA. Upstream emits
129+
/// `WorkspaceNeedsBinding` for unmapped folders.
130+
Unbound,
131+
/// Deprecated — no longer produced by the resolver. Retained for serde
132+
/// compatibility (e.g. `set_workspace_root` responses that may still
133+
/// carry this value from older gateway versions).
134+
#[doc(hidden)]
134135
SpaceDefault,
135136
/// No FeatureSet resolved at all. Defensive: reached only when there's no
136137
/// default Space, or — degenerately — the default Space somehow has no
137-
/// Starter FeatureSet. The Starter is builtin and seeded with every Space,
138-
/// so this is normally unreachable; to grant nothing by default the user
139-
/// empties the Starter (still `SpaceDefault`, just with no members).
138+
/// Starter FeatureSet.
140139
Deny,
141140
}
142141

@@ -300,41 +299,15 @@ impl FeatureSetResolverService {
300299
self
301300
}
302301

303-
/// Fall back to `space_id`'s Starter FeatureSet. `space_id` is the global
304-
/// default Space for rootless sessions, or a base-dir-scoped Space for an
305-
/// unmapped folder under that Space's base directory. Returns
306-
/// [`ResolutionSource::SpaceDefault`] when a Starter exists (the normal
307-
/// path — it's builtin and seeded per Space), or, defensively,
308-
/// [`ResolutionSource::Deny`] in the degenerate case where the Space has no
309-
/// Starter.
310-
async fn default_fallback(&self, space_id: Uuid) -> Result<ResolvedFeatureSet> {
311-
if let Some(fs) = self
312-
.feature_set_repo
313-
.get_starter_for_space(&space_id.to_string())
314-
.await?
315-
{
316-
debug!(
317-
%space_id,
318-
feature_set_id = %fs.id,
319-
"[FeatureSetResolver] resolved via SpaceDefault (Starter fallback)",
320-
);
321-
return Ok(ResolvedFeatureSet {
322-
feature_set_ids: vec![fs.id],
323-
space_id: Some(space_id),
324-
source: ResolutionSource::SpaceDefault,
325-
collision_client_id: None,
326-
});
327-
}
328-
debug!(
329-
%space_id,
330-
"[FeatureSetResolver] no Starter FeatureSet in Space — deny",
331-
);
332-
Ok(ResolvedFeatureSet {
302+
/// Deny by default: no binding matched. Carries `space_id` for base-dir
303+
/// context and the bind CTA; `feature_set_ids` is empty.
304+
fn unbound(&self, space_id: Uuid) -> ResolvedFeatureSet {
305+
ResolvedFeatureSet {
333306
feature_set_ids: vec![],
334307
space_id: Some(space_id),
335-
source: ResolutionSource::Deny,
308+
source: ResolutionSource::Unbound,
336309
collision_client_id: None,
337-
})
310+
}
338311
}
339312

340313
/// Borrow the session-roots registry. The notifier uses this to GC
@@ -419,24 +392,21 @@ impl FeatureSetResolverService {
419392
collision_client_id: None,
420393
});
421394
}
422-
// Tier 1b: had roots, no binding. The folder is unmapped, so
423-
// fall back to a Starter FS — the folder works immediately
424-
// instead of getting nothing. Scope it to the Space whose base
395+
// Tier 1b: had roots, no binding. The folder is unmapped —
396+
// deny by default. Scope `space_id` to the Space whose base
425397
// directory claims the root (longest-prefix), if any; otherwise
426-
// the global default Space. Upstream still emits
427-
// WorkspaceNeedsBinding (it prompts on SpaceDefault too) so the
428-
// user can attach an explicit mapping for something other than
429-
// the default.
398+
// the global default Space. Upstream emits WorkspaceNeedsBinding
399+
// so the user can attach an explicit binding.
430400
let target_space = self
431401
.space_for_roots(&reported_roots)
432402
.await?
433403
.unwrap_or(default_space_id);
434404
debug!(
435405
%target_space,
436406
scoped_by_base_dir = target_space != default_space_id,
437-
"[FeatureSetResolver] roots reported but no binding matched — SpaceDefault",
407+
"[FeatureSetResolver] roots reported but no binding matched — Unbound",
438408
);
439-
return self.default_fallback(target_space).await;
409+
return Ok(self.unbound(target_space));
440410
}
441411

442412
// Tier 1c: client declared `roots` but none have ARRIVED yet
@@ -472,17 +442,16 @@ impl FeatureSetResolverService {
472442
collision_client_id: None,
473443
});
474444
}
475-
// Grace lapsed with no root in sight — settle on the Space
476-
// default rather than stranding the client on meta-tools
477-
// forever. Go STRAIGHT to the default (not via Tier-2 grants):
478-
// a roots-capable session must never pick up another client's
479-
// grants (per-session isolation invariant).
445+
// Grace lapsed with no root in sight — deny by default. Go
446+
// STRAIGHT to Unbound (not via Tier-2 grants): a roots-capable
447+
// session must never pick up another client's grants
448+
// (per-session isolation invariant).
480449
debug!(
481450
session_id = %sid,
482451
capability = ?roots_capable_known,
483-
"[FeatureSetResolver] pending-roots grace lapsed, no root reported — SpaceDefault",
452+
"[FeatureSetResolver] pending-roots grace lapsed, no root reported — Unbound",
484453
);
485-
return self.default_fallback(default_space_id).await;
454+
return Ok(self.unbound(default_space_id));
486455
}
487456
}
488457

@@ -516,16 +485,14 @@ impl FeatureSetResolverService {
516485
}
517486
}
518487

519-
// Tier 3 — no roots, no grants. Fall back to the Space default so a
520-
// bare client still gets the Starter tools instead of nothing. The
521-
// mcpmux_* meta tools are appended unconditionally by the request
522-
// handler regardless, so the LLM can always self-bind / ask the user
523-
// for a grant from here.
488+
// Tier 3 — no roots, no grants. Deny by default. The mcpmux_* meta
489+
// tools are appended unconditionally by the request handler regardless,
490+
// so the LLM can always self-bind / ask the user for a grant from here.
524491
debug!(
525492
space_id = %default_space_id,
526493
?client_id,
527-
"[FeatureSetResolver] no roots + no grants — SpaceDefault",
494+
"[FeatureSetResolver] no roots + no grants — Unbound",
528495
);
529-
self.default_fallback(default_space_id).await
496+
Ok(self.unbound(default_space_id))
530497
}
531498
}

tests/rust/tests/integration/effective_features.rs

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -269,16 +269,14 @@ async fn empty_mapping_yields_zero_effective_tools() {
269269
assert!(ctx.effective_tools("sess").await.is_empty());
270270
}
271271

272-
/// A reported root with no mapping falls back to the default Space's Starter
273-
/// FS (the "every folder needs mapping" fix) → it sees exactly the Starter's
274-
/// tools, not nothing. We drop one tool into the Starter and confirm the
275-
/// unmapped session resolves to `SpaceDefault` and sees precisely that tool.
272+
/// A reported root with no mapping resolves to `Unbound` (deny by default) —
273+
/// zero backend tools regardless of Starter FS membership.
276274
#[tokio::test(flavor = "multi_thread")]
277-
async fn unbound_session_falls_back_to_starter_fs() {
275+
async fn unbound_session_returns_no_tools() {
278276
let ctx = Ctx::new().await;
279277

280-
// Put one tool in the default Space's Starter FS so the fallback is
281-
// observable (the seeded Starter is otherwise empty).
278+
// Put one tool in the default Space's Starter FS — unbound sessions must
279+
// NOT see it (Starter is no longer the silent fallback).
282280
let starter = ctx
283281
.fs_repo
284282
.get_starter_for_space(&ctx.space_id_str)
@@ -299,19 +297,13 @@ async fn unbound_session_falls_back_to_starter_fs() {
299297
ctx.session_roots.set_roots_capable("sess", true);
300298

301299
let resolved = ctx.resolver.resolve(Some("sess"), None).await.unwrap();
302-
assert_eq!(resolved.source, ResolutionSource::SpaceDefault);
303-
assert_eq!(resolved.feature_set_ids, vec![starter.id]);
304-
assert_eq!(
305-
ctx.effective_tools("sess").await,
306-
vec!["create_issue".to_string()],
307-
);
300+
assert_eq!(resolved.source, ResolutionSource::Unbound);
301+
assert!(resolved.feature_set_ids.is_empty());
302+
assert!(ctx.effective_tools("sess").await.is_empty());
308303
}
309304

310-
/// The "grant nothing by default" off-switch: the Starter is builtin and can't
311-
/// be deleted, but an operator can EMPTY it. An empty Starter still resolves
312-
/// (source `SpaceDefault`), but yields zero effective tools — so unmapped
313-
/// folders see nothing until they're either bound or the Starter is populated.
314-
/// The seeded Starter starts empty, which is exactly this state.
305+
/// Unbound sessions get zero effective tools even when the Starter FS is
306+
/// populated — deny by default is independent of Starter membership.
315307
#[tokio::test(flavor = "multi_thread")]
316308
async fn empty_starter_grants_nothing_to_unbound_session() {
317309
let ctx = Ctx::new().await;
@@ -341,9 +333,8 @@ async fn empty_starter_grants_nothing_to_unbound_session() {
341333
ctx.session_roots.set_roots_capable("sess", true);
342334

343335
let resolved = ctx.resolver.resolve(Some("sess"), None).await.unwrap();
344-
assert_eq!(resolved.source, ResolutionSource::SpaceDefault);
345-
assert_eq!(resolved.feature_set_ids, vec![starter.id]);
346-
// Resolves to the Starter, but it grants nothing.
336+
assert_eq!(resolved.source, ResolutionSource::Unbound);
337+
assert!(resolved.feature_set_ids.is_empty());
347338
assert!(ctx.effective_tools("sess").await.is_empty());
348339
}
349340

0 commit comments

Comments
 (0)