Skip to content

Commit 7b4536f

Browse files
feat: add edit-wiki-page safe output (#58)
* Initial plan * feat: add edit-wiki-page safe output Implements a new `edit-wiki-page` safe output that allows agents to create or update Azure DevOps wiki pages. Stage 1 (MCP server): - `edit-wiki-page` tool registered on the SafeOutputs MCP server - Validates path (no `..`, non-empty), content (≥10 chars) - Strips control characters from path; full sanitize_text() on content/comment - Queues result to safe_outputs.ndjson Stage 2 (executor): - Dispatches `edit-wiki-page` to EditWikiPageResult::execute_sanitized() - GET wiki page to check existence and obtain ETag - PUT to create/update with If-Match header for optimistic concurrency - Enforces path-prefix restriction configured in front matter - Applies title-prefix to final path segment if configured Front-matter configuration (safe-outputs.edit-wiki-page): - wiki-name: wiki identifier (required) - wiki-project: ADO project override (optional) - path-prefix: restrict writes to pages under this path (optional) - title-prefix: prepend text to each page title (optional) - comment: default commit message (optional) - create-if-missing: allow creating new pages (default: true) Compiler: - edit-wiki-page added to WRITE_REQUIRING_SAFE_OUTPUTS so compilation fails if no permissions.write service connection is configured Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * fix: percent-encode URL path segments in wiki and work-item API calls Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * refactor: lift PATH_SEGMENT, add null-byte check, fix dead test Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * feat: edit-wiki-page only updates existing pages, no creation 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 8eaf972 commit 7b4536f

9 files changed

Lines changed: 1029 additions & 6 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ serde_yaml = "0.9.34"
1414
serde_json = "1.0.149"
1515
schemars = "1.2"
1616
rmcp = { version = "0.8.0", features = ["server", "transport-io"] }
17+
percent-encoding = "2.3"
1718
reqwest = { version = "0.12", features = ["json"] }
1819
tempfile = "3"
1920
tokio = { version = "1.43", features = ["full"] }

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"];
556+
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item", "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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ use std::path::Path;
1010

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

1617
// Re-export memory types for use by main.rs
@@ -181,6 +182,17 @@ pub async fn execute_safe_output(
181182
);
182183
output.execute_sanitized(ctx).await?
183184
}
185+
"edit-wiki-page" => {
186+
debug!("Parsing edit-wiki-page payload");
187+
let mut output: EditWikiPageResult = serde_json::from_value(entry.clone())
188+
.map_err(|e| anyhow::anyhow!("Failed to parse edit-wiki-page: {}", e))?;
189+
debug!(
190+
"edit-wiki-page: path='{}', content length={}",
191+
output.path,
192+
output.content.len()
193+
);
194+
output.execute_sanitized(ctx).await?
195+
}
184196
"noop" => {
185197
debug!("Skipping noop entry");
186198
ExecutionResult::success("Skipped informational output: noop")
@@ -371,4 +383,46 @@ mod tests {
371383
assert!(result.is_err());
372384
assert!(result.unwrap_err().to_string().contains("evil-backdoor"));
373385
}
386+
387+
#[tokio::test]
388+
async fn test_execute_malformed_edit_wiki_page_returns_err() {
389+
// Missing required fields (path and content)
390+
let entry = serde_json::json!({"name": "edit-wiki-page"});
391+
let ctx = ExecutionContext::default();
392+
393+
let result = execute_safe_output(&entry, &ctx).await;
394+
assert!(result.is_err());
395+
}
396+
397+
#[tokio::test]
398+
async fn test_execute_edit_wiki_page_missing_context() {
399+
let entry = serde_json::json!({
400+
"name": "edit-wiki-page",
401+
"path": "/Overview",
402+
"content": "This is some valid wiki content."
403+
});
404+
405+
// Context without required fields (ado_org_url, etc.)
406+
let ctx = ExecutionContext {
407+
ado_org_url: None,
408+
ado_organization: None,
409+
ado_project: None,
410+
access_token: None,
411+
working_directory: PathBuf::from("."),
412+
source_directory: PathBuf::from("."),
413+
tool_configs: HashMap::new(),
414+
repository_id: None,
415+
repository_name: None,
416+
allowed_repositories: HashMap::new(),
417+
};
418+
419+
let result = execute_safe_output(&entry, &ctx).await;
420+
assert!(result.is_err());
421+
assert!(
422+
result
423+
.unwrap_err()
424+
.to_string()
425+
.contains("AZURE_DEVOPS_ORG_URL")
426+
);
427+
}
374428
}

src/mcp.rs

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ 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, MissingDataParams,
15-
MissingDataResult, MissingToolParams, MissingToolResult, NoopParams, NoopResult, ToolResult,
14+
CreatePrParams, CreatePrResult, CreateWorkItemParams, CreateWorkItemResult,
15+
EditWikiPageParams, EditWikiPageResult, MissingDataParams, MissingDataResult,
16+
MissingToolParams, MissingToolResult, NoopParams, NoopResult, ToolResult,
1617
anyhow_to_mcp_error,
1718
};
1819

@@ -402,6 +403,42 @@ impl SafeOutputs {
402403
repository, result.patch_file
403404
))]))
404405
}
406+
407+
#[tool(
408+
name = "edit-wiki-page",
409+
description = "Create or update an Azure DevOps wiki page with the provided markdown content. \
410+
The page path (e.g. '/Overview/Architecture') and the wiki to write to are determined by the \
411+
pipeline configuration. Use this to publish findings, summaries, documentation, or any other \
412+
structured output that should be visible in the project wiki."
413+
)]
414+
async fn edit_wiki_page(
415+
&self,
416+
params: Parameters<EditWikiPageParams>,
417+
) -> Result<CallToolResult, McpError> {
418+
info!("Tool called: edit-wiki-page - '{}'", params.0.path);
419+
debug!("Content length: {} chars", params.0.content.len());
420+
421+
// Sanitize untrusted agent-provided text fields (IS-01).
422+
// Path: strip control characters to prevent injection into the NDJSON record.
423+
// Content and comment: apply the full sanitization pipeline.
424+
let mut sanitized = params.0;
425+
sanitized.path = sanitized
426+
.path
427+
.chars()
428+
.filter(|c| !c.is_control() || *c == '\t')
429+
.collect();
430+
sanitized.content = sanitize_text(&sanitized.content);
431+
sanitized.comment = sanitized.comment.map(|c| sanitize_text(&c));
432+
433+
let result: EditWikiPageResult = sanitized.try_into()?;
434+
let _ = self.write_safe_output_file(&result).await;
435+
436+
info!("Wiki page edit queued: '{}'", result.path);
437+
Ok(CallToolResult::success(vec![Content::text(format!(
438+
"Wiki page edit queued for '{}'. The page will be created or updated during safe output processing.",
439+
result.path
440+
))]))
441+
}
405442
}
406443

407444
// Implement the server handler

src/tools/create_work_item.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
//! Create work item reporting schemas
22
33
use log::{debug, info};
4+
use percent_encoding::utf8_percent_encode;
45
use schemars::JsonSchema;
56
use serde::{Deserialize, Serialize};
67

8+
use super::PATH_SEGMENT;
79
use crate::tool_result;
810
use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate};
911
use crate::sanitize::{Sanitize, sanitize as sanitize_text};
@@ -257,8 +259,8 @@ impl Executor for CreateWorkItemResult {
257259
let url = format!(
258260
"{}/{}/_apis/wit/workitems/${}?api-version=7.0",
259261
org_url.trim_end_matches('/'),
260-
project,
261-
config.work_item_type,
262+
utf8_percent_encode(project, PATH_SEGMENT),
263+
utf8_percent_encode(&config.work_item_type, PATH_SEGMENT),
262264
);
263265
debug!("API URL: {}", url);
264266

0 commit comments

Comments
 (0)