Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ network: # optional network policy (standalone target only
permissions: # optional ADO access token configuration
read: my-read-arm-connection # ARM service connection for read-only ADO access (Stage 1 agent)
write: my-write-arm-connection # ARM service connection for write ADO access (Stage 2 executor only)
parameters: # optional ADO runtime parameters (surfaced in UI when queuing a run)
- name: clearMemory
displayName: "Clear agent memory"
type: boolean
default: false
---


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

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

### Runtime Parameters

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.

```yaml
parameters:
- name: verbose
displayName: "Verbose output"
type: boolean
default: false
- name: region
displayName: "Target region"
type: string
default: "us-east"
values:
- us-east
- eu-west
- ap-south
```

#### Fields

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `name` | string | Yes | Parameter identifier (valid ADO identifier) |
| `displayName` | string | No | Human-readable label in the ADO UI |
| `type` | string | No | ADO parameter type: `boolean`, `string`, `number`, `object` |
| `default` | any | No | Default value when not specified at queue time |
| `values` | list | No | Allowed values (for `string`/`number` parameters) |

Parameters can be referenced in custom steps using `${{ parameters.paramName }}`.

#### Auto-injected `clearMemory` Parameter

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:

- Is surfaced in the ADO UI when manually queuing a run
- When set to `true`, skips downloading the previous agent memory artifact
- Creates an empty memory directory so the agent starts fresh

If you define your own `clearMemory` parameter in the front matter, the auto-injected one is suppressed — your definition takes precedence.

### Tools Configuration

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

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).

## {{ parameters }}

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.

When `safe-outputs.memory` is configured, the compiler auto-injects a `clearMemory` boolean parameter (default: `false`) unless one is already user-defined.

Example output:
```yaml
parameters:
- name: clearMemory
displayName: Clear agent memory
type: boolean
default: false
- name: verbose
displayName: Verbose output
type: boolean
default: false
```

## {{ repositories }}
For each additional repository specified in the front matter append:

Expand Down
240 changes: 239 additions & 1 deletion src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use anyhow::{Context, Result};

use super::types::{FrontMatter, Repository, TriggerConfig};
use super::types::{FrontMatter, PipelineParameter, Repository, TriggerConfig};
use crate::compile::types::McpConfig;
use crate::fuzzy_schedule;

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

/// Generate a schedule YAML block from a ScheduleConfig.
/// When no explicit schedule branches are configured, defaults to `main`.
/// Generate the top-level `parameters:` YAML block from front matter parameters.
///
/// Returns a YAML block like:
/// ```yaml
/// parameters:
/// - name: clearMemory
/// displayName: "Clear agent memory"
/// type: boolean
/// default: false
/// ```
///
/// Returns an empty string if the parameters list is empty.
/// Returns an error if any parameter name is not a valid ADO identifier.
pub fn generate_parameters(parameters: &[PipelineParameter]) -> Result<String> {
if parameters.is_empty() {
return Ok(String::new());
}

// Validate parameter names — must be valid ADO identifiers to prevent
// YAML injection or template expression injection.
for p in parameters {
if !is_valid_parameter_name(&p.name) {
anyhow::bail!(
"Invalid parameter name '{}': must match [A-Za-z_][A-Za-z0-9_]* (ADO identifier)",
p.name
);
}
// Reject ADO expressions in string fields to prevent template expression injection.
// Parameter definitions should only contain literal values.
if let Some(ref display_name) = p.display_name {
reject_ado_expressions(display_name, &p.name, "displayName")?;
}
if let Some(ref default) = p.default {
reject_ado_expressions_in_value(default, &p.name, "default")?;
}
if let Some(ref values) = p.values {
for v in values {
reject_ado_expressions_in_value(v, &p.name, "values")?;
}
}
}

let yaml = serde_yaml::to_string(&serde_yaml::Value::Sequence(
parameters
.iter()
.map(|p| serde_yaml::to_value(p).context("Failed to serialize pipeline parameter"))
.collect::<Result<Vec<_>>>()?,
))
.context("Failed to serialize parameters to YAML")?;

// serde_yaml outputs the sequence without a key; we need to wrap it under `parameters:`
Ok(format!("parameters:\n{}", yaml))
}

/// Validate that a string is a valid ADO pipeline parameter name (`[A-Za-z_][A-Za-z0-9_]*`).
fn is_valid_parameter_name(name: &str) -> bool {
let mut chars = name.chars();
chars
.next()
.map_or(false, |c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
}

/// Reject ADO template expressions (`${{`) and macro expressions (`$(`) in a string value.
/// Parameter definitions should only contain literal values — expressions could enable
/// information disclosure or logic manipulation in the generated pipeline.
fn reject_ado_expressions(value: &str, param_name: &str, field_name: &str) -> Result<()> {
if value.contains("${{") || value.contains("$(") {
anyhow::bail!(
"Parameter '{}' field '{}' contains an ADO expression ('${{{{' or '$(') which is not \
allowed in parameter definitions. Use literal values only.",
param_name,
field_name,
);
}
Ok(())
}

/// Reject ADO expressions in a serde_yaml::Value, recursing into strings within sequences.
fn reject_ado_expressions_in_value(
value: &serde_yaml::Value,
param_name: &str,
field_name: &str,
) -> Result<()> {
match value {
serde_yaml::Value::String(s) => reject_ado_expressions(s, param_name, field_name),
serde_yaml::Value::Sequence(seq) => {
for item in seq {
reject_ado_expressions_in_value(item, param_name, field_name)?;
}
Ok(())
}
// Booleans, numbers, null — safe, no injection risk
_ => Ok(()),
}
}

/// Build the final parameters list by combining user-defined parameters
/// with auto-injected parameters (e.g., `clearMemory` when memory is enabled).
pub fn build_parameters(user_params: &[PipelineParameter], has_memory: bool) -> Vec<PipelineParameter> {
let mut params = user_params.to_vec();

// Auto-inject clearMemory parameter when memory is configured,
// unless the user already defined one with the same name.
if has_memory && !params.iter().any(|p| p.name == "clearMemory") {
params.insert(
0,
PipelineParameter {
name: "clearMemory".to_string(),
display_name: Some("Clear agent memory".to_string()),
param_type: Some("boolean".to_string()),
default: Some(serde_yaml::Value::Bool(false)),
values: None,
},
);
}

params
}

/// Generate a schedule YAML block from a fuzzy schedule expression.
pub fn generate_schedule(name: &str, config: &super::types::ScheduleConfig) -> Result<String> {
let branches = config.branches();
let fallback;
Expand Down Expand Up @@ -1787,6 +1908,123 @@ mod tests {
assert!(args.is_empty(), "memory-only safe-outputs should produce no args (all tools available)");
}

// ─── parameter name validation ──────────────────────────────────────────

#[test]
fn test_is_valid_parameter_name() {
assert!(is_valid_parameter_name("clearMemory"));
assert!(is_valid_parameter_name("myParam"));
assert!(is_valid_parameter_name("_private"));
assert!(is_valid_parameter_name("param123"));
assert!(!is_valid_parameter_name(""));
assert!(!is_valid_parameter_name("has space"));
assert!(!is_valid_parameter_name("has-dash"));
assert!(!is_valid_parameter_name("${{inject}}"));
assert!(!is_valid_parameter_name("123startsWithDigit"));
}

#[test]
fn test_generate_parameters_rejects_invalid_name() {
let params = vec![PipelineParameter {
name: "${{evil}}".to_string(),
display_name: None,
param_type: None,
default: None,
values: None,
}];
let result = generate_parameters(&params);
assert!(result.is_err(), "Should reject invalid parameter name");
assert!(
result.unwrap_err().to_string().contains("Invalid parameter name"),
"Error should mention invalid parameter name"
);
}

#[test]
fn test_build_parameters_auto_injects_clear_memory() {
let params = build_parameters(&[], true);
assert_eq!(params.len(), 1);
assert_eq!(params[0].name, "clearMemory");
}

#[test]
fn test_build_parameters_no_inject_without_memory() {
let params = build_parameters(&[], false);
assert!(params.is_empty());
}

#[test]
fn test_build_parameters_no_duplicate_clear_memory() {
let user = vec![PipelineParameter {
name: "clearMemory".to_string(),
display_name: Some("Custom".to_string()),
param_type: Some("boolean".to_string()),
default: Some(serde_yaml::Value::Bool(true)),
values: None,
}];
let params = build_parameters(&user, true);
assert_eq!(params.len(), 1, "Should not duplicate clearMemory");
assert_eq!(params[0].display_name.as_deref(), Some("Custom"), "Should keep user's definition");
}

#[test]
fn test_generate_parameters_rejects_expression_in_display_name() {
let params = vec![PipelineParameter {
name: "myParam".to_string(),
display_name: Some("Test ${{ variables.evil }}".to_string()),
param_type: None,
default: None,
values: None,
}];
let result = generate_parameters(&params);
assert!(result.is_err(), "Should reject ADO expression in displayName");
}

#[test]
fn test_generate_parameters_rejects_expression_in_default() {
let params = vec![PipelineParameter {
name: "myParam".to_string(),
display_name: None,
param_type: None,
default: Some(serde_yaml::Value::String("$(secretVar)".to_string())),
values: None,
}];
let result = generate_parameters(&params);
assert!(result.is_err(), "Should reject ADO macro expression in default");
}

#[test]
fn test_generate_parameters_rejects_expression_in_values() {
let params = vec![PipelineParameter {
name: "myParam".to_string(),
display_name: None,
param_type: None,
default: None,
values: Some(vec![
serde_yaml::Value::String("safe".to_string()),
serde_yaml::Value::String("${{ parameters.inject }}".to_string()),
]),
}];
let result = generate_parameters(&params);
assert!(result.is_err(), "Should reject ADO expression in values");
}

#[test]
fn test_generate_parameters_allows_literal_values() {
let params = vec![PipelineParameter {
name: "region".to_string(),
display_name: Some("Target Region".to_string()),
param_type: Some("string".to_string()),
default: Some(serde_yaml::Value::String("us-east".to_string())),
values: Some(vec![
serde_yaml::Value::String("us-east".to_string()),
serde_yaml::Value::String("eu-west".to_string()),
]),
}];
let result = generate_parameters(&params);
assert!(result.is_ok(), "Should accept literal values");
}

// ─── replace_with_indent ─────────────────────────────────────────────────

#[test]
Expand Down
Loading
Loading