Skip to main content

github_copilot_sdk/
mode.rs

1/*---------------------------------------------------------------------------------------------
2 *  Copyright (c) Microsoft Corporation. All rights reserved.
3 *--------------------------------------------------------------------------------------------*/
4
5//! Client-level "empty" mode for minimal/safe defaults.
6//!
7//! See the plan in <https://github.com/github/copilot-agent-runtime/issues/7155>:
8//! [`ClientMode::Empty`] disables ambient CLI-style behavior by default so an
9//! app must explicitly opt back into features. This module exposes the public
10//! enum, the [`ToolSet`] builder for source-qualified tool filter patterns,
11//! and the [`BUILTIN_TOOLS_ISOLATED`] curated allowlist.
12
13use std::collections::HashMap;
14
15use crate::types::{MemoryConfiguration, SectionOverride, SystemMessageConfig};
16
17/// Controls SDK defaults for ambient CLI-style behavior.
18///
19/// - [`ClientMode::CopilotCli`] (default): defaults equivalent to Copilot CLI.
20///   Useful when building a coding agent that shares sessions with Copilot CLI.
21///   **Do not use this mode for server-based multi-user applications** — the
22///   default coding agent has tools and capabilities that operate across
23///   sessions and can access the host OS environment.
24/// - [`ClientMode::Empty`]: disables optional features by default. The app
25///   must explicitly opt into anything it needs. Required for any scenario
26///   where CLI-like ambient behavior is unsafe (e.g. multi-user servers).
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum ClientMode {
29    /// Defaults equivalent to Copilot CLI (the default).
30    #[default]
31    CopilotCli,
32    /// Disables optional features by default; app must opt in explicitly.
33    Empty,
34}
35
36/// Tool name character set enforced by the runtime at every registration
37/// boundary. Mirrors the runtime's `VALID_TOOL_NAME_REGEX`.
38fn is_valid_tool_name(name: &str) -> bool {
39    !name.is_empty()
40        && name
41            .chars()
42            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
43}
44
45fn validate_name(kind: &str, name: &str) -> Result<(), crate::Error> {
46    if name == "*" {
47        return Ok(());
48    }
49    if !is_valid_tool_name(name) {
50        return Err(crate::Error::with_message(
51            crate::ErrorKind::InvalidConfig,
52            format!(
53                "Invalid {kind} tool name '{name}': tool names must match \
54             /^[a-zA-Z0-9_-]+$/ or be the wildcard '*'."
55            ),
56        ));
57    }
58    Ok(())
59}
60
61/// Builder that produces source-qualified tool filter strings (e.g.
62/// `"builtin:bash"`, `"mcp:*"`, `"custom:foo"`) for the session's
63/// `available_tools` list.
64///
65/// Tools are classified by the runtime at registration time, not from name
66/// parsing — so `add_builtin("foo")` matches only tools registered as
67/// built-in, even if an MCP server happens to register a tool with the same
68/// wire name.
69///
70/// # Example
71///
72/// ```
73/// # use github_copilot_sdk::mode::{ToolSet, BUILTIN_TOOLS_ISOLATED};
74/// let tools = ToolSet::new()
75///     .add_builtin_many(BUILTIN_TOOLS_ISOLATED)?
76///     .add_mcp("*")?
77///     .add_custom("*")?
78///     .to_vec();
79/// # Ok::<(), github_copilot_sdk::Error>(())
80/// ```
81#[derive(Debug, Clone, Default)]
82pub struct ToolSet {
83    items: Vec<String>,
84}
85
86impl ToolSet {
87    /// Construct an empty tool set.
88    pub fn new() -> Self {
89        Self::default()
90    }
91
92    /// Add a single built-in tool pattern. Pass a specific name (e.g.
93    /// `"bash"`) or `"*"` to match all built-in tools.
94    pub fn add_builtin(mut self, name: &str) -> Result<Self, crate::Error> {
95        validate_name("builtin", name)?;
96        self.items.push(format!("builtin:{name}"));
97        Ok(self)
98    }
99
100    /// Add a list of built-in tool patterns (e.g. [`BUILTIN_TOOLS_ISOLATED`]).
101    pub fn add_builtin_many<I, S>(mut self, names: I) -> Result<Self, crate::Error>
102    where
103        I: IntoIterator<Item = S>,
104        S: AsRef<str>,
105    {
106        for name in names {
107            let name = name.as_ref();
108            validate_name("builtin", name)?;
109            self.items.push(format!("builtin:{name}"));
110        }
111        Ok(self)
112    }
113
114    /// Add a custom tool pattern. Matches tools registered via the SDK's
115    /// `tools` option or via custom agents.
116    pub fn add_custom(mut self, name: &str) -> Result<Self, crate::Error> {
117        validate_name("custom", name)?;
118        self.items.push(format!("custom:{name}"));
119        Ok(self)
120    }
121
122    /// Add an MCP tool pattern. Pass the runtime's canonical wire name
123    /// (e.g. `"github-list_issues"`) or `"*"` to match all MCP tools.
124    pub fn add_mcp(mut self, tool_name: &str) -> Result<Self, crate::Error> {
125        validate_name("mcp", tool_name)?;
126        self.items.push(format!("mcp:{tool_name}"));
127        Ok(self)
128    }
129
130    /// Returns a defensive copy of the accumulated filter strings.
131    pub fn to_vec(&self) -> Vec<String> {
132        self.items.clone()
133    }
134
135    /// Returns the accumulated filter strings, consuming the builder.
136    pub fn into_vec(self) -> Vec<String> {
137        self.items
138    }
139
140    /// Number of accumulated filter strings.
141    pub fn len(&self) -> usize {
142        self.items.len()
143    }
144
145    /// Returns `true` if no filter strings have been added.
146    pub fn is_empty(&self) -> bool {
147        self.items.is_empty()
148    }
149}
150
151impl From<ToolSet> for Vec<String> {
152    fn from(value: ToolSet) -> Self {
153        value.into_vec()
154    }
155}
156
157/// Built-in tools that operate only within the bounds of a single session —
158/// no host filesystem access outside the session, no cross-session state,
159/// no host environment access, no network.
160///
161/// Safe to enable in [`ClientMode::Empty`] scenarios (e.g. multi-tenant
162/// servers) without leaking host capabilities.
163///
164/// **Contract:** tools in this set MUST NOT be extended (even behind options
165/// or args) to read or write state outside the session boundary. Adding
166/// cross-session or host-state behavior to one of these tools is a breaking
167/// change that requires removing it from this set.
168pub const BUILTIN_TOOLS_ISOLATED: &[&str] = &[
169    "ask_user",
170    "task_complete",
171    "exit_plan_mode",
172    "task",
173    "read_agent",
174    "write_agent",
175    "list_agents",
176    "send_inbox",
177    "context_board",
178    "skill",
179];
180
181/// Validate a tool filter list (`available_tools` or `excluded_tools`).
182/// Rejects the bare `"*"` shorthand with a clear error pointing the developer
183/// at the source-qualified forms.
184pub(crate) fn validate_tool_filter_list(
185    field: &str,
186    list: Option<&[String]>,
187) -> Result<(), crate::Error> {
188    let Some(list) = list else { return Ok(()) };
189    for item in list {
190        if item == "*" {
191            return Err(crate::Error::with_message(
192                crate::ErrorKind::InvalidConfig,
193                format!(
194                    "{field} contains a bare '*' which matches no tool. Use \
195                 source-qualified wildcards instead: \
196                 ToolSet::new().add_builtin(\"*\").add_mcp(\"*\").add_custom(\"*\")."
197                ),
198            ));
199        }
200    }
201    Ok(())
202}
203
204/// Returns the system message config to use, adjusted for the current mode.
205/// In empty mode we ensure the `environment_context` section is removed
206/// unless the app has already taken control of it.
207pub(crate) fn system_message_for_mode(
208    mode: ClientMode,
209    supplied: Option<SystemMessageConfig>,
210) -> Option<SystemMessageConfig> {
211    if mode != ClientMode::Empty {
212        return supplied;
213    }
214    let strip_env = || {
215        let mut sections = HashMap::new();
216        sections.insert(
217            "environment_context".to_string(),
218            SectionOverride {
219                action: Some("remove".to_string()),
220                content: None,
221            },
222        );
223        sections
224    };
225    let Some(supplied) = supplied else {
226        return Some(SystemMessageConfig {
227            mode: Some("customize".to_string()),
228            content: None,
229            sections: Some(strip_env()),
230        });
231    };
232    let mode_str = supplied.mode.as_deref().unwrap_or("append");
233    match mode_str {
234        "replace" => Some(supplied),
235        "customize" => {
236            if supplied
237                .sections
238                .as_ref()
239                .is_some_and(|s| s.contains_key("environment_context"))
240            {
241                Some(supplied)
242            } else {
243                let mut sections = supplied.sections.unwrap_or_default();
244                sections.insert(
245                    "environment_context".to_string(),
246                    SectionOverride {
247                        action: Some("remove".to_string()),
248                        content: None,
249                    },
250                );
251                Some(SystemMessageConfig {
252                    mode: Some("customize".to_string()),
253                    content: supplied.content,
254                    sections: Some(sections),
255                })
256            }
257        }
258        // "append" or any unrecognized value: promote to customize so we
259        // can also strip environment_context; the runtime appends `content`
260        // to additional instructions either way.
261        _ => Some(SystemMessageConfig {
262            mode: Some("customize".to_string()),
263            content: supplied.content,
264            sections: Some(strip_env()),
265        }),
266    }
267}
268
269/// Returns the memory configuration to use, adjusted for the current mode.
270///
271/// In [`ClientMode::Empty`] the memory feature defaults to disabled so an app
272/// must opt in explicitly. In [`ClientMode::CopilotCli`] no SDK default is
273/// applied: the configuration is left unset so the runtime applies its own
274/// default for the memory feature. A value supplied by the app always wins.
275pub(crate) fn memory_for_mode(
276    mode: ClientMode,
277    supplied: Option<MemoryConfiguration>,
278) -> Option<MemoryConfiguration> {
279    match supplied {
280        Some(config) => Some(config),
281        None if mode == ClientMode::Empty => Some(MemoryConfiguration::disabled()),
282        None => None,
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    #[test]
291    fn tool_set_emits_source_qualified_patterns() {
292        let v = ToolSet::new()
293            .add_builtin("bash")
294            .unwrap()
295            .add_builtin("*")
296            .unwrap()
297            .add_custom("foo")
298            .unwrap()
299            .add_custom("*")
300            .unwrap()
301            .add_mcp("github-list_issues")
302            .unwrap()
303            .add_mcp("*")
304            .unwrap()
305            .to_vec();
306        assert_eq!(
307            v,
308            vec![
309                "builtin:bash",
310                "builtin:*",
311                "custom:foo",
312                "custom:*",
313                "mcp:github-list_issues",
314                "mcp:*",
315            ]
316        );
317    }
318
319    #[test]
320    fn tool_set_add_builtin_many() {
321        let v = ToolSet::new()
322            .add_builtin_many(BUILTIN_TOOLS_ISOLATED)
323            .unwrap()
324            .into_vec();
325        assert_eq!(v.len(), BUILTIN_TOOLS_ISOLATED.len());
326        assert_eq!(v[0], format!("builtin:{}", BUILTIN_TOOLS_ISOLATED[0]));
327    }
328
329    #[test]
330    fn tool_set_rejects_invalid_names() {
331        for bad in ["bash!", "with space", "colon:name", "", "wild*card"] {
332            assert!(
333                ToolSet::new().add_builtin(bad).is_err(),
334                "expected '{bad}' to be rejected"
335            );
336            assert!(ToolSet::new().add_custom(bad).is_err());
337            assert!(ToolSet::new().add_mcp(bad).is_err());
338        }
339    }
340
341    #[test]
342    fn tool_set_accepts_wildcard_and_underscores_and_dashes() {
343        assert!(ToolSet::new().add_builtin("*").is_ok());
344        assert!(ToolSet::new().add_mcp("github-list_issues").is_ok());
345        assert!(ToolSet::new().add_custom("A_b-9").is_ok());
346    }
347
348    #[test]
349    fn into_vec_is_idempotent_with_to_vec() {
350        let ts = ToolSet::new().add_builtin("bash").unwrap();
351        assert_eq!(ts.to_vec(), vec!["builtin:bash"]);
352        assert_eq!(ts.into_vec(), vec!["builtin:bash"]);
353    }
354
355    #[test]
356    fn into_vec_string_conversion() {
357        let v: Vec<String> = ToolSet::new().add_mcp("*").unwrap().into();
358        assert_eq!(v, vec!["mcp:*"]);
359    }
360
361    #[test]
362    fn validate_tool_filter_list_rejects_bare_star() {
363        let bad = vec!["*".to_string()];
364        assert!(validate_tool_filter_list("availableTools", Some(&bad)).is_err());
365    }
366
367    #[test]
368    fn validate_tool_filter_list_allows_qualified_star() {
369        let ok = vec!["builtin:*".to_string(), "mcp:*".to_string()];
370        assert!(validate_tool_filter_list("availableTools", Some(&ok)).is_ok());
371    }
372
373    #[test]
374    fn validate_tool_filter_list_none_is_ok() {
375        assert!(validate_tool_filter_list("availableTools", None).is_ok());
376    }
377
378    #[test]
379    fn builtin_tools_isolated_contents() {
380        assert!(BUILTIN_TOOLS_ISOLATED.contains(&"ask_user"));
381        assert!(BUILTIN_TOOLS_ISOLATED.contains(&"task_complete"));
382        assert!(BUILTIN_TOOLS_ISOLATED.contains(&"skill"));
383        assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"bash"));
384        assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"edit"));
385        assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"web_fetch"));
386    }
387
388    #[test]
389    fn client_mode_default_is_copilot_cli() {
390        assert_eq!(ClientMode::default(), ClientMode::CopilotCli);
391    }
392
393    #[test]
394    fn system_message_copilot_cli_passes_through_unchanged() {
395        let cfg = SystemMessageConfig {
396            mode: Some("append".to_string()),
397            content: Some("hello".to_string()),
398            sections: None,
399        };
400        let out = system_message_for_mode(ClientMode::CopilotCli, Some(cfg.clone()));
401        let out = out.unwrap();
402        assert_eq!(out.mode.as_deref(), Some("append"));
403        assert_eq!(out.content.as_deref(), Some("hello"));
404    }
405
406    #[test]
407    fn system_message_empty_none_injects_strip() {
408        let out = system_message_for_mode(ClientMode::Empty, None).unwrap();
409        assert_eq!(out.mode.as_deref(), Some("customize"));
410        let sections = out.sections.unwrap();
411        let env = sections.get("environment_context").unwrap();
412        assert_eq!(env.action.as_deref(), Some("remove"));
413    }
414
415    #[test]
416    fn system_message_empty_append_promoted_to_customize() {
417        let cfg = SystemMessageConfig {
418            mode: Some("append".to_string()),
419            content: Some("hi".to_string()),
420            sections: None,
421        };
422        let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
423        assert_eq!(out.mode.as_deref(), Some("customize"));
424        assert_eq!(out.content.as_deref(), Some("hi"));
425        let sections = out.sections.unwrap();
426        assert!(sections.contains_key("environment_context"));
427    }
428
429    #[test]
430    fn system_message_empty_replace_passes_through() {
431        let cfg = SystemMessageConfig {
432            mode: Some("replace".to_string()),
433            content: Some("verbatim".to_string()),
434            sections: None,
435        };
436        let out = system_message_for_mode(ClientMode::Empty, Some(cfg.clone())).unwrap();
437        assert_eq!(out.mode.as_deref(), Some("replace"));
438        assert_eq!(out.content.as_deref(), Some("verbatim"));
439        assert!(out.sections.is_none());
440    }
441
442    #[test]
443    fn system_message_empty_customize_with_env_context_preserved() {
444        let mut sections = HashMap::new();
445        sections.insert(
446            "environment_context".to_string(),
447            SectionOverride {
448                action: Some("replace".to_string()),
449                content: Some("custom env".to_string()),
450            },
451        );
452        let cfg = SystemMessageConfig {
453            mode: Some("customize".to_string()),
454            content: None,
455            sections: Some(sections),
456        };
457        let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
458        let env = out.sections.unwrap().remove("environment_context").unwrap();
459        assert_eq!(env.action.as_deref(), Some("replace"));
460        assert_eq!(env.content.as_deref(), Some("custom env"));
461    }
462
463    #[test]
464    fn system_message_empty_customize_without_env_context_gets_strip() {
465        let mut sections = HashMap::new();
466        sections.insert(
467            "other_section".to_string(),
468            SectionOverride {
469                action: Some("replace".to_string()),
470                content: Some("body".to_string()),
471            },
472        );
473        let cfg = SystemMessageConfig {
474            mode: Some("customize".to_string()),
475            content: None,
476            sections: Some(sections),
477        };
478        let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
479        let secs = out.sections.unwrap();
480        assert!(secs.contains_key("other_section"));
481        let env = secs.get("environment_context").unwrap();
482        assert_eq!(env.action.as_deref(), Some("remove"));
483    }
484
485    #[test]
486    fn memory_copilot_cli_leaves_unset_when_not_supplied() {
487        assert_eq!(memory_for_mode(ClientMode::CopilotCli, None), None);
488    }
489
490    #[test]
491    fn memory_copilot_cli_preserves_supplied() {
492        assert_eq!(
493            memory_for_mode(ClientMode::CopilotCli, Some(MemoryConfiguration::enabled())),
494            Some(MemoryConfiguration::enabled())
495        );
496    }
497
498    #[test]
499    fn memory_empty_defaults_to_disabled() {
500        assert_eq!(
501            memory_for_mode(ClientMode::Empty, None),
502            Some(MemoryConfiguration::disabled())
503        );
504    }
505
506    #[test]
507    fn memory_empty_preserves_supplied() {
508        assert_eq!(
509            memory_for_mode(ClientMode::Empty, Some(MemoryConfiguration::enabled())),
510            Some(MemoryConfiguration::enabled())
511        );
512    }
513}