Skip to content

Commit 2aa8acf

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

3 files changed

Lines changed: 100 additions & 41 deletions

File tree

src/execute.rs

Lines changed: 38 additions & 1 deletion
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-
CommentOnWorkItemResult, 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());
@@ -134,6 +139,38 @@ pub async fn execute_safe_outputs(
134139
update_wi_executed += 1;
135140
}
136141

142+
// Enforce comment-on-work-item max: skip excess entries rather than aborting the whole batch
143+
if entry.get("name").and_then(|n| n.as_str()) == Some("comment-on-work-item") {
144+
if comment_wi_executed >= max_comment_wi {
145+
let wi_id = entry
146+
.get("work_item_id")
147+
.and_then(|v| v.as_i64())
148+
.map(|id| format!(" (work item #{})", id))
149+
.unwrap_or_default();
150+
warn!(
151+
"[{}/{}] Skipping comment-on-work-item{} entry: max ({}) already reached for this run",
152+
i + 1,
153+
entries.len(),
154+
wi_id,
155+
max_comment_wi
156+
);
157+
let result = ExecutionResult::failure(format!(
158+
"Skipped{}: maximum comment-on-work-item count ({}) already reached. \
159+
Increase 'max' in safe-outputs.comment-on-work-item to allow more comments.",
160+
wi_id, max_comment_wi
161+
));
162+
println!(
163+
"[{}/{}] comment-on-work-item - ✗ - {}",
164+
i + 1,
165+
entries.len(),
166+
result.message
167+
);
168+
results.push(result);
169+
continue;
170+
}
171+
comment_wi_executed += 1;
172+
}
173+
137174
match execute_safe_output(entry, ctx).await {
138175
Ok((tool_name, result)) => {
139176
if result.success {

src/mcp.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -357,14 +357,8 @@ pipeline configuration."
357357
let mut sanitized = params.0;
358358
sanitized.body = sanitize_text(&sanitized.body);
359359
let result: CommentOnWorkItemResult = sanitized.try_into()?;
360-
let written = self.write_safe_output_file_with_maximum(&result, 1).await
360+
let _ = self.write_safe_output_file(&result).await
361361
.map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?;
362-
if !written {
363-
warn!("comment-on-work-item limit reached, ignoring");
364-
return Ok(CallToolResult::success(vec![Content::text(
365-
"Maximum number of comments reached for this run. This comment was not recorded.",
366-
)]));
367-
}
368362
info!("Comment queued for work item #{}", result.work_item_id);
369363
Ok(CallToolResult::success(vec![Content::text(format!(
370364
"Comment queued for work item #{}. The comment will be posted during safe output processing.",

src/tools/comment_on_work_item.rs

Lines changed: 61 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ pub struct CommentOnWorkItemConfig {
9999
#[serde(default = "default_max")]
100100
pub max: u32,
101101

102-
/// Target scope — which work items can be commented on (required)
103-
pub target: CommentTarget,
102+
/// Target scope — which work items can be commented on.
103+
/// `None` means no target was configured; execution must reject this.
104+
pub target: Option<CommentTarget>,
104105
}
105106

106107
fn default_max() -> u32 {
@@ -111,7 +112,7 @@ impl Default for CommentOnWorkItemConfig {
111112
fn default() -> Self {
112113
Self {
113114
max: default_max(),
114-
target: CommentTarget::StringTarget("*".to_string()),
115+
target: None,
115116
}
116117
}
117118
}
@@ -191,10 +192,21 @@ impl Executor for CommentOnWorkItemResult {
191192
let config: CommentOnWorkItemConfig = ctx.get_tool_config("comment-on-work-item");
192193
debug!("Target: {:?}, max: {}", config.target, config.max);
193194

195+
let target = match &config.target {
196+
Some(t) => t,
197+
None => {
198+
return Ok(ExecutionResult::failure(
199+
"comment-on-work-item target is not configured. \
200+
This is required to scope which work items the agent can comment on."
201+
.to_string(),
202+
));
203+
}
204+
};
205+
194206
let client = reqwest::Client::new();
195207

196208
// Validate work item ID against target policy
197-
match config.target.allows_id(self.work_item_id) {
209+
match target.allows_id(self.work_item_id) {
198210
Some(true) => {
199211
debug!(
200212
"Work item #{} allowed by target policy",
@@ -209,29 +221,30 @@ impl Executor for CommentOnWorkItemResult {
209221
}
210222
None => {
211223
// Area path validation — need to fetch the work item
212-
if let Some(prefix) = config.target.area_path_prefix() {
213-
debug!(
214-
"Validating area path for work item #{} against prefix '{}'",
215-
self.work_item_id, prefix
216-
);
217-
match get_work_item_area_path(&client, org_url, project, token, self.work_item_id)
218-
.await
219-
{
220-
Ok(area_path) => {
221-
if !area_path.starts_with(prefix) {
222-
return Ok(ExecutionResult::failure(format!(
223-
"Work item #{} has area path '{}' which is not under allowed prefix '{}'",
224-
self.work_item_id, area_path, prefix
225-
)));
226-
}
227-
debug!("Area path '{}' validated against '{}'", area_path, prefix);
228-
}
229-
Err(e) => {
224+
let prefix = target.area_path_prefix().expect(
225+
"allows_id returned None but area_path_prefix is also None; this is a bug",
226+
);
227+
debug!(
228+
"Validating area path for work item #{} against prefix '{}'",
229+
self.work_item_id, prefix
230+
);
231+
match get_work_item_area_path(&client, org_url, project, token, self.work_item_id)
232+
.await
233+
{
234+
Ok(area_path) => {
235+
if !area_path.starts_with(prefix) {
230236
return Ok(ExecutionResult::failure(format!(
231-
"Failed to validate area path for work item #{}: {}",
232-
self.work_item_id, e
237+
"Work item #{} has area path '{}' which is not under allowed prefix '{}'",
238+
self.work_item_id, area_path, prefix
233239
)));
234240
}
241+
debug!("Area path '{}' validated against '{}'", area_path, prefix);
242+
}
243+
Err(e) => {
244+
return Ok(ExecutionResult::failure(format!(
245+
"Failed to validate area path for work item #{}: {}",
246+
self.work_item_id, e
247+
)));
235248
}
236249
}
237250
}
@@ -382,6 +395,7 @@ mod tests {
382395
fn test_config_defaults() {
383396
let config = CommentOnWorkItemConfig::default();
384397
assert_eq!(config.max, 1);
398+
assert!(config.target.is_none());
385399
}
386400

387401
#[test]
@@ -392,6 +406,7 @@ target: "*"
392406
"#;
393407
let config: CommentOnWorkItemConfig = serde_yaml::from_str(yaml).unwrap();
394408
assert_eq!(config.max, 5);
409+
assert!(config.target.is_some());
395410
}
396411

397412
#[test]
@@ -401,8 +416,9 @@ max: 1
401416
target: 12345
402417
"#;
403418
let config: CommentOnWorkItemConfig = serde_yaml::from_str(yaml).unwrap();
404-
assert!(config.target.allows_id(12345) == Some(true));
405-
assert!(config.target.allows_id(99999) == Some(false));
419+
let target = config.target.unwrap();
420+
assert!(target.allows_id(12345) == Some(true));
421+
assert!(target.allows_id(99999) == Some(false));
406422
}
407423

408424
#[test]
@@ -415,9 +431,10 @@ target:
415431
- 300
416432
"#;
417433
let config: CommentOnWorkItemConfig = serde_yaml::from_str(yaml).unwrap();
418-
assert!(config.target.allows_id(100) == Some(true));
419-
assert!(config.target.allows_id(200) == Some(true));
420-
assert!(config.target.allows_id(999) == Some(false));
434+
let target = config.target.unwrap();
435+
assert!(target.allows_id(100) == Some(true));
436+
assert!(target.allows_id(200) == Some(true));
437+
assert!(target.allows_id(999) == Some(false));
421438
}
422439

423440
#[test]
@@ -426,8 +443,9 @@ target:
426443
target: "*"
427444
"#;
428445
let config: CommentOnWorkItemConfig = serde_yaml::from_str(yaml).unwrap();
429-
assert!(config.target.allows_id(1) == Some(true));
430-
assert!(config.target.allows_id(99999) == Some(true));
446+
let target = config.target.unwrap();
447+
assert!(target.allows_id(1) == Some(true));
448+
assert!(target.allows_id(99999) == Some(true));
431449
}
432450

433451
#[test]
@@ -436,8 +454,18 @@ target: "*"
436454
target: "4x4\\QED"
437455
"#;
438456
let config: CommentOnWorkItemConfig = serde_yaml::from_str(yaml).unwrap();
439-
assert!(config.target.allows_id(1).is_none());
440-
assert_eq!(config.target.area_path_prefix(), Some("4x4\\QED"));
457+
let target = config.target.unwrap();
458+
assert!(target.allows_id(1).is_none());
459+
assert_eq!(target.area_path_prefix(), Some("4x4\\QED"));
460+
}
461+
462+
#[test]
463+
fn test_config_missing_target_defaults_to_none() {
464+
let yaml = r#"
465+
max: 3
466+
"#;
467+
let config: CommentOnWorkItemConfig = serde_yaml::from_str(yaml).unwrap();
468+
assert!(config.target.is_none());
441469
}
442470

443471
#[test]

0 commit comments

Comments
 (0)