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
32 changes: 32 additions & 0 deletions docs/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,38 @@ safe-outputs:
max: 1 # Maximum per run (default: 1)
```

### upload-artifact
Publishes a workspace file as a pipeline artifact attached to the current Azure DevOps build run. The Stage 3 executor emits an `##vso[artifact.upload]` logging command for each accepted file; the build agent then asynchronously uploads the file under the chosen artifact name and the result appears in the run's **Artifacts** list. This is the closest ADO equivalent to `actions/upload-artifact` in GitHub Actions.

Use `upload-artifact` for build outputs, generated reports, screenshots, logs, and other files that should be available to download from the run summary. For files that should be linked from a specific work item instead, use [`upload-attachment`](#upload-attachment).

**Agent parameters:**
- `artifact_name` - Name to publish the artifact under in the run (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-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
name-prefix: "agent-" # Optional — prefix prepended to the agent-supplied artifact name
max: 1 # Maximum per run (default: 1)
```

**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 the patch file.

**Validation performed at Stage 3:**
- The staged file is re-canonicalized inside the safe-outputs working directory (defense in depth).
- 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.
- Text files containing `##vso[` or `##[` sequences are rejected to prevent pipeline-logging-command injection. Binary files skip this check (the agent does not parse logging commands out of binary content).

### 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
5 changes: 4 additions & 1 deletion src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ use crate::safeoutputs::{
ExecutionContext, ExecutionResult, Executor, LinkWorkItemsResult, MissingDataResult,
MissingToolResult, NoopResult, QueueBuildResult, ReplyToPrCommentResult,
ReportIncompleteResult, ResolvePrThreadResult, SubmitPrReviewResult, ToolResult,
UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadAttachmentResult,
UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadArtifactResult,
UploadAttachmentResult,
};

// Re-export memory types for use by main.rs
Expand Down Expand Up @@ -92,6 +93,7 @@ pub async fn execute_safe_outputs(
CreateBranchResult,
UpdatePrResult,
UploadAttachmentResult,
UploadArtifactResult,
SubmitPrReviewResult,
ReplyToPrCommentResult,
ResolvePrThreadResult,
Expand Down Expand Up @@ -263,6 +265,7 @@ pub async fn execute_safe_output(
"create-branch" => CreateBranchResult,
"update-pr" => UpdatePrResult,
"upload-attachment" => UploadAttachmentResult,
"upload-artifact" => UploadArtifactResult,
"submit-pr-review" => SubmitPrReviewResult,
"reply-to-pr-review-comment" => ReplyToPrCommentResult,
"resolve-pr-thread" => ResolvePrThreadResult,
Expand Down
111 changes: 111 additions & 0 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use crate::safeoutputs::{
UpdatePrParams, UpdatePrResult,
UpdateWorkItemParams, UpdateWorkItemResult,
UploadAttachmentParams, UploadAttachmentResult,
UploadArtifactParams, UploadArtifactResult,
anyhow_to_mcp_error,
};

Expand Down Expand Up @@ -983,6 +984,116 @@ uploaded and linked during safe output processing. File size and type restrictio
))]))
}

#[tool(
name = "upload-artifact",
description = "Publish a workspace file as a pipeline artifact attached to the current \
Azure DevOps build run. The file will be uploaded during safe output processing. File size, \
extension, and artifact-name restrictions may apply per the workflow's safe-outputs config."
)]
async fn upload_artifact(
&self,
params: Parameters<UploadArtifactParams>,
) -> Result<CallToolResult, McpError> {
info!(
"Tool called: upload-artifact - artifact '{}' file '{}'",
params.0.artifact_name, params.0.file_path
);

// Validate the agent-supplied params (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 — the upload-artifact tool 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-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 (used by Stage 3
// for the extension-allow-list check) and embeds a short random
// suffix to avoid collisions across multiple upload 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-artifact-{}-{}",
params.0.artifact_name,
generate_short_id()
)
} else {
format!(
"upload-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 = UploadArtifactResult::new(
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). The file will be published during safe output processing.",
result.artifact_name, result.file_path, file_size
))]))
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot right now this will do nothing, in the end. We need to save the agent's provided file to the output_directory, and then retrieve it in the executor phase. This is a similar idea to create-pull-request tool.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restructured in fb479e5 to mirror the create-pull-request staging pattern:

Stage 1 (MCP, mcp.rs) now:

  1. Validates UploadArtifactParams.
  2. Resolves file_path against bounding_directory (workspace root) and canonicalizes to guard against symlink escape.
  3. Rejects directories.
  4. Copies the file into self.output_directory under a generated unique name upload-artifact-<artifact_name>-<short-id>.<ext> (extension preserved for the Stage 3 allow-list check).
  5. Constructs the result via UploadArtifactResult::new(artifact_name, file_path, staged_file, file_size) and writes the NDJSON record.

Stage 3 (upload_artifact.rs::execute_impl) now reads from ctx.working_directory.join(&self.staged_file) and re-canonicalizes (defense in depth), then runs the existing size / extension / name-allow-list / name-prefix / ##vso[-injection / abs-path-charset checks before emitting ##vso[artifact.upload artifactname=...]<staged-abs-path>.

The result struct gained staged_file and file_size fields and an explicit ::new() constructor (an internal UploadArtifactResultFields mirror keeps the tool_result! macro happy). Added 3 new executor tests (success, missing-file, ##vso[-rejection) on top of the existing 16 — all 1034 tests + clippy pass.


#[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,
UploadArtifactResult,
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,
UploadArtifactResult,
SubmitPrReviewResult,
ReplyToPrCommentResult,
ResolvePrThreadResult,
Expand Down Expand Up @@ -262,6 +264,7 @@ mod submit_pr_review;
mod update_pr;
mod update_wiki_page;
mod update_work_item;
mod upload_artifact;
mod upload_attachment;

pub use add_build_tag::*;
Expand All @@ -287,6 +290,7 @@ pub use submit_pr_review::*;
pub use update_pr::*;
pub use update_wiki_page::*;
pub use update_work_item::*;
pub use upload_artifact::*;
pub use upload_attachment::*;

#[cfg(test)]
Expand Down Expand Up @@ -345,6 +349,7 @@ mod tests {
assert!(CreateBranchResult::REQUIRES_WRITE);
assert!(UpdatePrResult::REQUIRES_WRITE);
assert!(UploadAttachmentResult::REQUIRES_WRITE);
assert!(UploadArtifactResult::REQUIRES_WRITE);
assert!(SubmitPrReviewResult::REQUIRES_WRITE);
assert!(ReplyToPrCommentResult::REQUIRES_WRITE);
assert!(ResolvePrThreadResult::REQUIRES_WRITE);
Expand Down
Loading