Skip to main content

recursive/
coordinator.rs

1//! Coordinator mode gate and tool-set allow-list.
2//!
3//! # What is coordinator mode?
4//!
5//! When a `recursive` process is launched with both:
6//! - the env var `RECURSIVE_COORDINATOR_MODE=1`, AND
7//! - the cargo feature `coordinator-mode`
8//!
9//! the process runs as a *coordinator* — a higher-level agent that
10//! dispatches work to teammates via the `team_*`, `task_*`, and
11//! `send_message` tools, but is itself restricted to a curated
12//! allow-list of tools.  In particular, the coordinator must NOT have
13//! direct `Edit` / `Write` / `Bash` access; otherwise it would
14//! short-circuit the team and do the work itself.
15//!
16//! # Allow-list
17//!
18//! The set of tools available in coordinator mode is *additive* over
19//! the standard toolset: all non-mutating read-only tools are kept,
20//! plus a handful of "meta" tools.  Tools that let the coordinator
21//! reach into the filesystem or shell are excluded.
22//!
23//! This is intentionally conservative.  A real product would also
24//! exclude `MultiEdit`, `NotebookEdit`, etc.; we mirror that intent
25//! below for completeness even if those tools don't exist in the
26//! current kernel.
27
28use crate::tools::ToolRegistry;
29
30/// Returns `true` iff this process is running in coordinator mode.
31///
32/// Both the env var (`RECURSIVE_COORDINATOR_MODE=1`) AND the
33/// `coordinator-mode` cargo feature must be active.  The cargo gate
34/// is intentional: it means the feature can be turned off at build
35/// time for deployments that don't want coordinator semantics at all.
36pub fn is_coordinator_mode() -> bool {
37    cfg!(feature = "coordinator-mode")
38        && std::env::var("RECURSIVE_COORDINATOR_MODE").as_deref() == Ok("1")
39}
40
41/// The set of tool names available in coordinator mode.
42///
43/// Anything not in this list must be filtered out of the coordinator's
44/// tool registry before the kernel is built.  See `builder.rs` for the
45/// wiring.
46///
47/// Note: this list is consulted at *registry build time* — it does not
48/// dynamically revoke permissions mid-run.  If you need runtime
49/// enforcement, layer in a `PermissionHook` later.
50pub fn coordinator_tool_set() -> &'static [&'static str] {
51    &[
52        // --- Read-only introspection (PascalCase matches Tool::spec().name) ---
53        "Read",
54        "Grep",
55        "Glob",
56        "shared_memory_read",
57        "list_workers",
58        // --- Team / task meta-tools ---
59        "team_create",
60        "team_delete",
61        "send_message",
62        "task_create",
63        "task_get",
64        "task_list",
65        "task_output",
66        "task_stop",
67        "task_update",
68        // --- Local context helpers (read-only / non-shell) ---
69        "TodoWrite",
70        "WebFetch",
71        "WebSearch",
72        // --- The agent tool itself (so the coordinator can dispatch) ---
73        "agent",
74        // --- Plan mode ---
75        "enter_plan_mode",
76        "exit_plan_mode",
77        "request_plan_mode",
78    ]
79}
80
81/// Returns `true` if `tool_name` is allowed in coordinator mode.
82///
83/// Equivalent to `coordinator_tool_set().contains(&tool_name)`, but
84/// provided as a free function so call sites read cleanly.
85pub fn is_coordinator_tool(tool_name: &str) -> bool {
86    coordinator_tool_set().contains(&tool_name)
87}
88
89/// Tools that the coordinator mode MUST NOT have access to, even if
90/// they otherwise pass the read-only filter.  This is a defense-in-depth
91/// list — the primary filter is `coordinator_tool_set()`, but if a
92/// future change accidentally broadens that list, the deny list here
93/// will still keep these out.
94///
95/// Currently mirrors the spec's "Edit / Write / Bash" prohibition.
96pub fn coordinator_deny_list() -> &'static [&'static str] {
97    &[
98        // PascalCase matches actual Tool::spec().name values.
99        "Edit", "Write", "Bash",
100    ]
101}
102
103/// Sanity check: a tool name is allowed in coordinator mode iff it is
104/// in the allow-list AND not in the deny-list.  The deny-list exists
105/// to catch accidental future additions; the allow-list is the source
106/// of truth.
107pub fn is_allowed_in_coordinator_mode(tool_name: &str) -> bool {
108    is_coordinator_tool(tool_name) && !coordinator_deny_list().contains(&tool_name)
109}
110
111/// Prune a [`ToolRegistry`] down to the coordinator allow-list when the
112/// process is running in coordinator mode.  No-op otherwise.  This is
113/// the wiring point that the kernel calls right after
114/// `build_tools()` — see `src/cli/builder.rs`.
115pub fn filter_registry(registry: &mut ToolRegistry) {
116    if is_coordinator_mode() {
117        let allow: Vec<String> = coordinator_tool_set()
118            .iter()
119            .copied()
120            .filter(|name| is_allowed_in_coordinator_mode(name))
121            .map(str::to_string)
122            .collect();
123        registry.retain_tools(&allow);
124    }
125}
126
127// ---------------------------------------------------------------------------
128// Tests
129// ---------------------------------------------------------------------------
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::tools::build_standard_tools;
135
136    #[test]
137    fn allow_list_excludes_mutating_tools() {
138        // The whole point of coordinator mode: no direct mutation.
139        // Tool names are PascalCase (matching Tool::spec().name).
140        for forbidden in ["Edit", "Write", "Bash"] {
141            assert!(
142                !is_allowed_in_coordinator_mode(forbidden),
143                "coordinator mode must forbid `{forbidden}`"
144            );
145        }
146    }
147
148    #[test]
149    fn allow_list_includes_team_and_task_tools() {
150        for required in [
151            "team_create",
152            "team_delete",
153            "send_message",
154            "task_create",
155            "task_get",
156            "task_list",
157            "task_output",
158            "task_stop",
159            "task_update",
160        ] {
161            assert!(
162                is_allowed_in_coordinator_mode(required),
163                "coordinator mode must allow `{required}`"
164            );
165        }
166    }
167
168    #[test]
169    fn allow_list_includes_agent_and_read_only_introspection() {
170        for required in ["agent", "Read", "Grep", "Glob", "shared_memory_read"] {
171            assert!(
172                is_allowed_in_coordinator_mode(required),
173                "coordinator mode must allow `{required}`"
174            );
175        }
176    }
177
178    #[test]
179    fn deny_list_blocks_even_if_allowlist_grows() {
180        // The deny list is the safety net.  Even if a future change
181        // adds "Edit" to the allow list (which it shouldn't), the
182        // deny list still excludes it. Tool names are PascalCase.
183        assert!(coordinator_deny_list().contains(&"Edit"));
184        assert!(coordinator_deny_list().contains(&"Write"));
185        assert!(coordinator_deny_list().contains(&"Bash"));
186    }
187
188    #[test]
189    fn is_coordinator_tool_false_for_unknown_tool() {
190        // kills `replace is_coordinator_tool -> bool with true` mutation
191        assert!(
192            !is_coordinator_tool("NonExistentTool"),
193            "unknown tool must not be in the coordinator tool set"
194        );
195    }
196
197    #[test]
198    fn is_allowed_in_coordinator_mode_false_for_unknown_tool() {
199        // kills `replace is_allowed_in_coordinator_mode -> bool with true` mutation
200        assert!(
201            !is_allowed_in_coordinator_mode("NotATool"),
202            "unknown tool must not be allowed in coordinator mode"
203        );
204    }
205
206    #[test]
207    fn is_allowed_requires_both_allowlist_and_not_denylist() {
208        // specifically kills `replace && with ||` mutation in is_allowed_in_coordinator_mode:
209        // if the `&&` became `||`, deny-listed tools that aren't in the allow-list
210        // might still be blocked, but the real risk is an allowlist-only check.
211        // We verify the combined semantics by checking that "Edit" (in deny list
212        // but possibly in allow list too) is always rejected.
213        assert!(
214            !is_allowed_in_coordinator_mode("Edit"),
215            "Edit must be blocked regardless of allow list membership"
216        );
217    }
218
219    #[test]
220    fn coordinator_tool_set_is_non_empty() {
221        // kills function-level replacement of coordinator_tool_set with &[]
222        assert!(
223            !coordinator_tool_set().is_empty(),
224            "coordinator tool set must not be empty"
225        );
226    }
227
228    #[test]
229    fn is_coordinator_tool_true_for_agent_tool() {
230        // kills `coordinator_tool_set().contains(&tool_name)` → always-false mutation
231        assert!(
232            is_coordinator_tool("agent"),
233            "'agent' must be in coordinator tool set"
234        );
235    }
236
237    #[test]
238    fn is_coordinator_mode_false_without_env_var() {
239        let _env_lock = crate::test_util::env_lock();
240        // kills `is_coordinator_mode` → always-true mutation
241        let prev = std::env::var("RECURSIVE_COORDINATOR_MODE").ok();
242        std::env::remove_var("RECURSIVE_COORDINATOR_MODE");
243        // Without the env var, coordinator mode must be off (regardless of feature flag)
244        let mode = is_coordinator_mode();
245        if let Some(v) = prev {
246            std::env::set_var("RECURSIVE_COORDINATOR_MODE", v);
247        }
248        // With no feature flag, must always be false; with feature flag but no env var also false
249        if !cfg!(feature = "coordinator-mode") {
250            assert!(!mode, "coordinator mode must be false without feature");
251        } else {
252            assert!(
253                !mode,
254                "coordinator mode must be false without the env var even with feature"
255            );
256        }
257    }
258
259    #[test]
260    fn cargo_feature_gate_respected() {
261        let _env_lock = crate::test_util::env_lock();
262        // We can't flip cargo features at test time, but we can verify
263        // that the env-var side of the gate works deterministically.
264        // (The cargo side is checked by `cfg!(feature = "coordinator-mode")`.)
265        let prev = std::env::var("RECURSIVE_COORDINATOR_MODE").ok();
266        std::env::set_var("RECURSIVE_COORDINATOR_MODE", "1");
267        let on_with_feature = cfg!(feature = "coordinator-mode");
268        let _ = is_coordinator_mode(); // shouldn't panic
269                                       // Restore
270        match prev {
271            Some(v) => std::env::set_var("RECURSIVE_COORDINATOR_MODE", v),
272            None => std::env::remove_var("RECURSIVE_COORDINATOR_MODE"),
273        }
274        // Just ensure the test doesn't blow up regardless of the build cfg.
275        let _ = on_with_feature;
276    }
277
278    #[test]
279    fn is_coordinator_mode_rejects_non_one_env_value() {
280        let _env_lock = crate::test_util::env_lock();
281        // Kills: `replace == with !=` on `as_deref() == Ok("1")`.
282        // With the mutant, any value other than "1" (including "0" / "true")
283        // would incorrectly enable coordinator mode when the feature is on.
284        let prev = std::env::var("RECURSIVE_COORDINATOR_MODE").ok();
285        std::env::set_var("RECURSIVE_COORDINATOR_MODE", "0");
286        let mode = is_coordinator_mode();
287        match prev {
288            Some(v) => std::env::set_var("RECURSIVE_COORDINATOR_MODE", v),
289            None => std::env::remove_var("RECURSIVE_COORDINATOR_MODE"),
290        }
291        assert!(
292            !mode,
293            "RECURSIVE_COORDINATOR_MODE=0 must not enable coordinator mode"
294        );
295    }
296
297    #[test]
298    fn is_coordinator_mode_false_when_env_one_without_cargo_feature() {
299        let _env_lock = crate::test_util::env_lock();
300        // Kills: `delete &&` / feature-gate mutants when coordinator-mode is
301        // not compiled in — env=1 alone must not enable coordinator mode.
302        if cfg!(feature = "coordinator-mode") {
303            return;
304        }
305        let prev = std::env::var("RECURSIVE_COORDINATOR_MODE").ok();
306        std::env::set_var("RECURSIVE_COORDINATOR_MODE", "1");
307        assert!(
308            !is_coordinator_mode(),
309            "env=1 must not enable coordinator mode without the cargo feature"
310        );
311        match prev {
312            Some(v) => std::env::set_var("RECURSIVE_COORDINATOR_MODE", v),
313            None => std::env::remove_var("RECURSIVE_COORDINATOR_MODE"),
314        }
315    }
316
317    #[cfg(feature = "coordinator-mode")]
318    #[test]
319    fn is_coordinator_mode_true_when_feature_and_env_one() {
320        let _env_lock = crate::test_util::env_lock();
321        // Kills: feature-gate mutants that drop the cargo `cfg!` check.
322        let prev = std::env::var("RECURSIVE_COORDINATOR_MODE").ok();
323        std::env::set_var("RECURSIVE_COORDINATOR_MODE", "1");
324        assert!(
325            is_coordinator_mode(),
326            "coordinator mode must be on when feature and env=1"
327        );
328        match prev {
329            Some(v) => std::env::set_var("RECURSIVE_COORDINATOR_MODE", v),
330            None => std::env::remove_var("RECURSIVE_COORDINATOR_MODE"),
331        }
332    }
333
334    #[test]
335    fn filter_registry_noop_outside_coordinator_mode() {
336        let _env_lock = crate::test_util::env_lock();
337        // Kills: `replace filter_registry with ()` and inverted `if` guards
338        // that would prune tools when coordinator mode is off.
339        let prev = std::env::var("RECURSIVE_COORDINATOR_MODE").ok();
340        std::env::remove_var("RECURSIVE_COORDINATOR_MODE");
341        let tmp = tempfile::tempdir().expect("tempdir");
342        let mut reg = build_standard_tools(tmp.path(), &[], 30);
343        assert!(
344            reg.find_by_name("Edit").is_some(),
345            "fixture must include Edit before filter"
346        );
347        filter_registry(&mut reg);
348        assert!(
349            reg.find_by_name("Edit").is_some(),
350            "filter_registry must be a no-op outside coordinator mode"
351        );
352        match prev {
353            Some(v) => std::env::set_var("RECURSIVE_COORDINATOR_MODE", v),
354            None => std::env::remove_var("RECURSIVE_COORDINATOR_MODE"),
355        }
356    }
357
358    #[cfg(feature = "coordinator-mode")]
359    #[test]
360    fn filter_registry_prunes_mutating_tools_in_coordinator_mode() {
361        let _env_lock = crate::test_util::env_lock();
362        // Kills: `replace filter_registry with ()` and delete `if` guard.
363        let prev = std::env::var("RECURSIVE_COORDINATOR_MODE").ok();
364        std::env::set_var("RECURSIVE_COORDINATOR_MODE", "1");
365        let tmp = tempfile::tempdir().expect("tempdir");
366        let mut reg = build_standard_tools(tmp.path(), &[], 30);
367        filter_registry(&mut reg);
368        assert!(
369            reg.find_by_name("Edit").is_none(),
370            "Edit must be pruned in coordinator mode"
371        );
372        assert!(
373            reg.find_by_name("Read").is_some(),
374            "Read must remain in coordinator mode"
375        );
376        match prev {
377            Some(v) => std::env::set_var("RECURSIVE_COORDINATOR_MODE", v),
378            None => std::env::remove_var("RECURSIVE_COORDINATOR_MODE"),
379        }
380    }
381}