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    /// Resolve project root from MCP client roots (once per session).
140    /// Called on the first tool call. If the client supports `roots/list`,
141    /// we query it and pick the best root with project markers.
142    ///
143    /// Roots is SEP-2577-deprecated in rmcp 2.0 but still fully functional; we
144    /// keep it for client-driven project-root auto-detection until MCP removes it.
145    #[expect(deprecated)]
146    pub(super) async fn resolve_roots_once(&self) {
147        use std::sync::atomic::Ordering;
148        // A launcher-bound Workspace is the only root authority (#2120): the
149        // client is not asked for roots, the session follows the snapshot.
150        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        // #1783: clone the handle and release the lock *before* awaiting. The
169        // request below can hang indefinitely, and holding `self.peer` across it
170        // would block every writer as well.
171        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                // A silent peer is not a permanent failure — it may be busy or
196                // mid-restart — so this consumes the same retry budget as a
197                // transport error rather than giving up forever.
198                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        // Indices warm lazily on first use of a tool that needs them (#152) —
219        // the dispatch path for this very call handles it via
220        // `index_orchestrator::ensure_warm_for_tool`, so no eager scan here.
221    }
222
223    /// Adopt the roots a client reported. A different project gets its own
224    /// session instance (see [`Self::switch_session_root`]) and the
225    /// previous instance stays with the calls already bound to it (AP04-G1).
226    /// A shared (HTTP daemon) session is fixed to the root it was created
227    /// for and takes no client's roots: one client's roots must neither move
228    /// nor widen the session every other client works in (AP04-G5).
229    ///
230    /// Returns the detached session saves; callers may drop them.
231    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        // Defense-in-depth: never adopt a broad/unsafe root (HOME, `/`, agent
248        // sandbox dirs) even if the client reports it — it would pollute the
249        // session and resolve relative paths against the wrong tree.
250        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        // AP04-G7: the choice among several roots is kept (#699) and recorded
261        // so it can be named (`ctx_session status`, `/api/scope`).
262        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            // Under the guard `prepare_save` only detaches a copy; see
279            // `switch_session_root` (#1783).
280            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    /// Keep the live session inside the Workspace snapshot (#2120): its root
298    /// stays where it is while a granting root contains it, else moves to the
299    /// primary root; its extra roots are exactly the snapshot's other roots,
300    /// so a detached root leaves the session on the next call.
301    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    /// Install a session for `new_root` in place of `current` — the session
334    /// this server itself left that project with, or a fresh one; a rootless
335    /// session is carried over — and save both. The one way a session changes
336    /// project: client roots and a re-root after path resolution both come
337    /// here (AP04-G1/G6). `None`: `current` is no longer live, another switch
338    /// won.
339    ///
340    /// Never the project's latest saved session: that is whichever agent
341    /// saved last — taking it over handed one agent's private task, findings
342    /// and decisions to another and let both write one session file (AP01
343    /// §1/§3, ABNAHME I07/C06). A server starts with its own fresh session
344    /// (`new_with_startup`); resuming a saved one is the explicit
345    /// `ctx_session load`.
346    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        // The new project's own configuration (and trust) decides its
358        // configured extra roots, never the project being left.
359        // Under a Workspace the snapshot alone names the roots (#2120).
360        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        // Under the guard `prepare_save` only detaches a copy of the session:
373        // no file, configuration or policy input is read, so a slow or
374        // unavailable policy refresh cannot hold this lock. Storage admission,
375        // the authority re-check and the write all happen in `write_to_disk`
376        // on the blocking pool, after the guard is dropped.
377        // This runs before the handler watchdog on every call once the probe
378        // re-arms, and `call_tool_guarded` needs `session.read()` for each tool;
379        // tokio's RwLock is write-preferring, so blocking file I/O held here
380        // would park every later call behind it with nothing able to cancel it
381        // (#1783).
382        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        // The new project's first save is admitted under it, not the project left.
401        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            // The calling request continues in the new project, consistently.
413            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
421/// What [`LeanCtxServer::switch_session_root`] installs.
422pub(crate) struct SessionSwitch<'a> {
423    pub(crate) new_root: &'a str,
424    pub(crate) extra_roots: Vec<String>,
425    /// `None` keeps the shell directory of the session taken over.
426    pub(crate) shell_cwd: Option<String>,
427    /// Whether the calling request moves to the new session (a re-root it
428    /// caused itself) or stays bound to the old one (a host's root change).
429    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                // A refused or failed save publishes nothing and is never
441                // acknowledged, so the live session keeps its pending
442                // changes for the next batch save.
443                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    /// The direct gate under the stand-in for a withheld workflow: closed for
469    /// pass-through tools and write actions alike, not opened by its age, the
470    /// stored workflow untouched. Control: a real run still expires and still
471    /// lets its pass-through tools pass.
472    #[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        // The server loads the workflow outside its project: closed.
482        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            // A refusal is a tool error, like a role or policy denial.
496            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        // Control: a real run passes its pass-through tools, refuses others,
514        // and is cleared once it is stale.
515        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}