Skip to content

Commit dff476f

Browse files
committed
fix(gateway): Phase 1 — declare-root-before-grant gate for rootless clients
Autonomous decisions: - Reused ResolutionSource::PendingRoots for the awaiting-declaration state — matches existing prior art and keeps the enum small per planning doc decision 5 - Gate runs only when grants are non-empty — rootless sessions without grants still resolve to Unbound unchanged Signed-off-by: crimsonsunset <jsangio1@gmail.com>
1 parent f0355de commit dff476f

2 files changed

Lines changed: 144 additions & 18 deletions

File tree

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

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
//!
1313
//! // Tier 1b — roots reported, no binding matched
1414
//! if session reported roots AND no binding matched:
15-
//! return (scoped_space, [], Unbound) // deny by default
16-
//! // (upstream emits WorkspaceNeedsBinding so the user can bind)
15+
//! if roots_capable == false (true rootless):
16+
//! fall through to Tier 3 (declare-root gate + grant lookup)
17+
//! else:
18+
//! return (scoped_space, [], Unbound) // deny by default
19+
//! // (upstream emits WorkspaceNeedsBinding so the user can bind)
1720
//!
1821
//! // Tier 1c — declared `roots` but they haven't arrived yet
1922
//! if session declared `roots` AND none yet in registry:
@@ -28,7 +31,10 @@
2831
//!
2932
//! // Signal 3 — client identity (rootless-by-design: Claude.ai web, …)
3033
//! if client has grants in the default space:
31-
//! return (default_space, grants, ClientGrant)
34+
//! if rootless session AND no declared root yet:
35+
//! return ([], default_space, PendingRoots) // meta tools only
36+
//! else:
37+
//! return (default_space, grants, ClientGrant)
3238
//!
3339
//! // Tier 4 — no roots, no id binding, no grants
3440
//! return (default_space, [], Unbound)
@@ -522,21 +528,34 @@ impl FeatureSetResolverService {
522528
);
523529
}
524530
// Tier 1b: had roots, no binding (or binding outside lock).
525-
// The folder is unmapped — deny by default. When locked, scope
526-
// `space_id` to the locked Space; otherwise longest-prefix base dir.
527-
let target_space = if space_lock.is_some() {
528-
deny_space_id
531+
if roots_capable_known == Some(false) {
532+
// True rootless client declared a root (e.g. via
533+
// `mcpmux_set_workspace_root`) but it didn't exact-match any
534+
// binding — fall through to Tier 3 grant lookup instead of
535+
// hard-denying. The pre-Tier-3 gate treats the declared root
536+
// as the identity signal it was waiting for.
537+
debug!(
538+
session_id = %sid,
539+
"[FeatureSetResolver] rootless session declared root but no binding matched — fall through to Tier 3",
540+
);
529541
} else {
530-
self.space_for_roots(&reported_roots)
531-
.await?
532-
.unwrap_or(default_space_id)
533-
};
534-
debug!(
535-
%target_space,
536-
scoped_by_base_dir = target_space != default_space_id,
537-
"[FeatureSetResolver] roots reported but no binding matched — Unbound",
538-
);
539-
return Ok(self.unbound(target_space));
542+
// Roots-capable or still-probing session: unmapped folder
543+
// denies by default. When locked, scope `space_id` to the
544+
// locked Space; otherwise longest-prefix base dir.
545+
let target_space = if space_lock.is_some() {
546+
deny_space_id
547+
} else {
548+
self.space_for_roots(&reported_roots)
549+
.await?
550+
.unwrap_or(default_space_id)
551+
};
552+
debug!(
553+
%target_space,
554+
scoped_by_base_dir = target_space != default_space_id,
555+
"[FeatureSetResolver] roots reported but no binding matched — Unbound",
556+
);
557+
return Ok(self.unbound(target_space));
558+
}
540559
}
541560

542561
// Tier 1c: client declared `roots` but none have ARRIVED yet
@@ -627,6 +646,30 @@ impl FeatureSetResolverService {
627646
.get_grants_for_space(cid, &grant_space_id.to_string())
628647
.await?;
629648
if !grants.is_empty() {
649+
// Pre-Tier-3 gate: rootless sessions must declare a workspace
650+
// root (via `mcpmux_set_workspace_root` or equivalent) before
651+
// the blanket grant unlocks. Reuses PendingRoots so meta tools
652+
// stay reachable while the client self-unblocks.
653+
if let Some(sid) = session_id {
654+
if self.session_roots.is_roots_capable(sid) == Some(false) {
655+
let has_declared_root = self
656+
.session_roots
657+
.get(sid)
658+
.is_some_and(|roots| !roots.is_empty());
659+
if !has_declared_root {
660+
debug!(
661+
session_id = %sid,
662+
client_id = %cid,
663+
"[FeatureSetResolver] rootless session has grant but no declared root — PendingRoots",
664+
);
665+
return Ok(ResolvedFeatureSet {
666+
feature_set_ids: vec![],
667+
space_id: Some(grant_space_id),
668+
source: ResolutionSource::PendingRoots,
669+
});
670+
}
671+
}
672+
}
630673
debug!(
631674
client_id = %cid,
632675
space_id = %grant_space_id,

tests/rust/tests/integration/feature_set_resolver.rs

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,8 +554,67 @@ async fn rootless_client_uses_grants() {
554554
.await
555555
.unwrap();
556556

557-
// Session declared no roots capability — Tier-2 grant lookup applies.
557+
// Session declared no roots capability — Tier-3 grant lookup applies once
558+
// the session has declared a workspace root (declare-root gate).
558559
f.session_roots.set_roots_capable("s", false);
560+
let declared = if cfg!(windows) {
561+
"d:\\workspace\\repo"
562+
} else {
563+
"/workspace/repo"
564+
};
565+
f.session_roots.set("s", [declared]);
566+
let r = f
567+
.resolver
568+
.resolve(Some("s"), Some(client_id), None)
569+
.await
570+
.unwrap();
571+
assert_eq!(r.source, ResolutionSource::ClientGrant);
572+
assert_eq!(r.feature_set_ids, vec![f.fs_a_id]);
573+
}
574+
575+
#[tokio::test]
576+
async fn rootless_client_with_grant_but_no_declared_root_is_pending() {
577+
// Phase 1 gate: blanket grant does not unlock until the rootless client
578+
// declares a workspace root (e.g. via mcpmux_set_workspace_root).
579+
let f = Fixture::new().await;
580+
let client_id = "rootless.example/grant-no-declare";
581+
f.make_client(client_id).await;
582+
f.client_repo
583+
.grant_feature_set(client_id, &f.space_id.to_string(), &f.fs_a_id)
584+
.await
585+
.unwrap();
586+
587+
f.session_roots.set_roots_capable("s", false);
588+
let r = f
589+
.resolver
590+
.resolve(Some("s"), Some(client_id), None)
591+
.await
592+
.unwrap();
593+
assert_eq!(r.source, ResolutionSource::PendingRoots);
594+
assert!(r.feature_set_ids.is_empty());
595+
assert_eq!(r.space_id, Some(f.space_id));
596+
}
597+
598+
#[tokio::test]
599+
async fn rootless_declared_unmatched_root_falls_through_to_grant() {
600+
// Tier 1b fall-through: a true rootless client that declared a root with
601+
// no exact binding match must still reach its blanket grant (today's
602+
// fallback), not hard-deny.
603+
let f = Fixture::new().await;
604+
let client_id = "rootless.example/cloud-agent";
605+
f.make_client(client_id).await;
606+
f.client_repo
607+
.grant_feature_set(client_id, &f.space_id.to_string(), &f.fs_a_id)
608+
.await
609+
.unwrap();
610+
611+
let cloud_root = if cfg!(windows) {
612+
"d:\\workspace\\repo"
613+
} else {
614+
"/workspace/repo"
615+
};
616+
f.session_roots.set_roots_capable("s", false);
617+
f.session_roots.set("s", [cloud_root]);
559618
let r = f
560619
.resolver
561620
.resolve(Some("s"), Some(client_id), None)
@@ -565,6 +624,30 @@ async fn rootless_client_uses_grants() {
565624
assert_eq!(r.feature_set_ids, vec![f.fs_a_id]);
566625
}
567626

627+
#[tokio::test]
628+
async fn roots_capable_unmapped_root_stays_unbound_not_grant() {
629+
// Regression: Tier 1b hard-deny for roots-capable sessions is unchanged.
630+
let f = Fixture::new().await;
631+
let client_id = "cursor.example/unmapped";
632+
f.make_client(client_id).await;
633+
f.client_repo
634+
.grant_feature_set(client_id, &f.space_id.to_string(), &f.fs_a_id)
635+
.await
636+
.unwrap();
637+
638+
let other = if cfg!(windows) { "d:\\tmp" } else { "/tmp" };
639+
f.session_roots.set("s", [other]);
640+
f.session_roots.set_roots_capable("s", true);
641+
let r = f
642+
.resolver
643+
.resolve(Some("s"), Some(client_id), None)
644+
.await
645+
.unwrap();
646+
assert_eq!(r.source, ResolutionSource::Unbound);
647+
assert!(r.feature_set_ids.is_empty());
648+
assert_ne!(r.feature_set_ids, vec![f.fs_a_id]);
649+
}
650+
568651
#[tokio::test]
569652
async fn rootless_client_without_grants_falls_back_to_default() {
570653
let f = Fixture::new().await;

0 commit comments

Comments
 (0)