1use super::store::{is_run_stale, is_withheld_sentinel};
8use super::types::WorkflowRun;
9
10const CONTROL_TOOL: &str = "ctx_workflow";
13
14const WRAPPER_TOOL: &str = "ctx_call";
19
20pub 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#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ToolGate {
39 Expired,
42 Pass,
44 Refuse(String),
46}
47
48#[must_use]
60pub fn tool_gate(run: &WorkflowRun, tool: &str, passthrough: &[&str]) -> ToolGate {
61 if is_withheld_sentinel(run) {
62 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"))]
105pub mod test_support {
107 use crate::core::workflow::types::{StateSpec, WorkflowRun, WorkflowSpec};
108
109 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 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 const PASSTHROUGH: &[&str] = super::WORKFLOW_PASSTHROUGH_TOOLS;
155
156 #[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 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 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 #[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 #[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}