Skip to content
Merged
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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ Every compiled pipeline runs as three sequential jobs:
│ │ ├── update_pr.rs
│ │ ├── update_wiki_page.rs
│ │ ├── update_work_item.rs
│ │ ├── upload_build_attachment.rs
│ │ ├── upload_pipeline_artifact.rs
│ │ └── upload_workitem_attachment.rs
│ ├── runtimes/ # Runtime environment implementations (one dir per runtime)
│ │ ├── mod.rs # Module entry point
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,8 @@ actions, and the executor processes them after threat analysis.
| `create-git-tag` | Creates a git tag on a repository ref |
| `create-branch` | Creates a new branch from an existing ref |
| `add-build-tag` | Adds a tag to an ADO build |
| `upload-build-attachment` | Attaches a file to a build (accessible via REST API or custom extension only) |
| `upload-pipeline-artifact` | Publishes a file as a pipeline artifact visible in the ADO Artifacts tab |
| `upload-workitem-attachment` | Uploads a workspace file as an attachment to a work item |
| `report-incomplete` | Reports that a task could not be completed |
| `noop` | Reports no action was needed |
Expand Down
65 changes: 59 additions & 6 deletions docs/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,11 +418,18 @@ safe-outputs:
max: 1 # Maximum per run (default: 1)
```

### upload-build-artifact
Attaches a workspace file to an Azure DevOps build as a build attachment via the
ADO build attachments REST API
### upload-build-attachment

Attaches a workspace file to an Azure DevOps build as a **build attachment** via
the ADO build attachments REST API
(`PUT /_apis/build/builds/{buildId}/attachments/{type}/{name}`).

> **Important:** Build attachments are **not visible** in the standard Azure
> DevOps build summary UI. They are only accessible via the REST API or through
> a custom Azure DevOps extension that registers a tab matching the
> `attachment-type` value. For artifacts that should appear in the **Artifacts
> tab**, use [`upload-pipeline-artifact`](#upload-pipeline-artifact) instead.

**Omit `build_id` to target the current pipeline run** — the executor resolves
the build ID from the `BUILD_BUILDID` environment variable automatically. When
`build_id` is provided, the file is attached to that specific build — useful for
Expand All @@ -435,13 +442,13 @@ API.

**Agent parameters:**
- `build_id` *(optional)* - Target build ID. Omit to attach to the current pipeline run. Must be positive when specified.
- `artifact_name` - Artifact name (1–100 chars, alphanumeric / `-` / `_` / `.`, no leading `.`)
- `artifact_name` - Attachment name (1–100 chars, alphanumeric / `-` / `_` / `.`, no leading `.`)
- `file_path` - Relative path to the file in the workspace (no directory traversal)

**Configuration options (front matter):**
```yaml
safe-outputs:
upload-build-artifact:
upload-build-attachment:
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 — restrict names (suffix `*` = prefix match)
Expand All @@ -454,7 +461,53 @@ safe-outputs:
**Notes:**
- Single-file only; directory uploads are not supported.
- When `build_id` is omitted and `allowed-build-ids` is configured, the allow-list check is skipped — the current build is implicitly trusted.
- The default `attachment-type` is `agent-artifact` so executor contributions are visually distinguishable from the build's own artifacts.

**About `attachment-type`:** This is the `{type}` segment in the ADO build
attachments URL (`PUT .../attachments/{type}/{name}`). It acts as a category
label. Azure DevOps extensions can register to display attachments of a specific
type — for example, the built-in code coverage extension displays attachments
with type `CodeCoverageSummary`. The default `agent-artifact` is a custom type;
without a matching ADO extension installed, attachments with this type are only
accessible via the REST API. Change this only if you have a custom extension
that displays attachments of a specific type. Most users should use
[`upload-pipeline-artifact`](#upload-pipeline-artifact) for user-visible
artifacts instead.

### upload-pipeline-artifact

Publishes a workspace file as an Azure DevOps **pipeline artifact** that appears
in the **Artifacts tab** of the build summary page. Uses the ADO build artifacts
REST API (container creation + file upload + artifact association).

**Omit `build_id` to target the current pipeline run** — the executor resolves
the build ID from the `BUILD_BUILDID` environment variable automatically. When
`build_id` is provided, the artifact is published to that specific build.

The tool stages the file during Stage 1 (MCP) by copying it into the
safe-outputs directory; Stage 3 reads the staged copy and executes the three-step
REST API flow to create the artifact.

**Agent parameters:**
- `build_id` *(optional)* - Target build ID. Omit to publish to the current pipeline run. Must be positive when specified.
- `artifact_name` - Artifact name shown in the Artifacts tab (1–100 chars, alphanumeric / `-` / `_` / `.`, no leading `.`)
- `file_path` - Relative path to the file in the workspace (no directory traversal)

**Configuration options (front matter):**
```yaml
safe-outputs:
upload-pipeline-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 — restrict names (suffix `*` = prefix match)
allowed-build-ids: [] # Optional — restrict target builds (skipped when targeting current build)
name-prefix: "" # Optional — prepended to the agent-supplied artifact name
max: 3 # Maximum per run (default: 3)
```

**Notes:**
- Single-file only; directory uploads are not supported.
- When `build_id` is omitted and `allowed-build-ids` is configured, the allow-list check is skipped — the current build is implicitly trusted.
- Requires `SYSTEM_TEAMPROJECTID` to be available in the execution environment (set automatically by Azure DevOps).

### 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 @@ -18,8 +18,8 @@ use crate::safeoutputs::{
ExecutionContext, ExecutionResult, Executor, LinkWorkItemsResult, MissingDataResult,
MissingToolResult, NoopResult, QueueBuildResult, ReplyToPrCommentResult,
ReportIncompleteResult, ResolvePrThreadResult, SubmitPrReviewResult, ToolResult,
UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildArtifactResult,
UploadWorkitemAttachmentResult,
UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildAttachmentResult,
UploadPipelineArtifactResult, UploadWorkitemAttachmentResult,
};

// Re-export memory types for use by main.rs
Expand Down Expand Up @@ -93,7 +93,8 @@ pub async fn execute_safe_outputs(
AddBuildTagResult,
CreateBranchResult,
UpdatePrResult,
UploadBuildArtifactResult,
UploadBuildAttachmentResult,
UploadPipelineArtifactResult,
UploadWorkitemAttachmentResult,
SubmitPrReviewResult,
ReplyToPrCommentResult,
Expand Down Expand Up @@ -346,7 +347,8 @@ async fn dispatch_resource_tools(
"create-git-tag" => CreateGitTagResult,
"add-build-tag" => AddBuildTagResult,
"create-branch" => CreateBranchResult,
"upload-build-artifact" => UploadBuildArtifactResult,
"upload-build-attachment" => UploadBuildAttachmentResult,
"upload-pipeline-artifact" => UploadPipelineArtifactResult,
})
}

Expand Down
173 changes: 154 additions & 19 deletions src/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ use crate::safeoutputs::{
QueueBuildResult, SubmitPrReviewParams, SubmitPrReviewResult, ToolResult,
UpdatePrParams, UpdatePrResult,
UpdateWorkItemParams, UpdateWorkItemResult,
UploadBuildArtifactParams, UploadBuildArtifactResult, DEFAULT_MAX_FILE_SIZE,
UploadBuildAttachmentParams, UploadBuildAttachmentResult, DEFAULT_MAX_FILE_SIZE,
UploadPipelineArtifactParams, UploadPipelineArtifactResult, PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE,
UploadWorkitemAttachmentParams, UploadWorkitemAttachmentResult,
anyhow_to_mcp_error,
};
Expand Down Expand Up @@ -990,21 +991,22 @@ uploaded and linked during safe output processing. File size and type restrictio
}

#[tool(
name = "upload-build-artifact",
description = "Attach a workspace file to an Azure DevOps build as a build attachment. \
Omit `build_id` to target the current pipeline run (the executor resolves it from the \
BUILD_BUILDID environment variable automatically). When `build_id` is provided, the file is \
name = "upload-build-attachment",
description = "Attach a workspace file to an Azure DevOps build as a build attachment via \
the ADO build attachments REST API. Build attachments are NOT visible in the standard ADO UI — \
they are only accessible via the REST API or a custom Azure DevOps extension. For files that \
should appear in the Artifacts tab, use upload-pipeline-artifact instead. \
Omit `build_id` to target the current pipeline run. When `build_id` is provided, the file is \
attached to that specific build — useful for posthumously decorating a finished build with a \
generated report, screenshot, or log bundle. The file will be staged now and uploaded 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."
generated report or log bundle. File size, extension, artifact-name and build-id restrictions \
may apply per the workflow's safe-outputs config."
)]
async fn upload_build_artifact(
async fn upload_build_attachment(
&self,
params: Parameters<UploadBuildArtifactParams>,
params: Parameters<UploadBuildAttachmentParams>,
) -> Result<CallToolResult, McpError> {
info!(
"Tool called: upload-build-artifact - artifact '{}' file '{}' build {:?}",
"Tool called: upload-build-attachment - artifact '{}' file '{}' build {:?}",
params.0.artifact_name, params.0.file_path, params.0.build_id
);

Expand Down Expand Up @@ -1038,26 +1040,26 @@ artifact-name and build-id restrictions may apply per the workflow's safe-output
)));
}

// Reject directories — upload-build-artifact is single-file only.
// Reject directories — upload-build-attachment 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",
"File '{}' is a directory; upload-build-attachment only supports single files",
params.0.file_path
)));
}
let file_size = metadata.len();
let metadata_size = metadata.len();

// Defense-in-depth: reject files exceeding the default max size at
// Stage 1 to prevent a misbehaving agent from filling the staging
// disk before Stage 3 gets a chance to enforce the operator's limit.
if file_size > DEFAULT_MAX_FILE_SIZE {
if metadata_size > DEFAULT_MAX_FILE_SIZE {
return Err(anyhow_to_mcp_error(anyhow::anyhow!(
"File '{}' is {} bytes, exceeding the maximum staging size of {} bytes",
params.0.file_path,
file_size,
metadata_size,
DEFAULT_MAX_FILE_SIZE
)));
}
Expand All @@ -1082,13 +1084,13 @@ artifact-name and build-id restrictions may apply per the workflow's safe-output
// max length (~140 chars) is well within filesystem limits.
let staged_filename = if extension.is_empty() {
format!(
"upload-build-artifact-{}-{}",
"upload-build-attachment-{}-{}",
params.0.artifact_name,
generate_short_id()
)
} else {
format!(
"upload-build-artifact-{}-{}.{}",
"upload-build-attachment-{}-{}.{}",
params.0.artifact_name,
generate_short_id(),
extension
Expand All @@ -1106,6 +1108,10 @@ artifact-name and build-id restrictions may apply per the workflow's safe-output
))
})?;
let staged_sha256 = crate::hash::sha256_hex(&source_bytes);
// Use the actual byte count rather than the earlier metadata.len() so
// that the recorded size matches the staged content exactly, closing
// a TOCTOU window if the source file changes between stat and read.
let file_size = source_bytes.len() as u64;

let staged_path = self.output_directory.join(&staged_filename);
tokio::fs::write(&staged_path, &source_bytes).await.map_err(|e| {
Expand All @@ -1116,7 +1122,7 @@ artifact-name and build-id restrictions may apply per the workflow's safe-output
))
})?;

let result = UploadBuildArtifactResult::new(
let result = UploadBuildAttachmentResult::new(
params.0.build_id,
params.0.artifact_name.clone(),
params.0.file_path.clone(),
Expand All @@ -1137,6 +1143,135 @@ artifact-name and build-id restrictions may apply per the workflow's safe-output
))]))
}

#[tool(
name = "upload-pipeline-artifact",
description = "Publish a workspace file as an Azure DevOps pipeline artifact that appears \
in the Artifacts tab of the build summary page — visible to all users viewing the build. Use \
this tool when you want users to be able to find and download the file from the ADO UI. \
Omit `build_id` to target the current pipeline run. When `build_id` is provided, the artifact \
is published to that specific build. File size, extension, artifact-name and build-id \
restrictions may apply per the workflow's safe-outputs config."
)]
async fn upload_pipeline_artifact(
&self,
params: Parameters<UploadPipelineArtifactParams>,
) -> Result<CallToolResult, McpError> {
info!(
"Tool called: upload-pipeline-artifact - artifact '{}' file '{}' build {:?}",
params.0.artifact_name, params.0.file_path, params.0.build_id
);

crate::safeoutputs::Validate::validate(&params.0).map_err(anyhow_to_mcp_error)?;

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
)));
}

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-pipeline-artifact only supports single files",
params.0.file_path
)));
}
let metadata_size = metadata.len();

if metadata_size > PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE {
return Err(anyhow_to_mcp_error(anyhow::anyhow!(
"File '{}' is {} bytes, exceeding the maximum staging size of {} bytes",
params.0.file_path,
metadata_size,
PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE
)));
}

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-pipeline-artifact-{}-{}",
params.0.artifact_name,
generate_short_id()
)
} else {
format!(
"upload-pipeline-artifact-{}-{}.{}",
params.0.artifact_name,
generate_short_id(),
extension
)
};

let source_bytes = tokio::fs::read(&canonical).await.map_err(|e| {
anyhow_to_mcp_error(anyhow::anyhow!(
"Failed to read source file '{}': {}",
params.0.file_path,
e
))
})?;
let staged_sha256 = crate::hash::sha256_hex(&source_bytes);
// Use the actual byte count rather than the earlier metadata.len() so
// that the recorded size matches the staged content exactly, closing
// a TOCTOU window if the source file changes between stat and read.
let file_size = source_bytes.len() as u64;

let staged_path = self.output_directory.join(&staged_filename);
tokio::fs::write(&staged_path, &source_bytes).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 = UploadPipelineArtifactResult::new(
params.0.build_id,
params.0.artifact_name.clone(),
params.0.file_path.clone(),
staged_filename.clone(),
file_size,
staged_sha256,
);
self.write_safe_output_file(&result).await
.map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?;

let build_desc = match params.0.build_id {
Some(id) => format!("build #{}", id),
None => "the current build".to_string(),
};
Ok(CallToolResult::success(vec![Content::text(format!(
"Pipeline artifact '{}' queued from file '{}' ({} bytes) for {}. The artifact will appear in the Artifacts tab after safe output processing.",
result.artifact_name, result.file_path, file_size, build_desc
))]))
}

#[tool(
name = "submit-pr-review",
description = "Submit a pull request review with a decision (approve, request-changes, \
Expand Down
1 change: 1 addition & 0 deletions src/safeoutputs/create_pr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2444,6 +2444,7 @@ new file mode 100755
ado_org_url: Some("https://dev.azure.com/test".to_string()),
ado_organization: Some("test".to_string()),
ado_project: Some("TestProject".to_string()),
ado_project_id: None,
access_token: Some("fake-token".to_string()),
source_directory: dir.path().to_path_buf(),
working_directory: dir.path().to_path_buf(),
Expand Down
Loading
Loading