Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,40 @@ safe-outputs:
max: 1 # Maximum per run (default: 1)
```

### upload-build-artifact
Attaches a workspace file to an existing Azure DevOps build (typically one that has **already finished**) using the [build attachments REST API](https://learn.microsoft.com/en-us/rest/api/azure/devops/build/attachments/) (`PUT /_apis/build/builds/{buildId}/attachments/{type}/{name}`). Use this when an agent needs to decorate a previously-finished build run with a generated report, screenshot, or log bundle. Unlike `upload-artifact` (which targets the *current* run via a `##vso[artifact.upload]` logging command), `upload-build-artifact` can target *any* build the executor's token has access to.

**Agent parameters:**
- `build_id` - The ID of the build to attach the file to (required, must be positive)
- `artifact_name` - Name to attach the file under (required; 1-100 chars; alphanumerics, `-`, `_`, `.`; must not start with `.`)
- `file_path` - Relative path to the file in the workspace (required; no directory traversal, no absolute paths, no `.git` segments)

**Configuration options (front matter):**
```yaml
safe-outputs:
upload-build-artifact:
max-file-size: 52428800 # Maximum file size in bytes (default: 50 MB)
allowed-extensions: [] # Optional — restrict file types (e.g., [".png", ".pdf", ".log"])
allowed-artifact-names: [] # Optional — allow-list of artifact names; entries ending with `*` match by prefix
allowed-build-ids: [] # Optional — allow-list of build IDs the agent may attach to
name-prefix: "agent-" # Optional — prefix prepended to the agent-supplied artifact name
attachment-type: "agent-artifact" # Optional — value used for the `{type}` segment of the attachments URL (default: "agent-artifact")
max: 3 # Maximum per run (default: 3)
```

**Validation performed at Stage 1 (MCP / sandbox):**
- `file_path` is resolved against the agent's workspace, canonicalized, and rejected if it escapes via symlinks.
- Directories are rejected — only single files are supported.
- The accepted file is **copied** into the safe-outputs working directory under a generated unique name. This staged copy is what Stage 3 reads — the agent's sandbox workspace is no longer accessible at execution time, mirroring how `create-pull-request` stages its patch file.

**Validation performed at Stage 3:**
- The staged file is re-canonicalized inside the safe-outputs working directory (defense in depth).
- When `allowed-build-ids` is non-empty, the requested `build_id` must match an entry.
- Files larger than `max-file-size` are rejected.
- When `allowed-extensions` is non-empty, the original `file_path` extension must match (case-insensitive).
- When `allowed-artifact-names` is non-empty, the resolved artifact name (after `name-prefix`) must match an allow-list entry.
- The configured `attachment-type` is re-validated against the same charset rules as an artifact name before being used in the URL.

### cache-memory (moved to `tools:`)
Memory is now configured as a first-class tool under `tools: cache-memory:` instead of `safe-outputs: memory:`. See the [Cache Memory section](./tools.md#cache-memory-cache-memory) in `docs/tools.md` for details.

Expand Down
10 changes: 6 additions & 4 deletions src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@ use std::path::Path;

use crate::ndjson::{self, SAFE_OUTPUT_FILENAME};
use crate::safeoutputs::{
AddBuildTagResult, AddPrCommentResult, CreateBranchResult, CreateGitTagResult,
CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, CommentOnWorkItemResult,
ExecutionContext, ExecutionResult, Executor, LinkWorkItemsResult, MissingDataResult,
MissingToolResult, NoopResult, QueueBuildResult, ReplyToPrCommentResult,
AddBuildTagResult, AddPrCommentResult, UploadBuildArtifactResult, CreateBranchResult,
CreateGitTagResult, CreatePrResult, CreateWikiPageResult, CreateWorkItemResult,
CommentOnWorkItemResult, ExecutionContext, ExecutionResult, Executor, LinkWorkItemsResult,
MissingDataResult, MissingToolResult, NoopResult, QueueBuildResult, ReplyToPrCommentResult,
ReportIncompleteResult, ResolvePrThreadResult, SubmitPrReviewResult, ToolResult,
UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadAttachmentResult,
};
Expand Down Expand Up @@ -92,6 +92,7 @@ pub async fn execute_safe_outputs(
CreateBranchResult,
UpdatePrResult,
UploadAttachmentResult,
UploadBuildArtifactResult,
SubmitPrReviewResult,
ReplyToPrCommentResult,
ResolvePrThreadResult,
Expand Down Expand Up @@ -263,6 +264,7 @@ pub async fn execute_safe_output(
"create-branch" => CreateBranchResult,
"update-pr" => UpdatePrResult,
"upload-attachment" => UploadAttachmentResult,
"upload-build-artifact" => UploadBuildArtifactResult,
"submit-pr-review" => SubmitPrReviewResult,
"reply-to-pr-review-comment" => ReplyToPrCommentResult,
"resolve-pr-thread" => ResolvePrThreadResult,
Expand Down
113 changes: 113 additions & 0 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::sanitize::{SanitizeContent, sanitize as sanitize_text};
use crate::safeoutputs::{
AddBuildTagParams, AddBuildTagResult,
AddPrCommentParams, AddPrCommentResult,
UploadBuildArtifactParams, UploadBuildArtifactResult,
CommentOnWorkItemParams, CommentOnWorkItemResult,
CreateBranchParams, CreateBranchResult,
CreateGitTagParams, CreateGitTagResult,
Expand Down Expand Up @@ -983,6 +984,118 @@ uploaded and linked during safe output processing. File size and type restrictio
))]))
}

#[tool(
name = "upload-build-artifact",
description = "Attach a workspace file to an existing Azure DevOps build (typically one \
that has already finished) as a build attachment. The file will be staged now and uploaded to \
the build via the ADO build attachments REST API during safe output processing. File size, \
extension, artifact-name and build-id restrictions may apply per the workflow's safe-outputs \
config."
)]
async fn upload_build_artifact(
&self,
params: Parameters<UploadBuildArtifactParams>,
) -> Result<CallToolResult, McpError> {
info!(
"Tool called: upload-build-artifact - artifact '{}' file '{}' build #{}",
params.0.artifact_name, params.0.file_path, params.0.build_id
);

// Validate the agent-supplied params (build id, artifact name charset,
// path traversal / absolute / null bytes, etc.) before touching the
// filesystem.
crate::safeoutputs::Validate::validate(&params.0).map_err(anyhow_to_mcp_error)?;

// Resolve the agent-supplied file path against the bounding directory
// (the agent's workspace root inside the sandbox) and verify it
// canonicalises to a location *inside* that directory — guarding
// against symlink escapes.
let resolved = self.bounding_directory.join(&params.0.file_path);
let canonical = resolved.canonicalize().map_err(|e| {
anyhow_to_mcp_error(anyhow::anyhow!(
"File '{}' could not be located inside the workspace: {}",
params.0.file_path,
e
))
})?;
let canonical_root = self.bounding_directory.canonicalize().map_err(|e| {
anyhow_to_mcp_error(anyhow::anyhow!(
"Failed to canonicalize bounding directory: {}",
e
))
})?;
if !canonical.starts_with(&canonical_root) {
return Err(anyhow_to_mcp_error(anyhow::anyhow!(
"File '{}' resolves outside the workspace (symlink escape)",
params.0.file_path
)));
}

// Reject directories — upload-build-artifact is single-file only.
let metadata = tokio::fs::metadata(&canonical).await.map_err(|e| {
anyhow_to_mcp_error(anyhow::anyhow!("Failed to stat '{}': {}", params.0.file_path, e))
})?;
if metadata.is_dir() {
return Err(anyhow_to_mcp_error(anyhow::anyhow!(
"File '{}' is a directory; upload-build-artifact only supports single files",
params.0.file_path
)));
}
let file_size = metadata.len();

// Generate a unique staged filename and copy the file into the
// safe-outputs directory. Stage 3 reads it back from there because
// the agent's sandbox workspace is no longer accessible by then.
// The staged name preserves the original extension and embeds a
// short random suffix to avoid collisions across multiple calls.
let extension = std::path::Path::new(&params.0.file_path)
.extension()
.and_then(|s| s.to_str())
.map(|s| {
s.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(16)
.collect::<String>()
})
.unwrap_or_default();
let staged_filename = if extension.is_empty() {
format!(
"upload-build-artifact-{}-{}",
params.0.artifact_name,
generate_short_id()
)
} else {
format!(
"upload-build-artifact-{}-{}.{}",
params.0.artifact_name,
generate_short_id(),
extension
)
};
let staged_path = self.output_directory.join(&staged_filename);
tokio::fs::copy(&canonical, &staged_path).await.map_err(|e| {
anyhow_to_mcp_error(anyhow::anyhow!(
"Failed to stage file '{}' into safe-outputs directory: {}",
params.0.file_path,
e
))
})?;

let result = UploadBuildArtifactResult::new(
params.0.build_id,
params.0.artifact_name.clone(),
params.0.file_path.clone(),
staged_filename.clone(),
file_size,
);
self.write_safe_output_file(&result).await
.map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Artifact '{}' queued from file '{}' ({} bytes) for build #{}. The file will be uploaded to the build during safe output processing.",
result.artifact_name, result.file_path, file_size, result.build_id
))]))
}

#[tool(
name = "submit-pr-review",
description = "Submit a pull request review with a decision (approve, request-changes, \
Expand Down
5 changes: 5 additions & 0 deletions src/safeoutputs/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pub const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = tool_names![
CreateBranchResult,
UpdatePrResult,
UploadAttachmentResult,
UploadBuildArtifactResult,
SubmitPrReviewResult,
ReplyToPrCommentResult,
ResolvePrThreadResult,
Expand Down Expand Up @@ -76,6 +77,7 @@ pub const ALL_KNOWN_SAFE_OUTPUTS: &[&str] = all_safe_output_names![
CreateBranchResult,
UpdatePrResult,
UploadAttachmentResult,
UploadBuildArtifactResult,
SubmitPrReviewResult,
ReplyToPrCommentResult,
ResolvePrThreadResult,
Expand Down Expand Up @@ -243,6 +245,7 @@ pub(crate) fn validate_git_ref_name(name: &str, label: &str) -> anyhow::Result<(

mod add_build_tag;
mod add_pr_comment;
mod upload_build_artifact;
mod comment_on_work_item;
mod create_branch;
mod create_git_tag;
Expand All @@ -266,6 +269,7 @@ mod upload_attachment;

pub use add_build_tag::*;
pub use add_pr_comment::*;
pub use upload_build_artifact::*;
pub use comment_on_work_item::*;
pub use create_branch::*;
pub use create_git_tag::*;
Expand Down Expand Up @@ -345,6 +349,7 @@ mod tests {
assert!(CreateBranchResult::REQUIRES_WRITE);
assert!(UpdatePrResult::REQUIRES_WRITE);
assert!(UploadAttachmentResult::REQUIRES_WRITE);
assert!(UploadBuildArtifactResult::REQUIRES_WRITE);
assert!(SubmitPrReviewResult::REQUIRES_WRITE);
assert!(ReplyToPrCommentResult::REQUIRES_WRITE);
assert!(ResolvePrThreadResult::REQUIRES_WRITE);
Expand Down
Loading
Loading