From 02c798438956dec64d70310bbe445b908ab353f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:25:00 +0000 Subject: [PATCH 01/41] Initial plan From 023b3f001b13f0a7c13a12d1e883657c524aa828 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 16 Aug 2026 08:32:33 +0000 Subject: [PATCH 02/41] fix: make create-work-item body field configurable Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/front-matter.md | 1 + docs/safe-outputs.md | 7 +- src/mcp.rs | 25 +++++- src/safe_outputs/create_work_item.rs | 127 ++++++++++++++++++++++++++- src/sanitize.rs | 24 +++++ 5 files changed, 177 insertions(+), 7 deletions(-) diff --git a/docs/front-matter.md b/docs/front-matter.md index bddcfd9f..d2a2c69f 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -105,6 +105,7 @@ safe-outputs: # optional per-tool configuration for safe output staged: false # cooperative preview default; per-tool override supported create-work-item: work-item-type: Task + description-field: System.Description tags: - automated - agent-created diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index dbba8c4a..79d6b137 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -821,11 +821,13 @@ safe-outputs: **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. ### create-work-item -Creates an Azure DevOps work item. +Creates an Azure DevOps work item. The agent-provided description is written as +Markdown, and the executor sets `multilineFieldsFormat` to `Markdown` for the +field that receives the body. **Agent parameters:** - `title` - A concise title for the work item (required, must be more than 5 characters) -- `description` - Work item description in markdown format (required, must be more than 30 characters) +- `description` - Work item description in Markdown format (required, must be more than 30 characters). Inline HTML is preserved for Azure DevOps to render/sanitize. - `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. On success, the MCP tool returns a generated gh-aw-compatible `#aw_...` @@ -834,6 +836,7 @@ choose this ID; they use the returned value in later safe-output calls. **Configuration options (front matter):** - `work-item-type` - Work item type (default: "Task") +- `description-field` - Field reference name that receives the agent-provided description. Defaults to `Microsoft.VSTS.TCM.ReproSteps` for `Bug` work items and `System.Description` for all other work item types. - `area-path` - Area path for the work item - `iteration-path` - Iteration path for the work item - `assignee` - Static user to assign (email, UPN, or display name). When omitted, the work item is created unassigned. diff --git a/src/mcp.rs b/src/mcp.rs index 83a8d566..682ff1cd 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -35,7 +35,7 @@ use crate::safe_outputs::{ UploadBuildAttachmentResult, UploadPipelineArtifactParams, UploadPipelineArtifactResult, UploadWorkitemAttachmentParams, UploadWorkitemAttachmentResult, Validate, anyhow_to_mcp_error, }; -use crate::sanitize::{SanitizeContent, sanitize as sanitize_text}; +use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_markdown}; use crate::secure::WorkItemTemporaryId; /// Sanitize a title into a safe branch name slug. @@ -813,7 +813,7 @@ can be passed as work_item_id to later safe outputs such as assign-work-item." // Sanitize untrusted agent-provided text fields (IS-01) let mut sanitized = params.0; sanitized.title = sanitize_text(&sanitized.title); - sanitized.description = sanitize_text(&sanitized.description); + sanitized.description = sanitize_markdown(&sanitized.description); let temporary_id = self.write_create_work_item_proposal(sanitized).await?; let canonical = temporary_id.canonical(); info!("Work item queued for creation as {}", canonical); @@ -2041,6 +2041,27 @@ mod tests { assert_eq!(proposals[0]["temporary_id"], temporary_id); } + #[tokio::test] + async fn create_work_item_preserves_html_description_in_proposal() { + let (safe_outputs, _temp_dir) = create_test_safe_outputs().await; + let params = CreateWorkItemParams { + title: "Create work item with body".to_string(), + description: "

Hi

x <string> y

".to_string(), + tags: Vec::new(), + }; + + safe_outputs + .create_work_item(Parameters(params)) + .await + .unwrap(); + + let proposals = safe_outputs.read_safe_output_file().await.unwrap(); + assert_eq!( + proposals[0]["description"], + "

Hi

x <string> y

" + ); + } + #[tokio::test] async fn concurrent_create_work_item_calls_generate_distinct_ids() { let (safe_outputs, _temp_dir) = create_test_safe_outputs().await; diff --git a/src/safe_outputs/create_work_item.rs b/src/safe_outputs/create_work_item.rs index 6f3b8dd2..7266d37d 100644 --- a/src/safe_outputs/create_work_item.rs +++ b/src/safe_outputs/create_work_item.rs @@ -9,6 +9,7 @@ use super::PATH_SEGMENT; use crate::safe_outputs::{ ExecutionContext, ExecutionResult, Executor, ToolResult, Validate, anyhow_to_mcp_error, }; +use crate::sanitize::sanitize_markdown; use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; use crate::secure::WorkItemTemporaryId; use ado_aw_derive::SanitizeConfig; @@ -91,7 +92,7 @@ impl TryFrom<(CreateWorkItemParams, WorkItemTemporaryId)> for CreateWorkItemResu impl SanitizeContent for CreateWorkItemResult { fn sanitize_content_fields(&mut self) { self.title = sanitize_text(&self.title); - self.description = sanitize_text(&self.description); + self.description = sanitize_markdown(&self.description); for tag in &mut self.tags { *tag = sanitize_config(tag); } @@ -125,6 +126,12 @@ pub struct CreateWorkItemConfig { #[serde(default = "default_work_item_type", rename = "work-item-type")] pub work_item_type: String, + /// Field reference name that receives the agent-provided description. + /// Defaults to System.Description, except Bug work items default to + /// Microsoft.VSTS.TCM.ReproSteps. + #[serde(default, rename = "description-field")] + pub description_field: Option, + /// Area path for the work item #[serde(default, rename = "area-path")] pub area_path: Option, @@ -202,6 +209,7 @@ impl Default for CreateWorkItemConfig { fn default() -> Self { Self { work_item_type: default_work_item_type(), + description_field: None, area_path: None, iteration_path: None, assignee: None, @@ -218,6 +226,25 @@ fn default_work_item_type() -> String { "Task".to_string() } +const SYSTEM_DESCRIPTION_FIELD: &str = "System.Description"; +const BUG_REPRO_STEPS_FIELD: &str = "Microsoft.VSTS.TCM.ReproSteps"; + +fn default_description_field_for(work_item_type: &str) -> &'static str { + if work_item_type.eq_ignore_ascii_case("Bug") { + BUG_REPRO_STEPS_FIELD + } else { + SYSTEM_DESCRIPTION_FIELD + } +} + +fn description_field_for(config: &CreateWorkItemConfig) -> &str { + config + .description_field + .as_deref() + .filter(|field| !field.trim().is_empty()) + .unwrap_or_else(|| default_description_field_for(&config.work_item_type)) +} + /// Build a field patch operation for work item creation fn field_op(field: &str, value: impl Into) -> serde_json::Value { serde_json::json!({ @@ -431,13 +458,14 @@ impl Executor for CreateWorkItemResult { // Build the patch document for work item creation let description_with_stats = crate::agent_stats::append_stats_to_body(&self.description, ctx, config.include_stats); + let description_field = description_field_for(&config); let mut patch_doc = vec![ field_op("System.Title", &self.title), - field_op("System.Description", &description_with_stats), + field_op(description_field, &description_with_stats), // Tell Azure DevOps the description is markdown serde_json::json!({ "op": "add", - "path": "/multilineFieldsFormat/System.Description", + "path": format!("/multilineFieldsFormat/{description_field}"), "value": "Markdown" }), ]; @@ -734,6 +762,7 @@ mod tests { fn test_config_defaults() { let config = CreateWorkItemConfig::default(); assert_eq!(config.work_item_type, "Task"); + assert_eq!(description_field_for(&config), "System.Description"); assert!(config.area_path.is_none()); assert!(config.iteration_path.is_none()); assert!(config.assignee.is_none()); @@ -746,6 +775,7 @@ mod tests { fn test_config_deserializes_from_yaml() { let yaml = r#" work-item-type: Bug +description-field: Custom.Body area-path: "MyProject\\MyTeam" assignee: "user@example.com" tags: @@ -759,6 +789,8 @@ custom-fields: "#; let config: CreateWorkItemConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(config.work_item_type, "Bug"); + assert_eq!(config.description_field, Some("Custom.Body".to_string())); + assert_eq!(description_field_for(&config), "Custom.Body"); assert_eq!(config.area_path, Some("MyProject\\MyTeam".to_string())); assert_eq!(config.assignee, Some("user@example.com".to_string())); assert_eq!(config.tags, vec!["agent-created", "automated"]); @@ -777,8 +809,97 @@ tags: "#; let config: CreateWorkItemConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(config.work_item_type, "Task"); // default + assert_eq!(description_field_for(&config), "System.Description"); assert!(config.area_path.is_none()); // default assert_eq!(config.tags, vec!["my-tag"]); assert!(config.allowed_tags.is_empty()); // default } + + #[test] + fn test_bug_defaults_description_field_to_repro_steps() { + let config: CreateWorkItemConfig = serde_yaml::from_str("work-item-type: Bug\n").unwrap(); + assert_eq!( + description_field_for(&config), + "Microsoft.VSTS.TCM.ReproSteps" + ); + } + + #[test] + fn test_result_sanitization_preserves_description_html() { + let mut result = CreateWorkItemResult { + name: CreateWorkItemResult::NAME.to_string(), + title: "Test work item".to_string(), + description: "

Hi

x <string> y

".to_string(), + tags: Vec::new(), + temporary_id: WorkItemTemporaryId::parse("#aw_test1").unwrap(), + }; + + result.sanitize_content_fields(); + + assert_eq!(result.description, "

Hi

x <string> y

"); + } + + #[tokio::test] + async fn test_execute_bug_uses_repro_steps_and_preserves_html() { + use std::collections::HashMap; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let expected_patch = serde_json::json!([ + { + "op": "add", + "path": "/fields/System.Title", + "value": "Bug with visible body" + }, + { + "op": "add", + "path": "/fields/Microsoft.VSTS.TCM.ReproSteps", + "value": "

Hi

x <string> y

" + }, + { + "op": "add", + "path": "/multilineFieldsFormat/Microsoft.VSTS.TCM.ReproSteps", + "value": "Markdown" + } + ]); + Mock::given(method("POST")) + .and(path("/Project/_apis/wit/workitems/$Bug")) + .and(body_json(expected_patch)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": 42, + "_links": { + "html": { + "href": "https://example.test/workitems/42" + } + } + }))) + .mount(&server) + .await; + + let ctx = ExecutionContext { + ado_org_url: Some(server.uri()), + ado_project: Some("Project".to_string()), + access_token: Some("token".to_string()), + tool_configs: HashMap::from([( + "create-work-item".to_string(), + serde_json::json!({ + "work-item-type": "Bug", + "include-stats": false + }), + )]), + ..Default::default() + }; + let mut result = CreateWorkItemResult { + name: CreateWorkItemResult::NAME.to_string(), + title: "Bug with visible body".to_string(), + description: "

Hi

x <string> y

".to_string(), + tags: Vec::new(), + temporary_id: WorkItemTemporaryId::parse("#aw_test1").unwrap(), + }; + + let execution = result.execute_sanitized(&ctx).await.unwrap(); + + assert!(execution.success, "{}", execution.message); + } } diff --git a/src/sanitize.rs b/src/sanitize.rs index 47380717..b4d01ac4 100644 --- a/src/sanitize.rs +++ b/src/sanitize.rs @@ -72,6 +72,24 @@ pub fn sanitize(input: &str) -> String { s } +/// Sanitize untrusted Markdown content while preserving inline HTML for renderers +/// that accept it natively. +pub(crate) fn sanitize_markdown(input: &str) -> String { + let mut s = remove_control_characters(input); + s = neutralize_pipeline_commands(&s); + s = neutralize_mentions(&s); + s = neutralize_bot_triggers(&s); + s = remove_xml_comments(&s); + s = sanitize_url_protocols(&s); + s = enforce_content_limits(&s); + debug!( + "Sanitized markdown content: {} -> {} bytes", + input.len(), + s.len() + ); + s +} + /// Sanitize operator-controlled configuration values. /// /// Applies a subset of the full pipeline appropriate for config identifiers: @@ -643,6 +661,12 @@ mod tests { assert_eq!(remove_xml_comments("beforeb"), "ab"); + assert_eq!(sanitize_markdown("a