Skip to main content

lean_ctx/core/workflow/
gate.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The workflow tool gate: the one decision both call paths apply — the
3//! direct call (`server/call_tool/policy.rs::guard_workflow`) and the inner
4//! tool of `ctx_call` (`server/dispatch/mod.rs`). They pass the active run and
5//! the tool name and act on the answer; neither decides anything itself.
6
7use super::store::{is_run_stale, is_withheld_sentinel};
8use super::types::WorkflowRun;
9
10/// The workflow control tool. It is never gated: it is how a run is seen,
11/// advanced and ended.
12const CONTROL_TOOL: &str = "ctx_workflow";
13
14/// The `ctx_call` wrapper. Under the stand-in for a withheld workflow the
15/// wrapper name itself passes, because on a surface that exposes only the
16/// wrapper it is the one way to reach [`CONTROL_TOOL`]. The tool it invokes
17/// is gated by its own name at both call paths, so this opens nothing else.
18const WRAPPER_TOOL: &str = "ctx_call";
19
20/// Tools that always pass through the workflow gate regardless of state.
21/// Read-only tools should never be blocked — agents need them for context
22/// recovery after crashes or session transitions. The server passes this
23/// list to [`tool_gate`] on both call paths.
24pub const WORKFLOW_PASSTHROUGH_TOOLS: &[&str] = &[
25    "ctx",
26    "ctx_workflow",
27    "ctx_read",
28    "ctx_multi_read",
29    "ctx_smart_read",
30    "ctx_search",
31    "ctx_tree",
32    "ctx_session",
33    "ctx_ledger",
34];
35
36/// What the gate answers for one tool call under an active run.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ToolGate {
39    /// The run is finished or stale: the caller clears it (memory and store)
40    /// and the call proceeds ungated.
41    Expired,
42    /// The call proceeds.
43    Pass,
44    /// The call is refused with this message.
45    Refuse(String),
46}
47
48/// Decide `tool` under `run`. `passthrough` is the server's list of tools a
49/// real run never blocks.
50///
51/// - The stand-in for a withheld workflow is CLOSED: only the control tool
52///   (and the wrapper that reaches it) passes. The pass-through list does not
53///   apply, and the stand-in never expires — an expiry would clear it into
54///   "no workflow", which gates nothing, and delete the stored workflow it
55///   stands for. The refusal names no workflow, state or file.
56/// - A real run: finished or stale → [`ToolGate::Expired`]; otherwise the
57///   control tool and the pass-through tools pass, and any other tool must be
58///   in the allowed list of the current state, when that state has one.
59#[must_use]
60pub fn tool_gate(run: &WorkflowRun, tool: &str, passthrough: &[&str]) -> ToolGate {
61    if is_withheld_sentinel(run) {
62        // `ctx` is the meta name; a call through it reaches the gate under
63        // the name of the tool it resolves to, so it opens nothing either.
64        return if matches!(tool, CONTROL_TOOL | WRAPPER_TOOL | "ctx") {
65            ToolGate::Pass
66        } else {
67            ToolGate::Refuse(format!(
68                "Tool '{tool}' blocked: the stored workflow is withheld by the context gateway. \
69                 Use ctx_workflow(action=\"status\") to check it again, or \
70                 ctx_workflow(action=\"stop\") to discard it."
71            ))
72        };
73    }
74    if tool == CONTROL_TOOL {
75        return ToolGate::Pass;
76    }
77    if run.current == "done" || is_run_stale(run) {
78        return ToolGate::Expired;
79    }
80    if passthrough.contains(&tool) {
81        return ToolGate::Pass;
82    }
83    let Some(allowed) = run
84        .spec
85        .state(&run.current)
86        .and_then(|state| state.allowed_tools.as_ref())
87    else {
88        return ToolGate::Pass;
89    };
90    if allowed.iter().any(|permitted| permitted == tool) {
91        return ToolGate::Pass;
92    }
93    let mut shown = allowed.clone();
94    shown.sort();
95    shown.truncate(30);
96    ToolGate::Refuse(format!(
97        "Tool '{tool}' blocked by workflow '{}' (state: {}). Allowed: {}. Use ctx_workflow(action=\"stop\") to exit.",
98        run.spec.name,
99        run.current,
100        shown.join(", ")
101    ))
102}
103
104#[cfg(any(test, feature = "test-support"))]
105/// Workflow fixtures shared with the tests of the two server gates.
106pub mod test_support {
107    use crate::core::workflow::types::{StateSpec, WorkflowRun, WorkflowSpec};
108
109    /// A real run whose only state allows `ctx_overview`. Shared with the
110    /// tests of the two server gates.
111    pub fn review() -> WorkflowRun {
112        let now = chrono::Utc::now();
113        WorkflowRun {
114            spec: WorkflowSpec {
115                name: "review".to_string(),
116                description: Some("Check the parser change".to_string()),
117                initial: "planning".to_string(),
118                states: vec![StateSpec {
119                    name: "planning".to_string(),
120                    description: None,
121                    allowed_tools: Some(vec!["ctx_overview".to_string()]),
122                    requires_evidence: None,
123                }],
124                transitions: Vec::new(),
125            },
126            current: "planning".to_string(),
127            started_at: now,
128            updated_at: now,
129            transitions: Vec::new(),
130            evidence: Vec::new(),
131        }
132    }
133
134    /// Where the store keeps the active workflow.
135    pub fn workflow_file() -> std::path::PathBuf {
136        crate::core::data_dir::lean_ctx_data_dir()
137            .expect("data dir")
138            .join("workflows")
139            .join("active.json")
140    }
141}
142
143#[cfg(test)]
144pub(crate) mod tests {
145    pub(crate) use super::test_support::{review, workflow_file};
146    use super::{ToolGate, tool_gate};
147    use crate::core::context_admission::stored::lineage_support::{governed, project, scoped};
148    use crate::core::policy::runtime::TestPolicyOverride;
149    use crate::core::workflow::store::{
150        is_run_stale, is_withheld_sentinel, load_active, save_active,
151    };
152
153    /// The list both server call paths pass.
154    const PASSTHROUGH: &[&str] = super::WORKFLOW_PASSTHROUGH_TOOLS;
155
156    /// The stand-in is closed and stays closed: neither the pass-through list
157    /// nor its age opens it, the stored workflow it stands for is untouched,
158    /// and it is gone once the workflow may be read again.
159    #[test]
160    fn the_withheld_stand_in_is_closed_and_never_expires() {
161        let _data = governed();
162        let _community = TestPolicyOverride::set(None);
163        let (_directory, root) = project();
164        let (_other_directory, other) = project();
165        scoped(&root, || save_active(&review())).expect("saved in its project");
166        let stored = std::fs::read(workflow_file()).expect("stored workflow");
167
168        let mut closed = scoped(&other, load_active)
169            .expect("load")
170            .expect("a withheld workflow is still a workflow");
171        assert!(is_withheld_sentinel(&closed));
172        // Older than the stale threshold: still not stale, still closed.
173        closed.updated_at = chrono::Utc::now() - chrono::Duration::minutes(45);
174        assert!(!is_run_stale(&closed));
175        for tool in [
176            "ctx_read",
177            "ctx_session",
178            "ctx_ledger",
179            "ctx_search",
180            "ctx_overview",
181        ] {
182            let ToolGate::Refuse(reason) = tool_gate(&closed, tool, PASSTHROUGH) else {
183                panic!("{tool} must be refused under the stand-in");
184            };
185            assert!(
186                reason.contains("withheld by the context gateway"),
187                "{reason}"
188            );
189            assert!(!reason.contains("review"), "no workflow name: {reason}");
190            assert!(!reason.contains("planning"), "no state name: {reason}");
191            assert!(!reason.contains("active.json"), "no file name: {reason}");
192        }
193        assert_eq!(
194            tool_gate(&closed, "ctx_workflow", PASSTHROUGH),
195            ToolGate::Pass
196        );
197        assert_eq!(tool_gate(&closed, "ctx_call", PASSTHROUGH), ToolGate::Pass);
198        assert_eq!(
199            std::fs::read(workflow_file()).expect("stored workflow"),
200            stored,
201            "the withheld workflow is byte-identical on disk"
202        );
203
204        // The authority holds again: the real workflow loads.
205        let real = scoped(&root, load_active).expect("load").expect("active");
206        assert!(!is_withheld_sentinel(&real));
207        assert_eq!(real.spec.name, "review");
208        assert_eq!(
209            tool_gate(&real, "ctx_overview", PASSTHROUGH),
210            ToolGate::Pass
211        );
212    }
213
214    /// A withheld workflow whose own run is older than the stale threshold is
215    /// kept and loads closed; once it may be read, the normal stale rule
216    /// applies to it.
217    #[test]
218    fn a_withheld_workflow_is_not_deleted_for_its_age() {
219        let _data = governed();
220        let _community = TestPolicyOverride::set(None);
221        let (_directory, root) = project();
222        let (_other_directory, other) = project();
223        let mut old = review();
224        old.updated_at = chrono::Utc::now() - chrono::Duration::minutes(45);
225        scoped(&root, || save_active(&old)).expect("saved in its project");
226        let stored = std::fs::read(workflow_file()).expect("stored workflow");
227
228        let closed = scoped(&other, load_active).expect("load").expect("closed");
229        assert!(is_withheld_sentinel(&closed));
230        assert_eq!(std::fs::read(workflow_file()).expect("kept"), stored);
231
232        assert!(scoped(&root, load_active).expect("load").is_none());
233        assert!(
234            !workflow_file().exists(),
235            "a served stale run is cleared as before"
236        );
237    }
238
239    /// Control: a real run is gated exactly as before.
240    #[test]
241    fn a_real_run_goes_stale_and_keeps_its_pass_through_tools() {
242        let _data = crate::core::data_dir::isolated_data_dir();
243        let _community = TestPolicyOverride::set(None);
244        let mut run = review();
245        assert_eq!(tool_gate(&run, "ctx_read", PASSTHROUGH), ToolGate::Pass);
246        assert_eq!(tool_gate(&run, "ctx_session", PASSTHROUGH), ToolGate::Pass);
247        assert_eq!(tool_gate(&run, "ctx_overview", PASSTHROUGH), ToolGate::Pass);
248        assert_eq!(tool_gate(&run, "ctx_workflow", PASSTHROUGH), ToolGate::Pass);
249        assert_eq!(
250            tool_gate(&run, "ctx_shell", PASSTHROUGH),
251            ToolGate::Refuse(
252                "Tool 'ctx_shell' blocked by workflow 'review' (state: planning). Allowed: ctx_overview. Use ctx_workflow(action=\"stop\") to exit."
253                    .to_string()
254            )
255        );
256
257        run.updated_at = chrono::Utc::now() - chrono::Duration::minutes(31);
258        assert!(is_run_stale(&run));
259        assert_eq!(tool_gate(&run, "ctx_shell", PASSTHROUGH), ToolGate::Expired);
260        assert_eq!(tool_gate(&run, "ctx_read", PASSTHROUGH), ToolGate::Expired);
261        assert_eq!(tool_gate(&run, "ctx_workflow", PASSTHROUGH), ToolGate::Pass);
262
263        run.updated_at = chrono::Utc::now();
264        run.current = "done".to_string();
265        assert_eq!(tool_gate(&run, "ctx_shell", PASSTHROUGH), ToolGate::Expired);
266    }
267}