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 pub(crate) fn guard_session_rights(&self, name: &str) -> Option<CallToolResult> {
143 let canonical = self
144 .registry
145 .as_ref()
146 .and_then(|registry| registry.get_arc(name))
147 .map_or(name, |tool| tool.rights_name());
148 lean_ctx::core::session_rights::current()
149 .check_tool_as(name, canonical)
150 .err()
151 .map(|reason| CallToolResult::error(vec![ContentBlock::text(reason)]))
152 }
153
154 #[expect(deprecated)]
161 pub(super) async fn resolve_roots_once(&self) {
162 use std::sync::atomic::Ordering;
163 match lean_ctx::core::workspace_authority::current() {
166 lean_ctx::core::workspace_authority::Authority::Legacy => {}
167 lean_ctx::core::workspace_authority::Authority::Workspace(snapshot) => {
168 drop(self.bind_workspace_roots(&snapshot).await);
169 return;
170 }
171 lean_ctx::core::workspace_authority::Authority::Unavailable(_) => return,
172 }
173 if !self.has_client_roots.load(Ordering::Relaxed) {
174 return;
175 }
176 if self.roots_resolved.swap(true, Ordering::Relaxed) {
177 return;
178 }
179 let peer_guard = self.peer.read().await;
180 let Some(peer) = peer_guard.as_ref() else {
181 return;
182 };
183 let peer = peer.clone();
187 drop(peer_guard);
188
189 let list_result = match tokio::time::timeout(ROOTS_LIST_TIMEOUT, peer.list_roots()).await {
190 Ok(Ok(r)) => r,
191 Ok(Err(e)) => {
192 let permanent = roots_list_failure_is_permanent(&e);
193 let attempts = self.roots_list_attempts.fetch_add(1, Ordering::Relaxed) + 1;
194 if !permanent && attempts < ROOTS_LIST_MAX_ATTEMPTS {
195 self.roots_resolved.store(false, Ordering::Relaxed);
196 }
197 tracing::warn!(
198 "roots/list failed (attempt {attempts}, {}): {e}",
199 if permanent {
200 "client does not implement it — giving up"
201 } else if attempts < ROOTS_LIST_MAX_ATTEMPTS {
202 "will retry on a later tool call"
203 } else {
204 "retry budget exhausted"
205 }
206 );
207 return;
208 }
209 Err(_elapsed) => {
210 let attempts = self.roots_list_attempts.fetch_add(1, Ordering::Relaxed) + 1;
214 if attempts < ROOTS_LIST_MAX_ATTEMPTS {
215 self.roots_resolved.store(false, Ordering::Relaxed);
216 }
217 tracing::warn!(
218 "roots/list timed out after {}s (attempt {attempts}, {}) — \
219 serving tools with the project root we already have",
220 ROOTS_LIST_TIMEOUT.as_secs(),
221 if attempts < ROOTS_LIST_MAX_ATTEMPTS {
222 "will retry on a later tool call"
223 } else {
224 "retry budget exhausted"
225 }
226 );
227 return;
228 }
229 };
230
231 let uris: Vec<String> = list_result.roots.iter().map(|r| r.uri.clone()).collect();
232 drop(self.adopt_client_roots(&uris).await);
233 }
237
238 pub(crate) async fn adopt_client_roots(
247 &self,
248 uris: &[String],
249 ) -> Vec<tokio::task::JoinHandle<()>> {
250 if lean_ctx::core::workspace_authority::is_configured() {
251 tracing::warn!(
252 "MCP roots: ignored — this server is bound to a launcher Workspace ({})",
253 lean_ctx::core::workspace_authority::ENV_FILE
254 );
255 return Vec::new();
256 }
257 let validated_paths = roots::valid_dir_paths_from_uris(uris);
258 let Some(selection) = roots::root_selection_from_uris(uris) else {
259 return Vec::new();
260 };
261 let new_root = selection.chosen_root.clone();
262 if lean_ctx::core::pathutil::is_broad_or_unsafe_root(std::path::Path::new(&new_root)) {
266 tracing::warn!("MCP roots: ignoring unsafe project root {new_root}");
267 return Vec::new();
268 }
269 if self.session_mode == crate::tools::SessionMode::Shared {
270 tracing::warn!(
271 "MCP roots: shared session keeps its own root; ignoring client roots ({new_root})"
272 );
273 return Vec::new();
274 }
275 lean_ctx::core::root_selection::record(&selection);
278
279 let other_roots: Vec<String> = validated_paths
280 .iter()
281 .filter(|p| p.as_str() != new_root)
282 .cloned()
283 .collect();
284 if !other_roots.is_empty() {
285 tracing::info!("MCP roots: {} extra root(s) registered", other_roots.len());
286 }
287 let live = self.session.live();
288 if live.read().await.project_root.as_deref() == Some(&new_root) {
289 let mut session = live.write().await;
290 if !other_roots.is_empty() {
291 session.extra_roots = other_roots;
292 }
293 let prepared = session.prepare_save().ok();
296 drop(session);
297 return publish_saves([prepared]);
298 }
299 self.switch_session_root(
300 &live,
301 SessionSwitch {
302 new_root: &new_root,
303 extra_roots: other_roots,
304 shell_cwd: None,
305 rebind_request: false,
306 },
307 )
308 .await
309 .unwrap_or_default()
310 }
311
312 pub(crate) async fn bind_workspace_roots(
317 &self,
318 snapshot: &lean_ctx::core::workspace_authority::Snapshot,
319 ) -> Vec<tokio::task::JoinHandle<()>> {
320 let live = self.session.live();
321 let current = live.read().await.project_root.clone();
322 let root = snapshot.project_root_for(current.as_deref().unwrap_or(""));
323 let extra_roots: Vec<String> = snapshot
324 .granting_roots()
325 .map(|(path, _)| path.to_string_lossy().into_owned())
326 .filter(|path| *path != root)
327 .collect();
328 if current.as_deref() == Some(root.as_str()) {
329 let mut session = live.write().await;
330 if session.extra_roots != extra_roots {
331 session.extra_roots = extra_roots;
332 }
333 return Vec::new();
334 }
335 self.switch_session_root(
336 &live,
337 SessionSwitch {
338 new_root: &root,
339 extra_roots,
340 shell_cwd: None,
341 rebind_request: false,
342 },
343 )
344 .await
345 .unwrap_or_default()
346 }
347
348 pub(crate) async fn switch_session_root(
362 &self,
363 current: &lean_ctx::core::session::SharedSession,
364 switch: SessionSwitch<'_>,
365 ) -> Option<Vec<tokio::task::JoinHandle<()>>> {
366 let SessionSwitch {
367 new_root,
368 extra_roots,
369 shell_cwd,
370 rebind_request,
371 } = switch;
372 let configured_extra = if lean_ctx::core::workspace_authority::is_configured() {
376 Vec::new()
377 } else {
378 lean_ctx::core::policy::runtime::in_project(new_root, || {
379 lean_ctx::core::config::Config::load().extra_roots
380 })
381 };
382 let own = self.session.own_session_id(new_root).and_then(|id| {
383 lean_ctx::core::session::SessionState::load_by_id_for_project_root(&id, new_root)
384 .ok()
385 .flatten()
386 });
387 let mut session = current.write().await;
398 let old_root = session.project_root.clone();
399 tracing::info!("session: switching project root from {old_root:?} to {new_root}");
400 if let Some(old_root) = old_root.as_deref() {
401 self.session.remember_own(old_root, &session.id);
402 }
403 let carried = old_root.is_none().then(|| session.clone());
404 let mut next = own.or(carried).unwrap_or_default();
405 next.extra_roots = extra_roots;
406 for configured in configured_extra {
407 if !next.extra_roots.contains(&configured) {
408 next.extra_roots.push(configured);
409 }
410 }
411 next.project_root = Some(new_root.to_owned());
412 if shell_cwd.is_some() {
413 next.shell_cwd = shell_cwd;
414 }
415 let next_save =
417 lean_ctx::core::policy::runtime::in_project(new_root, || next.prepare_save().ok());
418 let old_save = old_root
419 .is_some()
420 .then(|| session.prepare_save().ok())
421 .flatten();
422 let next = std::sync::Arc::new(tokio::sync::RwLock::new(next));
423 if !self.session.replace_if_live(current, next.clone()) {
424 return None;
425 }
426 if rebind_request {
427 self.session.rebind(next);
429 lean_ctx::core::policy::runtime::bind_request_project(Some(new_root));
430 }
431 drop(session);
432 Some(publish_saves([old_save, next_save]))
433 }
434}
435
436pub(crate) struct SessionSwitch<'a> {
438 pub(crate) new_root: &'a str,
439 pub(crate) extra_roots: Vec<String>,
440 pub(crate) shell_cwd: Option<String>,
442 pub(crate) rebind_request: bool,
445}
446
447fn publish_saves(
448 saves: impl IntoIterator<Item = Option<lean_ctx::core::session::PreparedSave>>,
449) -> Vec<tokio::task::JoinHandle<()>> {
450 saves
451 .into_iter()
452 .flatten()
453 .map(|prepared| {
454 tokio::task::spawn_blocking(move || {
455 if let Err(error) = prepared.publish() {
459 tracing::debug!("session save not published: {error}");
460 }
461 })
462 })
463 .collect()
464}
465
466#[cfg(test)]
467mod session_rights_tests {
468 use crate::tools::registered::ctx_execute::session_rights_fixture::developer;
469
470 #[test]
473 fn session_rights_denied_ctx_shell_also_denies_the_shell_alias() {
474 let fx = developer(&["ctx_shell"]);
475 let server = crate::tools::create_server();
476 lean_ctx::core::session_rights::sync_scope(fx.state(), || {
477 for name in ["shell", "ctx_shell"] {
478 let refused = server
479 .guard_session_rights(name)
480 .unwrap_or_else(|| panic!("{name} must be refused"));
481 assert_eq!(refused.is_error, Some(true), "{name}");
482 }
483 assert!(server.guard_session_rights("ctx_read").is_none());
484 });
485 assert!(
486 server.guard_session_rights("shell").is_none(),
487 "no profile outside the scope"
488 );
489 }
490}
491
492#[cfg(test)]
493mod workflow_gate_tests {
494 use lean_ctx::core::context_admission::stored::lineage_support::{governed, project, scoped};
495 use lean_ctx::core::policy::runtime::TestPolicyOverride;
496 use lean_ctx::core::workflow::gate::test_support::{review, workflow_file};
497 use lean_ctx::core::workflow::{is_withheld_sentinel, load_active, save_active};
498 use rmcp::model::CallToolResult;
499
500 fn text(result: &CallToolResult) -> String {
501 result
502 .content
503 .first()
504 .and_then(|block| block.as_text())
505 .map(|block| block.text.clone())
506 .unwrap_or_default()
507 }
508
509 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
514 async fn the_direct_gate_keeps_a_withheld_workflow_closed() {
515 let _data = governed();
516 let _community = TestPolicyOverride::set(None);
517 let (_directory, root) = project();
518 let (_other_directory, other) = project();
519 scoped(&root, || save_active(&review())).expect("saved in its project");
520 let stored = std::fs::read(workflow_file()).expect("stored workflow");
521
522 let server = crate::tools::LeanCtxServer::new_with_project_root(Some(&other));
524 let mut closed = scoped(&other, load_active)
525 .expect("load")
526 .expect("a withheld workflow is still a workflow");
527 assert!(is_withheld_sentinel(&closed));
528 closed.updated_at = chrono::Utc::now() - chrono::Duration::minutes(45);
529 *server.workflow.write().await = Some(closed);
530
531 for tool in ["ctx_read", "ctx_session", "ctx_ledger"] {
532 let refused = server
533 .guard_workflow(tool)
534 .await
535 .unwrap_or_else(|| panic!("{tool} must be refused"));
536 assert_eq!(refused.is_error, Some(true), "{tool}");
538 let reason = text(&refused);
539 assert!(
540 reason.contains("withheld by the context gateway"),
541 "{reason}"
542 );
543 assert!(!reason.contains("review"), "{reason}");
544 }
545 assert!(server.guard_workflow("ctx_workflow").await.is_none());
546 let still = server.workflow.read().await.clone().expect("not cleared");
547 assert!(is_withheld_sentinel(&still));
548 assert_eq!(
549 std::fs::read(workflow_file()).expect("kept"),
550 stored,
551 "the withheld workflow is byte-identical on disk"
552 );
553
554 let mut real = review();
557 *server.workflow.write().await = Some(real.clone());
558 assert!(server.guard_workflow("ctx_read").await.is_none());
559 assert!(server.guard_workflow("ctx_session").await.is_none());
560 assert!(server.guard_workflow("ctx_shell").await.is_some());
561 real.updated_at = chrono::Utc::now() - chrono::Duration::minutes(31);
562 *server.workflow.write().await = Some(real);
563 assert!(server.guard_workflow("ctx_shell").await.is_none());
564 assert!(
565 server.workflow.read().await.is_none(),
566 "a stale run is cleared"
567 );
568 }
569}