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
6 changes: 3 additions & 3 deletions docs/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Creates an Azure DevOps work item.
- `iteration-path` - Iteration path for the work item
- `assignee` - User to assign (email or display name)
- `tags` - Static list of tags always applied to the work item (regardless of agent input)
- `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.).
- `allowed-tags` - Allowlist of tags the agent is permitted to use via the `tags` parameter. If empty, any agent-provided tags are accepted. Supports `*` wildcards anywhere in the pattern (e.g., `"agent-*"` matches `"agent-created"`; `"copilot:repo=org/project/*@main"` matches any repo name).
- `custom-fields` - Map of custom field reference names to values (e.g., `Custom.MyField: "value"`)
- `max` - Maximum number of create-work-item outputs allowed per run (default: 1)
- `include-stats` - Whether to append agent execution stats to the work item description (default: true)
Expand Down Expand Up @@ -112,7 +112,7 @@ safe-outputs:
iteration-path: true # enable iteration path updates (default: false)
assignee: true # enable assignee updates (default: false)
tags: true # enable tag updates (default: false)
allowed-tags: [] # Optional — restrict which tags the agent can set (empty = any; supports prefix wildcards like "agent-*")
allowed-tags: [] # Optional — restrict which tags the agent can set (empty = any; supports * wildcards like "agent-*")
```

**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.
Expand Down Expand Up @@ -378,7 +378,7 @@ Adds a tag to an Azure DevOps build.
```yaml
safe-outputs:
add-build-tag:
allowed-tags: [] # Optional — restrict which tags can be applied (supports prefix wildcards)
allowed-tags: [] # Optional — restrict which tags can be applied (supports * wildcards)
tag-prefix: "agent-" # Optional — prefix prepended to all tags
allow-any-build: false # When false, only the current pipeline build can be tagged (default: false)
max: 1 # Maximum per run (default: 1)
Expand Down
11 changes: 4 additions & 7 deletions src/safeoutputs/add_build_tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,10 @@ impl Executor for AddBuildTagResult {

// 4. Validate against allowed tags (if non-empty)
if !config.allowed_tags.is_empty() {
let allowed = config.allowed_tags.iter().any(|pattern| {
if let Some(prefix) = pattern.strip_suffix('*') {
final_tag.starts_with(prefix)
} else {
*pattern == final_tag
}
});
let allowed = config
.allowed_tags
.iter()
.any(|pattern| super::tag_matches_pattern(&final_tag, pattern));
if !allowed {
return Ok(ExecutionResult::failure(format!(
"Tag '{}' is not in the allowed tags list",
Expand Down
171 changes: 159 additions & 12 deletions src/safeoutputs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,22 +209,73 @@ pub(crate) fn resolve_repo_name(
}
}

/// Match a `value` against a `pattern` where `*` matches zero or more of **any**
/// character (including `/`).
///
/// Unlike file-path glob matching, `/` is **not** treated as a segment separator —
/// these patterns are used for tags, artifact names, and similar non-path strings.
///
/// Only the `*` wildcard is supported; there is no `?`, `[…]`, or `**` syntax.
/// Literal `*` characters cannot be escaped — this is intentional since the values
/// being matched (ADO tags, artifact names) cannot contain `*`.
pub(crate) fn wildcard_match(pattern: &str, value: &str) -> bool {
let p = pattern.as_bytes();
let v = value.as_bytes();
let (pn, vn) = (p.len(), v.len());

let mut pi = 0;
let mut vi = 0;
// Saved positions for backtracking on `*`
let mut star_p = usize::MAX;
let mut star_v: usize = 0;

while vi < vn {
if pi < pn && p[pi] == b'*' {
star_p = pi;
star_v = vi;
pi += 1;
} else if pi < pn && p[pi] == v[vi] {
pi += 1;
vi += 1;
} else if star_p != usize::MAX {
// Backtrack: let the last `*` consume one more character
pi = star_p + 1;
star_v += 1;
vi = star_v;
} else {
return false;
}
}

// Consume any trailing `*`s in the pattern
while pi < pn && p[pi] == b'*' {
pi += 1;
}

pi == pn
}

/// Return `true` if `tag` is matched by `pattern`.
///
/// Pattern matching rules (consistent with `add-build-tag` and `allowed-labels` in gh-aw):
/// - Patterns ending with `*` are prefix wildcards: `"agent-*"` matches any tag whose
/// prefix (before the `*`) case-insensitively equals the start of `tag`.
/// - All other patterns are compared with case-insensitive exact equality.
/// Uses [`wildcard_match`] with **case-insensitive** comparison. `*` in the
/// pattern matches zero or more of any character (including `/`), so
/// `copilot:repo=org/project/*@main` correctly matches
/// `copilot:repo=org/project/MyRepo@main`.
///
/// Both comparisons are **case-insensitive** so that an operator who writes
/// `allowed-tags: ["Agent-*"]` correctly matches an agent-provided tag `"agent-created"`.
/// This is the shared matcher for `allowed-tags` in `create-work-item`,
/// `update-work-item`, and `add-build-tag`.
pub(crate) fn tag_matches_pattern(tag: &str, pattern: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
tag.to_ascii_lowercase()
.starts_with(&prefix.to_ascii_lowercase())
} else {
pattern.eq_ignore_ascii_case(tag)
}
wildcard_match(
&pattern.to_ascii_lowercase(),
&tag.to_ascii_lowercase(),
)
}

/// Return `true` if `name` is matched by `pattern` (**case-sensitive**).
///
/// Uses [`wildcard_match`] for artifact-name allow-lists where case matters.
pub(crate) fn name_matches_pattern(name: &str, pattern: &str) -> bool {
wildcard_match(pattern, name)
}

/// Validate a string against `git check-ref-format` rules.
Expand Down Expand Up @@ -481,6 +532,55 @@ mod tests {
assert!(validate_git_ref_name("release/2026-04-17", "b").is_ok());
}

// ─── wildcard_match ─────────────────────────────────────────────────

#[test]
fn test_wildcard_match_exact() {
assert!(wildcard_match("hello", "hello"));
assert!(!wildcard_match("hello", "world"));
}

#[test]
fn test_wildcard_match_star_any() {
assert!(wildcard_match("*", "anything"));
assert!(wildcard_match("*", ""));
assert!(wildcard_match("*", "a/b/c"));
}

#[test]
fn test_wildcard_match_trailing_star() {
assert!(wildcard_match("agent-*", "agent-created"));
assert!(wildcard_match("agent-*", "agent-"));
assert!(!wildcard_match("agent-*", "bot-created"));
}

#[test]
fn test_wildcard_match_middle_star() {
assert!(wildcard_match("a*z", "az"));
assert!(wildcard_match("a*z", "abcz"));
assert!(!wildcard_match("a*z", "abcy"));
}

#[test]
fn test_wildcard_match_star_crosses_slash() {
// Unlike file-path globs, * matches across /
assert!(wildcard_match("team/*", "team/sub/item"));
assert!(wildcard_match("prefix/*@main", "prefix/a/b/c@main"));
}

#[test]
fn test_wildcard_match_multiple_stars() {
assert!(wildcard_match("*-*", "a-b"));
assert!(wildcard_match("*-*", "abc-def"));
assert!(!wildcard_match("*-*", "abc"));
}

#[test]
fn test_wildcard_match_case_sensitive() {
// wildcard_match itself is case-sensitive
assert!(!wildcard_match("Hello", "hello"));
}

// ─── tag_matches_pattern ───────────────────────────────────────────────

#[test]
Expand Down Expand Up @@ -515,4 +615,51 @@ mod tests {
assert!(tag_matches_pattern("anything", "*"));
assert!(tag_matches_pattern("", "*"));
}

#[test]
fn test_tag_matches_pattern_middle_wildcard() {
// Glob wildcard in the middle of the pattern
assert!(tag_matches_pattern(
"copilot:repo=msazuresphere/4x4/VsCodeExtension@main",
"copilot:repo=msazuresphere/4x4/*@main"
));
assert!(tag_matches_pattern(
"copilot:repo=msazuresphere/4x4/DevTools@main",
"copilot:repo=msazuresphere/4x4/*@main"
));
// Wrong suffix should not match
assert!(!tag_matches_pattern(
"copilot:repo=msazuresphere/4x4/DevTools@dev",
"copilot:repo=msazuresphere/4x4/*@main"
));
}

#[test]
fn test_tag_matches_pattern_middle_wildcard_case_insensitive() {
assert!(tag_matches_pattern(
"Copilot:Repo=MSAzureSphere/4x4/Tools@Main",
"copilot:repo=msazuresphere/4x4/*@main"
));
}

#[test]
fn test_tag_matches_pattern_star_crosses_slash() {
// Hierarchical tags: * must match across /
assert!(tag_matches_pattern("team/subgroup/item", "team/*"));
}

// ─── name_matches_pattern ───────────────────────────────────────────────

#[test]
fn test_name_matches_pattern_case_sensitive() {
assert!(name_matches_pattern("report", "report"));
assert!(!name_matches_pattern("Report", "report"));
}

#[test]
fn test_name_matches_pattern_wildcard() {
assert!(name_matches_pattern("agent-report-123", "agent-*"));
assert!(name_matches_pattern("agent-report", "agent-*"));
assert!(!name_matches_pattern("bot-report", "agent-*"));
}
}
11 changes: 4 additions & 7 deletions src/safeoutputs/upload_build_attachment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -346,13 +346,10 @@ impl Executor for UploadBuildAttachmentResult {

// Check artifact-name allow-list (if configured).
if !config.allowed_artifact_names.is_empty() {
let allowed = config.allowed_artifact_names.iter().any(|pattern| {
if let Some(prefix) = pattern.strip_suffix('*') {
final_name.starts_with(prefix)
} else {
*pattern == final_name
}
});
let allowed = config
.allowed_artifact_names
.iter()
.any(|pattern| super::name_matches_pattern(&final_name, pattern));
if !allowed {
return Ok(ExecutionResult::failure(format!(
"Artifact name '{}' is not in the allowed list",
Expand Down
11 changes: 4 additions & 7 deletions src/safeoutputs/upload_pipeline_artifact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,13 +321,10 @@ impl Executor for UploadPipelineArtifactResult {

// ── Artifact-name allow-list ─────────────────────────────────────
if !config.allowed_artifact_names.is_empty() {
let allowed = config.allowed_artifact_names.iter().any(|pattern| {
if let Some(prefix) = pattern.strip_suffix('*') {
final_name.starts_with(prefix)
} else {
*pattern == final_name
}
});
let allowed = config
.allowed_artifact_names
.iter()
.any(|pattern| super::name_matches_pattern(&final_name, pattern));
if !allowed {
return Ok(ExecutionResult::failure(format!(
"Artifact name '{}' is not in the allowed list",
Expand Down
Loading