@@ -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,34 @@ 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
470+ . chars ( )
471+ . all ( |c| c. is_ascii_alphanumeric ( ) || matches ! ( c, '.' | '_' | ':' | '-' ) )
472+ {
473+ anyhow:: bail!(
474+ "Model name '{}' contains invalid characters. \
475+ Only ASCII alphanumerics, '.', '_', ':', and '-' are allowed.",
476+ model
477+ ) ;
478+ }
479+ params. push ( format ! ( "--model {}" , model) ) ;
431480 if front_matter. engine . max_turns ( ) . is_some ( ) {
432481 eprintln ! (
433482 "Warning: Agent '{}' has max-turns set, but max-turns is not supported by Copilot CLI \
@@ -458,7 +507,7 @@ pub fn generate_copilot_params(front_matter: &FrontMatter) -> String {
458507 }
459508 }
460509
461- params. join ( " " )
510+ Ok ( params. join ( " " ) )
462511}
463512
464513/// Compute the effective workspace based on explicit setting and checkout configuration.
@@ -1194,7 +1243,7 @@ mod tests {
11941243 cache_memory : None ,
11951244 azure_devops : None ,
11961245 } ) ;
1197- let params = generate_copilot_params ( & fm) ;
1246+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
11981247 assert ! ( params. contains( "--allow-tool \" shell(:*)\" " ) ) ;
11991248 }
12001249
@@ -1207,7 +1256,7 @@ mod tests {
12071256 cache_memory : None ,
12081257 azure_devops : None ,
12091258 } ) ;
1210- let params = generate_copilot_params ( & fm) ;
1259+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12111260 assert ! ( !params. contains( "shell(" ) ) ;
12121261 }
12131262
@@ -1221,7 +1270,7 @@ mod tests {
12211270 ..Default :: default ( )
12221271 } ) ,
12231272 ) ;
1224- let params = generate_copilot_params ( & fm) ;
1273+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12251274 assert ! ( !params. contains( "--mcp my-tool" ) ) ;
12261275 }
12271276
@@ -1230,7 +1279,7 @@ mod tests {
12301279 let mut fm = minimal_front_matter ( ) ;
12311280 fm. mcp_servers
12321281 . insert ( "ado" . to_string ( ) , McpConfig :: Enabled ( true ) ) ;
1233- let params = generate_copilot_params ( & fm) ;
1282+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12341283 // Copilot CLI has no built-in MCPs — all MCPs are handled via the MCP firewall
12351284 assert ! ( !params. contains( "--mcp ado" ) ) ;
12361285 }
@@ -1241,14 +1290,14 @@ mod tests {
12411290 "---\n name: test\n description: test\n engine:\n model: claude-opus-4.5\n max-turns: 50\n ---\n " ,
12421291 )
12431292 . unwrap ( ) ;
1244- let params = generate_copilot_params ( & fm) ;
1293+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12451294 assert ! ( !params. contains( "--max-turns" ) , "max-turns should not be emitted as a CLI arg" ) ;
12461295 }
12471296
12481297 #[ test]
12491298 fn test_copilot_params_no_max_turns_when_simple_engine ( ) {
12501299 let fm = minimal_front_matter ( ) ;
1251- let params = generate_copilot_params ( & fm) ;
1300+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12521301 assert ! ( !params. contains( "--max-turns" ) ) ;
12531302 }
12541303
@@ -1258,14 +1307,14 @@ mod tests {
12581307 "---\n name: test\n description: test\n engine:\n model: claude-opus-4.5\n timeout-minutes: 30\n ---\n " ,
12591308 )
12601309 . unwrap ( ) ;
1261- let params = generate_copilot_params ( & fm) ;
1310+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12621311 assert ! ( !params. contains( "--max-timeout" ) , "timeout-minutes should not be emitted as a CLI arg" ) ;
12631312 }
12641313
12651314 #[ test]
12661315 fn test_copilot_params_no_max_timeout_when_simple_engine ( ) {
12671316 let fm = minimal_front_matter ( ) ;
1268- let params = generate_copilot_params ( & fm) ;
1317+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12691318 assert ! ( !params. contains( "--max-timeout" ) ) ;
12701319 }
12711320
@@ -1275,7 +1324,7 @@ mod tests {
12751324 "---\n name: test\n description: test\n engine:\n model: claude-opus-4.5\n max-turns: 0\n ---\n " ,
12761325 )
12771326 . unwrap ( ) ;
1278- let params = generate_copilot_params ( & fm) ;
1327+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12791328 assert ! ( !params. contains( "--max-turns" ) , "max-turns should not be emitted as a CLI arg" ) ;
12801329 }
12811330
@@ -1285,7 +1334,7 @@ mod tests {
12851334 "---\n name: test\n description: test\n engine:\n model: claude-opus-4.5\n timeout-minutes: 0\n ---\n " ,
12861335 )
12871336 . unwrap ( ) ;
1288- let params = generate_copilot_params ( & fm) ;
1337+ let params = generate_copilot_params ( & fm) . unwrap ( ) ;
12891338 assert ! ( !params. contains( "--max-timeout" ) , "timeout-minutes should not be emitted as a CLI arg" ) ;
12901339 }
12911340
@@ -2200,4 +2249,109 @@ mod tests {
22002249 "None service connection should produce empty env block"
22012250 ) ;
22022251 }
2252+
2253+ // ─── Security validation tests ────────────────────────────────────────────
2254+
2255+ #[ test]
2256+ fn test_model_name_rejects_single_quote ( ) {
2257+ let ( mut fm, _) = parse_markdown (
2258+ "---\n name: test\n description: test\n engine: claude-opus-4.5\n ---\n " ,
2259+ )
2260+ . unwrap ( ) ;
2261+ fm. engine = crate :: compile:: types:: EngineConfig :: Simple ( "model' && echo pwned" . to_string ( ) ) ;
2262+ let result = generate_copilot_params ( & fm) ;
2263+ assert ! ( result. is_err( ) ) ;
2264+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "invalid characters" ) ) ;
2265+ }
2266+
2267+ #[ test]
2268+ fn test_model_name_rejects_space ( ) {
2269+ let mut fm = minimal_front_matter ( ) ;
2270+ fm. engine = crate :: compile:: types:: EngineConfig :: Simple ( "model && curl evil.com" . to_string ( ) ) ;
2271+ let result = generate_copilot_params ( & fm) ;
2272+ assert ! ( result. is_err( ) ) ;
2273+ }
2274+
2275+ #[ test]
2276+ fn test_model_name_allows_valid_names ( ) {
2277+ for name in & [ "claude-opus-4.5" , "gpt-5.2-codex" , "gemini-3-pro-preview" , "my_model:latest" ] {
2278+ let mut fm = minimal_front_matter ( ) ;
2279+ fm. engine = crate :: compile:: types:: EngineConfig :: Simple ( name. to_string ( ) ) ;
2280+ let result = generate_copilot_params ( & fm) ;
2281+ assert ! ( result. is_ok( ) , "Model name '{}' should be valid" , name) ;
2282+ }
2283+ }
2284+
2285+ #[ test]
2286+ fn test_bash_command_rejects_single_quote ( ) {
2287+ let mut fm = minimal_front_matter ( ) ;
2288+ fm. tools = Some ( crate :: compile:: types:: ToolsConfig {
2289+ bash : Some ( vec ! [ "cat'" . to_string( ) ] ) ,
2290+ edit : None ,
2291+ cache_memory : None ,
2292+ azure_devops : None ,
2293+ } ) ;
2294+ let result = generate_copilot_params ( & fm) ;
2295+ assert ! ( result. is_err( ) ) ;
2296+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "single quote" ) ) ;
2297+ }
2298+
2299+ #[ test]
2300+ fn test_validate_front_matter_identity_rejects_ado_expression_in_name ( ) {
2301+ let mut fm = minimal_front_matter ( ) ;
2302+ fm. name = "My Agent ${{ variables['System.AccessToken'] }}" . to_string ( ) ;
2303+ let result = validate_front_matter_identity ( & fm) ;
2304+ assert ! ( result. is_err( ) ) ;
2305+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "ADO expression" ) ) ;
2306+ }
2307+
2308+ #[ test]
2309+ fn test_validate_front_matter_identity_rejects_macro_in_description ( ) {
2310+ let mut fm = minimal_front_matter ( ) ;
2311+ fm. description = "Agent $(System.AccessToken)" . to_string ( ) ;
2312+ let result = validate_front_matter_identity ( & fm) ;
2313+ assert ! ( result. is_err( ) ) ;
2314+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "ADO expression" ) ) ;
2315+ }
2316+
2317+ #[ test]
2318+ fn test_validate_front_matter_identity_rejects_newline_in_name ( ) {
2319+ let mut fm = minimal_front_matter ( ) ;
2320+ fm. name = "My Agent\n injected: true" . to_string ( ) ;
2321+ let result = validate_front_matter_identity ( & fm) ;
2322+ assert ! ( result. is_err( ) ) ;
2323+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "single line" ) ) ;
2324+ }
2325+
2326+ #[ test]
2327+ fn test_validate_front_matter_identity_allows_valid_values ( ) {
2328+ let mut fm = minimal_front_matter ( ) ;
2329+ fm. name = "Daily Code Review Agent" . to_string ( ) ;
2330+ fm. description = "Reviews code daily for quality issues" . to_string ( ) ;
2331+ let result = validate_front_matter_identity ( & fm) ;
2332+ assert ! ( result. is_ok( ) ) ;
2333+ }
2334+
2335+ #[ test]
2336+ fn test_validate_front_matter_identity_rejects_runtime_expression ( ) {
2337+ let mut fm = minimal_front_matter ( ) ;
2338+ fm. name = "Agent $[variables['System.AccessToken']]" . to_string ( ) ;
2339+ let result = validate_front_matter_identity ( & fm) ;
2340+ assert ! ( result. is_err( ) ) ;
2341+ assert ! ( result. unwrap_err( ) . to_string( ) . contains( "ADO expression" ) ) ;
2342+ }
2343+
2344+ #[ test]
2345+ fn test_pipeline_resources_escapes_single_quotes ( ) {
2346+ let triggers = Some ( TriggerConfig {
2347+ pipeline : Some ( crate :: compile:: types:: PipelineTrigger {
2348+ name : "Build's Pipeline" . to_string ( ) ,
2349+ project : Some ( "My'Project" . to_string ( ) ) ,
2350+ branches : vec ! [ "main" . to_string( ) ] ,
2351+ } ) ,
2352+ } ) ;
2353+ let result = generate_pipeline_resources ( & triggers) . unwrap ( ) ;
2354+ assert ! ( result. contains( "source: 'Build''s Pipeline'" ) ) ;
2355+ assert ! ( result. contains( "project: 'My''Project'" ) ) ;
2356+ }
22032357}
0 commit comments