Skip to content

Commit fd2cf5c

Browse files
jamesadevineCopilot
andcommitted
fix(safe-outputs): address reviewer feedback on work-item filing
- Sanitize agent_last_committer via sanitize_config() to prevent pipeline command injection from malicious git committer identity - Add enabled: bool to WorkItemReportConfig (default true) so operators can opt out of work-item filing with enabled: false - Add message-content assertions to noop/missing-tool execute tests - Document serde-default layering quirk for title: None in partial work-item configs - Document --follow trade-off in discover_last_committer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent b5741a1 commit fd2cf5c

6 files changed

Lines changed: 121 additions & 4 deletions

File tree

docs/safe-outputs.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@ The executor always files an Azure DevOps work item or appends a comment to an e
209209
safe-outputs:
210210
noop:
211211
work-item: # Work item config — always active with these defaults
212+
enabled: true # Set to false to disable work-item filing entirely
212213
title: "[ado-aw] Agent reported no operation" # Default title (used to find existing items too)
213214
work-item-type: Task # Work item type (default: "Task")
214215
area-path: "MyProject\\MyTeam" # Optional — area path
@@ -242,6 +243,7 @@ The executor always files an Azure DevOps work item or appends a comment to an e
242243
safe-outputs:
243244
missing-tool:
244245
work-item: # Work item config — always active with these defaults
246+
enabled: true # Set to false to disable work-item filing entirely
245247
title: "[ado-aw] Agent encountered missing tool" # Default title (used to find existing items too)
246248
work-item-type: Task # Work item type (default: "Task")
247249
area-path: "MyProject\\MyTeam" # Optional — area path

src/execute.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,11 @@ mod tests {
581581
// noop always attempts to file a work item; without ADO credentials it
582582
// returns a warning (success=true) rather than failing hard.
583583
assert!(result.success);
584+
assert!(
585+
result.message.contains("work item") || result.message.contains("not set"),
586+
"noop warning should mention work item or missing config, got: {}",
587+
result.message
588+
);
584589
}
585590

586591
#[tokio::test]
@@ -595,6 +600,11 @@ mod tests {
595600
// missing-tool always attempts to file a work item; without ADO credentials
596601
// it returns a warning (success=true) rather than failing hard.
597602
assert!(result.success);
603+
assert!(
604+
result.message.contains("work item") || result.message.contains("not set"),
605+
"missing-tool warning should mention work item or missing config, got: {}",
606+
result.message
607+
);
598608
}
599609

600610
#[tokio::test]

src/main.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,11 @@ async fn build_execution_context(
361361
/// Runs `git log -1 --format='%ae' -- <path>` in the file's parent directory.
362362
/// Returns `None` (with a debug log) when the lookup fails — e.g. shallow
363363
/// clone with no relevant history, or git is unavailable.
364+
///
365+
/// Note: we pass the bare filename (not a full path) so git resolves it
366+
/// relative to `cwd`. This means renames in history are not followed
367+
/// (`--follow` has its own edge-cases with merge commits and is not worth
368+
/// the complexity here).
364369
async fn discover_last_committer(path: &Path) -> Option<String> {
365370
let dir = path.parent().unwrap_or(Path::new("."));
366371
let output = tokio::process::Command::new("git")
@@ -377,7 +382,11 @@ async fn discover_last_committer(path: &Path) -> Option<String> {
377382
log::debug!("git log returned no committer for {}", path.display());
378383
None
379384
} else {
380-
Some(email)
385+
// Sanitize the email: git committer values can contain
386+
// arbitrary text (e.g. ADO pipeline log commands like
387+
// ##vso[task.setvariable ...]). Apply the same config-level
388+
// sanitization used for operator-supplied fields.
389+
Some(crate::sanitize::sanitize_config(&email))
381390
}
382391
}
383392
Ok(o) => {

src/safeoutputs/missing_tool.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ fn missing_tool_default_work_item_title() -> String {
4343

4444
fn missing_tool_default_work_item() -> WorkItemReportConfig {
4545
WorkItemReportConfig {
46+
enabled: true,
4647
title: Some(missing_tool_default_work_item_title()),
4748
work_item_type: "Task".to_string(),
4849
area_path: None,
@@ -164,6 +165,7 @@ mod tests {
164165
#[test]
165166
fn test_config_default_has_sensible_work_item() {
166167
let config = MissingToolConfig::default();
168+
assert!(config.work_item.enabled);
167169
assert_eq!(config.work_item.title.as_deref(), Some("[ado-aw] Agent encountered missing tool"));
168170
assert_eq!(config.work_item.work_item_type, "Task");
169171
assert!(config.work_item.area_path.is_none());
@@ -199,6 +201,12 @@ work-item:
199201

200202
#[test]
201203
fn test_config_partial_work_item_preserves_overrides() {
204+
// When a partial work-item: block is provided in front matter (e.g.
205+
// only `work-item-type:` with no `title:`), serde deserializes
206+
// `title` as `None` via `#[serde(default)]` — NOT via the per-tool
207+
// default function `missing_tool_default_work_item()`. The caller's
208+
// `unwrap_or(default_title)` in `file_or_append_work_item` recovers
209+
// the intended title at execution time.
202210
let yaml = r#"
203211
work-item:
204212
work-item-type: Bug
@@ -211,6 +219,37 @@ work-item:
211219
assert_eq!(config.work_item.tags, vec!["agent-missing-tool"]);
212220
}
213221

222+
#[tokio::test]
223+
async fn test_execute_impl_disabled_skips_work_item() {
224+
let result: MissingToolResult = MissingToolParams {
225+
tool_name: "bash".to_string(),
226+
context: None,
227+
}
228+
.try_into()
229+
.unwrap();
230+
231+
let mut ctx = crate::safeoutputs::ExecutionContext::default();
232+
ctx.tool_configs.insert(
233+
"missing-tool".to_string(),
234+
serde_json::to_value(MissingToolConfig {
235+
work_item: WorkItemReportConfig {
236+
enabled: false,
237+
..missing_tool_default_work_item()
238+
},
239+
})
240+
.unwrap(),
241+
);
242+
243+
let exec = result.execute_impl(&ctx).await.unwrap();
244+
assert!(exec.success);
245+
assert!(!exec.is_warning());
246+
assert!(
247+
exec.message.contains("disabled"),
248+
"expected disabled message, got: {}",
249+
exec.message
250+
);
251+
}
252+
214253
#[tokio::test]
215254
async fn test_execute_impl_without_ado_credentials_returns_warning() {
216255
let result: MissingToolResult = MissingToolParams {

src/safeoutputs/mod.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,8 @@ fn work_item_report_default_type() -> String {
390390
///
391391
/// Both `noop` and `missing-tool` default to always creating/appending a work item.
392392
/// Override the defaults in front matter to customise the title, type, area path, etc.
393+
/// Set `enabled: false` to disable work-item filing entirely and restore the old
394+
/// pass-through behaviour.
393395
///
394396
/// Example:
395397
/// ```yaml
@@ -404,12 +406,21 @@ fn work_item_report_default_type() -> String {
404406
/// ```
405407
#[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)]
406408
pub struct WorkItemReportConfig {
409+
/// Whether work-item filing is enabled (default: `true`).
410+
/// Set to `false` to disable work-item creation/appending entirely.
411+
#[serde(default = "default_enabled")]
412+
pub enabled: bool,
413+
407414
/// Title of the work item to file or append a comment to.
408415
/// If a non-closed work item with this exact title already exists,
409416
/// a comment is appended rather than creating a new work item.
410417
///
411418
/// When `None`, each caller supplies a context-appropriate default
412-
/// (e.g. noop vs missing-tool).
419+
/// (e.g. noop vs missing-tool). This can happen when a partial
420+
/// `work-item:` block is provided in front matter (e.g. overriding
421+
/// only `work-item-type:`) — serde deserializes `title` as `None`
422+
/// because `#[serde(default)]` applies per-field, not via the
423+
/// per-tool default function.
413424
#[serde(default)]
414425
pub title: Option<String>,
415426

@@ -434,6 +445,10 @@ pub struct WorkItemReportConfig {
434445
pub include_stats: bool,
435446
}
436447

448+
fn default_enabled() -> bool {
449+
true
450+
}
451+
437452
/// Search for a non-closed work item by exact title using WIQL.
438453
/// Returns the ID of the most-recently-changed matching work item, or `None` if none found.
439454
async fn wiql_find_work_item_by_title(
@@ -523,6 +538,11 @@ pub(crate) async fn file_or_append_work_item(
523538
body: &str,
524539
ctx: &ExecutionContext,
525540
) -> anyhow::Result<ExecutionResult> {
541+
if !config.enabled {
542+
return Ok(ExecutionResult::success(
543+
"Work-item filing disabled via enabled: false".to_string(),
544+
));
545+
}
526546
let title = config.title.as_deref().unwrap_or(default_title);
527547
let org_url = match &ctx.ado_org_url {
528548
Some(u) => u,

src/safeoutputs/noop.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ fn noop_default_work_item_title() -> String {
3737

3838
fn noop_default_work_item() -> WorkItemReportConfig {
3939
WorkItemReportConfig {
40+
enabled: true,
4041
title: Some(noop_default_work_item_title()),
4142
work_item_type: "Task".to_string(),
4243
area_path: None,
@@ -175,6 +176,7 @@ mod tests {
175176
#[test]
176177
fn test_config_default_has_sensible_work_item() {
177178
let config = NoopConfig::default();
179+
assert!(config.work_item.enabled);
178180
assert_eq!(config.work_item.title.as_deref(), Some("[ado-aw] Agent reported no operation"));
179181
assert_eq!(config.work_item.work_item_type, "Task");
180182
assert!(config.work_item.area_path.is_none());
@@ -210,8 +212,12 @@ work-item:
210212

211213
#[test]
212214
fn test_config_partial_work_item_preserves_overrides() {
213-
// Regression: a partial work-item config (no title) must not
214-
// silently discard the user's other overrides.
215+
// When a partial work-item: block is provided in front matter (e.g.
216+
// only `work-item-type:` with no `title:`), serde deserializes
217+
// `title` as `None` via `#[serde(default)]` — NOT via the per-tool
218+
// default function `noop_default_work_item()`. The caller's
219+
// `unwrap_or(default_title)` in `file_or_append_work_item` recovers
220+
// the intended title at execution time.
215221
let yaml = r#"
216222
work-item:
217223
work-item-type: Bug
@@ -223,6 +229,37 @@ work-item:
223229
assert_eq!(config.work_item.area_path.as_deref(), Some("MyProject\\MyTeam"));
224230
}
225231

232+
#[tokio::test]
233+
async fn test_execute_impl_disabled_skips_work_item() {
234+
let result: NoopResult = NoopParams {
235+
context: Some("nothing to do".to_string()),
236+
}
237+
.try_into()
238+
.unwrap();
239+
240+
let mut ctx = crate::safeoutputs::ExecutionContext::default();
241+
// Configure noop with enabled: false
242+
ctx.tool_configs.insert(
243+
"noop".to_string(),
244+
serde_json::to_value(NoopConfig {
245+
work_item: WorkItemReportConfig {
246+
enabled: false,
247+
..noop_default_work_item()
248+
},
249+
})
250+
.unwrap(),
251+
);
252+
253+
let exec = result.execute_impl(&ctx).await.unwrap();
254+
assert!(exec.success);
255+
assert!(!exec.is_warning());
256+
assert!(
257+
exec.message.contains("disabled"),
258+
"expected disabled message, got: {}",
259+
exec.message
260+
);
261+
}
262+
226263
#[tokio::test]
227264
async fn test_execute_impl_without_ado_credentials_returns_warning() {
228265
let result: NoopResult = NoopParams {

0 commit comments

Comments
 (0)