@@ -175,6 +175,129 @@ impl Engine {
175175 }
176176}
177177
178+ /// Collects the list of allowed tool identifiers when bash is not in wildcard mode.
179+ ///
180+ /// Returns a flat `Vec<String>` of fully-qualified tool identifiers ready to be
181+ /// passed as `--allow-tool` arguments. Only called when `use_allow_all_tools` is
182+ /// `false`; the caller upholds that invariant.
183+ fn collect_allowed_tools (
184+ front_matter : & FrontMatter ,
185+ extensions : & [ Extension ] ,
186+ edit_enabled : bool ,
187+ ) -> Result < Vec < String > > {
188+ let mut allowed_tools: Vec < String > = Vec :: new ( ) ;
189+
190+ // Tools from compiler extensions (github, safeoutputs, azure-devops, etc.)
191+ for ext in extensions {
192+ for tool in ext. allowed_copilot_tools ( ) {
193+ if !allowed_tools. contains ( & tool) {
194+ allowed_tools. push ( tool) ;
195+ }
196+ }
197+ }
198+
199+ // Tools from user-defined MCP servers (sorted for deterministic output).
200+ // Only add --allow-tool for MCPs that will actually produce an MCPG entry (i.e.,
201+ // WithOptions that have a container or url). McpConfig::Enabled(true) has no backing
202+ // server in MCPG, so granting the permission would cause confusing runtime errors.
203+ let mut sorted_mcps: Vec < _ > = front_matter. mcp_servers . iter ( ) . collect ( ) ;
204+ sorted_mcps. sort_by ( |( a, _) , ( b, _) | a. cmp ( b) ) ;
205+ for ( name, config) in sorted_mcps {
206+ // Skip servers already provided by extensions (case-insensitive to match
207+ // generate_mcpg_config's eq_ignore_ascii_case guard for reserved names)
208+ if allowed_tools. iter ( ) . any ( |t| t. eq_ignore_ascii_case ( name) ) {
209+ continue ;
210+ }
211+ // Only add MCPs that have a backing server (container or url)
212+ let has_backing_server = match config {
213+ McpConfig :: Enabled ( _) => false ,
214+ McpConfig :: WithOptions ( opts) => {
215+ opts. enabled . unwrap_or ( true ) && ( opts. container . is_some ( ) || opts. url . is_some ( ) )
216+ }
217+ } ;
218+ if has_backing_server {
219+ allowed_tools. push ( name. clone ( ) ) ;
220+ }
221+ }
222+
223+ // Intentional: with restricted bash, both --allow-tool write (tool identity)
224+ // and --allow-all-paths (path scope) are emitted. --allow-all-tools subsumes
225+ // --allow-tool write, so only --allow-all-paths is needed on that path.
226+ if edit_enabled {
227+ allowed_tools. push ( "write" . to_string ( ) ) ;
228+ }
229+
230+ // Bash tool: use the explicitly configured list.
231+ // When bash is None (not specified), use_allow_all_tools is true and this
232+ // function is not called — that invariant is upheld by the caller.
233+ let mut bash_commands: Vec < String > =
234+ match front_matter. tools . as_ref ( ) . and_then ( |t| t. bash . as_ref ( ) ) {
235+ Some ( cmds) if cmds. is_empty ( ) => {
236+ // Explicitly disabled: no bash commands
237+ vec ! [ ]
238+ }
239+ Some ( cmds) => {
240+ // Explicit list of commands
241+ cmds. clone ( )
242+ }
243+ None => {
244+ // Invariant: bash=None → use_allow_all_tools=true → this function is
245+ // not called. Panic if the invariant is ever broken.
246+ unreachable ! ( "bash=None should imply use_allow_all_tools=true" )
247+ }
248+ } ;
249+
250+ // Auto-add extension-declared bash commands (runtimes + first-party tools)
251+ for ext in extensions {
252+ for cmd in ext. required_bash_commands ( ) {
253+ if !bash_commands. contains ( & cmd) {
254+ bash_commands. push ( cmd) ;
255+ }
256+ }
257+ }
258+
259+ for cmd in & bash_commands {
260+ // Reject single quotes in bash commands — copilot_params are embedded inside
261+ // a single-quoted bash string in the AWF command.
262+ if cmd. contains ( '\'' ) {
263+ anyhow:: bail!(
264+ "Bash command '{}' contains a single quote, which is not allowed \
265+ (would break AWF shell quoting).",
266+ cmd
267+ ) ;
268+ }
269+ allowed_tools. push ( format ! ( "shell({})" , cmd) ) ;
270+ }
271+
272+ Ok ( allowed_tools)
273+ }
274+
275+ /// Validates a single `engine.args` entry.
276+ ///
277+ /// Returns an error if the argument contains unsafe characters or attempts to
278+ /// override a compiler-controlled flag.
279+ fn validate_user_arg ( arg : & str ) -> Result < ( ) > {
280+ if !is_valid_arg ( arg) {
281+ anyhow:: bail!(
282+ "engine.args entry '{}' contains invalid characters. \
283+ Only ASCII alphanumerics and '.', '_', ':', '-', '=', '/', '@' are allowed.",
284+ arg
285+ ) ;
286+ }
287+ // Reject args that attempt to override compiler-controlled flags
288+ for blocked in BLOCKED_ARG_PREFIXES {
289+ if arg. starts_with ( blocked) {
290+ anyhow:: bail!(
291+ "engine.args entry '{}' conflicts with compiler-controlled flag '{}'. \
292+ These flags are managed by the compiler and cannot be overridden.",
293+ arg,
294+ blocked
295+ ) ;
296+ }
297+ }
298+ Ok ( ( ) )
299+ }
300+
178301fn copilot_args (
179302 front_matter : & FrontMatter ,
180303 extensions : & [ Extension ] ,
@@ -203,93 +326,11 @@ fn copilot_args(
203326
204327 // When --allow-all-tools is active, skip individual tool collection entirely.
205328 // --allow-all-tools is a superset that permits all tool calls regardless.
206- let mut allowed_tools: Vec < String > = Vec :: new ( ) ;
207-
208- if !use_allow_all_tools {
209- // Collect tool permissions from extensions (github, safeoutputs, azure-devops, etc.)
210- for ext in extensions {
211- for tool in ext. allowed_copilot_tools ( ) {
212- if !allowed_tools. contains ( & tool) {
213- allowed_tools. push ( tool) ;
214- }
215- }
216- }
217-
218- // Collect tool permissions from user-defined MCP servers (sorted for deterministic output).
219- // Only add --allow-tool for MCPs that will actually produce an MCPG entry (i.e.,
220- // WithOptions that have a container or url). McpConfig::Enabled(true) has no backing
221- // server in MCPG, so granting the permission would cause confusing runtime errors.
222- let mut sorted_mcps: Vec < _ > = front_matter. mcp_servers . iter ( ) . collect ( ) ;
223- sorted_mcps. sort_by ( |( a, _) , ( b, _) | a. cmp ( b) ) ;
224- for ( name, config) in sorted_mcps {
225- // Skip servers already provided by extensions (case-insensitive to match
226- // generate_mcpg_config's eq_ignore_ascii_case guard for reserved names)
227- if allowed_tools. iter ( ) . any ( |t| t. eq_ignore_ascii_case ( name) ) {
228- continue ;
229- }
230- // Only add MCPs that have a backing server (container or url)
231- let has_backing_server = match config {
232- McpConfig :: Enabled ( _) => false ,
233- McpConfig :: WithOptions ( opts) => {
234- opts. enabled . unwrap_or ( true )
235- && ( opts. container . is_some ( ) || opts. url . is_some ( ) )
236- }
237- } ;
238- if !has_backing_server {
239- continue ;
240- }
241- allowed_tools. push ( name. clone ( ) ) ;
242- }
243-
244- // Intentional: with restricted bash, both --allow-tool write (tool identity)
245- // and --allow-all-paths (path scope) are emitted. --allow-all-tools subsumes
246- // --allow-tool write, so only --allow-all-paths is needed on that path.
247- if edit_enabled {
248- allowed_tools. push ( "write" . to_string ( ) ) ;
249- }
250-
251- // Bash tool: use the explicitly configured list.
252- // When bash is None (not specified), use_allow_all_tools is true and this
253- // block is skipped entirely (gh-aw sandbox default = wildcard).
254- let mut bash_commands: Vec < String > =
255- match front_matter. tools . as_ref ( ) . and_then ( |t| t. bash . as_ref ( ) ) {
256- Some ( cmds) if cmds. is_empty ( ) => {
257- // Explicitly disabled: no bash commands
258- vec ! [ ]
259- }
260- Some ( cmds) => {
261- // Explicit list of commands
262- cmds. clone ( )
263- }
264- None => {
265- // Invariant: bash=None → use_allow_all_tools=true → this block is
266- // skipped. Panic if the invariant is ever broken.
267- unreachable ! ( "bash=None should imply use_allow_all_tools=true" )
268- }
269- } ;
270-
271- // Auto-add extension-declared bash commands (runtimes + first-party tools)
272- for ext in extensions {
273- for cmd in ext. required_bash_commands ( ) {
274- if !bash_commands. contains ( & cmd) {
275- bash_commands. push ( cmd) ;
276- }
277- }
278- }
279-
280- for cmd in & bash_commands {
281- // Reject single quotes in bash commands — copilot_params are embedded inside
282- // a single-quoted bash string in the AWF command.
283- if cmd. contains ( '\'' ) {
284- anyhow:: bail!(
285- "Bash command '{}' contains a single quote, which is not allowed \
286- (would break AWF shell quoting).",
287- cmd
288- ) ;
289- }
290- allowed_tools. push ( format ! ( "shell({})" , cmd) ) ;
291- }
292- }
329+ let allowed_tools: Vec < String > = if use_allow_all_tools {
330+ Vec :: new ( )
331+ } else {
332+ collect_allowed_tools ( front_matter, extensions, edit_enabled) ?
333+ } ;
293334
294335 let mut params = Vec :: new ( ) ;
295336
@@ -368,24 +409,7 @@ fn copilot_args(
368409 // User args are additive; they cannot remove compiler security flags but may override
369410 // non-security defaults via last-wins semantics (e.g., --model).
370411 for arg in front_matter. engine . args ( ) {
371- if !is_valid_arg ( arg) {
372- anyhow:: bail!(
373- "engine.args entry '{}' contains invalid characters. \
374- Only ASCII alphanumerics and '.', '_', ':', '-', '=', '/', '@' are allowed.",
375- arg
376- ) ;
377- }
378- // Reject args that attempt to override compiler-controlled flags
379- for blocked in BLOCKED_ARG_PREFIXES {
380- if arg. starts_with ( blocked) {
381- anyhow:: bail!(
382- "engine.args entry '{}' conflicts with compiler-controlled flag '{}'. \
383- These flags are managed by the compiler and cannot be overridden.",
384- arg,
385- blocked
386- ) ;
387- }
388- }
412+ validate_user_arg ( arg) ?;
389413 params. push ( arg. to_string ( ) ) ;
390414 }
391415
0 commit comments