Skip to content

Commit 618fc0e

Browse files
refactor: reduce complexity of copilot_args in engine.rs (#329)
1 parent e728671 commit 618fc0e

1 file changed

Lines changed: 129 additions & 105 deletions

File tree

src/engine.rs

Lines changed: 129 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
178301
fn 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

Comments
 (0)