Skip to content

Commit b66037d

Browse files
feat(safe-outputs): add unified upload-build-artifact safe output (#380)
* feat(safe-outputs): add unified upload-build-artifact safe output Combines the concepts from #363 (upload-artifact, current build) and #373 (upload-build-artifact, arbitrary build) into a single tool that handles both use cases via the ADO build attachments REST API. Key design: - build_id is Optional — omit to target the current pipeline run (resolved from BUILD_BUILDID in Stage 3) - REST API (PUT .../builds/{buildId}/attachments/{type}/{name}) for all cases, replacing the ##vso[artifact.upload] logging command approach - allowed-build-ids check is skipped when targeting the current build (implicitly trusted) - Full Stage 1 → Stage 3 file staging contract: validate, canonicalize, bounds-check, stage file with extension preservation, re-canonicalize and re-verify in Stage 3 Supersedes #363 and #373. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: move file read after dry-run guard and use Path::extension() for allowlist check - Move std::fs::read after the dry_run early return to avoid reading up to 50 MB into memory only to discard it in dry-run mode. - Switch extension allowlist from suffix matching on the full path to Path::extension() comparison, preventing false matches like 'catalog' matching allowed-extensions: ['log']. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: restore cache-memory heading in safe-outputs.md The heading was accidentally dropped when the upload-build-artifact section was inserted, leaving the cache-memory paragraph as a stray note under the wrong section. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use async file read, add staged-file integrity check, clarify comments - Switch std::fs::read to tokio::fs::read().await to avoid blocking the tokio runtime for files up to 50 MB. - Add a warn!() when the live staged-file size differs from the size recorded at Stage 1, catching potential file tampering between stages. - Add comments on is_valid_version() call sites noting intentional reuse for artifact-name and attachment-type charset validation. - Add comment on staged filename explaining max-length reasoning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make staged-file size mismatch a hard failure A size mismatch between Stage 1 and Stage 3 indicates the staged file was modified between stages — fail hard rather than warning and uploading potentially tampered content. Adds a test for the integrity check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add attachment-type dot guard, Stage 1 size cap, executor tests - Add starts_with('.') guard to attachment-type validation, matching the artifact_name check for consistency. - Add defense-in-depth file size cap at Stage 1 (MCP) using the default 50 MB limit to prevent staging-disk exhaustion before Stage 3 enforces the operator's configured limit. - Add three missing executor tests: extension allowlist rejection, artifact-name allowlist rejection, and name-prefix application. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add SHA-256 cross-stage integrity verification Add SHA-256 hash recording at Stage 1 and verification at Stage 3 for both file-staging safe outputs: - upload-build-artifact: staged_sha256 field recorded after copying the file, verified before uploading to ADO. Catches same-size file swaps that the size-only check would miss. - create-pull-request: patch_sha256 field recorded after writing the patch, verified before applying. Field is Optional for backward compatibility with existing NDJSON records. Also adds sha256_hex() helper in upload_build_artifact module, reused by both tools and the MCP handlers. A separate issue (#381) tracks adding the full staging pattern (including SHA-256) to upload-workitem-attachment, which currently reads directly from source_directory without staging. Also fixes: name-prefix length cap (50 chars), attachment-type leading-dot guard, comment typo in MCP handler. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make patch_sha256 a required field on CreatePrResult Pipelines are versioned with their YAML — no need for backward compatibility with old NDJSON records. Making the hash optional created a security hole where a missing hash would skip verification entirely. Now patch_sha256 is a required String and verification is unconditional. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: move sha256_hex to src/hash.rs, fix prefix slice panic - Move sha256_hex() from upload_build_artifact.rs to a dedicated src/hash.rs module, removing the coupling between create_pr, mcp.rs and the upload_build_artifact module. - Fix potential panic in name-prefix error path: &prefix[..20] can panic on multi-byte UTF-8 boundaries. Use prefix.chars().take(20).collect() instead. - Revert upload_build_artifact module visibility back to private. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: add SHA-256 mismatch tests, fix TOCTOU, extract is_valid_artifact_name - Add non-dry-run SHA-256 mismatch tests for both upload-build-artifact and create-pull-request (the mismatch path fires before any HTTP call, so no credentials are needed). - Fix TOCTOU in MCP handler: hash the source bytes before writing to the staging directory, instead of re-reading the staged copy. - Extract is_valid_artifact_name() in validate.rs to decouple artifact name validation from version string validation, preventing silent breakage if is_valid_version is ever tightened. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: import DEFAULT_MAX_FILE_SIZE in MCP handler Replace the floating STAGE1_MAX_FILE_SIZE copy with a direct import of DEFAULT_MAX_FILE_SIZE from upload_build_artifact, ensuring the Stage 1 cap stays in sync if the default is ever changed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1790b9b commit b66037d

11 files changed

Lines changed: 1584 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 62 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ rand = "0.10.1"
3434
base64 = "0.22.1"
3535
glob-match = "0.2.1"
3636
similar = "3.1.0"
37+
sha2 = "0.11.0"
3738

3839
[dev-dependencies]
3940
reqwest = { version = "0.12", features = ["blocking"] }

docs/safe-outputs.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,44 @@ 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
424+
(`PUT /_apis/build/builds/{buildId}/attachments/{type}/{name}`).
425+
426+
**Omit `build_id` to target the current pipeline run** — the executor resolves
427+
the build ID from the `BUILD_BUILDID` environment variable automatically. When
428+
`build_id` is provided, the file is attached to that specific build — useful for
429+
posthumously decorating a finished build with a generated report, screenshot, or
430+
log bundle.
431+
432+
The tool stages the file during Stage 1 (MCP) by copying it into the
433+
safe-outputs directory; Stage 3 reads the staged copy and uploads it via the REST
434+
API.
435+
436+
**Agent parameters:**
437+
- `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 `.`)
439+
- `file_path` - Relative path to the file in the workspace (no directory traversal)
440+
441+
**Configuration options (front matter):**
442+
```yaml
443+
safe-outputs:
444+
upload-build-artifact:
445+
max-file-size: 52428800 # Maximum file size in bytes (default: 50 MB)
446+
allowed-extensions: [] # Optional — restrict file types (e.g., [".png", ".pdf", ".log"])
447+
allowed-artifact-names: [] # Optional — restrict names (suffix `*` = prefix match)
448+
allowed-build-ids: [] # Optional — restrict target builds (skipped when targeting current build)
449+
name-prefix: "" # Optional — prepended to the agent-supplied artifact name
450+
attachment-type: "agent-artifact" # Optional — {type} segment in the attachments URL (default: "agent-artifact")
451+
max: 3 # Maximum per run (default: 3)
452+
```
453+
454+
**Notes:**
455+
- Single-file only; directory uploads are not supported.
456+
- 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.
458+
421459
### cache-memory (moved to `tools:`)
422460
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.
423461

src/execute.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ use crate::safeoutputs::{
1717
ExecutionContext, ExecutionResult, Executor, LinkWorkItemsResult, MissingDataResult,
1818
MissingToolResult, NoopResult, QueueBuildResult, ReplyToPrCommentResult,
1919
ReportIncompleteResult, ResolvePrThreadResult, SubmitPrReviewResult, ToolResult,
20-
UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadWorkitemAttachmentResult,
20+
UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildArtifactResult,
21+
UploadWorkitemAttachmentResult,
2122
};
2223

2324
// Re-export memory types for use by main.rs
@@ -91,6 +92,7 @@ pub async fn execute_safe_outputs(
9192
AddBuildTagResult,
9293
CreateBranchResult,
9394
UpdatePrResult,
95+
UploadBuildArtifactResult,
9496
UploadWorkitemAttachmentResult,
9597
SubmitPrReviewResult,
9698
ReplyToPrCommentResult,
@@ -273,6 +275,7 @@ pub async fn execute_safe_output(
273275
"add-build-tag" => AddBuildTagResult,
274276
"create-branch" => CreateBranchResult,
275277
"update-pr" => UpdatePrResult,
278+
"upload-build-artifact" => UploadBuildArtifactResult,
276279
"upload-workitem-attachment" => UploadWorkitemAttachmentResult,
277280
"submit-pr-review" => SubmitPrReviewResult,
278281
"reply-to-pr-review-comment" => ReplyToPrCommentResult,

src/hash.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
//! Cryptographic hash utilities shared across the crate.
2+
//!
3+
//! Used by safe-output tools to record and verify file integrity between
4+
//! Stage 1 (MCP, in-sandbox) and Stage 3 (executor, outside sandbox).
5+
6+
use sha2::{Digest, Sha256};
7+
8+
/// Compute the SHA-256 hex digest of a byte slice.
9+
pub(crate) fn sha256_hex(data: &[u8]) -> String {
10+
let hash = Sha256::digest(data);
11+
hash.iter().map(|b| format!("{:02x}", b)).collect()
12+
}
13+
14+
#[cfg(test)]
15+
mod tests {
16+
use super::*;
17+
18+
#[test]
19+
fn test_sha256_empty() {
20+
// SHA-256 of empty input is well-known.
21+
assert_eq!(
22+
sha256_hex(b""),
23+
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
24+
);
25+
}
26+
27+
#[test]
28+
fn test_sha256_hello() {
29+
assert_eq!(
30+
sha256_hex(b"hello"),
31+
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
32+
);
33+
}
34+
}

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod ecosystem_domains;
77
mod engine;
88
mod execute;
99
mod fuzzy_schedule;
10+
mod hash;
1011
mod init;
1112
mod logging;
1213
mod mcp;

0 commit comments

Comments
 (0)