use super::store::{is_run_stale, is_withheld_sentinel};
use super::types::WorkflowRun;
const CONTROL_TOOL: &str = "ctx_workflow";
const WRAPPER_TOOL: &str = "ctx_call";
pub const WORKFLOW_PASSTHROUGH_TOOLS: &[&str] = &[
"ctx",
"ctx_workflow",
"ctx_read",
"ctx_multi_read",
"ctx_smart_read",
"ctx_search",
"ctx_tree",
"ctx_session",
"ctx_ledger",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolGate {
Expired,
Pass,
Refuse(String),
}
#[must_use]
pub fn tool_gate(run: &WorkflowRun, tool: &str, passthrough: &[&str]) -> ToolGate {
if is_withheld_sentinel(run) {
return if matches!(tool, CONTROL_TOOL | WRAPPER_TOOL | "ctx") {
ToolGate::Pass
} else {
ToolGate::Refuse(format!(
"Tool '{tool}' blocked: the stored workflow is withheld by the context gateway. \
Use ctx_workflow(action=\"status\") to check it again, or \
ctx_workflow(action=\"stop\") to discard it."
))
};
}
if tool == CONTROL_TOOL {
return ToolGate::Pass;
}
if run.current == "done" || is_run_stale(run) {
return ToolGate::Expired;
}
if passthrough.contains(&tool) {
return ToolGate::Pass;
}
let Some(allowed) = run
.spec
.state(&run.current)
.and_then(|state| state.allowed_tools.as_ref())
else {
return ToolGate::Pass;
};
if allowed.iter().any(|permitted| permitted == tool) {
return ToolGate::Pass;
}
let mut shown = allowed.clone();
shown.sort();
shown.truncate(30);
ToolGate::Refuse(format!(
"Tool '{tool}' blocked by workflow '{}' (state: {}). Allowed: {}. Use ctx_workflow(action=\"stop\") to exit.",
run.spec.name,
run.current,
shown.join(", ")
))
}
#[cfg(any(test, feature = "test-support"))]
pub mod test_support {
use crate::core::workflow::types::{StateSpec, WorkflowRun, WorkflowSpec};
pub fn review() -> WorkflowRun {
let now = chrono::Utc::now();
WorkflowRun {
spec: WorkflowSpec {
name: "review".to_string(),
description: Some("Check the parser change".to_string()),
initial: "planning".to_string(),
states: vec![StateSpec {
name: "planning".to_string(),
description: None,
allowed_tools: Some(vec!["ctx_overview".to_string()]),
requires_evidence: None,
}],
transitions: Vec::new(),
},
current: "planning".to_string(),
started_at: now,
updated_at: now,
transitions: Vec::new(),
evidence: Vec::new(),
}
}
pub fn workflow_file() -> std::path::PathBuf {
crate::core::data_dir::lean_ctx_data_dir()
.expect("data dir")
.join("workflows")
.join("active.json")
}
}
#[cfg(test)]
pub(crate) mod tests {
pub(crate) use super::test_support::{review, workflow_file};
use super::{ToolGate, tool_gate};
use crate::core::context_admission::stored::lineage_support::{governed, project, scoped};
use crate::core::policy::runtime::TestPolicyOverride;
use crate::core::workflow::store::{
is_run_stale, is_withheld_sentinel, load_active, save_active,
};
const PASSTHROUGH: &[&str] = super::WORKFLOW_PASSTHROUGH_TOOLS;
#[test]
fn the_withheld_stand_in_is_closed_and_never_expires() {
let _data = governed();
let _community = TestPolicyOverride::set(None);
let (_directory, root) = project();
let (_other_directory, other) = project();
scoped(&root, || save_active(&review())).expect("saved in its project");
let stored = std::fs::read(workflow_file()).expect("stored workflow");
let mut closed = scoped(&other, load_active)
.expect("load")
.expect("a withheld workflow is still a workflow");
assert!(is_withheld_sentinel(&closed));
closed.updated_at = chrono::Utc::now() - chrono::Duration::minutes(45);
assert!(!is_run_stale(&closed));
for tool in [
"ctx_read",
"ctx_session",
"ctx_ledger",
"ctx_search",
"ctx_overview",
] {
let ToolGate::Refuse(reason) = tool_gate(&closed, tool, PASSTHROUGH) else {
panic!("{tool} must be refused under the stand-in");
};
assert!(
reason.contains("withheld by the context gateway"),
"{reason}"
);
assert!(!reason.contains("review"), "no workflow name: {reason}");
assert!(!reason.contains("planning"), "no state name: {reason}");
assert!(!reason.contains("active.json"), "no file name: {reason}");
}
assert_eq!(
tool_gate(&closed, "ctx_workflow", PASSTHROUGH),
ToolGate::Pass
);
assert_eq!(tool_gate(&closed, "ctx_call", PASSTHROUGH), ToolGate::Pass);
assert_eq!(
std::fs::read(workflow_file()).expect("stored workflow"),
stored,
"the withheld workflow is byte-identical on disk"
);
let real = scoped(&root, load_active).expect("load").expect("active");
assert!(!is_withheld_sentinel(&real));
assert_eq!(real.spec.name, "review");
assert_eq!(
tool_gate(&real, "ctx_overview", PASSTHROUGH),
ToolGate::Pass
);
}
#[test]
fn a_withheld_workflow_is_not_deleted_for_its_age() {
let _data = governed();
let _community = TestPolicyOverride::set(None);
let (_directory, root) = project();
let (_other_directory, other) = project();
let mut old = review();
old.updated_at = chrono::Utc::now() - chrono::Duration::minutes(45);
scoped(&root, || save_active(&old)).expect("saved in its project");
let stored = std::fs::read(workflow_file()).expect("stored workflow");
let closed = scoped(&other, load_active).expect("load").expect("closed");
assert!(is_withheld_sentinel(&closed));
assert_eq!(std::fs::read(workflow_file()).expect("kept"), stored);
assert!(scoped(&root, load_active).expect("load").is_none());
assert!(
!workflow_file().exists(),
"a served stale run is cleared as before"
);
}
#[test]
fn a_real_run_goes_stale_and_keeps_its_pass_through_tools() {
let _data = crate::core::data_dir::isolated_data_dir();
let _community = TestPolicyOverride::set(None);
let mut run = review();
assert_eq!(tool_gate(&run, "ctx_read", PASSTHROUGH), ToolGate::Pass);
assert_eq!(tool_gate(&run, "ctx_session", PASSTHROUGH), ToolGate::Pass);
assert_eq!(tool_gate(&run, "ctx_overview", PASSTHROUGH), ToolGate::Pass);
assert_eq!(tool_gate(&run, "ctx_workflow", PASSTHROUGH), ToolGate::Pass);
assert_eq!(
tool_gate(&run, "ctx_shell", PASSTHROUGH),
ToolGate::Refuse(
"Tool 'ctx_shell' blocked by workflow 'review' (state: planning). Allowed: ctx_overview. Use ctx_workflow(action=\"stop\") to exit."
.to_string()
)
);
run.updated_at = chrono::Utc::now() - chrono::Duration::minutes(31);
assert!(is_run_stale(&run));
assert_eq!(tool_gate(&run, "ctx_shell", PASSTHROUGH), ToolGate::Expired);
assert_eq!(tool_gate(&run, "ctx_read", PASSTHROUGH), ToolGate::Expired);
assert_eq!(tool_gate(&run, "ctx_workflow", PASSTHROUGH), ToolGate::Pass);
run.updated_at = chrono::Utc::now();
run.current = "done".to_string();
assert_eq!(tool_gate(&run, "ctx_shell", PASSTHROUGH), ToolGate::Expired);
}
}