Skip to content

Commit d3aad31

Browse files
feat(safe-outputs): rename upload-build-artifact to upload-build-attachment and add upload-pipeline-artifact (#404)
* feat(safe-outputs): rename upload-build-artifact to upload-build-attachment and add upload-pipeline-artifact - Rename upload-build-artifact → upload-build-attachment to match ADO terminology (the tool uses the build attachments REST API, not the deprecated build artifacts API) - Add central canonical_safe_output_name() alias layer for backward compatibility: old front-matter keys, NDJSON entries, and config are normalized to the new name - Implement new upload-pipeline-artifact safe output that publishes files as pipeline artifacts visible in the ADO Artifacts tab (3-step REST: create container → upload file → associate artifact with build) - Add ado_project_id field to ExecutionContext (from SYSTEM_TEAMPROJECTID) - Update docs/safe-outputs.md with renamed tool, new tool, and attachment-type clarification Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(safe-outputs): remove backward-compat alias, improve tool descriptions - Remove canonical_safe_output_name() alias layer — no backward compatibility for the old upload-build-artifact name - Fix stale upload-build-artifact references in mcp.rs staged filenames/comments - Improve MCP tool descriptions: clearly state that build attachments are NOT visible in ADO UI and recommend upload-pipeline-artifact for user-visible files - Add upload-build-attachment and upload-pipeline-artifact to AGENTS.md architecture tree and README.md safe-outputs table - Remove deprecation notice from docs/safe-outputs.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(safe-outputs): fix swapped format args and TOCTOU file_size in MCP handlers - Fix swapped effective_build_id/final_name in upload-pipeline-artifact success message (the info!() log was correct but the returned ExecutionResult message had them reversed) - Use source_bytes.len() instead of metadata.len() for recorded file_size in both upload-build-attachment and upload-pipeline-artifact MCP handlers, closing a TOCTOU window where the source file could change between stat and read Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(safe-outputs): add project scope to container creation request Include the project GUID in the container creation POST body so the container is scoped to the project, matching the scope query param used in the subsequent file upload step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 991621a commit d3aad31

10 files changed

Lines changed: 1163 additions & 104 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ Every compiled pipeline runs as three sequential jobs:
9696
│ │ ├── update_pr.rs
9797
│ │ ├── update_wiki_page.rs
9898
│ │ ├── update_work_item.rs
99+
│ │ ├── upload_build_attachment.rs
100+
│ │ ├── upload_pipeline_artifact.rs
99101
│ │ └── upload_workitem_attachment.rs
100102
│ ├── runtimes/ # Runtime environment implementations (one dir per runtime)
101103
│ │ ├── mod.rs # Module entry point

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,8 @@ actions, and the executor processes them after threat analysis.
389389
| `create-git-tag` | Creates a git tag on a repository ref |
390390
| `create-branch` | Creates a new branch from an existing ref |
391391
| `add-build-tag` | Adds a tag to an ADO build |
392+
| `upload-build-attachment` | Attaches a file to a build (accessible via REST API or custom extension only) |
393+
| `upload-pipeline-artifact` | Publishes a file as a pipeline artifact visible in the ADO Artifacts tab |
392394
| `upload-workitem-attachment` | Uploads a workspace file as an attachment to a work item |
393395
| `report-incomplete` | Reports that a task could not be completed |
394396
| `noop` | Reports no action was needed |

docs/safe-outputs.md

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -418,11 +418,18 @@ safe-outputs:
418418
max: 1 # Maximum per run (default: 1)
419419
```
420420

421-
### upload-build-artifact
422-
Attaches a workspace file to an Azure DevOps build as a build attachment via the
423-
ADO build attachments REST API
421+
### upload-build-attachment
422+
423+
Attaches a workspace file to an Azure DevOps build as a **build attachment** via
424+
the ADO build attachments REST API
424425
(`PUT /_apis/build/builds/{buildId}/attachments/{type}/{name}`).
425426

427+
> **Important:** Build attachments are **not visible** in the standard Azure
428+
> DevOps build summary UI. They are only accessible via the REST API or through
429+
> a custom Azure DevOps extension that registers a tab matching the
430+
> `attachment-type` value. For artifacts that should appear in the **Artifacts
431+
> tab**, use [`upload-pipeline-artifact`](#upload-pipeline-artifact) instead.
432+
426433
**Omit `build_id` to target the current pipeline run** — the executor resolves
427434
the build ID from the `BUILD_BUILDID` environment variable automatically. When
428435
`build_id` is provided, the file is attached to that specific build — useful for
@@ -435,13 +442,13 @@ API.
435442

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

441448
**Configuration options (front matter):**
442449
```yaml
443450
safe-outputs:
444-
upload-build-artifact:
451+
upload-build-attachment:
445452
max-file-size: 52428800 # Maximum file size in bytes (default: 50 MB)
446453
allowed-extensions: [] # Optional — restrict file types (e.g., [".png", ".pdf", ".log"])
447454
allowed-artifact-names: [] # Optional — restrict names (suffix `*` = prefix match)
@@ -454,7 +461,53 @@ safe-outputs:
454461
**Notes:**
455462
- Single-file only; directory uploads are not supported.
456463
- When `build_id` is omitted and `allowed-build-ids` is configured, the allow-list check is skipped — the current build is implicitly trusted.
457-
- The default `attachment-type` is `agent-artifact` so executor contributions are visually distinguishable from the build's own artifacts.
464+
465+
**About `attachment-type`:** This is the `{type}` segment in the ADO build
466+
attachments URL (`PUT .../attachments/{type}/{name}`). It acts as a category
467+
label. Azure DevOps extensions can register to display attachments of a specific
468+
type — for example, the built-in code coverage extension displays attachments
469+
with type `CodeCoverageSummary`. The default `agent-artifact` is a custom type;
470+
without a matching ADO extension installed, attachments with this type are only
471+
accessible via the REST API. Change this only if you have a custom extension
472+
that displays attachments of a specific type. Most users should use
473+
[`upload-pipeline-artifact`](#upload-pipeline-artifact) for user-visible
474+
artifacts instead.
475+
476+
### upload-pipeline-artifact
477+
478+
Publishes a workspace file as an Azure DevOps **pipeline artifact** that appears
479+
in the **Artifacts tab** of the build summary page. Uses the ADO build artifacts
480+
REST API (container creation + file upload + artifact association).
481+
482+
**Omit `build_id` to target the current pipeline run** — the executor resolves
483+
the build ID from the `BUILD_BUILDID` environment variable automatically. When
484+
`build_id` is provided, the artifact is published to that specific build.
485+
486+
The tool stages the file during Stage 1 (MCP) by copying it into the
487+
safe-outputs directory; Stage 3 reads the staged copy and executes the three-step
488+
REST API flow to create the artifact.
489+
490+
**Agent parameters:**
491+
- `build_id` *(optional)* - Target build ID. Omit to publish to the current pipeline run. Must be positive when specified.
492+
- `artifact_name` - Artifact name shown in the Artifacts tab (1–100 chars, alphanumeric / `-` / `_` / `.`, no leading `.`)
493+
- `file_path` - Relative path to the file in the workspace (no directory traversal)
494+
495+
**Configuration options (front matter):**
496+
```yaml
497+
safe-outputs:
498+
upload-pipeline-artifact:
499+
max-file-size: 52428800 # Maximum file size in bytes (default: 50 MB)
500+
allowed-extensions: [] # Optional — restrict file types (e.g., [".png", ".pdf", ".log"])
501+
allowed-artifact-names: [] # Optional — restrict names (suffix `*` = prefix match)
502+
allowed-build-ids: [] # Optional — restrict target builds (skipped when targeting current build)
503+
name-prefix: "" # Optional — prepended to the agent-supplied artifact name
504+
max: 3 # Maximum per run (default: 3)
505+
```
506+
507+
**Notes:**
508+
- Single-file only; directory uploads are not supported.
509+
- When `build_id` is omitted and `allowed-build-ids` is configured, the allow-list check is skipped — the current build is implicitly trusted.
510+
- Requires `SYSTEM_TEAMPROJECTID` to be available in the execution environment (set automatically by Azure DevOps).
458511

459512
### cache-memory (moved to `tools:`)
460513
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.

src/execute.rs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ use crate::safeoutputs::{
1818
ExecutionContext, ExecutionResult, Executor, LinkWorkItemsResult, MissingDataResult,
1919
MissingToolResult, NoopResult, QueueBuildResult, ReplyToPrCommentResult,
2020
ReportIncompleteResult, ResolvePrThreadResult, SubmitPrReviewResult, ToolResult,
21-
UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildArtifactResult,
22-
UploadWorkitemAttachmentResult,
21+
UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildAttachmentResult,
22+
UploadPipelineArtifactResult, UploadWorkitemAttachmentResult,
2323
};
2424

2525
// Re-export memory types for use by main.rs
@@ -93,7 +93,8 @@ pub async fn execute_safe_outputs(
9393
AddBuildTagResult,
9494
CreateBranchResult,
9595
UpdatePrResult,
96-
UploadBuildArtifactResult,
96+
UploadBuildAttachmentResult,
97+
UploadPipelineArtifactResult,
9798
UploadWorkitemAttachmentResult,
9899
SubmitPrReviewResult,
99100
ReplyToPrCommentResult,
@@ -348,7 +349,8 @@ async fn dispatch_resource_tools(
348349
"create-git-tag" => CreateGitTagResult,
349350
"add-build-tag" => AddBuildTagResult,
350351
"create-branch" => CreateBranchResult,
351-
"upload-build-artifact" => UploadBuildArtifactResult,
352+
"upload-build-attachment" => UploadBuildAttachmentResult,
353+
"upload-pipeline-artifact" => UploadPipelineArtifactResult,
352354
})
353355
}
354356

src/mcp.rs

Lines changed: 154 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ use crate::safeoutputs::{
2727
QueueBuildResult, SubmitPrReviewParams, SubmitPrReviewResult, ToolResult,
2828
UpdatePrParams, UpdatePrResult,
2929
UpdateWorkItemParams, UpdateWorkItemResult,
30-
UploadBuildArtifactParams, UploadBuildArtifactResult, DEFAULT_MAX_FILE_SIZE,
30+
UploadBuildAttachmentParams, UploadBuildAttachmentResult, DEFAULT_MAX_FILE_SIZE,
31+
UploadPipelineArtifactParams, UploadPipelineArtifactResult, PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE,
3132
UploadWorkitemAttachmentParams, UploadWorkitemAttachmentResult,
3233
anyhow_to_mcp_error,
3334
};
@@ -990,21 +991,22 @@ uploaded and linked during safe output processing. File size and type restrictio
990991
}
991992

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

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

1041-
// Reject directories — upload-build-artifact is single-file only.
1043+
// Reject directories — upload-build-attachment is single-file only.
10421044
let metadata = tokio::fs::metadata(&canonical).await.map_err(|e| {
10431045
anyhow_to_mcp_error(anyhow::anyhow!("Failed to stat '{}': {}", params.0.file_path, e))
10441046
})?;
10451047
if metadata.is_dir() {
10461048
return Err(anyhow_to_mcp_error(anyhow::anyhow!(
1047-
"File '{}' is a directory; upload-build-artifact only supports single files",
1049+
"File '{}' is a directory; upload-build-attachment only supports single files",
10481050
params.0.file_path
10491051
)));
10501052
}
1051-
let file_size = metadata.len();
1053+
let metadata_size = metadata.len();
10521054

10531055
// Defense-in-depth: reject files exceeding the default max size at
10541056
// Stage 1 to prevent a misbehaving agent from filling the staging
10551057
// disk before Stage 3 gets a chance to enforce the operator's limit.
1056-
if file_size > DEFAULT_MAX_FILE_SIZE {
1058+
if metadata_size > DEFAULT_MAX_FILE_SIZE {
10571059
return Err(anyhow_to_mcp_error(anyhow::anyhow!(
10581060
"File '{}' is {} bytes, exceeding the maximum staging size of {} bytes",
10591061
params.0.file_path,
1060-
file_size,
1062+
metadata_size,
10611063
DEFAULT_MAX_FILE_SIZE
10621064
)));
10631065
}
@@ -1082,13 +1084,13 @@ artifact-name and build-id restrictions may apply per the workflow's safe-output
10821084
// max length (~140 chars) is well within filesystem limits.
10831085
let staged_filename = if extension.is_empty() {
10841086
format!(
1085-
"upload-build-artifact-{}-{}",
1087+
"upload-build-attachment-{}-{}",
10861088
params.0.artifact_name,
10871089
generate_short_id()
10881090
)
10891091
} else {
10901092
format!(
1091-
"upload-build-artifact-{}-{}.{}",
1093+
"upload-build-attachment-{}-{}.{}",
10921094
params.0.artifact_name,
10931095
generate_short_id(),
10941096
extension
@@ -1106,6 +1108,10 @@ artifact-name and build-id restrictions may apply per the workflow's safe-output
11061108
))
11071109
})?;
11081110
let staged_sha256 = crate::hash::sha256_hex(&source_bytes);
1111+
// Use the actual byte count rather than the earlier metadata.len() so
1112+
// that the recorded size matches the staged content exactly, closing
1113+
// a TOCTOU window if the source file changes between stat and read.
1114+
let file_size = source_bytes.len() as u64;
11091115

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

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

1146+
#[tool(
1147+
name = "upload-pipeline-artifact",
1148+
description = "Publish a workspace file as an Azure DevOps pipeline artifact that appears \
1149+
in the Artifacts tab of the build summary page — visible to all users viewing the build. Use \
1150+
this tool when you want users to be able to find and download the file from the ADO UI. \
1151+
Omit `build_id` to target the current pipeline run. When `build_id` is provided, the artifact \
1152+
is published to that specific build. File size, extension, artifact-name and build-id \
1153+
restrictions may apply per the workflow's safe-outputs config."
1154+
)]
1155+
async fn upload_pipeline_artifact(
1156+
&self,
1157+
params: Parameters<UploadPipelineArtifactParams>,
1158+
) -> Result<CallToolResult, McpError> {
1159+
info!(
1160+
"Tool called: upload-pipeline-artifact - artifact '{}' file '{}' build {:?}",
1161+
params.0.artifact_name, params.0.file_path, params.0.build_id
1162+
);
1163+
1164+
crate::safeoutputs::Validate::validate(&params.0).map_err(anyhow_to_mcp_error)?;
1165+
1166+
let resolved = self.bounding_directory.join(&params.0.file_path);
1167+
let canonical = resolved.canonicalize().map_err(|e| {
1168+
anyhow_to_mcp_error(anyhow::anyhow!(
1169+
"File '{}' could not be located inside the workspace: {}",
1170+
params.0.file_path,
1171+
e
1172+
))
1173+
})?;
1174+
let canonical_root = self.bounding_directory.canonicalize().map_err(|e| {
1175+
anyhow_to_mcp_error(anyhow::anyhow!(
1176+
"Failed to canonicalize bounding directory: {}",
1177+
e
1178+
))
1179+
})?;
1180+
if !canonical.starts_with(&canonical_root) {
1181+
return Err(anyhow_to_mcp_error(anyhow::anyhow!(
1182+
"File '{}' resolves outside the workspace (symlink escape)",
1183+
params.0.file_path
1184+
)));
1185+
}
1186+
1187+
let metadata = tokio::fs::metadata(&canonical).await.map_err(|e| {
1188+
anyhow_to_mcp_error(anyhow::anyhow!("Failed to stat '{}': {}", params.0.file_path, e))
1189+
})?;
1190+
if metadata.is_dir() {
1191+
return Err(anyhow_to_mcp_error(anyhow::anyhow!(
1192+
"File '{}' is a directory; upload-pipeline-artifact only supports single files",
1193+
params.0.file_path
1194+
)));
1195+
}
1196+
let metadata_size = metadata.len();
1197+
1198+
if metadata_size > PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE {
1199+
return Err(anyhow_to_mcp_error(anyhow::anyhow!(
1200+
"File '{}' is {} bytes, exceeding the maximum staging size of {} bytes",
1201+
params.0.file_path,
1202+
metadata_size,
1203+
PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE
1204+
)));
1205+
}
1206+
1207+
let extension = std::path::Path::new(&params.0.file_path)
1208+
.extension()
1209+
.and_then(|s| s.to_str())
1210+
.map(|s| {
1211+
s.chars()
1212+
.filter(|c| c.is_ascii_alphanumeric())
1213+
.take(16)
1214+
.collect::<String>()
1215+
})
1216+
.unwrap_or_default();
1217+
let staged_filename = if extension.is_empty() {
1218+
format!(
1219+
"upload-pipeline-artifact-{}-{}",
1220+
params.0.artifact_name,
1221+
generate_short_id()
1222+
)
1223+
} else {
1224+
format!(
1225+
"upload-pipeline-artifact-{}-{}.{}",
1226+
params.0.artifact_name,
1227+
generate_short_id(),
1228+
extension
1229+
)
1230+
};
1231+
1232+
let source_bytes = tokio::fs::read(&canonical).await.map_err(|e| {
1233+
anyhow_to_mcp_error(anyhow::anyhow!(
1234+
"Failed to read source file '{}': {}",
1235+
params.0.file_path,
1236+
e
1237+
))
1238+
})?;
1239+
let staged_sha256 = crate::hash::sha256_hex(&source_bytes);
1240+
// Use the actual byte count rather than the earlier metadata.len() so
1241+
// that the recorded size matches the staged content exactly, closing
1242+
// a TOCTOU window if the source file changes between stat and read.
1243+
let file_size = source_bytes.len() as u64;
1244+
1245+
let staged_path = self.output_directory.join(&staged_filename);
1246+
tokio::fs::write(&staged_path, &source_bytes).await.map_err(|e| {
1247+
anyhow_to_mcp_error(anyhow::anyhow!(
1248+
"Failed to stage file '{}' into safe-outputs directory: {}",
1249+
params.0.file_path,
1250+
e
1251+
))
1252+
})?;
1253+
1254+
let result = UploadPipelineArtifactResult::new(
1255+
params.0.build_id,
1256+
params.0.artifact_name.clone(),
1257+
params.0.file_path.clone(),
1258+
staged_filename.clone(),
1259+
file_size,
1260+
staged_sha256,
1261+
);
1262+
self.write_safe_output_file(&result).await
1263+
.map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)))?;
1264+
1265+
let build_desc = match params.0.build_id {
1266+
Some(id) => format!("build #{}", id),
1267+
None => "the current build".to_string(),
1268+
};
1269+
Ok(CallToolResult::success(vec![Content::text(format!(
1270+
"Pipeline artifact '{}' queued from file '{}' ({} bytes) for {}. The artifact will appear in the Artifacts tab after safe output processing.",
1271+
result.artifact_name, result.file_path, file_size, build_desc
1272+
))]))
1273+
}
1274+
11401275
#[tool(
11411276
name = "submit-pr-review",
11421277
description = "Submit a pull request review with a decision (approve, request-changes, \

src/safeoutputs/create_pr.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2444,6 +2444,7 @@ new file mode 100755
24442444
ado_org_url: Some("https://dev.azure.com/test".to_string()),
24452445
ado_organization: Some("test".to_string()),
24462446
ado_project: Some("TestProject".to_string()),
2447+
ado_project_id: None,
24472448
access_token: Some("fake-token".to_string()),
24482449
source_directory: dir.path().to_path_buf(),
24492450
working_directory: dir.path().to_path_buf(),

0 commit comments

Comments
 (0)