Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion crates/detectord/crates/mcp_backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,13 @@ pub enum Error {
pub type Result<T> = std::result::Result<T, Error>;

/// The org policy flag governing quarantine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Policy {
pub quarantine: bool,
/// The org the backend attributes this policy to. Lets a caller verify the
/// answer came from the tenant it is enrolled in before acting on it.
/// `None` when the backend is too old to report one.
pub org_id: Option<String>,
}

/// Lifecycle status of a known server in the caller's org.
Expand Down Expand Up @@ -343,13 +347,16 @@ pub fn user_part_hash(composite_key: &str) -> String {
struct DomainConfigDto {
#[serde(default)]
auto_quarantine_other_mcp_servers: bool,
#[serde(default)]
org_id: Option<String>,
}

/// Parse the domain-config body into a [`Policy`].
pub fn parse_policy(body: &str) -> std::result::Result<Policy, String> {
let dto: DomainConfigDto = serde_json::from_str(body).map_err(|e| e.to_string())?;
Ok(Policy {
quarantine: dto.auto_quarantine_other_mcp_servers,
org_id: dto.org_id.filter(|s| !s.is_empty()),
})
}

Expand Down Expand Up @@ -471,6 +478,21 @@ mod tests {
assert!(!parse_policy(r#"{}"#).unwrap().quarantine);
}

#[test]
fn policy_carries_org_id_when_present() {
assert_eq!(
parse_policy(r#"{"org_id":"org-a","auto_quarantine_other_mcp_servers":true}"#)
.unwrap()
.org_id
.as_deref(),
Some("org-a")
);
// Absent or blank both mean "backend did not tell us", so callers can
// treat the tenant as unverifiable with one check.
assert!(parse_policy(r#"{}"#).unwrap().org_id.is_none());
assert!(parse_policy(r#"{"org_id":""}"#).unwrap().org_id.is_none());
}

#[test]
fn policy_rejects_garbage() {
assert!(parse_policy("not json").is_err());
Expand Down
15 changes: 12 additions & 3 deletions crates/detectord/crates/mcp_detector_daemon/src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,12 +382,21 @@ pub fn status(user: &str) -> anyhow::Result<Status> {
}

/// Re-fetch the policy (fail-closed) and return the updated status.
///
/// A failed fetch keeps the cached policy, but it is LOGGED rather than
/// swallowed: the returned `Status` is indistinguishable from a successful
/// refresh, so a silently-dropped error let a dead API key look like a
/// confirmed-fresh policy to whoever asked for the refresh.
pub async fn refresh_policy(user: &str) -> anyhow::Result<Status> {
if let Some(mut e) = Enrollment::load_for(user)? {
let client = BackendClient::new(e.api_base_url.clone(), e.api_key.clone());
if let Ok(p) = client.fetch_policy().await {
e.quarantine = p.quarantine;
e.save_for(user)?;
match client.fetch_policy().await {
// Same tenant check the reconcile loop applies; `apply_policy`
// persists on its own.
Ok(p) => crate::runner::apply_policy(&p, &mut e, user),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the enrollment file cannot be written, apply_policy discards save_for's error, so this refresh returns Ok(Status) after status() reloads the old policy. Make policy application fallible and propagate the persistence error here, as the previous direct save did.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/detectord/crates/mcp_detector_daemon/src/ops.rs, line 396:

<comment>When the enrollment file cannot be written, `apply_policy` discards `save_for`'s error, so this refresh returns `Ok(Status)` after `status()` reloads the old policy. Make policy application fallible and propagate the persistence error here, as the previous direct save did.</comment>

<file context>
@@ -382,12 +382,21 @@ pub fn status(user: &str) -> anyhow::Result<Status> {
+        match client.fetch_policy().await {
+            // Same tenant check the reconcile loop applies; `apply_policy`
+            // persists on its own.
+            Ok(p) => crate::runner::apply_policy(&p, &mut e, user),
+            Err(err) => {
+                tracing::warn!(error = %err, "policy refresh failed; keeping last-known-good")
</file context>

Err(err) => {
tracing::warn!(error = %err, "policy refresh failed; keeping last-known-good")
}
}
}
status(user)
Expand Down
97 changes: 88 additions & 9 deletions crates/detectord/crates/mcp_detector_daemon/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ pub async fn worker(user: String, enforce: bool, events: Option<EventTx>) -> any
paths::ensure_user_dir(&user)?;
let mut enrollment = Enrollment::load_for(&user)?
.ok_or_else(|| anyhow::anyhow!("not enrolled; run `enroll` first"))?;
let client = BackendClient::new(enrollment.api_base_url.clone(), enrollment.api_key.clone());
let mut client =
BackendClient::new(enrollment.api_base_url.clone(), enrollment.api_key.clone());
let mut seen = SeenStore::open(paths::seen_store_path(&user), enrollment.org_id.clone())?;
refresh(&client, &mut enrollment, &mut seen, &user).await;

Expand Down Expand Up @@ -106,6 +107,7 @@ pub async fn worker(user: String, enforce: bool, events: Option<EventTx>) -> any
);

let mut last_refresh = Instant::now();
let mut force_refresh = false;
let mut reported = HashSet::new();
loop {
// Re-read the enrollment from disk each pass so an onboarding-completion
Expand All @@ -123,6 +125,20 @@ pub async fn worker(user: String, enforce: bool, events: Option<EventTx>) -> any
if fresh.selected_agents != enrollment.selected_agents {
tracing::info!(agents = ?fresh.selected_agents, "selected agents changed");
}
// `client` is bound to the URL + key it was built from, so a
// re-enroll that repoints the daemon (a different deploy
// environment, or a rotated key) has to rebuild it. Without this
// the worker keeps polling the OLD backend for the whole life of
// the process: policy and fingerprints come back for the wrong
// tenant, and `refresh` persists that answer over the correct one.
if fresh.api_base_url != enrollment.api_base_url || fresh.api_key != enrollment.api_key
{
tracing::info!(url = %fresh.api_base_url, "backend credentials changed; rebuilding client");
client = BackendClient::new(fresh.api_base_url.clone(), fresh.api_key.clone());
// Don't sit on the wrong tenant's cached policy for up to
// REFRESH_INTERVAL; re-fetch on this pass.
force_refresh = true;
}
Comment on lines +134 to +141

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a running worker is re-enrolled into a different org, this block updates enrollment but leaves seen bound to the original org. Reopen SeenStore with fresh.org_id before assigning enrollment; otherwise release fingerprints are keyed as demo data, and a restart loses them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/detectord/crates/mcp_detector_daemon/src/runner.rs, line 134:

<comment>When a running worker is re-enrolled into a different org, this block updates `enrollment` but leaves `seen` bound to the original org. Reopen `SeenStore` with `fresh.org_id` before assigning `enrollment`; otherwise release fingerprints are keyed as demo data, and a restart loses them.</comment>

<file context>
@@ -123,6 +125,20 @@ pub async fn worker(user: String, enforce: bool, events: Option<EventTx>) -> any
+            // the worker keeps polling the OLD backend for the whole life of
+            // the process: policy and fingerprints come back for the wrong
+            // tenant, and `refresh` persists that answer over the correct one.
+            if fresh.api_base_url != enrollment.api_base_url || fresh.api_key != enrollment.api_key
+            {
+                tracing::info!(url = %fresh.api_base_url, "backend credentials changed; rebuilding client");
</file context>
Suggested change
if fresh.api_base_url != enrollment.api_base_url || fresh.api_key != enrollment.api_key
{
tracing::info!(url = %fresh.api_base_url, "backend credentials changed; rebuilding client");
client = BackendClient::new(fresh.api_base_url.clone(), fresh.api_key.clone());
// Don't sit on the wrong tenant's cached policy for up to
// REFRESH_INTERVAL; re-fetch on this pass.
force_refresh = true;
}
if fresh.api_base_url != enrollment.api_base_url || fresh.api_key != enrollment.api_key
{
tracing::info!(url = %fresh.api_base_url, "backend credentials changed; rebuilding client");
client = BackendClient::new(fresh.api_base_url.clone(), fresh.api_key.clone());
// Don't sit on the wrong tenant's cached policy for up to
// REFRESH_INTERVAL; re-fetch on this pass.
force_refresh = true;
}
if fresh.org_id != enrollment.org_id {
seen = SeenStore::open(paths::seen_store_path(&user), fresh.org_id.clone())?;
}

enrollment = fresh;
}
reconcile_once(
Expand All @@ -145,9 +161,10 @@ pub async fn worker(user: String, enforce: bool, events: Option<EventTx>) -> any
tracing::info!(count = healed, "self-healed sealgate install");
}
}
if last_refresh.elapsed() >= REFRESH_INTERVAL {
if force_refresh || last_refresh.elapsed() >= REFRESH_INTERVAL {
refresh(&client, &mut enrollment, &mut seen, &user).await;
last_refresh = Instant::now();
force_refresh = false;
}
// Cheap (one open() of a file that is either readable or not), and this
// tick is at most every RESCAN_INTERVAL, so no need to pace it further.
Expand Down Expand Up @@ -323,6 +340,42 @@ fn chown_new_files(record: &QuarantineRecord, user: &str) {
}
}

/// Whether a fetched policy may be trusted for the org we are enrolled in.
///
/// Split out from [`apply_policy`] so the tenant rule is testable without
/// touching the on-disk enrollment.
fn policy_matches_tenant(p: &mcp_backend::Policy, enrolled_org: &str) -> bool {
match p.org_id.as_deref() {
Some(org) => org == enrolled_org,
// Unverifiable, not wrong: an older backend reports no org at all.
None => true,
}
}

/// Cache a freshly-fetched policy onto `enrollment` and persist it.
///
/// Defence in depth behind the credential rebuild in [`worker`]: a policy is
/// only trusted when the backend attributes it to the org we are enrolled in,
/// so a client left pointing at another tenant can never write that tenant's
/// answer over ours. An older backend that reports no `org_id` is unverifiable
/// rather than wrong, so it is applied as before instead of bricking
/// quarantine for everyone on it.
pub fn apply_policy(p: &mcp_backend::Policy, enrollment: &mut Enrollment, user: &str) {
if !policy_matches_tenant(p, &enrollment.org_id) {
tracing::warn!(
policy_org = ?p.org_id,
enrolled_org = %enrollment.org_id,
"policy org mismatch; keeping last-known-good (re-enroll to repoint this daemon)"
);
return;
}
if p.quarantine != enrollment.quarantine {
tracing::info!(quarantine = p.quarantine, "policy updated");
}
enrollment.quarantine = p.quarantine;
let _ = enrollment.save_for(user);
}

/// Refresh policy + known fingerprints into the enrollment/seen-store.
/// Fail-closed: on any error the cached values are kept, never downgraded.
pub async fn refresh(
Expand All @@ -332,13 +385,7 @@ pub async fn refresh(
user: &str,
) {
match client.fetch_policy().await {
Ok(p) => {
if p.quarantine != enrollment.quarantine {
tracing::info!(quarantine = p.quarantine, "policy updated");
}
enrollment.quarantine = p.quarantine;
let _ = enrollment.save_for(user);
}
Ok(p) => apply_policy(&p, enrollment, user),
Err(e) => tracing::warn!(error = %e, "policy refresh failed; keeping last-known-good"),
}

Expand Down Expand Up @@ -574,3 +621,35 @@ fn log_skips(skips: &[Skip]) {
tracing::debug!(server = %s.name, agent = s.agent, reason = s.reason, fingerprint = %s.fingerprint, "will not quarantine");
}
}

#[cfg(test)]
mod tests {
use super::*;

fn policy(org: Option<&str>) -> mcp_backend::Policy {
mcp_backend::Policy {
quarantine: true,
org_id: org.map(str::to_string),
}
}

#[test]
fn tenant_check_accepts_the_enrolled_org() {
assert!(policy_matches_tenant(&policy(Some("org-a")), "org-a"));
}

#[test]
fn tenant_check_rejects_another_org() {
// The demo-backend-after-release-re-enroll case: a perfectly valid 200
// for the wrong tenant must not overwrite our cached policy.
assert!(!policy_matches_tenant(
&policy(Some("org-demo")),
"org-release"
));
}

#[test]
fn tenant_check_allows_a_backend_that_reports_no_org() {
assert!(policy_matches_tenant(&policy(None), "org-a"));
}
}