Skip to content

Commit 513f7fe

Browse files
feat: add comment-on-work-item safe output tool (#80)
* feat: add comment-on-work-item safe output tool Add a new safe output tool that allows agents to comment on existing Azure DevOps work items. This is the ADO equivalent of gh-aw's add-comment tool. Features: - Agent provides work_item_id and body (markdown comment text) - Required 'target' field in frontmatter scopes which work items can be commented on: wildcard, specific ID(s), or area path prefix - max field (default: 1) limits comments per run - Area path targets validated via ADO API at Stage 2 - Compile-time validation ensures target is specified Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: remove redundant area: prefix from comment-on-work-item target field The target field type system already disambiguates naturally: - number → single work item ID - array of numbers → list of IDs - "*" → wildcard - any other string → area path prefix The area: prefix was unnecessary since no other string variant could collide with area paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: close access-control bypass and enforce max for comment-on-work-item Three fixes for comment-on-work-item: 1. Silent access-control bypass: the None arm in execute_impl now uses expect() instead of if-let on area_path_prefix(), making any mismatch between allows_id and area_path_prefix a hard panic rather than a silent pass-through. 2. max never enforced: Stage 1 (mcp.rs) no longer hardcodes max=1 via write_safe_output_file_with_maximum — it uses the unbounded write_safe_output_file, matching update-work-item. Stage 2 (execute.rs) now enforces the configured max with the same skip-and-continue pattern used by update-work-item. 3. Default config is wildcard: target is now Option<CommentTarget> (default None). If tool_configs is missing or malformed at runtime, execute_impl fails explicitly instead of silently granting unrestricted access. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: propagate write errors and harden comment-on-work-item - update_work_item MCP handler: propagate write_safe_output_file errors instead of silently discarding them with let _ = - update_work_item MCP handler: remove redundant pre-sanitization; the Sanitize trait impl handles this in Stage 2 via execute_sanitized, matching the pattern other tools follow - comment-on-work-item: use case-insensitive area path prefix matching since ADO area paths are case-insensitive - Add integration tests for comment-on-work-item compile-time validation: requires target field, requires write SC, and succeeds with both Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden area path matching and improve code clarity - Replace expect() with unreachable!() for the impossible branch where allows_id returns None but area_path_prefix is also None - Require path separator boundary in area path prefix matching so prefix "4x4" does not accidentally match "4x4Production" - Add comments explaining why max budget is consumed before execution (prevents unbounded retries against failing endpoints) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: restore Stage 1 sanitization for update-work-item and add fixture test - Restore defense-in-depth sanitization in the update-work-item MCP handler via result.sanitize_fields() before persisting to NDJSON, using the Sanitize trait impl rather than field-by-field calls - Add comment-on-work-item fixture (tests/fixtures/) and integration test verifying compiled pipeline output includes the tool reference, safeoutputs MCP, write SC, and has no unreplaced template markers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: require explicit target for update-work-item and fix Unicode boundary check - update-work-item target is now Option<TargetConfig> (default None), matching the comment-on-work-item pattern. Omitting target no longer silently defaults to "*" (unrestricted). Both compile-time and runtime validation reject missing target explicitly. - Add validate_update_work_item_target compile-time check in both standalone and 1ES compilers. - Fix area path boundary check to use str slicing (ap[pf.len()..]) instead of byte indexing, which is sound because starts_with guarantees pf.len() is a valid char boundary in ap. - Add integration test for update-work-item missing target. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ccac1d6 commit 513f7fe

11 files changed

Lines changed: 1036 additions & 39 deletions

File tree

AGENTS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Alongside the correctly generated pipeline yaml, an agent file is generated from
3939
│ ├── sanitize.rs # Input sanitization for safe outputs
4040
│ └── tools/ # MCP tool implementations
4141
│ ├── mod.rs
42+
│ ├── comment_on_work_item.rs
4243
│ ├── create_pr.rs
4344
│ ├── create_wiki_page.rs
4445
│ ├── create_work_item.rs
@@ -760,6 +761,31 @@ Safe output configurations are passed to Stage 2 execution and used when process
760761

761762
### Available Safe Output Tools
762763

764+
#### comment-on-work-item
765+
Adds a comment to an existing Azure DevOps work item. This is the ADO equivalent of gh-aw's `add-comment` tool.
766+
767+
**Agent parameters:**
768+
- `work_item_id` - The work item ID to comment on (required, must be positive)
769+
- `body` - Comment text in markdown format (required, must be at least 10 characters)
770+
771+
**Configuration options (front matter):**
772+
- `max` - Maximum number of comments per run (default: 1)
773+
- `target` - **Required** — scoping policy for which work items can be commented on:
774+
- `"*"` - Any work item in the project (unrestricted, must be explicit)
775+
- `12345` - A specific work item ID
776+
- `[12345, 67890]` - A list of allowed work item IDs
777+
- `"Some\\Path"` - Work items under the specified area path prefix (any string that isn't `"*"`, validated via ADO API at Stage 2)
778+
779+
**Example configuration:**
780+
```yaml
781+
safe-outputs:
782+
comment-on-work-item:
783+
max: 3
784+
target: "4x4\\QED"
785+
```
786+
787+
**Note:** The `target` field is required. If omitted, compilation fails with an error. This ensures operators are intentional about which work items agents can comment on.
788+
763789
#### create-work-item
764790
Creates an Azure DevOps work item.
765791

src/compile/common.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,7 @@ pub fn generate_executor_ado_env(write_service_connection: Option<&str>) -> Stri
548548
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &[
549549
"create-pull-request",
550550
"create-work-item",
551+
"comment-on-work-item",
551552
"update-work-item",
552553
"create-wiki-page",
553554
"update-wiki-page",
@@ -582,6 +583,56 @@ pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> {
582583
Ok(())
583584
}
584585

586+
/// Validate that comment-on-work-item has a required `target` field when configured.
587+
pub fn validate_comment_target(front_matter: &FrontMatter) -> Result<()> {
588+
if let Some(config_value) = front_matter.safe_outputs.get("comment-on-work-item") {
589+
// Check that "target" key is present in the config
590+
if let Some(obj) = config_value.as_object() {
591+
if !obj.contains_key("target") {
592+
anyhow::bail!(
593+
"safe-outputs.comment-on-work-item requires a 'target' field to scope \
594+
which work items the agent can comment on. Options:\n\n \
595+
target: \"*\" # any work item (unrestricted)\n \
596+
target: 12345 # specific work item ID\n \
597+
target: [12345, 67890] # list of work item IDs\n \
598+
target: \"Path\\\\Sub\" # work items under area path prefix\n"
599+
);
600+
}
601+
} else {
602+
// If the value is not an object (e.g., `comment-on-work-item: true`), that's invalid
603+
anyhow::bail!(
604+
"safe-outputs.comment-on-work-item must be a configuration object with at \
605+
least a 'target' field. Example:\n\n \
606+
safe-outputs:\n comment-on-work-item:\n target: \"*\"\n"
607+
);
608+
}
609+
}
610+
Ok(())
611+
}
612+
613+
/// Validate that update-work-item has a required `target` field when configured.
614+
pub fn validate_update_work_item_target(front_matter: &FrontMatter) -> Result<()> {
615+
if let Some(config_value) = front_matter.safe_outputs.get("update-work-item") {
616+
if let Some(obj) = config_value.as_object() {
617+
if !obj.contains_key("target") {
618+
anyhow::bail!(
619+
"safe-outputs.update-work-item requires a 'target' field to scope \
620+
which work items the agent can update. Options:\n\n \
621+
target: \"*\" # any work item (unrestricted)\n \
622+
target: 42 # specific work item ID\n"
623+
);
624+
}
625+
} else {
626+
anyhow::bail!(
627+
"safe-outputs.update-work-item must be a configuration object with at \
628+
least a 'target' field. Example:\n\n \
629+
safe-outputs:\n update-work-item:\n target: \"*\"\n title: true\n"
630+
);
631+
}
632+
}
633+
Ok(())
634+
}
635+
585636
#[cfg(test)]
586637
mod tests {
587638
use super::*;

src/compile/onees.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ use super::common::{
2222
generate_ci_trigger, generate_copilot_ado_env, generate_executor_ado_env,
2323
generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger,
2424
generate_repositories, generate_schedule, generate_source_path,
25-
generate_working_directory, replace_with_indent, validate_write_permissions,
25+
generate_working_directory, replace_with_indent, validate_comment_target,
26+
validate_update_work_item_target, validate_write_permissions,
2627
};
2728
use super::types::{FrontMatter, McpConfig};
2829

@@ -132,6 +133,10 @@ displayName: "Finalize""#,
132133

133134
// Validate that write-requiring safe-outputs have a write service connection
134135
validate_write_permissions(front_matter)?;
136+
// Validate comment-on-work-item has required target field
137+
validate_comment_target(front_matter)?;
138+
// Validate update-work-item has required target field
139+
validate_update_work_item_target(front_matter)?;
135140

136141
// Replace all template markers
137142
let compiler_version = env!("CARGO_PKG_VERSION");

src/compile/standalone.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ use super::common::{
2121
generate_pr_trigger, generate_repositories, generate_schedule, generate_source_path,
2222
generate_working_directory, replace_with_indent, sanitize_filename,
2323
validate_write_permissions,
24+
validate_comment_target,
25+
validate_update_work_item_target,
2426
};
2527
use super::types::{FrontMatter, McpConfig};
2628
use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts};
@@ -124,6 +126,10 @@ impl Compiler for StandaloneCompiler {
124126

125127
// Validate that write-requiring safe-outputs have a write service connection
126128
validate_write_permissions(front_matter)?;
129+
// Validate comment-on-work-item has required target field
130+
validate_comment_target(front_matter)?;
131+
// Validate update-work-item has required target field
132+
validate_update_work_item_target(front_matter)?;
127133

128134
// Load threat analysis prompt template
129135
let threat_analysis_prompt = include_str!("../../templates/threat-analysis.md");

src/execute.rs

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ use std::path::Path;
1010

1111
use crate::ndjson::{self, SAFE_OUTPUT_FILENAME};
1212
use crate::tools::{
13-
CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, ExecutionContext, ExecutionResult,
13+
CommentOnWorkItemConfig, CommentOnWorkItemResult, CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, ExecutionContext, ExecutionResult,
1414
Executor, UpdateWikiPageResult, UpdateWorkItemConfig, UpdateWorkItemResult,
1515
};
1616

@@ -92,6 +92,11 @@ pub async fn execute_safe_outputs(
9292
let max_update_wi = update_wi_config.max as usize;
9393
let mut update_wi_executed: usize = 0;
9494

95+
// Fetch the comment-on-work-item max once; same skip-and-continue pattern
96+
let comment_wi_config: CommentOnWorkItemConfig = ctx.get_tool_config("comment-on-work-item");
97+
let max_comment_wi = comment_wi_config.max as usize;
98+
let mut comment_wi_executed: usize = 0;
99+
95100
let mut results = Vec::new();
96101
for (i, entry) in entries.iter().enumerate() {
97102
let entry_json = serde_json::to_string(entry).unwrap_or_else(|_| "<invalid>".to_string());
@@ -102,7 +107,9 @@ pub async fn execute_safe_outputs(
102107
entry_json
103108
);
104109

105-
// Enforce update-work-item max: skip excess entries rather than aborting the whole batch
110+
// Enforce update-work-item max: skip excess entries rather than aborting the whole batch.
111+
// Budget is consumed before execution so that failed attempts (target policy rejection,
112+
// network errors) still count — this prevents unbounded retries against a failing endpoint.
106113
if entry.get("name").and_then(|n| n.as_str()) == Some("update-work-item") {
107114
if update_wi_executed >= max_update_wi {
108115
let wi_id = entry
@@ -134,6 +141,39 @@ pub async fn execute_safe_outputs(
134141
update_wi_executed += 1;
135142
}
136143

144+
// Enforce comment-on-work-item max: same skip-and-continue pattern as update-work-item.
145+
// Budget is consumed before execution so that failed attempts still count.
146+
if entry.get("name").and_then(|n| n.as_str()) == Some("comment-on-work-item") {
147+
if comment_wi_executed >= max_comment_wi {
148+
let wi_id = entry
149+
.get("work_item_id")
150+
.and_then(|v| v.as_i64())
151+
.map(|id| format!(" (work item #{})", id))
152+
.unwrap_or_default();
153+
warn!(
154+
"[{}/{}] Skipping comment-on-work-item{} entry: max ({}) already reached for this run",
155+
i + 1,
156+
entries.len(),
157+
wi_id,
158+
max_comment_wi
159+
);
160+
let result = ExecutionResult::failure(format!(
161+
"Skipped{}: maximum comment-on-work-item count ({}) already reached. \
162+
Increase 'max' in safe-outputs.comment-on-work-item to allow more comments.",
163+
wi_id, max_comment_wi
164+
));
165+
println!(
166+
"[{}/{}] comment-on-work-item - ✗ - {}",
167+
i + 1,
168+
entries.len(),
169+
result.message
170+
);
171+
results.push(result);
172+
continue;
173+
}
174+
comment_wi_executed += 1;
175+
}
176+
137177
match execute_safe_output(entry, ctx).await {
138178
Ok((tool_name, result)) => {
139179
if result.success {
@@ -209,6 +249,17 @@ pub async fn execute_safe_output(
209249
);
210250
output.execute_sanitized(ctx).await?
211251
}
252+
"comment-on-work-item" => {
253+
debug!("Parsing comment-on-work-item payload");
254+
let mut output: CommentOnWorkItemResult = serde_json::from_value(entry.clone())
255+
.map_err(|e| anyhow::anyhow!("Failed to parse comment-on-work-item: {}", e))?;
256+
debug!(
257+
"comment-on-work-item: work_item_id={}, body length={}",
258+
output.work_item_id,
259+
output.body.len()
260+
);
261+
output.execute_sanitized(ctx).await?
262+
}
212263
"update-work-item" => {
213264
debug!("Parsing update-work-item payload");
214265
let mut output: UpdateWorkItemResult = serde_json::from_value(entry.clone())
@@ -523,6 +574,48 @@ mod tests {
523574
);
524575
}
525576

577+
#[tokio::test]
578+
async fn test_execute_malformed_comment_on_work_item_returns_err() {
579+
// Missing required fields (work_item_id and body)
580+
let entry = serde_json::json!({"name": "comment-on-work-item"});
581+
let ctx = ExecutionContext::default();
582+
583+
let result = execute_safe_output(&entry, &ctx).await;
584+
assert!(result.is_err());
585+
}
586+
587+
#[tokio::test]
588+
async fn test_execute_comment_on_work_item_missing_context() {
589+
let entry = serde_json::json!({
590+
"name": "comment-on-work-item",
591+
"work_item_id": 12345,
592+
"body": "This is a comment on the work item."
593+
});
594+
595+
// Context without required fields (ado_org_url, etc.)
596+
let ctx = ExecutionContext {
597+
ado_org_url: None,
598+
ado_organization: None,
599+
ado_project: None,
600+
access_token: None,
601+
working_directory: PathBuf::from("."),
602+
source_directory: PathBuf::from("."),
603+
tool_configs: HashMap::new(),
604+
repository_id: None,
605+
repository_name: None,
606+
allowed_repositories: HashMap::new(),
607+
};
608+
609+
let result = execute_safe_output(&entry, &ctx).await;
610+
assert!(result.is_err());
611+
assert!(
612+
result
613+
.unwrap_err()
614+
.to_string()
615+
.contains("AZURE_DEVOPS_ORG_URL")
616+
);
617+
}
618+
526619
/// Excess update-work-item entries beyond `max` are skipped (failure result added) rather than
527620
/// aborting the entire batch. Other tool entries must still execute.
528621
#[tokio::test]
@@ -541,7 +634,8 @@ mod tests {
541634
// Config: update-work-item with max=1 (default), title=true so the field check passes
542635
let update_cfg = serde_json::json!({
543636
"title": true,
544-
"max": 1
637+
"max": 1,
638+
"target": "*"
545639
});
546640
let mut tool_configs = HashMap::new();
547641
tool_configs.insert("update-work-item".to_string(), update_cfg);

src/mcp.rs

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use serde_json::Value;
99
use std::path::PathBuf;
1010

1111
use crate::ndjson::{self, SAFE_OUTPUT_FILENAME};
12-
use crate::sanitize::sanitize as sanitize_text;
12+
use crate::sanitize::{Sanitize, sanitize as sanitize_text};
1313
use crate::tools::{
14+
CommentOnWorkItemParams, CommentOnWorkItemResult,
1415
CreatePrParams, CreatePrResult, CreateWikiPageParams, CreateWikiPageResult,
1516
CreateWorkItemParams, CreateWorkItemResult,
1617
UpdateWikiPageParams, UpdateWikiPageResult, MissingDataParams, MissingDataResult,
@@ -336,6 +337,35 @@ impl SafeOutputs {
336337
Ok(CallToolResult::success(vec![]))
337338
}
338339

340+
#[tool(
341+
name = "comment-on-work-item",
342+
description = "Add a comment to an existing Azure DevOps work item. \
343+
Provide the work item ID and the comment body in markdown. The comment will be \
344+
posted during safe output processing. Target restrictions may apply based on \
345+
pipeline configuration."
346+
)]
347+
async fn comment_on_work_item(
348+
&self,
349+
params: Parameters<CommentOnWorkItemParams>,
350+
) -> Result<CallToolResult, McpError> {
351+
info!(
352+
"Tool called: comment-on-work-item - work item #{}",
353+
params.0.work_item_id
354+
);
355+
debug!("Body length: {} chars", params.0.body.len());
356+
// Sanitize untrusted agent-provided text fields (IS-01)
357+
let mut sanitized = params.0;
358+
sanitized.body = sanitize_text(&sanitized.body);
359+
let result: CommentOnWorkItemResult = sanitized.try_into()?;
360+
self.write_safe_output_file(&result).await
361+
.map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?;
362+
info!("Comment queued for work item #{}", result.work_item_id);
363+
Ok(CallToolResult::success(vec![Content::text(format!(
364+
"Comment queued for work item #{}. The comment will be posted during safe output processing.",
365+
result.work_item_id
366+
))]))
367+
}
368+
339369
#[tool(
340370
name = "update-work-item",
341371
description = "Update an existing Azure DevOps work item. Only fields explicitly enabled \
@@ -349,19 +379,11 @@ fields you want to update."
349379
params: Parameters<UpdateWorkItemParams>,
350380
) -> Result<CallToolResult, McpError> {
351381
info!("Tool called: update-work-item - id={}", params.0.id);
352-
// Sanitize untrusted agent-provided text fields (IS-01)
353-
let mut sanitized = params.0;
354-
sanitized.title = sanitized.title.map(|t| sanitize_text(&t));
355-
sanitized.body = sanitized.body.map(|b| sanitize_text(&b));
356-
sanitized.state = sanitized.state.map(|s| sanitize_text(&s));
357-
sanitized.area_path = sanitized.area_path.map(|p| sanitize_text(&p));
358-
sanitized.iteration_path = sanitized.iteration_path.map(|p| sanitize_text(&p));
359-
sanitized.assignee = sanitized.assignee.map(|a| sanitize_text(&a));
360-
sanitized.tags = sanitized
361-
.tags
362-
.map(|ts| ts.into_iter().map(|t| sanitize_text(&t)).collect());
363-
let result: UpdateWorkItemResult = sanitized.try_into()?;
364-
let _ = self.write_safe_output_file(&result).await;
382+
let mut result: UpdateWorkItemResult = params.0.try_into()?;
383+
// Sanitize before persisting to NDJSON (defense-in-depth; Stage 2 sanitizes again)
384+
result.sanitize_fields();
385+
self.write_safe_output_file(&result).await
386+
.map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?;
365387
info!("Work item update queued for #{}", result.id);
366388
Ok(CallToolResult::success(vec![Content::text(format!(
367389
"Work item #{} update queued. Changes will be applied during safe output processing.",

0 commit comments

Comments
 (0)