Skip to content

Commit 80cc20a

Browse files
jamesadevineCopilot
andcommitted
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>
1 parent 2aa8acf commit 80cc20a

3 files changed

Lines changed: 156 additions & 15 deletions

File tree

src/mcp.rs

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -357,7 +357,7 @@ pipeline configuration."
357357
let mut sanitized = params.0;
358358
sanitized.body = sanitize_text(&sanitized.body);
359359
let result: CommentOnWorkItemResult = sanitized.try_into()?;
360-
let _ = self.write_safe_output_file(&result).await
360+
self.write_safe_output_file(&result).await
361361
.map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?;
362362
info!("Comment queued for work item #{}", result.work_item_id);
363363
Ok(CallToolResult::success(vec![Content::text(format!(
@@ -379,19 +379,9 @@ fields you want to update."
379379
params: Parameters<UpdateWorkItemParams>,
380380
) -> Result<CallToolResult, McpError> {
381381
info!("Tool called: update-work-item - id={}", params.0.id);
382-
// Sanitize untrusted agent-provided text fields (IS-01)
383-
let mut sanitized = params.0;
384-
sanitized.title = sanitized.title.map(|t| sanitize_text(&t));
385-
sanitized.body = sanitized.body.map(|b| sanitize_text(&b));
386-
sanitized.state = sanitized.state.map(|s| sanitize_text(&s));
387-
sanitized.area_path = sanitized.area_path.map(|p| sanitize_text(&p));
388-
sanitized.iteration_path = sanitized.iteration_path.map(|p| sanitize_text(&p));
389-
sanitized.assignee = sanitized.assignee.map(|a| sanitize_text(&a));
390-
sanitized.tags = sanitized
391-
.tags
392-
.map(|ts| ts.into_iter().map(|t| sanitize_text(&t)).collect());
393-
let result: UpdateWorkItemResult = sanitized.try_into()?;
394-
let _ = self.write_safe_output_file(&result).await;
382+
let result: UpdateWorkItemResult = params.0.try_into()?;
383+
self.write_safe_output_file(&result).await
384+
.map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?;
395385
info!("Work item update queued for #{}", result.id);
396386
Ok(CallToolResult::success(vec![Content::text(format!(
397387
"Work item #{} update queued. Changes will be applied during safe output processing.",

src/tools/comment_on_work_item.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,8 @@ impl Executor for CommentOnWorkItemResult {
232232
.await
233233
{
234234
Ok(area_path) => {
235-
if !area_path.starts_with(prefix) {
235+
// ADO area paths are case-insensitive
236+
if !area_path.to_lowercase().starts_with(&prefix.to_lowercase()) {
236237
return Ok(ExecutionResult::failure(format!(
237238
"Work item #{} has area path '{}' which is not under allowed prefix '{}'",
238239
self.work_item_id, area_path, prefix

tests/compiler_tests.rs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1168,3 +1168,153 @@ Update existing work items.
11681168

11691169
let _ = fs::remove_dir_all(&temp_dir);
11701170
}
1171+
1172+
/// Test that comment-on-work-item requires a target field
1173+
#[test]
1174+
fn test_comment_on_work_item_requires_target_field() {
1175+
let temp_dir = std::env::temp_dir().join(format!(
1176+
"agentic-pipeline-cwi-target-{}",
1177+
std::process::id()
1178+
));
1179+
fs::create_dir_all(&temp_dir).expect("Failed to create temp directory");
1180+
1181+
let test_input = temp_dir.join("cwi-agent.md");
1182+
let test_content = r#"---
1183+
name: "Comment Agent"
1184+
description: "Agent that comments on work items but has no target"
1185+
permissions:
1186+
write: my-write-sc
1187+
safe-outputs:
1188+
comment-on-work-item:
1189+
max: 3
1190+
---
1191+
1192+
## Comment Agent
1193+
1194+
Comment on work items.
1195+
"#;
1196+
fs::write(&test_input, test_content).expect("Failed to write test input");
1197+
1198+
let output_path = temp_dir.join("cwi-agent.yml");
1199+
let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw"));
1200+
let output = std::process::Command::new(&binary_path)
1201+
.args([
1202+
"compile",
1203+
test_input.to_str().unwrap(),
1204+
"-o",
1205+
output_path.to_str().unwrap(),
1206+
])
1207+
.output()
1208+
.expect("Failed to run compiler");
1209+
1210+
assert!(
1211+
!output.status.success(),
1212+
"Compiler should fail when comment-on-work-item lacks a target field"
1213+
);
1214+
1215+
let stderr = String::from_utf8_lossy(&output.stderr);
1216+
assert!(
1217+
stderr.contains("target"),
1218+
"Error message should mention target: {stderr}"
1219+
);
1220+
1221+
let _ = fs::remove_dir_all(&temp_dir);
1222+
}
1223+
1224+
/// Test that comment-on-work-item requires a write service connection
1225+
#[test]
1226+
fn test_comment_on_work_item_requires_write_sc() {
1227+
let temp_dir = std::env::temp_dir().join(format!(
1228+
"agentic-pipeline-cwi-sc-{}",
1229+
std::process::id()
1230+
));
1231+
fs::create_dir_all(&temp_dir).expect("Failed to create temp directory");
1232+
1233+
let test_input = temp_dir.join("cwi-agent.md");
1234+
let test_content = r#"---
1235+
name: "Comment Agent"
1236+
description: "Agent that comments on work items but has no write SC"
1237+
safe-outputs:
1238+
comment-on-work-item:
1239+
target: "*"
1240+
---
1241+
1242+
## Comment Agent
1243+
1244+
Comment on work items.
1245+
"#;
1246+
fs::write(&test_input, test_content).expect("Failed to write test input");
1247+
1248+
let output_path = temp_dir.join("cwi-agent.yml");
1249+
let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw"));
1250+
let output = std::process::Command::new(&binary_path)
1251+
.args([
1252+
"compile",
1253+
test_input.to_str().unwrap(),
1254+
"-o",
1255+
output_path.to_str().unwrap(),
1256+
])
1257+
.output()
1258+
.expect("Failed to run compiler");
1259+
1260+
assert!(
1261+
!output.status.success(),
1262+
"Compiler should fail when comment-on-work-item lacks a write SC"
1263+
);
1264+
1265+
let stderr = String::from_utf8_lossy(&output.stderr);
1266+
assert!(
1267+
stderr.contains("permissions.write"),
1268+
"Error message should mention permissions.write: {stderr}"
1269+
);
1270+
1271+
let _ = fs::remove_dir_all(&temp_dir);
1272+
}
1273+
1274+
/// Test that comment-on-work-item compiles successfully with proper config
1275+
#[test]
1276+
fn test_comment_on_work_item_compiles_with_target_and_write_sc() {
1277+
let temp_dir = std::env::temp_dir().join(format!(
1278+
"agentic-pipeline-cwi-pass-{}",
1279+
std::process::id()
1280+
));
1281+
fs::create_dir_all(&temp_dir).expect("Failed to create temp directory");
1282+
1283+
let test_input = temp_dir.join("cwi-agent.md");
1284+
let test_content = r#"---
1285+
name: "Comment Agent"
1286+
description: "Agent that comments on work items"
1287+
permissions:
1288+
write: my-write-sc
1289+
safe-outputs:
1290+
comment-on-work-item:
1291+
target: "*"
1292+
max: 5
1293+
---
1294+
1295+
## Comment Agent
1296+
1297+
Comment on work items.
1298+
"#;
1299+
fs::write(&test_input, test_content).expect("Failed to write test input");
1300+
1301+
let output_path = temp_dir.join("cwi-agent.yml");
1302+
let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw"));
1303+
let output = std::process::Command::new(&binary_path)
1304+
.args([
1305+
"compile",
1306+
test_input.to_str().unwrap(),
1307+
"-o",
1308+
output_path.to_str().unwrap(),
1309+
])
1310+
.output()
1311+
.expect("Failed to run compiler");
1312+
1313+
assert!(
1314+
output.status.success(),
1315+
"Compiler should succeed when target and write SC are provided: {}",
1316+
String::from_utf8_lossy(&output.stderr)
1317+
);
1318+
1319+
let _ = fs::remove_dir_all(&temp_dir);
1320+
}

0 commit comments

Comments
 (0)