Skip to content

Commit d02997a

Browse files
feat(safeoutputs): add dynamic tags with allowed-tags to create/update work item (#420)
* feat(safeoutputs): add dynamic tags with allowed-tags to create/update work item Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/35468e9f-e355-404b-bfdf-b5f8b34d9d85 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * fix(safeoutputs): extract tag_matches_pattern helper and fix case-insensitive prefix matching - Add shared `pub(crate) fn tag_matches_pattern(tag, pattern)` to src/safeoutputs/mod.rs; both prefix-wildcard and exact branches are now fully case-insensitive (fixes the bug where `allowed-tags: ["Agent-*"]` silently failed to match `"agent-created"`) - Replace the duplicated inline closures in create_work_item.rs and update_work_item.rs with calls to the shared helper - Add unit tests covering case-insensitive prefix wildcard matching in mod.rs, create_work_item.rs, and update_work_item.rs Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/82fb0a02-078e-4c88-bf92-f4b0b0464462 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * refactor: remove duplicate tag_matches_pattern tests from work item files Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/82fb0a02-078e-4c88-bf92-f4b0b0464462 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent 2d460fd commit d02997a

4 files changed

Lines changed: 304 additions & 4 deletions

File tree

docs/safe-outputs.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,15 @@ Creates an Azure DevOps work item.
6464
**Agent parameters:**
6565
- `title` - A concise title for the work item (required, must be more than 5 characters)
6666
- `description` - Work item description in markdown format (required, must be more than 30 characters)
67+
- `tags` - Tags to apply to the work item (optional list; each tag must not contain a semicolon). May be subject to the `allowed-tags` allowlist. Merged with any static `tags` configured in front matter.
6768

6869
**Configuration options (front matter):**
6970
- `work-item-type` - Work item type (default: "Task")
7071
- `area-path` - Area path for the work item
7172
- `iteration-path` - Iteration path for the work item
7273
- `assignee` - User to assign (email or display name)
73-
- `tags` - List of tags to apply
74+
- `tags` - Static list of tags always applied to the work item (regardless of agent input)
75+
- `allowed-tags` - Allowlist of tags the agent is permitted to use via the `tags` parameter. If empty, any agent-provided tags are accepted. Supports prefix wildcards: entries ending with `*` match by prefix (e.g., `"agent-*"` matches `"agent-created"`, `"agent-review"`, etc.).
7476
- `custom-fields` - Map of custom field reference names to values (e.g., `Custom.MyField: "value"`)
7577
- `max` - Maximum number of create-work-item outputs allowed per run (default: 1)
7678
- `include-stats` - Whether to append agent execution stats to the work item description (default: true)
@@ -110,6 +112,7 @@ safe-outputs:
110112
iteration-path: true # enable iteration path updates (default: false)
111113
assignee: true # enable assignee updates (default: false)
112114
tags: true # enable tag updates (default: false)
115+
allowed-tags: [] # Optional — restrict which tags the agent can set (empty = any; supports prefix wildcards like "agent-*")
113116
```
114117
115118
**Security note:** Every field that can be modified requires explicit opt-in (`true`) in the front matter configuration. If the `max` limit is exceeded, additional entries are skipped rather than aborting the entire batch.

src/safeoutputs/create_work_item.rs

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,26 @@ pub struct CreateWorkItemParams {
2020

2121
/// Work item description in markdown format. Use headings, lists, code blocks, and other markdown formatting. Ensure adequate content > 30 characters.
2222
pub description: String,
23+
24+
/// Tags to apply to the work item. May be subject to an operator-configured
25+
/// allowlist (`allowed-tags` in front matter). Each tag must not contain a
26+
/// semicolon (ADO uses semicolons as tag separators).
27+
#[serde(default)]
28+
pub tags: Vec<String>,
2329
}
2430

2531
impl Validate for CreateWorkItemParams {
2632
fn validate(&self) -> anyhow::Result<()> {
2733
ensure!(self.title.len() > 5);
2834
ensure!(self.description.len() > 30);
35+
for tag in &self.tags {
36+
ensure!(
37+
!tag.contains(';'),
38+
"Tag '{}' contains a semicolon, which is not allowed \
39+
(ADO uses semicolons as tag separators)",
40+
tag
41+
);
42+
}
2943
Ok(())
3044
}
3145
}
@@ -38,13 +52,19 @@ tool_result! {
3852
pub struct CreateWorkItemResult {
3953
title: String,
4054
description: String,
55+
/// Agent-provided tags (validated against allowed-tags at execution time)
56+
#[serde(default)]
57+
tags: Vec<String>,
4158
}
4259
}
4360

4461
impl SanitizeContent for CreateWorkItemResult {
4562
fn sanitize_content_fields(&mut self) {
4663
self.title = sanitize_text(&self.title);
4764
self.description = sanitize_text(&self.description);
65+
for tag in &mut self.tags {
66+
*tag = sanitize_text(tag);
67+
}
4868
}
4969
}
5070

@@ -61,6 +81,9 @@ impl SanitizeContent for CreateWorkItemResult {
6181
/// tags:
6282
/// - agent-created
6383
/// - automated
84+
/// allowed-tags:
85+
/// - "agent-*"
86+
/// - automated
6487
/// artifact_link:
6588
/// enabled: true
6689
/// repository: "my-repo-name" # optional, defaults to current repo
@@ -84,10 +107,17 @@ pub struct CreateWorkItemConfig {
84107
#[serde(default)]
85108
pub assignee: Option<String>,
86109

87-
/// Tags to apply to the work item
110+
/// Static tags always applied to the work item (regardless of agent input)
88111
#[serde(default)]
89112
pub tags: Vec<String>,
90113

114+
/// Allowlist of tags the agent is permitted to use via the `tags` parameter.
115+
/// If empty, any agent-provided tags are accepted.
116+
/// Supports prefix wildcards: entries ending with `*` match by prefix
117+
/// (e.g., `"agent-*"` matches `"agent-created"`, `"agent-review"`, etc.).
118+
#[serde(default, rename = "allowed-tags")]
119+
pub allowed_tags: Vec<String>,
120+
91121
/// Additional custom fields as key-value pairs
92122
/// Keys should be the full field reference name (e.g., "Custom.MyField")
93123
#[serde(default, rename = "custom-fields")]
@@ -143,6 +173,7 @@ impl Default for CreateWorkItemConfig {
143173
iteration_path: None,
144174
assignee: None,
145175
tags: Vec::new(),
176+
allowed_tags: Vec::new(),
146177
custom_fields: std::collections::HashMap::new(),
147178
artifact_link: ArtifactLinkConfig::default(),
148179
include_stats: true,
@@ -271,6 +302,26 @@ impl Executor for CreateWorkItemResult {
271302
debug!("Iteration path: {:?}", config.iteration_path);
272303
debug!("Assignee: {:?}", config.assignee);
273304

305+
// Validate agent-provided tags against allowed-tags (if configured)
306+
if !self.tags.is_empty() && !config.allowed_tags.is_empty() {
307+
let disallowed: Vec<_> = self
308+
.tags
309+
.iter()
310+
.filter(|tag| {
311+
!config
312+
.allowed_tags
313+
.iter()
314+
.any(|pattern| super::tag_matches_pattern(tag, pattern))
315+
})
316+
.collect();
317+
if !disallowed.is_empty() {
318+
return Ok(ExecutionResult::failure(format!(
319+
"Agent-provided tags not in allowed-tags: {}",
320+
disallowed.iter().map(|t| t.as_str()).collect::<Vec<_>>().join(", ")
321+
)));
322+
}
323+
}
324+
274325
// Build the Azure DevOps REST API URL for creating work items
275326
// POST https://dev.azure.com/{organization}/{project}/_apis/wit/workitems/${type}?api-version=7.0
276327
debug!("Building work item creation request");
@@ -309,8 +360,15 @@ impl Executor for CreateWorkItemResult {
309360
if let Some(assignee) = &config.assignee {
310361
patch_doc.push(field_op("System.AssignedTo", assignee));
311362
}
312-
if !config.tags.is_empty() {
313-
patch_doc.push(field_op("System.Tags", config.tags.join("; ")));
363+
// Merge static config tags with validated agent-provided tags (dedup, case-insensitive)
364+
let mut all_tags = config.tags.clone();
365+
for tag in &self.tags {
366+
if !all_tags.iter().any(|t| t.eq_ignore_ascii_case(tag)) {
367+
all_tags.push(tag.clone());
368+
}
369+
}
370+
if !all_tags.is_empty() {
371+
patch_doc.push(field_op("System.Tags", all_tags.join("; ")));
314372
}
315373

316374
// Add any custom fields
@@ -456,6 +514,7 @@ mod tests {
456514
let params = CreateWorkItemParams {
457515
title: "Implement feature".to_string(),
458516
description: "This is a sufficiently long description for the work item.".to_string(),
517+
tags: vec![],
459518
};
460519
let result: CreateWorkItemResult = params.try_into().unwrap();
461520
assert_eq!(result.name, "create-work-item");
@@ -468,6 +527,7 @@ mod tests {
468527
let params = CreateWorkItemParams {
469528
title: "Hi".to_string(),
470529
description: "This is a sufficiently long description for the work item.".to_string(),
530+
tags: vec![],
471531
};
472532
let result: Result<CreateWorkItemResult, _> = params.try_into();
473533
assert!(result.is_err());
@@ -478,17 +538,41 @@ mod tests {
478538
let params = CreateWorkItemParams {
479539
title: "Valid title here".to_string(),
480540
description: "Too short".to_string(),
541+
tags: vec![],
481542
};
482543
let result: Result<CreateWorkItemResult, _> = params.try_into();
483544
assert!(result.is_err());
484545
}
485546

547+
#[test]
548+
fn test_validation_rejects_tag_with_semicolon() {
549+
let params = CreateWorkItemParams {
550+
title: "Valid title here".to_string(),
551+
description: "This is a sufficiently long description for the work item.".to_string(),
552+
tags: vec!["tag-one; tag-two".to_string()],
553+
};
554+
let result: Result<CreateWorkItemResult, _> = params.try_into();
555+
assert!(result.is_err());
556+
}
557+
558+
#[test]
559+
fn test_params_with_tags_converts_to_result() {
560+
let params = CreateWorkItemParams {
561+
title: "Implement feature".to_string(),
562+
description: "This is a sufficiently long description for the work item.".to_string(),
563+
tags: vec!["agent-created".to_string(), "automated".to_string()],
564+
};
565+
let result: CreateWorkItemResult = params.try_into().unwrap();
566+
assert_eq!(result.tags, vec!["agent-created", "automated"]);
567+
}
568+
486569
#[test]
487570
fn test_result_serializes_correctly() {
488571
let params = CreateWorkItemParams {
489572
title: "Test work item".to_string(),
490573
description: "A description that is definitely longer than thirty characters."
491574
.to_string(),
575+
tags: vec![],
492576
};
493577
let result: CreateWorkItemResult = params.try_into().unwrap();
494578
let json = serde_json::to_string(&result).unwrap();
@@ -505,6 +589,7 @@ mod tests {
505589
assert!(config.iteration_path.is_none());
506590
assert!(config.assignee.is_none());
507591
assert!(config.tags.is_empty());
592+
assert!(config.allowed_tags.is_empty());
508593
assert!(config.custom_fields.is_empty());
509594
}
510595

@@ -517,6 +602,9 @@ assignee: "user@example.com"
517602
tags:
518603
- agent-created
519604
- automated
605+
allowed-tags:
606+
- "agent-*"
607+
- review
520608
custom-fields:
521609
Custom.Priority: "High"
522610
"#;
@@ -525,6 +613,7 @@ custom-fields:
525613
assert_eq!(config.area_path, Some("MyProject\\MyTeam".to_string()));
526614
assert_eq!(config.assignee, Some("user@example.com".to_string()));
527615
assert_eq!(config.tags, vec!["agent-created", "automated"]);
616+
assert_eq!(config.allowed_tags, vec!["agent-*", "review"]);
528617
assert_eq!(
529618
config.custom_fields.get("Custom.Priority"),
530619
Some(&"High".to_string())
@@ -541,5 +630,6 @@ tags:
541630
assert_eq!(config.work_item_type, "Task"); // default
542631
assert!(config.area_path.is_none()); // default
543632
assert_eq!(config.tags, vec!["my-tag"]);
633+
assert!(config.allowed_tags.is_empty()); // default
544634
}
545635
}

src/safeoutputs/mod.rs

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,24 @@ pub(crate) fn resolve_repo_name(
209209
}
210210
}
211211

212+
/// Return `true` if `tag` is matched by `pattern`.
213+
///
214+
/// Pattern matching rules (consistent with `add-build-tag` and `allowed-labels` in gh-aw):
215+
/// - Patterns ending with `*` are prefix wildcards: `"agent-*"` matches any tag whose
216+
/// prefix (before the `*`) case-insensitively equals the start of `tag`.
217+
/// - All other patterns are compared with case-insensitive exact equality.
218+
///
219+
/// Both comparisons are **case-insensitive** so that an operator who writes
220+
/// `allowed-tags: ["Agent-*"]` correctly matches an agent-provided tag `"agent-created"`.
221+
pub(crate) fn tag_matches_pattern(tag: &str, pattern: &str) -> bool {
222+
if let Some(prefix) = pattern.strip_suffix('*') {
223+
tag.to_ascii_lowercase()
224+
.starts_with(&prefix.to_ascii_lowercase())
225+
} else {
226+
pattern.eq_ignore_ascii_case(tag)
227+
}
228+
}
229+
212230
/// Validate a string against `git check-ref-format` rules.
213231
///
214232
/// Returns `Ok(())` if the name is valid, or an `Err` describing the violation.
@@ -462,4 +480,39 @@ mod tests {
462480
assert!(validate_git_ref_name("v1.2.3", "b").is_ok());
463481
assert!(validate_git_ref_name("release/2026-04-17", "b").is_ok());
464482
}
483+
484+
// ─── tag_matches_pattern ───────────────────────────────────────────────
485+
486+
#[test]
487+
fn test_tag_matches_pattern_exact_case_insensitive() {
488+
assert!(tag_matches_pattern("Review", "review"));
489+
assert!(tag_matches_pattern("AUTOMATED", "Automated"));
490+
assert!(tag_matches_pattern("automated", "automated"));
491+
}
492+
493+
#[test]
494+
fn test_tag_matches_pattern_exact_mismatch() {
495+
assert!(!tag_matches_pattern("other", "review"));
496+
}
497+
498+
#[test]
499+
fn test_tag_matches_pattern_prefix_wildcard_case_insensitive() {
500+
// Uppercase pattern prefix must match lowercase tag
501+
assert!(tag_matches_pattern("agent-created", "Agent-*"));
502+
// Lowercase pattern prefix must match mixed-case tag
503+
assert!(tag_matches_pattern("Agent-Review", "agent-*"));
504+
// Exact prefix boundary
505+
assert!(tag_matches_pattern("agent-", "agent-*"));
506+
}
507+
508+
#[test]
509+
fn test_tag_matches_pattern_prefix_wildcard_mismatch() {
510+
assert!(!tag_matches_pattern("bot-created", "agent-*"));
511+
}
512+
513+
#[test]
514+
fn test_tag_matches_pattern_star_only_matches_everything() {
515+
assert!(tag_matches_pattern("anything", "*"));
516+
assert!(tag_matches_pattern("", "*"));
517+
}
465518
}

0 commit comments

Comments
 (0)