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