Skip to main content

lean_ctx_server/server/call_tool/
policy.rs

1// SPDX-License-Identifier: Apache-2.0
2#[allow(clippy::wildcard_imports)]
3use super::super::*;
4use super::roots_list_failure_is_permanent;
5
6/// #1783: how long the one-shot `roots/list` probe may take before the session
7/// gives up on it and serves tools with the project root it already has.
8///
9/// `Peer::list_roots` is generated by rmcp's `method!(peer_req …)` arm, which
10/// calls `send_request` — and that hands the request
11/// `PeerRequestOptions::no_options()`, i.e. `timeout: None`. rmcp offers a
12/// `peer_req_with_timeout` variant (it uses it for `create_elicitation`) and
13/// deliberately does not use it here, so this await never returns on its own
14/// when the client stops answering.
15///
16/// That is a hazard *in this position specifically*: `resolve_roots_once` runs
17/// in `call_tool_guarded`'s pre-dispatch stage — before the handler watchdog,
18/// which only covers the dispatch itself. A probe that never returns therefore
19/// wedges every subsequent tool call, reads and searches included, with no
20/// deadline able to reclaim it and no recovery short of restarting the process.
21///
22/// Ten seconds is far above a healthy reply (the client answers from a
23/// capability list it already holds in memory) and far below the ~120s budget
24/// an MCP host allows a single tool call.
25const ROOTS_LIST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
26
27/// How many `roots/list` probes a session may spend before it stops asking.
28/// Shared by the transport-error and timeout paths so an unresponsive client
29/// cannot cost more than this many probes over the session's lifetime.
30const ROOTS_LIST_MAX_ATTEMPTS: u32 = 3;
31
32impl LeanCtxServer {
33    /// The agent a guard's audit entry names: the registered bus agent, else
34    /// the session's presence id, else this process's identity
35    /// (`LEAN_CTX_AGENT_ID`, else `local`).
36    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    /// Role and policy-pack tool gating. A denial is a tool error carrying
48    /// the reason, audited for `agent_id`.
49    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        // #673 — context-policy-pack tool gating. Additive to the role guard:
61        // a pack's `allow_tools`/`deny_tools` are enforced here. No-op (allow)
62        // when no policy pack is active, so existing behavior is unchanged.
63        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    /// Egress DLP on agent writes and actions. A block is a tool error,
76    /// audited for `agent_id`.
77    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        // #676 — egress / output DLP on agent writes & actions. Inspect the
83        // payload of write/action tools BEFORE dispatch so a forbidden write
84        // never touches disk and a forbidden command never runs. Only the
85        // agent's tool-driven egress is governed here (a human's own editor
86        // writes never pass through this path). No-op unless the active pack has
87        // an `[egress]` section. Payload mapping (incl. all ctx_patch bodies)
88        // lives in `core::egress::write_payload` — shared with `policy enforce`.
89        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    /// Active-workflow tool gating. A refusal is a tool error carrying the
119    /// reason, like a role or policy denial.
120    pub(super) async fn guard_workflow(&self, name: &str) -> Option<CallToolResult> {
121        use lean_ctx::core::workflow::ToolGate;
122        // The decision is `core::workflow::tool_gate`, shared with the
123        // `ctx_call` gate in `server/dispatch/mod.rs`.
124        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    /// The session's rights profile (process-v1 §9): a denied tool, or any
140    /// tool while the rights file is unusable, is refused with its rule.
141    /// An alias is judged under the name of the handler it runs too.
142    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    /// Resolve project root from MCP client roots (once per session).
155    /// Called on the first tool call. If the client supports `roots/list`,
156    /// we query it and pick the best root with project markers.
157    ///
158    /// Roots is SEP-2577-deprecated in rmcp 2.0 but still fully functional; we
159    /// keep it for client-driven project-root auto-detection until MCP removes it.
160    #[expect(deprecated)]
161    pub(super) async fn resolve_roots_once(&self) {
162        use std::sync::atomic::Ordering;
163        // A launcher-bound Workspace is the only root authority (#2120): the
164        // client is not asked for roots, the session follows the snapshot.
165        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        // #1783: clone the handle and release the lock *before* awaiting. The
184        // request below can hang indefinitely, and holding `self.peer` across it
185        // would block every writer as well.
186        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                // A silent peer is not a permanent failure — it may be busy or
211                // mid-restart — so this consumes the same retry budget as a
212                // transport error rather than giving up forever.
213                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        // Indices warm lazily on first use of a tool that needs them (#152) —
234        // the dispatch path for this very call handles it via
235        // `index_orchestrator::ensure_warm_for_tool`, so no eager scan here.
236    }
237
238    /// Adopt the roots a client reported. A different project gets its own
239    /// session instance (see [`Self::switch_session_root`]) and the
240    /// previous instance stays with the calls already bound to it (AP04-G1).
241    /// A shared (HTTP daemon) session is fixed to the root it was created
242    /// for and takes no client's roots: one client's roots must neither move
243    /// nor widen the session every other client works in (AP04-G5).
244    ///
245    /// Returns the detached session saves; callers may drop them.
246    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        // Defense-in-depth: never adopt a broad/unsafe root (HOME, `/`, agent
263        // sandbox dirs) even if the client reports it — it would pollute the
264        // session and resolve relative paths against the wrong tree.
265        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        // AP04-G7: the choice among several roots is kept (#699) and recorded
276        // so it can be named (`ctx_session status`, `/api/scope`).
277        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            // Under the guard `prepare_save` only detaches a copy; see
294            // `switch_session_root` (#1783).
295            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    /// Keep the live session inside the Workspace snapshot (#2120): its root
313    /// stays where it is while a granting root contains it, else moves to the
314    /// primary root; its extra roots are exactly the snapshot's other roots,
315    /// so a detached root leaves the session on the next call.
316    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    /// Install a session for `new_root` in place of `current` — the session
349    /// this server itself left that project with, or a fresh one; a rootless
350    /// session is carried over — and save both. The one way a session changes
351    /// project: client roots and a re-root after path resolution both come
352    /// here (AP04-G1/G6). `None`: `current` is no longer live, another switch
353    /// won.
354    ///
355    /// Never the project's latest saved session: that is whichever agent
356    /// saved last — taking it over handed one agent's private task, findings
357    /// and decisions to another and let both write one session file (AP01
358    /// §1/§3, ABNAHME I07/C06). A server starts with its own fresh session
359    /// (`new_with_startup`); resuming a saved one is the explicit
360    /// `ctx_session load`.
361    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        // The new project's own configuration (and trust) decides its
373        // configured extra roots, never the project being left.
374        // Under a Workspace the snapshot alone names the roots (#2120).
375        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        // Under the guard `prepare_save` only detaches a copy of the session:
388        // no file, configuration or policy input is read, so a slow or
389        // unavailable policy refresh cannot hold this lock. Storage admission,
390        // the authority re-check and the write all happen in `write_to_disk`
391        // on the blocking pool, after the guard is dropped.
392        // This runs before the handler watchdog on every call once the probe
393        // re-arms, and `call_tool_guarded` needs `session.read()` for each tool;
394        // tokio's RwLock is write-preferring, so blocking file I/O held here
395        // would park every later call behind it with nothing able to cancel it
396        // (#1783).
397        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        // The new project's first save is admitted under it, not the project left.
416        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            // The calling request continues in the new project, consistently.
428            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
436/// What [`LeanCtxServer::switch_session_root`] installs.
437pub(crate) struct SessionSwitch<'a> {
438    pub(crate) new_root: &'a str,
439    pub(crate) extra_roots: Vec<String>,
440    /// `None` keeps the shell directory of the session taken over.
441    pub(crate) shell_cwd: Option<String>,
442    /// Whether the calling request moves to the new session (a re-root it
443    /// caused itself) or stays bound to the old one (a host's root change).
444    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                // A refused or failed save publishes nothing and is never
456                // acknowledged, so the live session keeps its pending
457                // changes for the next batch save.
458                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    /// F4: `denied_tools: ["ctx_shell"]` also closes the `shell` alias, which
471    /// runs the same handler.
472    #[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    /// The direct gate under the stand-in for a withheld workflow: closed for
510    /// pass-through tools and write actions alike, not opened by its age, the
511    /// stored workflow untouched. Control: a real run still expires and still
512    /// lets its pass-through tools pass.
513    #[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        // The server loads the workflow outside its project: closed.
523        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            // A refusal is a tool error, like a role or policy denial.
537            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        // Control: a real run passes its pass-through tools, refuses others,
555        // and is cleared once it is stale.
556        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}