lean_ctx_server/server/call_tool/
policy.rs1#[allow(clippy::wildcard_imports)]
3use super::super::*;
4use super::roots_list_failure_is_permanent;
5
6const ROOTS_LIST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
26
27const ROOTS_LIST_MAX_ATTEMPTS: u32 = 3;
31
32impl LeanCtxServer {
33 pub(crate) async fn audit_agent_id(&self) -> String {
37 if let Some(agent) = self.agent_id.read().await.clone() {
38 return agent;
39 }
40 self.presence_agent_id
41 .read()
42 .await
43 .clone()
44 .unwrap_or_else(|| lean_ctx::core::agent_identity::current_agent_id().to_owned())
45 }
46
47 pub(crate) fn guard_role_and_policy(name: &str, agent_id: &str) -> Option<CallToolResult> {
50 let role_check = role_guard::check_tool_access_as(name, agent_id);
51 if let Some(denied) = role_guard::into_call_tool_result(&role_check) {
52 tracing::warn!(
53 tool = name,
54 role = %role_check.role_name,
55 "Tool blocked by role policy"
56 );
57 return Some(denied);
58 }
59
60 let policy_check = policy_guard::check_tool_access_as(name, agent_id);
64 if let Some(denied) = policy_guard::into_call_tool_result(&policy_check) {
65 tracing::warn!(
66 tool = name,
67 policy = ?policy_check.policy_name,
68 "Tool blocked by context policy pack"
69 );
70 return Some(denied);
71 }
72 None
73 }
74
75 pub fn guard_egress(
78 guard_name: &str,
79 guard_args: Option<&serde_json::Map<String, serde_json::Value>>,
80 agent_id: &str,
81 ) -> Option<CallToolResult> {
82 if let Some(active) = lean_ctx::core::policy::runtime::active()
90 && active.egress.is_active()
91 {
92 let target = lean_ctx::core::egress::write_payload(guard_name, guard_args);
93 if let Some((payload, kind)) = target {
94 if let Some(reason) = active.egress.check_content(&payload, &active.redaction) {
95 tracing::warn!(tool = guard_name, %reason, "agent egress blocked by policy");
96 policy_guard::audit_egress(guard_name, &reason, agent_id);
97 return Some(CallToolResult::error(vec![ContentBlock::text(format!(
98 "[POLICY BLOCKED] {kind} blocked by context policy pack egress rule \
99 ({reason}). Review the effective local or organization \
100 egress policy."
101 ))]));
102 }
103 if let Some(max) = active.egress.max_writes_per_min
104 && !lean_ctx::core::egress::check_rate(max)
105 {
106 tracing::warn!(tool = guard_name, max, "agent egress rate limit exceeded");
107 policy_guard::audit_egress(guard_name, "rate-limit", agent_id);
108 return Some(CallToolResult::error(vec![ContentBlock::text(format!(
109 "[POLICY BLOCKED] {kind} rate limit exceeded ({max}/min) by context \
110 policy pack. Slow agent writes/actions or adjust .lean-ctx/policy.toml."
111 ))]));
112 }
113 }
114 }
115 None
116 }
117
118 pub(super) async fn guard_workflow(&self, name: &str) -> Option<CallToolResult> {
121 use lean_ctx::core::workflow::ToolGate;
122 let run = self.workflow.read().await.clone()?;
125 match lean_ctx::core::workflow::tool_gate(&run, name, WORKFLOW_PASSTHROUGH_TOOLS) {
126 ToolGate::Pass => None,
127 ToolGate::Expired => {
128 let mut wf = self.workflow.write().await;
129 *wf = None;
130 let _ = lean_ctx::core::workflow::clear_active();
131 None
132 }
133 ToolGate::Refuse(reason) => {
134 Some(CallToolResult::error(vec![ContentBlock::text(reason)]))
135 }
136 }
137 }
138
139 #[expect(deprecated)]
146 pub(super) async fn resolve_roots_once(&self) {
147 use std::sync::atomic::Ordering;
148 match lean_ctx::core::workspace_authority::current() {
151 lean_ctx::core::workspace_authority::Authority::Legacy => {}
152 lean_ctx::core::workspace_authority::Authority::Workspace(snapshot) => {
153 drop(self.bind_workspace_roots(&snapshot).await);
154 return;
155 }
156 lean_ctx::core::workspace_authority::Authority::Unavailable(_) => return,
157 }
158 if !self.has_client_roots.load(Ordering::Relaxed) {
159 return;
160 }
161 if self.roots_resolved.swap(true, Ordering::Relaxed) {
162 return;
163 }
164 let peer_guard = self.peer.read().await;
165 let Some(peer) = peer_guard.as_ref() else {
166 return;
167 };
168 let peer = peer.clone();
172 drop(peer_guard);
173
174 let list_result = match tokio::time::timeout(ROOTS_LIST_TIMEOUT, peer.list_roots()).await {
175 Ok(Ok(r)) => r,
176 Ok(Err(e)) => {
177 let permanent = roots_list_failure_is_permanent(&e);
178 let attempts = self.roots_list_attempts.fetch_add(1, Ordering::Relaxed) + 1;
179 if !permanent && attempts < ROOTS_LIST_MAX_ATTEMPTS {
180 self.roots_resolved.store(false, Ordering::Relaxed);
181 }
182 tracing::warn!(
183 "roots/list failed (attempt {attempts}, {}): {e}",
184 if permanent {
185 "client does not implement it — giving up"
186 } else if attempts < ROOTS_LIST_MAX_ATTEMPTS {
187 "will retry on a later tool call"
188 } else {
189 "retry budget exhausted"
190 }
191 );
192 return;
193 }
194 Err(_elapsed) => {
195 let attempts = self.roots_list_attempts.fetch_add(1, Ordering::Relaxed) + 1;
199 if attempts < ROOTS_LIST_MAX_ATTEMPTS {
200 self.roots_resolved.store(false, Ordering::Relaxed);
201 }
202 tracing::warn!(
203 "roots/list timed out after {}s (attempt {attempts}, {}) — \
204 serving tools with the project root we already have",
205 ROOTS_LIST_TIMEOUT.as_secs(),
206 if attempts < ROOTS_LIST_MAX_ATTEMPTS {
207 "will retry on a later tool call"
208 } else {
209 "retry budget exhausted"
210 }
211 );
212 return;
213 }
214 };
215
216 let uris: Vec<String> = list_result.roots.iter().map(|r| r.uri.clone()).collect();
217 drop(self.adopt_client_roots(&uris).await);
218 }
222
223 pub(crate) async fn adopt_client_roots(
232 &self,
233 uris: &[String],
234 ) -> Vec<tokio::task::JoinHandle<()>> {
235 if lean_ctx::core::workspace_authority::is_configured() {
236 tracing::warn!(
237 "MCP roots: ignored — this server is bound to a launcher Workspace ({})",
238 lean_ctx::core::workspace_authority::ENV_FILE
239 );
240 return Vec::new();
241 }
242 let validated_paths = roots::valid_dir_paths_from_uris(uris);
243 let Some(selection) = roots::root_selection_from_uris(uris) else {
244 return Vec::new();
245 };
246 let new_root = selection.chosen_root.clone();
247 if lean_ctx::core::pathutil::is_broad_or_unsafe_root(std::path::Path::new(&new_root)) {
251 tracing::warn!("MCP roots: ignoring unsafe project root {new_root}");
252 return Vec::new();
253 }
254 if self.session_mode == crate::tools::SessionMode::Shared {
255 tracing::warn!(
256 "MCP roots: shared session keeps its own root; ignoring client roots ({new_root})"
257 );
258 return Vec::new();
259 }
260 lean_ctx::core::root_selection::record(&selection);
263
264 let other_roots: Vec<String> = validated_paths
265 .iter()
266 .filter(|p| p.as_str() != new_root)
267 .cloned()
268 .collect();
269 if !other_roots.is_empty() {
270 tracing::info!("MCP roots: {} extra root(s) registered", other_roots.len());
271 }
272 let live = self.session.live();
273 if live.read().await.project_root.as_deref() == Some(&new_root) {
274 let mut session = live.write().await;
275 if !other_roots.is_empty() {
276 session.extra_roots = other_roots;
277 }
278 let prepared = session.prepare_save().ok();
281 drop(session);
282 return publish_saves([prepared]);
283 }
284 self.switch_session_root(
285 &live,
286 SessionSwitch {
287 new_root: &new_root,
288 extra_roots: other_roots,
289 shell_cwd: None,
290 rebind_request: false,
291 },
292 )
293 .await
294 .unwrap_or_default()
295 }
296
297 pub(crate) async fn bind_workspace_roots(
302 &self,
303 snapshot: &lean_ctx::core::workspace_authority::Snapshot,
304 ) -> Vec<tokio::task::JoinHandle<()>> {
305 let live = self.session.live();
306 let current = live.read().await.project_root.clone();
307 let root = snapshot.project_root_for(current.as_deref().unwrap_or(""));
308 let extra_roots: Vec<String> = snapshot
309 .granting_roots()
310 .map(|(path, _)| path.to_string_lossy().into_owned())
311 .filter(|path| *path != root)
312 .collect();
313 if current.as_deref() == Some(root.as_str()) {
314 let mut session = live.write().await;
315 if session.extra_roots != extra_roots {
316 session.extra_roots = extra_roots;
317 }
318 return Vec::new();
319 }
320 self.switch_session_root(
321 &live,
322 SessionSwitch {
323 new_root: &root,
324 extra_roots,
325 shell_cwd: None,
326 rebind_request: false,
327 },
328 )
329 .await
330 .unwrap_or_default()
331 }
332
333 pub(crate) async fn switch_session_root(
347 &self,
348 current: &lean_ctx::core::session::SharedSession,
349 switch: SessionSwitch<'_>,
350 ) -> Option<Vec<tokio::task::JoinHandle<()>>> {
351 let SessionSwitch {
352 new_root,
353 extra_roots,
354 shell_cwd,
355 rebind_request,
356 } = switch;
357 let configured_extra = if lean_ctx::core::workspace_authority::is_configured() {
361 Vec::new()
362 } else {
363 lean_ctx::core::policy::runtime::in_project(new_root, || {
364 lean_ctx::core::config::Config::load().extra_roots
365 })
366 };
367 let own = self.session.own_session_id(new_root).and_then(|id| {
368 lean_ctx::core::session::SessionState::load_by_id_for_project_root(&id, new_root)
369 .ok()
370 .flatten()
371 });
372 let mut session = current.write().await;
383 let old_root = session.project_root.clone();
384 tracing::info!("session: switching project root from {old_root:?} to {new_root}");
385 if let Some(old_root) = old_root.as_deref() {
386 self.session.remember_own(old_root, &session.id);
387 }
388 let carried = old_root.is_none().then(|| session.clone());
389 let mut next = own.or(carried).unwrap_or_default();
390 next.extra_roots = extra_roots;
391 for configured in configured_extra {
392 if !next.extra_roots.contains(&configured) {
393 next.extra_roots.push(configured);
394 }
395 }
396 next.project_root = Some(new_root.to_owned());
397 if shell_cwd.is_some() {
398 next.shell_cwd = shell_cwd;
399 }
400 let next_save =
402 lean_ctx::core::policy::runtime::in_project(new_root, || next.prepare_save().ok());
403 let old_save = old_root
404 .is_some()
405 .then(|| session.prepare_save().ok())
406 .flatten();
407 let next = std::sync::Arc::new(tokio::sync::RwLock::new(next));
408 if !self.session.replace_if_live(current, next.clone()) {
409 return None;
410 }
411 if rebind_request {
412 self.session.rebind(next);
414 lean_ctx::core::policy::runtime::bind_request_project(Some(new_root));
415 }
416 drop(session);
417 Some(publish_saves([old_save, next_save]))
418 }
419}
420
421pub(crate) struct SessionSwitch<'a> {
423 pub(crate) new_root: &'a str,
424 pub(crate) extra_roots: Vec<String>,
425 pub(crate) shell_cwd: Option<String>,
427 pub(crate) rebind_request: bool,
430}
431
432fn publish_saves(
433 saves: impl IntoIterator<Item = Option<lean_ctx::core::session::PreparedSave>>,
434) -> Vec<tokio::task::JoinHandle<()>> {
435 saves
436 .into_iter()
437 .flatten()
438 .map(|prepared| {
439 tokio::task::spawn_blocking(move || {
440 if let Err(error) = prepared.publish() {
444 tracing::debug!("session save not published: {error}");
445 }
446 })
447 })
448 .collect()
449}
450
451#[cfg(test)]
452mod workflow_gate_tests {
453 use lean_ctx::core::context_admission::stored::lineage_support::{governed, project, scoped};
454 use lean_ctx::core::policy::runtime::TestPolicyOverride;
455 use lean_ctx::core::workflow::gate::test_support::{review, workflow_file};
456 use lean_ctx::core::workflow::{is_withheld_sentinel, load_active, save_active};
457 use rmcp::model::CallToolResult;
458
459 fn text(result: &CallToolResult) -> String {
460 result
461 .content
462 .first()
463 .and_then(|block| block.as_text())
464 .map(|block| block.text.clone())
465 .unwrap_or_default()
466 }
467
468 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
473 async fn the_direct_gate_keeps_a_withheld_workflow_closed() {
474 let _data = governed();
475 let _community = TestPolicyOverride::set(None);
476 let (_directory, root) = project();
477 let (_other_directory, other) = project();
478 scoped(&root, || save_active(&review())).expect("saved in its project");
479 let stored = std::fs::read(workflow_file()).expect("stored workflow");
480
481 let server = crate::tools::LeanCtxServer::new_with_project_root(Some(&other));
483 let mut closed = scoped(&other, load_active)
484 .expect("load")
485 .expect("a withheld workflow is still a workflow");
486 assert!(is_withheld_sentinel(&closed));
487 closed.updated_at = chrono::Utc::now() - chrono::Duration::minutes(45);
488 *server.workflow.write().await = Some(closed);
489
490 for tool in ["ctx_read", "ctx_session", "ctx_ledger"] {
491 let refused = server
492 .guard_workflow(tool)
493 .await
494 .unwrap_or_else(|| panic!("{tool} must be refused"));
495 assert_eq!(refused.is_error, Some(true), "{tool}");
497 let reason = text(&refused);
498 assert!(
499 reason.contains("withheld by the context gateway"),
500 "{reason}"
501 );
502 assert!(!reason.contains("review"), "{reason}");
503 }
504 assert!(server.guard_workflow("ctx_workflow").await.is_none());
505 let still = server.workflow.read().await.clone().expect("not cleared");
506 assert!(is_withheld_sentinel(&still));
507 assert_eq!(
508 std::fs::read(workflow_file()).expect("kept"),
509 stored,
510 "the withheld workflow is byte-identical on disk"
511 );
512
513 let mut real = review();
516 *server.workflow.write().await = Some(real.clone());
517 assert!(server.guard_workflow("ctx_read").await.is_none());
518 assert!(server.guard_workflow("ctx_session").await.is_none());
519 assert!(server.guard_workflow("ctx_shell").await.is_some());
520 real.updated_at = chrono::Utc::now() - chrono::Duration::minutes(31);
521 *server.workflow.write().await = Some(real);
522 assert!(server.guard_workflow("ctx_shell").await.is_none());
523 assert!(
524 server.workflow.read().await.is_none(),
525 "a stale run is cleared"
526 );
527 }
528}