diff --git a/AGENTS.md b/AGENTS.md index 0488f842..67bf0d0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Alongside the correctly generated pipeline yaml, an agent file is generated from │ ├── mod.rs │ ├── create_pr.rs │ ├── create_work_item.rs -│ ├── edit_wiki_page.rs +│ ├── update_wiki_page.rs │ ├── memory.rs │ ├── missing_data.rs │ ├── missing_tool.rs @@ -894,7 +894,7 @@ safe-outputs: - Content validation: text files are scanned for `##vso[` commands - Extension filtering: can restrict to specific file types -#### edit-wiki-page +#### update-wiki-page Updates the content of an existing Azure DevOps wiki page. The wiki page must already exist; this tool edits its content but does not create new pages. **Agent parameters:** @@ -905,7 +905,7 @@ Updates the content of an existing Azure DevOps wiki page. The wiki page must al **Configuration options (front matter):** ```yaml safe-outputs: - edit-wiki-page: + update-wiki-page: wiki-name: "MyProject.wiki" # Required — wiki identifier (name or GUID) wiki-project: "OtherProject" # Optional — ADO project that owns the wiki; defaults to current pipeline project path-prefix: "/agent-output" # Optional — prepended to the agent-supplied path (restricts write scope) diff --git a/src/compile/common.rs b/src/compile/common.rs index f0b7b85e..6ab76f24 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -553,7 +553,7 @@ pub fn generate_executor_ado_env(write_service_connection: Option<&str>) -> Stri } /// Safe-output names that require write access to ADO. -const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item", "create-wiki-page", "edit-wiki-page"]; +const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item", "create-wiki-page", "update-wiki-page"]; /// Validate that write-requiring safe-outputs have a write service connection configured. pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> { diff --git a/src/execute.rs b/src/execute.rs index 5041b8e3..ac72de88 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -10,7 +10,7 @@ use std::path::Path; use crate::ndjson::{self, SAFE_OUTPUT_FILENAME}; use crate::tools::{ - CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, EditWikiPageResult, + CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, UpdateWikiPageResult, ExecutionContext, ExecutionResult, Executor, }; @@ -182,12 +182,12 @@ pub async fn execute_safe_output( ); output.execute_sanitized(ctx).await? } - "edit-wiki-page" => { - debug!("Parsing edit-wiki-page payload"); - let mut output: EditWikiPageResult = serde_json::from_value(entry.clone()) - .map_err(|e| anyhow::anyhow!("Failed to parse edit-wiki-page: {}", e))?; + "update-wiki-page" => { + debug!("Parsing update-wiki-page payload"); + let mut output: UpdateWikiPageResult = serde_json::from_value(entry.clone()) + .map_err(|e| anyhow::anyhow!("Failed to parse update-wiki-page: {}", e))?; debug!( - "edit-wiki-page: path='{}', content length={}", + "update-wiki-page: path='{}', content length={}", output.path, output.content.len() ); @@ -396,9 +396,9 @@ mod tests { } #[tokio::test] - async fn test_execute_malformed_edit_wiki_page_returns_err() { + async fn test_execute_malformed_update_wiki_page_returns_err() { // Missing required fields (path and content) - let entry = serde_json::json!({"name": "edit-wiki-page"}); + let entry = serde_json::json!({"name": "update-wiki-page"}); let ctx = ExecutionContext::default(); let result = execute_safe_output(&entry, &ctx).await; @@ -406,9 +406,9 @@ mod tests { } #[tokio::test] - async fn test_execute_edit_wiki_page_missing_context() { + async fn test_execute_update_wiki_page_missing_context() { let entry = serde_json::json!({ - "name": "edit-wiki-page", + "name": "update-wiki-page", "path": "/Overview", "content": "This is some valid wiki content." }); diff --git a/src/mcp.rs b/src/mcp.rs index cbc67301..40f700e3 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -13,7 +13,7 @@ use crate::sanitize::sanitize as sanitize_text; use crate::tools::{ CreatePrParams, CreatePrResult, CreateWikiPageParams, CreateWikiPageResult, CreateWorkItemParams, CreateWorkItemResult, - EditWikiPageParams, EditWikiPageResult, MissingDataParams, MissingDataResult, + UpdateWikiPageParams, UpdateWikiPageResult, MissingDataParams, MissingDataResult, MissingToolParams, MissingToolResult, NoopParams, NoopResult, ToolResult, anyhow_to_mcp_error, }; @@ -406,17 +406,17 @@ impl SafeOutputs { } #[tool( - name = "edit-wiki-page", + name = "update-wiki-page", description = "Create or update an Azure DevOps wiki page with the provided markdown content. \ The page path (e.g. '/Overview/Architecture') and the wiki to write to are determined by the \ pipeline configuration. Use this to publish findings, summaries, documentation, or any other \ structured output that should be visible in the project wiki." )] - async fn edit_wiki_page( + async fn update_wiki_page( &self, - params: Parameters, + params: Parameters, ) -> Result { - info!("Tool called: edit-wiki-page - '{}'", params.0.path); + info!("Tool called: update-wiki-page - '{}'", params.0.path); debug!("Content length: {} chars", params.0.content.len()); // Sanitize untrusted agent-provided text fields (IS-01). @@ -431,7 +431,7 @@ structured output that should be visible in the project wiki." sanitized.content = sanitize_text(&sanitized.content); sanitized.comment = sanitized.comment.map(|c| sanitize_text(&c)); - let result: EditWikiPageResult = sanitized.try_into()?; + let result: UpdateWikiPageResult = sanitized.try_into()?; let _ = self.write_safe_output_file(&result).await; info!("Wiki page edit queued: '{}'", result.path); @@ -445,7 +445,7 @@ structured output that should be visible in the project wiki." name = "create-wiki-page", description = "Create a new Azure DevOps wiki page with the provided markdown content. \ The page path (e.g. '/Overview/NewPage') and the wiki to write to are determined by the \ -pipeline configuration. The page must not already exist — use edit-wiki-page to update \ +pipeline configuration. The page must not already exist — use update-wiki-page to update \ existing pages. Use this to publish findings, summaries, documentation, or any other \ structured output that should be visible in the project wiki." )] diff --git a/src/tools/create_wiki_page.rs b/src/tools/create_wiki_page.rs index 77cdb200..984cc79f 100644 --- a/src/tools/create_wiki_page.rs +++ b/src/tools/create_wiki_page.rs @@ -269,7 +269,7 @@ impl Executor for CreateWikiPageResult { if page_exists { return Ok(ExecutionResult::failure(format!( "Wiki page '{effective_path}' already exists. \ - Use the edit-wiki-page safe output to update existing pages." + Use the update-wiki-page safe output to update existing pages." ))); } @@ -310,7 +310,7 @@ impl Executor for CreateWikiPageResult { if put_status.as_u16() == 412 { return Ok(ExecutionResult::failure(format!( "Wiki page '{effective_path}' already exists (conflict detected during creation). \ - Use the edit-wiki-page safe output to update existing pages." + Use the update-wiki-page safe output to update existing pages." ))); } @@ -798,7 +798,7 @@ wiki-name: "MyProject.wiki" let result = if page_exists { Some(ExecutionResult::failure(format!( "Wiki page '{effective_path}' already exists. \ - Use the edit-wiki-page safe output to update existing pages." + Use the update-wiki-page safe output to update existing pages." ))) } else { None @@ -818,7 +818,7 @@ wiki-name: "MyProject.wiki" let result: Option = if page_exists { Some(ExecutionResult::failure(format!( "Wiki page '{effective_path}' already exists. \ - Use the edit-wiki-page safe output to update existing pages." + Use the update-wiki-page safe output to update existing pages." ))) } else { None diff --git a/src/tools/mod.rs b/src/tools/mod.rs index b9e23e34..273c3ee9 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -12,7 +12,7 @@ pub(crate) const PATH_SEGMENT: &AsciiSet = &CONTROLS.add(b'#').add(b'?').add(b'/ mod create_pr; mod create_wiki_page; mod create_work_item; -mod edit_wiki_page; +mod update_wiki_page; pub mod memory; mod missing_data; mod missing_tool; @@ -22,7 +22,7 @@ mod result; pub use create_pr::*; pub use create_wiki_page::*; pub use create_work_item::*; -pub use edit_wiki_page::*; +pub use update_wiki_page::*; pub use missing_data::*; pub use missing_tool::*; pub use noop::*; diff --git a/src/tools/edit_wiki_page.rs b/src/tools/update_wiki_page.rs similarity index 90% rename from src/tools/edit_wiki_page.rs rename to src/tools/update_wiki_page.rs index e23f60e8..41d9c3a0 100644 --- a/src/tools/edit_wiki_page.rs +++ b/src/tools/update_wiki_page.rs @@ -1,4 +1,4 @@ -//! Edit wiki page safe output tool +//! Update wiki page safe output tool use anyhow::{Context, ensure}; use log::{debug, info}; @@ -13,7 +13,7 @@ use crate::tools::{ExecutionContext, ExecutionResult, Executor, Validate}; /// Parameters for editing a wiki page (agent-provided) #[derive(Deserialize, JsonSchema)] -pub struct EditWikiPageParams { +pub struct UpdateWikiPageParams { /// Path of the wiki page to update, e.g. "/Overview/Architecture". /// The page must already exist. The path must not contain "..". pub path: String, @@ -26,7 +26,7 @@ pub struct EditWikiPageParams { pub comment: Option, } -impl Validate for EditWikiPageParams { +impl Validate for UpdateWikiPageParams { fn validate(&self) -> anyhow::Result<()> { ensure!(!self.path.trim().is_empty(), "path must not be empty"); ensure!( @@ -51,17 +51,17 @@ impl Validate for EditWikiPageParams { } tool_result! { - name = "edit-wiki-page", - params = EditWikiPageParams, + name = "update-wiki-page", + params = UpdateWikiPageParams, /// Result of editing a wiki page - pub struct EditWikiPageResult { + pub struct UpdateWikiPageResult { path: String, content: String, comment: Option, } } -impl Sanitize for EditWikiPageResult { +impl Sanitize for UpdateWikiPageResult { fn sanitize_fields(&mut self) { // Path is a structural identifier — sanitize lightly (remove control chars) // but do not escape HTML or neutralize patterns that are valid in wiki paths. @@ -79,11 +79,11 @@ impl Sanitize for EditWikiPageResult { // Front-matter configuration // ============================================================================ -/// Configuration for the `edit-wiki-page` tool (specified in front matter). +/// Configuration for the `update-wiki-page` tool (specified in front matter). /// /// ```yaml /// safe-outputs: -/// edit-wiki-page: +/// update-wiki-page: /// wiki-name: "MyProject.wiki" /// wiki-project: "OtherProject" # optional, defaults to current project /// path-prefix: "/agent-output" @@ -91,7 +91,7 @@ impl Sanitize for EditWikiPageResult { /// comment: "Updated by agent" /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EditWikiPageConfig { +pub struct UpdateWikiPageConfig { /// Wiki identifier (name or ID). Required — execution fails without this. /// /// For a project wiki, the identifier is typically `.wiki`. @@ -121,7 +121,7 @@ pub struct EditWikiPageConfig { pub comment: Option, } -impl Default for EditWikiPageConfig { +impl Default for UpdateWikiPageConfig { fn default() -> Self { Self { wiki_name: None, @@ -168,7 +168,7 @@ fn apply_title_prefix(path: &str, prefix: &str) -> String { // ============================================================================ #[async_trait::async_trait] -impl Executor for EditWikiPageResult { +impl Executor for UpdateWikiPageResult { async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { info!("Editing wiki page: '{}'", self.path); debug!("Content length: {} chars", self.content.len()); @@ -186,12 +186,12 @@ impl Executor for EditWikiPageResult { .as_ref() .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; - let config: EditWikiPageConfig = ctx.get_tool_config("edit-wiki-page"); + let config: UpdateWikiPageConfig = ctx.get_tool_config("update-wiki-page"); let wiki_name = config .wiki_name .as_deref() - .context("wiki-name must be configured in safe-outputs.edit-wiki-page.wiki-name")?; + .context("wiki-name must be configured in safe-outputs.update-wiki-page.wiki-name")?; // Use the wiki-project override if present, otherwise use the pipeline project. let project = config @@ -356,13 +356,13 @@ mod tests { #[test] fn test_result_has_correct_name() { - assert_eq!(EditWikiPageResult::NAME, "edit-wiki-page"); + assert_eq!(UpdateWikiPageResult::NAME, "update-wiki-page"); } #[test] fn test_params_deserializes() { let json = r#"{"path": "/Overview", "content": "Hello, wiki!"}"#; - let params: EditWikiPageParams = serde_json::from_str(json).unwrap(); + let params: UpdateWikiPageParams = serde_json::from_str(json).unwrap(); assert_eq!(params.path, "/Overview"); assert_eq!(params.content, "Hello, wiki!"); assert!(params.comment.is_none()); @@ -371,19 +371,19 @@ mod tests { #[test] fn test_params_with_comment_deserializes() { let json = r#"{"path": "/Overview", "content": "Hello, wiki!", "comment": "initial"}"#; - let params: EditWikiPageParams = serde_json::from_str(json).unwrap(); + let params: UpdateWikiPageParams = serde_json::from_str(json).unwrap(); assert_eq!(params.comment, Some("initial".to_string())); } #[test] fn test_params_converts_to_result() { - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "/My Page".to_string(), content: "Some wiki content here".to_string(), comment: None, }; - let result: EditWikiPageResult = params.try_into().unwrap(); - assert_eq!(result.name, "edit-wiki-page"); + let result: UpdateWikiPageResult = params.try_into().unwrap(); + assert_eq!(result.name, "update-wiki-page"); assert_eq!(result.path, "/My Page"); assert_eq!(result.content, "Some wiki content here"); assert!(result.comment.is_none()); @@ -391,14 +391,14 @@ mod tests { #[test] fn test_result_serializes_correctly() { - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "/Folder/Page".to_string(), content: "Sufficient content here".to_string(), comment: Some("initial commit".to_string()), }; - let result: EditWikiPageResult = params.try_into().unwrap(); + let result: UpdateWikiPageResult = params.try_into().unwrap(); let json = serde_json::to_string(&result).unwrap(); - assert!(json.contains(r#""name":"edit-wiki-page""#)); + assert!(json.contains(r#""name":"update-wiki-page""#)); assert!(json.contains(r#""path":"/Folder/Page""#)); } @@ -406,67 +406,67 @@ mod tests { #[test] fn test_validation_rejects_empty_path() { - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "".to_string(), content: "Some content here".to_string(), comment: None, }; - let result: Result = params.try_into(); + let result: Result = params.try_into(); assert!(result.is_err()); } #[test] fn test_validation_rejects_path_traversal() { - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "/valid/../../../etc/passwd".to_string(), content: "Some content here".to_string(), comment: None, }; - let result: Result = params.try_into(); + let result: Result = params.try_into(); assert!(result.is_err()); } #[test] fn test_validation_rejects_null_bytes_in_path() { - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "/Page\x00Name".to_string(), content: "Some valid content here.".to_string(), comment: None, }; - let result: Result = params.try_into(); + let result: Result = params.try_into(); assert!(result.is_err()); } #[test] fn test_validation_rejects_short_content() { - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "/Page".to_string(), content: "short".to_string(), comment: None, }; - let result: Result = params.try_into(); + let result: Result = params.try_into(); assert!(result.is_err()); } #[test] fn test_validation_rejects_empty_content() { - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "/Page".to_string(), content: "".to_string(), comment: None, }; - let result: Result = params.try_into(); + let result: Result = params.try_into(); assert!(result.is_err()); } #[test] fn test_validation_accepts_valid_params() { - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "/Folder/My Page".to_string(), content: "This is sufficient content.".to_string(), comment: None, }; - let result: Result = params.try_into(); + let result: Result = params.try_into(); assert!(result.is_ok()); } @@ -474,7 +474,7 @@ mod tests { #[test] fn test_config_defaults() { - let config = EditWikiPageConfig::default(); + let config = UpdateWikiPageConfig::default(); assert!(config.wiki_name.is_none()); assert!(config.wiki_project.is_none()); assert!(config.path_prefix.is_none()); @@ -491,7 +491,7 @@ path-prefix: "/agent-output" title-prefix: "[Agent] " comment: "Updated by agent" "#; - let config: EditWikiPageConfig = serde_yaml::from_str(yaml).unwrap(); + let config: UpdateWikiPageConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(config.wiki_name.as_deref(), Some("MyProject.wiki")); assert_eq!(config.wiki_project.as_deref(), Some("OtherProject")); assert_eq!(config.path_prefix.as_deref(), Some("/agent-output")); @@ -504,7 +504,7 @@ comment: "Updated by agent" let yaml = r#" wiki-name: "MyProject.wiki" "#; - let config: EditWikiPageConfig = serde_yaml::from_str(yaml).unwrap(); + let config: UpdateWikiPageConfig = serde_yaml::from_str(yaml).unwrap(); assert_eq!(config.wiki_name.as_deref(), Some("MyProject.wiki")); assert!(config.path_prefix.is_none()); } @@ -557,24 +557,24 @@ wiki-name: "MyProject.wiki" // Use \x01 (SOH) — passes validate() but must be stripped by sanitize_fields(). // Null bytes are rejected earlier at the validate() stage (see // test_validation_rejects_null_bytes_in_path). - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "/Page\x01Name".to_string(), content: "Some valid content here.".to_string(), comment: None, }; - let mut result: EditWikiPageResult = params.try_into().unwrap(); + let mut result: UpdateWikiPageResult = params.try_into().unwrap(); result.sanitize_fields(); assert!(!result.path.contains('\x01')); } #[test] fn test_sanitize_preserves_path_structure() { - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "/Folder/My Page".to_string(), content: "Some valid content here.".to_string(), comment: None, }; - let mut result: EditWikiPageResult = params.try_into().unwrap(); + let mut result: UpdateWikiPageResult = params.try_into().unwrap(); result.sanitize_fields(); assert_eq!(result.path, "/Folder/My Page"); } @@ -583,12 +583,12 @@ wiki-name: "MyProject.wiki" #[tokio::test] async fn test_execute_missing_wiki_name_returns_err() { - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "/Page".to_string(), content: "Some valid content here.".to_string(), comment: None, }; - let mut result: EditWikiPageResult = params.try_into().unwrap(); + let mut result: UpdateWikiPageResult = params.try_into().unwrap(); result.sanitize_fields(); let ctx = crate::tools::ExecutionContext { @@ -612,12 +612,12 @@ wiki-name: "MyProject.wiki" #[tokio::test] async fn test_execute_missing_org_url_returns_err() { - let params = EditWikiPageParams { + let params = UpdateWikiPageParams { path: "/Page".to_string(), content: "Some valid content here.".to_string(), comment: None, }; - let mut result: EditWikiPageResult = params.try_into().unwrap(); + let mut result: UpdateWikiPageResult = params.try_into().unwrap(); result.sanitize_fields(); let ctx = crate::tools::ExecutionContext { @@ -641,14 +641,14 @@ wiki-name: "MyProject.wiki" let mut tool_configs = HashMap::new(); tool_configs.insert( - "edit-wiki-page".to_string(), + "update-wiki-page".to_string(), serde_json::json!({ "wiki-name": "Proj.wiki" }), ); // Bypass validation by building the result directly (simulates a // tampered safe-output file that somehow smuggled ".." through). - let result = EditWikiPageResult { - name: "edit-wiki-page".to_string(), + let result = UpdateWikiPageResult { + name: "update-wiki-page".to_string(), path: "/valid/../etc/passwd".to_string(), content: "pwned".to_string(), comment: None, @@ -678,15 +678,15 @@ wiki-name: "MyProject.wiki" let mut tool_configs = HashMap::new(); tool_configs.insert( - "edit-wiki-page".to_string(), + "update-wiki-page".to_string(), serde_json::json!({ "wiki-name": "Proj.wiki", "path-prefix": "/agent-output" }), ); - let result = EditWikiPageResult { - name: "edit-wiki-page".to_string(), + let result = UpdateWikiPageResult { + name: "update-wiki-page".to_string(), path: "/root-level-page".to_string(), content: "Some content here".to_string(), comment: None, @@ -718,13 +718,13 @@ wiki-name: "MyProject.wiki" let mut tool_configs = HashMap::new(); tool_configs.insert( - "edit-wiki-page".to_string(), + "update-wiki-page".to_string(), serde_json::json!({ "wiki-name": "Proj.wiki" }), ); // Build result directly to bypass Stage-1 validation - let result = EditWikiPageResult { - name: "edit-wiki-page".to_string(), + let result = UpdateWikiPageResult { + name: "update-wiki-page".to_string(), path: "/Agent/Page".to_string(), content: "some content here".to_string(), comment: None, diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index f0dd5649..801dc8e6 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -857,9 +857,9 @@ fn test_1es_compiled_output_no_unreplaced_markers() { let _ = fs::remove_dir_all(&temp_dir); } -/// Test that edit-wiki-page requires a write service connection +/// Test that update-wiki-page requires a write service connection #[test] -fn test_edit_wiki_page_requires_write_sc() { +fn test_update_wiki_page_requires_write_sc() { let temp_dir = std::env::temp_dir().join(format!( "agentic-pipeline-wiki-fail-{}", std::process::id() @@ -871,7 +871,7 @@ fn test_edit_wiki_page_requires_write_sc() { name: "Wiki Agent" description: "Agent that edits wiki pages but has no write SC" safe-outputs: - edit-wiki-page: + update-wiki-page: wiki-name: "MyProject.wiki" path-prefix: "/agent-output" --- @@ -896,7 +896,7 @@ Update the wiki. assert!( !output.status.success(), - "Compiler should fail when edit-wiki-page lacks a write SC" + "Compiler should fail when update-wiki-page lacks a write SC" ); let stderr = String::from_utf8_lossy(&output.stderr); @@ -908,9 +908,9 @@ Update the wiki. let _ = fs::remove_dir_all(&temp_dir); } -/// Test that edit-wiki-page compiles successfully when a write SC is present +/// Test that update-wiki-page compiles successfully when a write SC is present #[test] -fn test_edit_wiki_page_compiles_with_write_sc() { +fn test_update_wiki_page_compiles_with_write_sc() { let temp_dir = std::env::temp_dir().join(format!( "agentic-pipeline-wiki-pass-{}", std::process::id() @@ -924,7 +924,7 @@ description: "Agent that edits wiki pages with write SC" permissions: write: my-write-sc safe-outputs: - edit-wiki-page: + update-wiki-page: wiki-name: "MyProject.wiki" path-prefix: "/agent-output" title-prefix: "[Agent] "