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/// Returns the `enable_experimental_mode` value to send for the given mode.
295pub(crate) fn experimental_mode_for_mode(mode: ClientMode, supplied: Option<bool>) -> Option<bool> {
296    if mode == ClientMode::Empty {
297        Some(supplied.unwrap_or(false))
298    } else {
299        supplied
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn custom_agents_local_only_respects_mode_and_caller_value() {
309        assert_eq!(
310            resolve_custom_agents_local_only(ClientMode::Empty, None),
311            Some(true)
312        );
313        assert_eq!(
314            resolve_custom_agents_local_only(ClientMode::Empty, Some(false)),
315            Some(false)
316        );
317        assert_eq!(
318            resolve_custom_agents_local_only(ClientMode::CopilotCli, None),
319            None
320        );
321    }
322
323    #[test]
324    fn tool_set_emits_source_qualified_patterns() {
325        let v = ToolSet::new()
326            .add_builtin("bash")
327            .unwrap()
328            .add_builtin("*")
329            .unwrap()
330            .add_custom("foo")
331            .unwrap()
332            .add_custom("*")
333            .unwrap()
334            .add_mcp("github-list_issues")
335            .unwrap()
336            .add_mcp("*")
337            .unwrap()
338            .to_vec();
339        assert_eq!(
340            v,
341            vec![
342                "builtin:bash",
343                "builtin:*",
344                "custom:foo",
345                "custom:*",
346                "mcp:github-list_issues",
347                "mcp:*",
348            ]
349        );
350    }
351
352    #[test]
353    fn tool_set_add_builtin_many() {
354        let v = ToolSet::new()
355            .add_builtin_many(BUILTIN_TOOLS_ISOLATED)
356            .unwrap()
357            .into_vec();
358        assert_eq!(v.len(), BUILTIN_TOOLS_ISOLATED.len());
359        assert_eq!(v[0], format!("builtin:{}", BUILTIN_TOOLS_ISOLATED[0]));
360    }
361
362    #[test]
363    fn tool_set_rejects_invalid_names() {
364        for bad in ["bash!", "with space", "colon:name", "", "wild*card"] {
365            assert!(
366                ToolSet::new().add_builtin(bad).is_err(),
367                "expected '{bad}' to be rejected"
368            );
369            assert!(ToolSet::new().add_custom(bad).is_err());
370            assert!(ToolSet::new().add_mcp(bad).is_err());
371        }
372    }
373
374    #[test]
375    fn tool_set_accepts_wildcard_and_underscores_and_dashes() {
376        assert!(ToolSet::new().add_builtin("*").is_ok());
377        assert!(ToolSet::new().add_mcp("github-list_issues").is_ok());
378        assert!(ToolSet::new().add_custom("A_b-9").is_ok());
379    }
380
381    #[test]
382    fn into_vec_is_idempotent_with_to_vec() {
383        let ts = ToolSet::new().add_builtin("bash").unwrap();
384        assert_eq!(ts.to_vec(), vec!["builtin:bash"]);
385        assert_eq!(ts.into_vec(), vec!["builtin:bash"]);
386    }
387
388    #[test]
389    fn into_vec_string_conversion() {
390        let v: Vec<String> = ToolSet::new().add_mcp("*").unwrap().into();
391        assert_eq!(v, vec!["mcp:*"]);
392    }
393
394    #[test]
395    fn validate_tool_filter_list_rejects_bare_star() {
396        let bad = vec!["*".to_string()];
397        assert!(validate_tool_filter_list("availableTools", Some(&bad)).is_err());
398    }
399
400    #[test]
401    fn validate_tool_filter_list_allows_qualified_star() {
402        let ok = vec!["builtin:*".to_string(), "mcp:*".to_string()];
403        assert!(validate_tool_filter_list("availableTools", Some(&ok)).is_ok());
404    }
405
406    #[test]
407    fn validate_tool_filter_list_none_is_ok() {
408        assert!(validate_tool_filter_list("availableTools", None).is_ok());
409    }
410
411    #[test]
412    fn builtin_tools_isolated_contents() {
413        assert!(BUILTIN_TOOLS_ISOLATED.contains(&"ask_user"));
414        assert!(BUILTIN_TOOLS_ISOLATED.contains(&"task_complete"));
415        assert!(BUILTIN_TOOLS_ISOLATED.contains(&"skill"));
416        assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"bash"));
417        assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"edit"));
418        assert!(!BUILTIN_TOOLS_ISOLATED.contains(&"web_fetch"));
419    }
420
421    #[test]
422    fn client_mode_default_is_copilot_cli() {
423        assert_eq!(ClientMode::default(), ClientMode::CopilotCli);
424    }
425
426    #[test]
427    fn system_message_copilot_cli_passes_through_unchanged() {
428        let cfg = SystemMessageConfig {
429            mode: Some("append".to_string()),
430            content: Some("hello".to_string()),
431            sections: None,
432        };
433        let out = system_message_for_mode(ClientMode::CopilotCli, Some(cfg.clone()));
434        let out = out.unwrap();
435        assert_eq!(out.mode.as_deref(), Some("append"));
436        assert_eq!(out.content.as_deref(), Some("hello"));
437    }
438
439    #[test]
440    fn system_message_empty_none_injects_strip() {
441        let out = system_message_for_mode(ClientMode::Empty, None).unwrap();
442        assert_eq!(out.mode.as_deref(), Some("customize"));
443        let sections = out.sections.unwrap();
444        let env = sections.get("environment_context").unwrap();
445        assert_eq!(env.action.as_deref(), Some("remove"));
446    }
447
448    #[test]
449    fn system_message_empty_append_promoted_to_customize() {
450        let cfg = SystemMessageConfig {
451            mode: Some("append".to_string()),
452            content: Some("hi".to_string()),
453            sections: None,
454        };
455        let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
456        assert_eq!(out.mode.as_deref(), Some("customize"));
457        assert_eq!(out.content.as_deref(), Some("hi"));
458        let sections = out.sections.unwrap();
459        assert!(sections.contains_key("environment_context"));
460    }
461
462    #[test]
463    fn system_message_empty_replace_passes_through() {
464        let cfg = SystemMessageConfig {
465            mode: Some("replace".to_string()),
466            content: Some("verbatim".to_string()),
467            sections: None,
468        };
469        let out = system_message_for_mode(ClientMode::Empty, Some(cfg.clone())).unwrap();
470        assert_eq!(out.mode.as_deref(), Some("replace"));
471        assert_eq!(out.content.as_deref(), Some("verbatim"));
472        assert!(out.sections.is_none());
473    }
474
475    #[test]
476    fn system_message_empty_customize_with_env_context_preserved() {
477        let mut sections = HashMap::new();
478        sections.insert(
479            "environment_context".to_string(),
480            SectionOverride {
481                action: Some("replace".to_string()),
482                content: Some("custom env".to_string()),
483            },
484        );
485        let cfg = SystemMessageConfig {
486            mode: Some("customize".to_string()),
487            content: None,
488            sections: Some(sections),
489        };
490        let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
491        let env = out.sections.unwrap().remove("environment_context").unwrap();
492        assert_eq!(env.action.as_deref(), Some("replace"));
493        assert_eq!(env.content.as_deref(), Some("custom env"));
494    }
495
496    #[test]
497    fn system_message_empty_customize_without_env_context_gets_strip() {
498        let mut sections = HashMap::new();
499        sections.insert(
500            "other_section".to_string(),
501            SectionOverride {
502                action: Some("replace".to_string()),
503                content: Some("body".to_string()),
504            },
505        );
506        let cfg = SystemMessageConfig {
507            mode: Some("customize".to_string()),
508            content: None,
509            sections: Some(sections),
510        };
511        let out = system_message_for_mode(ClientMode::Empty, Some(cfg)).unwrap();
512        let secs = out.sections.unwrap();
513        assert!(secs.contains_key("other_section"));
514        let env = secs.get("environment_context").unwrap();
515        assert_eq!(env.action.as_deref(), Some("remove"));
516    }
517
518    #[test]
519    fn memory_copilot_cli_leaves_unset_when_not_supplied() {
520        assert_eq!(memory_for_mode(ClientMode::CopilotCli, None), None);
521    }
522
523    #[test]
524    fn memory_copilot_cli_preserves_supplied() {
525        assert_eq!(
526            memory_for_mode(ClientMode::CopilotCli, Some(MemoryConfiguration::enabled())),
527            Some(MemoryConfiguration::enabled())
528        );
529    }
530
531    #[test]
532    fn memory_empty_defaults_to_disabled() {
533        assert_eq!(
534            memory_for_mode(ClientMode::Empty, None),
535            Some(MemoryConfiguration::disabled())
536        );
537    }
538
539    #[test]
540    fn memory_empty_preserves_supplied() {
541        assert_eq!(
542            memory_for_mode(ClientMode::Empty, Some(MemoryConfiguration::enabled())),
543            Some(MemoryConfiguration::enabled())
544        );
545    }
546
547    #[test]
548    fn experimental_mode_defaults_false_in_empty_mode() {
549        assert_eq!(
550            experimental_mode_for_mode(ClientMode::Empty, None),
551            Some(false)
552        );
553        assert_eq!(
554            experimental_mode_for_mode(ClientMode::Empty, Some(true)),
555            Some(true)
556        );
557        assert_eq!(
558            experimental_mode_for_mode(ClientMode::Empty, Some(false)),
559            Some(false)
560        );
561    }
562
563    #[test]
564    fn experimental_mode_remains_runtime_controlled_in_copilot_cli_mode() {
565        assert_eq!(
566            experimental_mode_for_mode(ClientMode::CopilotCli, None),
567            None
568        );
569    }
570}