Skip to content

Commit 87d6527

Browse files
feat: add create-wiki-page safe output (#61)
* Initial plan * feat: add create-wiki-page safe output tool Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * fix: harden create-wiki-page with If-Match and bare-slash validation Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent 20f543f commit 87d6527

6 files changed

Lines changed: 1052 additions & 4 deletions

File tree

src/compile/common.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -553,7 +553,7 @@ pub fn generate_executor_ado_env(write_service_connection: Option<&str>) -> Stri
553553
}
554554

555555
/// Safe-output names that require write access to ADO.
556-
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item", "edit-wiki-page"];
556+
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item", "create-wiki-page", "edit-wiki-page"];
557557

558558
/// Validate that write-requiring safe-outputs have a write service connection configured.
559559
pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> {

src/execute.rs

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ use std::path::Path;
1010

1111
use crate::ndjson::{self, SAFE_OUTPUT_FILENAME};
1212
use crate::tools::{
13-
CreatePrResult, CreateWorkItemResult, EditWikiPageResult, ExecutionContext, ExecutionResult,
14-
Executor,
13+
CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, EditWikiPageResult,
14+
ExecutionContext, ExecutionResult, Executor,
1515
};
1616

1717
// Re-export memory types for use by main.rs
@@ -193,6 +193,17 @@ pub async fn execute_safe_output(
193193
);
194194
output.execute_sanitized(ctx).await?
195195
}
196+
"create-wiki-page" => {
197+
debug!("Parsing create-wiki-page payload");
198+
let mut output: CreateWikiPageResult = serde_json::from_value(entry.clone())
199+
.map_err(|e| anyhow::anyhow!("Failed to parse create-wiki-page: {}", e))?;
200+
debug!(
201+
"create-wiki-page: path='{}', content length={}",
202+
output.path,
203+
output.content.len()
204+
);
205+
output.execute_sanitized(ctx).await?
206+
}
196207
"noop" => {
197208
debug!("Skipping noop entry");
198209
ExecutionResult::success("Skipped informational output: noop")
@@ -425,4 +436,46 @@ mod tests {
425436
.contains("AZURE_DEVOPS_ORG_URL")
426437
);
427438
}
439+
440+
#[tokio::test]
441+
async fn test_execute_malformed_create_wiki_page_returns_err() {
442+
// Missing required fields (path and content)
443+
let entry = serde_json::json!({"name": "create-wiki-page"});
444+
let ctx = ExecutionContext::default();
445+
446+
let result = execute_safe_output(&entry, &ctx).await;
447+
assert!(result.is_err());
448+
}
449+
450+
#[tokio::test]
451+
async fn test_execute_create_wiki_page_missing_context() {
452+
let entry = serde_json::json!({
453+
"name": "create-wiki-page",
454+
"path": "/NewPage",
455+
"content": "This is some valid wiki content."
456+
});
457+
458+
// Context without required fields (ado_org_url, etc.)
459+
let ctx = ExecutionContext {
460+
ado_org_url: None,
461+
ado_organization: None,
462+
ado_project: None,
463+
access_token: None,
464+
working_directory: PathBuf::from("."),
465+
source_directory: PathBuf::from("."),
466+
tool_configs: HashMap::new(),
467+
repository_id: None,
468+
repository_name: None,
469+
allowed_repositories: HashMap::new(),
470+
};
471+
472+
let result = execute_safe_output(&entry, &ctx).await;
473+
assert!(result.is_err());
474+
assert!(
475+
result
476+
.unwrap_err()
477+
.to_string()
478+
.contains("AZURE_DEVOPS_ORG_URL")
479+
);
480+
}
428481
}

src/mcp.rs

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ use std::path::PathBuf;
1111
use crate::ndjson::{self, SAFE_OUTPUT_FILENAME};
1212
use crate::sanitize::sanitize as sanitize_text;
1313
use crate::tools::{
14-
CreatePrParams, CreatePrResult, CreateWorkItemParams, CreateWorkItemResult,
14+
CreatePrParams, CreatePrResult, CreateWikiPageParams, CreateWikiPageResult,
15+
CreateWorkItemParams, CreateWorkItemResult,
1516
EditWikiPageParams, EditWikiPageResult, MissingDataParams, MissingDataResult,
1617
MissingToolParams, MissingToolResult, NoopParams, NoopResult, ToolResult,
1718
anyhow_to_mcp_error,
@@ -439,6 +440,43 @@ structured output that should be visible in the project wiki."
439440
result.path
440441
))]))
441442
}
443+
444+
#[tool(
445+
name = "create-wiki-page",
446+
description = "Create a new Azure DevOps wiki page with the provided markdown content. \
447+
The page path (e.g. '/Overview/NewPage') and the wiki to write to are determined by the \
448+
pipeline configuration. The page must not already exist — use edit-wiki-page to update \
449+
existing pages. Use this to publish findings, summaries, documentation, or any other \
450+
structured output that should be visible in the project wiki."
451+
)]
452+
async fn create_wiki_page(
453+
&self,
454+
params: Parameters<CreateWikiPageParams>,
455+
) -> Result<CallToolResult, McpError> {
456+
info!("Tool called: create-wiki-page - '{}'", params.0.path);
457+
debug!("Content length: {} chars", params.0.content.len());
458+
459+
// Sanitize untrusted agent-provided text fields (IS-01).
460+
// Path: strip control characters to prevent injection into the NDJSON record.
461+
// Content and comment: apply the full sanitization pipeline.
462+
let mut sanitized = params.0;
463+
sanitized.path = sanitized
464+
.path
465+
.chars()
466+
.filter(|c| !c.is_control() || *c == '\t')
467+
.collect();
468+
sanitized.content = sanitize_text(&sanitized.content);
469+
sanitized.comment = sanitized.comment.map(|c| sanitize_text(&c));
470+
471+
let result: CreateWikiPageResult = sanitized.try_into()?;
472+
let _ = self.write_safe_output_file(&result).await;
473+
474+
info!("Wiki page creation queued: '{}'", result.path);
475+
Ok(CallToolResult::success(vec![Content::text(format!(
476+
"Wiki page creation queued for '{}'. The page will be created during safe output processing.",
477+
result.path
478+
))]))
479+
}
442480
}
443481

444482
// Implement the server handler

0 commit comments

Comments
 (0)