@@ -469,11 +469,48 @@ fn collect_allowed_tools(
469469 edit_enabled : bool ,
470470) -> Result < Vec < String > > {
471471 let mut allowed_tools = collect_extension_allowed_tools ( extension_declarations) ;
472+ add_mcp_server_tools ( front_matter, & mut allowed_tools) ;
472473
473- // Tools from user-defined MCP servers (sorted for deterministic output).
474- // Only add --allow-tool for MCPs that will actually produce an MCPG entry (i.e.,
475- // WithOptions that have a container or url). McpConfig::Enabled(true) has no backing
476- // server in MCPG, so granting the permission would cause confusing runtime errors.
474+ // Intentional: with restricted bash, both --allow-tool write (tool identity)
475+ // and --allow-all-paths (path scope) are emitted. --allow-all-tools subsumes
476+ // --allow-tool write, so only --allow-all-paths is needed on that path.
477+ if edit_enabled {
478+ allowed_tools. push ( "write" . to_string ( ) ) ;
479+ }
480+
481+ let bash_commands = build_bash_command_list ( front_matter, extension_declarations) ;
482+ for cmd in bash_commands {
483+ // Reject single quotes — copilot_params are embedded inside a single-quoted
484+ // bash string in the AWF command.
485+ if cmd. contains ( '\'' ) {
486+ anyhow:: bail!(
487+ "Bash command '{}' contains a single quote, which is not allowed \
488+ (would break AWF shell quoting).",
489+ cmd
490+ ) ;
491+ }
492+ allowed_tools. push ( format ! ( "shell({})" , cmd) ) ;
493+ }
494+
495+ Ok ( allowed_tools)
496+ }
497+
498+ /// Returns `true` when an MCP server config has a real backing server (container
499+ /// or URL). `McpConfig::Enabled(true)` has no backing server in MCPG, so granting
500+ /// its permission would cause confusing runtime errors.
501+ fn mcp_server_has_backing_server ( config : & McpConfig ) -> bool {
502+ match config {
503+ McpConfig :: Enabled ( _) => false ,
504+ McpConfig :: WithOptions ( opts) => {
505+ opts. enabled . unwrap_or ( true ) && ( opts. container . is_some ( ) || opts. url . is_some ( ) )
506+ }
507+ }
508+ }
509+
510+ /// Appends user-defined MCP server names to `allowed_tools` (sorted for
511+ /// deterministic output), skipping any server already provided by an extension
512+ /// and any server without a backing MCPG entry.
513+ fn add_mcp_server_tools ( front_matter : & FrontMatter , allowed_tools : & mut Vec < String > ) {
477514 let mut sorted_mcps: Vec < _ > = front_matter. mcp_servers . iter ( ) . collect ( ) ;
478515 sorted_mcps. sort_by_key ( |( a, _) | * a) ;
479516 for ( name, config) in sorted_mcps {
@@ -482,68 +519,36 @@ fn collect_allowed_tools(
482519 if allowed_tools. iter ( ) . any ( |t| t. eq_ignore_ascii_case ( name) ) {
483520 continue ;
484521 }
485- // Only add MCPs that have a backing server (container or url)
486- let has_backing_server = match config {
487- McpConfig :: Enabled ( _) => false ,
488- McpConfig :: WithOptions ( opts) => {
489- opts. enabled . unwrap_or ( true ) && ( opts. container . is_some ( ) || opts. url . is_some ( ) )
490- }
491- } ;
492- if has_backing_server {
522+ if mcp_server_has_backing_server ( config) {
493523 allowed_tools. push ( name. clone ( ) ) ;
494524 }
495525 }
526+ }
496527
497- // Intentional: with restricted bash, both --allow-tool write (tool identity)
498- // and --allow-all-paths (path scope) are emitted. --allow-all-tools subsumes
499- // --allow-tool write, so only --allow-all-paths is needed on that path.
500- if edit_enabled {
501- allowed_tools. push ( "write" . to_string ( ) ) ;
502- }
503-
504- // Bash tool: use the explicitly configured list.
505- // When bash is None (not specified), use_allow_all_tools is true and this
506- // function is not called — that invariant is upheld by the caller.
528+ /// Builds the deduplicated bash command list from the front-matter configuration
529+ /// augmented with any extension-declared commands (runtimes + first-party tools).
530+ ///
531+ /// When bash is `None`, `use_allow_all_tools` is `true` and this function is never
532+ /// called — the caller upholds that invariant.
533+ fn build_bash_command_list (
534+ front_matter : & FrontMatter ,
535+ extension_declarations : & [ Declarations ] ,
536+ ) -> Vec < String > {
507537 let mut bash_commands: Vec < String > =
508538 match front_matter. tools . as_ref ( ) . and_then ( |t| t. bash . as_ref ( ) ) {
509- Some ( cmds) if cmds. is_empty ( ) => {
510- // Explicitly disabled: no bash commands
511- vec ! [ ]
512- }
513- Some ( cmds) => {
514- // Explicit list of commands
515- cmds. clone ( )
516- }
517- None => {
518- // Invariant: bash=None → use_allow_all_tools=true → this function is
519- // not called. Panic if the invariant is ever broken.
520- unreachable ! ( "bash=None should imply use_allow_all_tools=true" )
521- }
539+ Some ( cmds) if cmds. is_empty ( ) => vec ! [ ] ,
540+ Some ( cmds) => cmds. clone ( ) ,
541+ // Invariant: bash=None → use_allow_all_tools=true → not called.
542+ None => unreachable ! ( "bash=None should imply use_allow_all_tools=true" ) ,
522543 } ;
523-
524- // Auto-add extension-declared bash commands (runtimes + first-party tools)
525544 for decl in extension_declarations {
526545 for cmd in & decl. bash_commands {
527546 if !bash_commands. contains ( cmd) {
528547 bash_commands. push ( cmd. clone ( ) ) ;
529548 }
530549 }
531550 }
532-
533- for cmd in & bash_commands {
534- // Reject single quotes in bash commands — copilot_params are embedded inside
535- // a single-quoted bash string in the AWF command.
536- if cmd. contains ( '\'' ) {
537- anyhow:: bail!(
538- "Bash command '{}' contains a single quote, which is not allowed \
539- (would break AWF shell quoting).",
540- cmd
541- ) ;
542- }
543- allowed_tools. push ( format ! ( "shell({})" , cmd) ) ;
544- }
545-
546- Ok ( allowed_tools)
551+ bash_commands
547552}
548553
549554/// Validates a single `engine.args` entry.
0 commit comments