Skip to content

Commit dc5b766

Browse files
feat: add runtime parameters support with auto-injected clearMemory (#166)
* feat: add runtime parameters support with auto-injected clearMemory Add a `parameters` front matter field that emits Azure DevOps runtime parameters at the top of generated pipeline YAML. Parameters are surfaced in the ADO UI when manually queuing a run. First use case: when `safe-outputs.memory` is configured, a `clearMemory` boolean parameter (default: false) is automatically injected. Setting it to true skips downloading the previous memory artifact and starts the agent with a fresh memory directory. - Add PipelineParameter type in types.rs - Add generate_parameters() helper in common.rs - Add {{ parameters }} template marker to base.yml and 1es-base.yml - Wire parameters in both standalone and 1ES compilers - Auto-inject clearMemory when memory is enabled (deduped if user-defined) - Make memory download/restore conditional on clearMemory parameter - Add 4 integration tests covering parameters, auto-injection, dedup, markers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: fix pre-existing broken test in onees.rs (command → container) The McpOptions struct no longer has a `command` field — it was renamed to `container` in a prior refactor. Update the 1ES test to use the correct field name. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR review feedback - Fix 1ES compiler missing clearMemory auto-injection (was calling generate_parameters directly instead of build_parameters) - Move build_parameters to common.rs so both compilers share it - Add parameter name validation ([A-Za-z_][A-Za-z0-9_]*) to prevent YAML/template expression injection via malicious parameter names - Change generate_parameters to return Result, replacing expect() with context() per project convention - Add doc comment to generate_schedule (was lost during insertion) - Add 5 unit tests for parameter validation and build_parameters Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden parameter validation and fix condition syntax - Fix condition syntax: remove quotes around ${{ parameters.clearMemory }} so ADO correctly compares boolean values (not string-to-boolean) - Add validation to reject ADO expressions (${{ and $() in parameter displayName, default, and values fields — parameter definitions must contain only literal values to prevent template expression injection - Add 4 unit tests for expression rejection + 1 for literal acceptance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0f8f996 commit dc5b766

8 files changed

Lines changed: 613 additions & 23 deletions

File tree

AGENTS.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,11 @@ network: # optional network policy (standalone target only
172172
permissions: # optional ADO access token configuration
173173
read: my-read-arm-connection # ARM service connection for read-only ADO access (Stage 1 agent)
174174
write: my-write-arm-connection # ARM service connection for write ADO access (Stage 2 executor only)
175+
parameters: # optional ADO runtime parameters (surfaced in UI when queuing a run)
176+
- name: clearMemory
177+
displayName: "Clear agent memory"
178+
type: boolean
179+
default: false
175180
---
176181

177182

@@ -304,6 +309,48 @@ The `timeout-minutes` field sets a wall-clock limit (in minutes) for the entire
304309

305310
When omitted, Azure DevOps uses its default job timeout (60 minutes). When set, the compiler emits `timeoutInMinutes: <value>` on the agentic job.
306311

312+
### Runtime Parameters
313+
314+
The `parameters` field defines Azure DevOps [runtime parameters](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/runtime-parameters) that are surfaced in the ADO UI when manually queuing a pipeline run. Parameters are emitted as a top-level `parameters:` block in the generated pipeline YAML.
315+
316+
```yaml
317+
parameters:
318+
- name: verbose
319+
displayName: "Verbose output"
320+
type: boolean
321+
default: false
322+
- name: region
323+
displayName: "Target region"
324+
type: string
325+
default: "us-east"
326+
values:
327+
- us-east
328+
- eu-west
329+
- ap-south
330+
```
331+
332+
#### Fields
333+
334+
| Field | Type | Required | Description |
335+
|-------|------|----------|-------------|
336+
| `name` | string | Yes | Parameter identifier (valid ADO identifier) |
337+
| `displayName` | string | No | Human-readable label in the ADO UI |
338+
| `type` | string | No | ADO parameter type: `boolean`, `string`, `number`, `object` |
339+
| `default` | any | No | Default value when not specified at queue time |
340+
| `values` | list | No | Allowed values (for `string`/`number` parameters) |
341+
342+
Parameters can be referenced in custom steps using `${{ parameters.paramName }}`.
343+
344+
#### Auto-injected `clearMemory` Parameter
345+
346+
When `safe-outputs.memory` is configured, the compiler automatically injects a `clearMemory` boolean parameter (default: `false`) at the beginning of the parameters list. This parameter:
347+
348+
- Is surfaced in the ADO UI when manually queuing a run
349+
- When set to `true`, skips downloading the previous agent memory artifact
350+
- Creates an empty memory directory so the agent starts fresh
351+
352+
If you define your own `clearMemory` parameter in the front matter, the auto-injected one is suppressed — your definition takes precedence.
353+
307354
### Tools Configuration
308355

309356
The `tools` field controls which tools are available to the agent. Both sub-fields are optional and have sensible defaults.
@@ -387,6 +434,25 @@ The compiler transforms the input into valid Azure DevOps pipeline YAML based on
387434

388435
Explicit markings are embedded in these templates that the compiler is allowed to replace e.g. `{{ copilot_params }}` denotes parameters which are passed to the copilot command line tool. The compiler should not replace sections denoted by `${{ some content }}`. What follows is a mapping of markings to responsibilities (primarily for the standalone template).
389436

437+
## {{ parameters }}
438+
439+
Should be replaced with the top-level `parameters:` block generated from the `parameters` front matter field. If no parameters are defined (and no auto-injected parameters apply), this marker is replaced with an empty string.
440+
441+
When `safe-outputs.memory` is configured, the compiler auto-injects a `clearMemory` boolean parameter (default: `false`) unless one is already user-defined.
442+
443+
Example output:
444+
```yaml
445+
parameters:
446+
- name: clearMemory
447+
displayName: Clear agent memory
448+
type: boolean
449+
default: false
450+
- name: verbose
451+
displayName: Verbose output
452+
type: boolean
453+
default: false
454+
```
455+
390456
## {{ repositories }}
391457
For each additional repository specified in the front matter append:
392458

src/compile/common.rs

Lines changed: 239 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use anyhow::{Context, Result};
44

5-
use super::types::{FrontMatter, Repository, TriggerConfig};
5+
use super::types::{FrontMatter, PipelineParameter, Repository, TriggerConfig};
66
use crate::compile::types::McpConfig;
77
use crate::fuzzy_schedule;
88

@@ -82,6 +82,127 @@ pub fn replace_with_indent(template: &str, placeholder: &str, replacement: &str)
8282

8383
/// Generate a schedule YAML block from a ScheduleConfig.
8484
/// When no explicit schedule branches are configured, defaults to `main`.
85+
/// Generate the top-level `parameters:` YAML block from front matter parameters.
86+
///
87+
/// Returns a YAML block like:
88+
/// ```yaml
89+
/// parameters:
90+
/// - name: clearMemory
91+
/// displayName: "Clear agent memory"
92+
/// type: boolean
93+
/// default: false
94+
/// ```
95+
///
96+
/// Returns an empty string if the parameters list is empty.
97+
/// Returns an error if any parameter name is not a valid ADO identifier.
98+
pub fn generate_parameters(parameters: &[PipelineParameter]) -> Result<String> {
99+
if parameters.is_empty() {
100+
return Ok(String::new());
101+
}
102+
103+
// Validate parameter names — must be valid ADO identifiers to prevent
104+
// YAML injection or template expression injection.
105+
for p in parameters {
106+
if !is_valid_parameter_name(&p.name) {
107+
anyhow::bail!(
108+
"Invalid parameter name '{}': must match [A-Za-z_][A-Za-z0-9_]* (ADO identifier)",
109+
p.name
110+
);
111+
}
112+
// Reject ADO expressions in string fields to prevent template expression injection.
113+
// Parameter definitions should only contain literal values.
114+
if let Some(ref display_name) = p.display_name {
115+
reject_ado_expressions(display_name, &p.name, "displayName")?;
116+
}
117+
if let Some(ref default) = p.default {
118+
reject_ado_expressions_in_value(default, &p.name, "default")?;
119+
}
120+
if let Some(ref values) = p.values {
121+
for v in values {
122+
reject_ado_expressions_in_value(v, &p.name, "values")?;
123+
}
124+
}
125+
}
126+
127+
let yaml = serde_yaml::to_string(&serde_yaml::Value::Sequence(
128+
parameters
129+
.iter()
130+
.map(|p| serde_yaml::to_value(p).context("Failed to serialize pipeline parameter"))
131+
.collect::<Result<Vec<_>>>()?,
132+
))
133+
.context("Failed to serialize parameters to YAML")?;
134+
135+
// serde_yaml outputs the sequence without a key; we need to wrap it under `parameters:`
136+
Ok(format!("parameters:\n{}", yaml))
137+
}
138+
139+
/// Validate that a string is a valid ADO pipeline parameter name (`[A-Za-z_][A-Za-z0-9_]*`).
140+
fn is_valid_parameter_name(name: &str) -> bool {
141+
let mut chars = name.chars();
142+
chars
143+
.next()
144+
.map_or(false, |c| c.is_ascii_alphabetic() || c == '_')
145+
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
146+
}
147+
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.
151+
fn reject_ado_expressions(value: &str, param_name: &str, field_name: &str) -> Result<()> {
152+
if value.contains("${{") || value.contains("$(") {
153+
anyhow::bail!(
154+
"Parameter '{}' field '{}' contains an ADO expression ('${{{{' or '$(') which is not \
155+
allowed in parameter definitions. Use literal values only.",
156+
param_name,
157+
field_name,
158+
);
159+
}
160+
Ok(())
161+
}
162+
163+
/// Reject ADO expressions in a serde_yaml::Value, recursing into strings within sequences.
164+
fn reject_ado_expressions_in_value(
165+
value: &serde_yaml::Value,
166+
param_name: &str,
167+
field_name: &str,
168+
) -> Result<()> {
169+
match value {
170+
serde_yaml::Value::String(s) => reject_ado_expressions(s, param_name, field_name),
171+
serde_yaml::Value::Sequence(seq) => {
172+
for item in seq {
173+
reject_ado_expressions_in_value(item, param_name, field_name)?;
174+
}
175+
Ok(())
176+
}
177+
// Booleans, numbers, null — safe, no injection risk
178+
_ => Ok(()),
179+
}
180+
}
181+
182+
/// Build the final parameters list by combining user-defined parameters
183+
/// with auto-injected parameters (e.g., `clearMemory` when memory is enabled).
184+
pub fn build_parameters(user_params: &[PipelineParameter], has_memory: bool) -> Vec<PipelineParameter> {
185+
let mut params = user_params.to_vec();
186+
187+
// Auto-inject clearMemory parameter when memory is configured,
188+
// unless the user already defined one with the same name.
189+
if has_memory && !params.iter().any(|p| p.name == "clearMemory") {
190+
params.insert(
191+
0,
192+
PipelineParameter {
193+
name: "clearMemory".to_string(),
194+
display_name: Some("Clear agent memory".to_string()),
195+
param_type: Some("boolean".to_string()),
196+
default: Some(serde_yaml::Value::Bool(false)),
197+
values: None,
198+
},
199+
);
200+
}
201+
202+
params
203+
}
204+
205+
/// Generate a schedule YAML block from a fuzzy schedule expression.
85206
pub fn generate_schedule(name: &str, config: &super::types::ScheduleConfig) -> Result<String> {
86207
let branches = config.branches();
87208
let fallback;
@@ -1787,6 +1908,123 @@ mod tests {
17871908
assert!(args.is_empty(), "memory-only safe-outputs should produce no args (all tools available)");
17881909
}
17891910

1911+
// ─── parameter name validation ──────────────────────────────────────────
1912+
1913+
#[test]
1914+
fn test_is_valid_parameter_name() {
1915+
assert!(is_valid_parameter_name("clearMemory"));
1916+
assert!(is_valid_parameter_name("myParam"));
1917+
assert!(is_valid_parameter_name("_private"));
1918+
assert!(is_valid_parameter_name("param123"));
1919+
assert!(!is_valid_parameter_name(""));
1920+
assert!(!is_valid_parameter_name("has space"));
1921+
assert!(!is_valid_parameter_name("has-dash"));
1922+
assert!(!is_valid_parameter_name("${{inject}}"));
1923+
assert!(!is_valid_parameter_name("123startsWithDigit"));
1924+
}
1925+
1926+
#[test]
1927+
fn test_generate_parameters_rejects_invalid_name() {
1928+
let params = vec![PipelineParameter {
1929+
name: "${{evil}}".to_string(),
1930+
display_name: None,
1931+
param_type: None,
1932+
default: None,
1933+
values: None,
1934+
}];
1935+
let result = generate_parameters(&params);
1936+
assert!(result.is_err(), "Should reject invalid parameter name");
1937+
assert!(
1938+
result.unwrap_err().to_string().contains("Invalid parameter name"),
1939+
"Error should mention invalid parameter name"
1940+
);
1941+
}
1942+
1943+
#[test]
1944+
fn test_build_parameters_auto_injects_clear_memory() {
1945+
let params = build_parameters(&[], true);
1946+
assert_eq!(params.len(), 1);
1947+
assert_eq!(params[0].name, "clearMemory");
1948+
}
1949+
1950+
#[test]
1951+
fn test_build_parameters_no_inject_without_memory() {
1952+
let params = build_parameters(&[], false);
1953+
assert!(params.is_empty());
1954+
}
1955+
1956+
#[test]
1957+
fn test_build_parameters_no_duplicate_clear_memory() {
1958+
let user = vec![PipelineParameter {
1959+
name: "clearMemory".to_string(),
1960+
display_name: Some("Custom".to_string()),
1961+
param_type: Some("boolean".to_string()),
1962+
default: Some(serde_yaml::Value::Bool(true)),
1963+
values: None,
1964+
}];
1965+
let params = build_parameters(&user, true);
1966+
assert_eq!(params.len(), 1, "Should not duplicate clearMemory");
1967+
assert_eq!(params[0].display_name.as_deref(), Some("Custom"), "Should keep user's definition");
1968+
}
1969+
1970+
#[test]
1971+
fn test_generate_parameters_rejects_expression_in_display_name() {
1972+
let params = vec![PipelineParameter {
1973+
name: "myParam".to_string(),
1974+
display_name: Some("Test ${{ variables.evil }}".to_string()),
1975+
param_type: None,
1976+
default: None,
1977+
values: None,
1978+
}];
1979+
let result = generate_parameters(&params);
1980+
assert!(result.is_err(), "Should reject ADO expression in displayName");
1981+
}
1982+
1983+
#[test]
1984+
fn test_generate_parameters_rejects_expression_in_default() {
1985+
let params = vec![PipelineParameter {
1986+
name: "myParam".to_string(),
1987+
display_name: None,
1988+
param_type: None,
1989+
default: Some(serde_yaml::Value::String("$(secretVar)".to_string())),
1990+
values: None,
1991+
}];
1992+
let result = generate_parameters(&params);
1993+
assert!(result.is_err(), "Should reject ADO macro expression in default");
1994+
}
1995+
1996+
#[test]
1997+
fn test_generate_parameters_rejects_expression_in_values() {
1998+
let params = vec![PipelineParameter {
1999+
name: "myParam".to_string(),
2000+
display_name: None,
2001+
param_type: None,
2002+
default: None,
2003+
values: Some(vec![
2004+
serde_yaml::Value::String("safe".to_string()),
2005+
serde_yaml::Value::String("${{ parameters.inject }}".to_string()),
2006+
]),
2007+
}];
2008+
let result = generate_parameters(&params);
2009+
assert!(result.is_err(), "Should reject ADO expression in values");
2010+
}
2011+
2012+
#[test]
2013+
fn test_generate_parameters_allows_literal_values() {
2014+
let params = vec![PipelineParameter {
2015+
name: "region".to_string(),
2016+
display_name: Some("Target Region".to_string()),
2017+
param_type: Some("string".to_string()),
2018+
default: Some(serde_yaml::Value::String("us-east".to_string())),
2019+
values: Some(vec![
2020+
serde_yaml::Value::String("us-east".to_string()),
2021+
serde_yaml::Value::String("eu-west".to_string()),
2022+
]),
2023+
}];
2024+
let result = generate_parameters(&params);
2025+
assert!(result.is_ok(), "Should accept literal values");
2026+
}
2027+
17902028
// ─── replace_with_indent ─────────────────────────────────────────────────
17912029

17922030
#[test]

0 commit comments

Comments
 (0)