π΄ Red Team Security Audit
Audit focus: Category A β Input Sanitization & Injection (Stage 3 executor)
Severity: High
Findings
| # |
Vulnerability |
Severity |
File(s) |
Exploitable? |
| 1 |
repository alias not sanitized for ##vso[ in reply_to_pr_comment.rs |
High |
src/safeoutputs/reply_to_pr_comment.rs:62-65, 144-147, 160-163 |
Yes β triggered by any non-existent alias |
| 2 |
repository alias strips control chars only (not ##vso[) in add_pr_comment.rs |
High |
src/safeoutputs/add_pr_comment.rs:112-120, 238-241, 292-295 |
Yes β triggered by any non-existent alias |
| 3 |
repository alias not sanitized for ##vso[ in create_branch.rs |
High |
src/safeoutputs/create_branch.rs:92-95, 254-258 |
Yes β when allowed-repositories is configured |
| 4 |
repository alias strips control chars only (not ##vso[) in create_git_tag.rs |
High |
src/safeoutputs/create_git_tag.rs:97-112, 289-293 |
Yes β when allowed-repositories is configured |
Details
Root Cause
The SanitizeContent::sanitize_content_fields() implementations for these tools fully sanitize "content" text fields (agent-supplied prose via sanitize_text()) but incompletely sanitize structural identifier fields like repository. The repository field receives either:
- No sanitization (
reply_to_pr_comment.rs, create_branch.rs) β field is used raw
- Control-character stripping only (
add_pr_comment.rs line 116, create_git_tag.rs line 109-111) β strips \n/\r but ##vso[ contains no control characters
Stage 1 (MCP server AddPrCommentParams::validate(), ReplyToPrCommentParams::validate(), CreateBranchParams::validate()) does not validate the repository field for ADO pipeline command sequences.
These unsanitized values are embedded in ExecutionResult::failure() messages that are printed to stdout by src/execute.rs:217:
println!("[{}/{}] {} - {} - {}", i + 1, total, tool_name, symbol, result.message);
Azure DevOps processes ##vso[...] commands from all subprocess stdout output in a step, including from ado-aw execute.
Finding 1 & 2: add_pr_comment.rs and reply_to_pr_comment.rs
Attack vector: A Stage 1 agent calls either tool with:
{
"pull_request_id": 1,
"thread_id": 1,
"content": "legitimate content here for the PR",
"repository": "##vso[task.setvariable variable=AZURE_DEVOPS_EXT_PAT;issecret=false]stolen"
}
```
**Trigger path** (no special operator config required): `execute_impl()` at `reply_to_pr_comment.rs:157-163` always tries `lookup_allowed_repository()` for any non-"self" alias. Since the malicious alias won't exist in the checkout list, it returns `None` and emits:
```
[1/1] reply-to-pr-review-comment - β - Repository alias '##vso[task.setvariable variable=AZURE_DEVOPS_EXT_PAT;issecret=false]stolen' not found in allowed repositories
```
ADO runner interprets `##vso[task.setvariable variable=AZURE_DEVOPS_EXT_PAT;issecret=false]stolen` from stdout.
For `add_pr_comment.rs`, the same path exists at lines 292-295 (`lookup_allowed_repository` failure), plus an additional path at lines 238-241 triggered when `allowed_repositories` is configured.
#### Finding 3 & 4: `create_branch.rs` and `create_git_tag.rs`
**Attack vector**: A Stage 1 agent calls either tool with `repository: "##vso[task.complete result=SucceededWithIssues;done=true]task done"`.
**Trigger path**: `execute_impl()` at `create_branch.rs:254-258` returns an `ExecutionResult::failure` (bypassing the `neutralize_pipeline_commands` applied in the `Err` path) when `allowed_repositories` is configured and the alias doesn't match:
```
[1/1] create-branch - β - Repository '##vso[task.complete result=SucceededWithIssues;done=true]task done' is not in the allowed-repositories list: [self, main-repo]
This only fires when the operator has set safe-outputs.create-branch.allowed-repositories (or create-git-tag).
Impact
An agent (operating in Stage 1 AWF read-only sandbox) can inject arbitrary ADO VSO commands into the Stage 3 Execution job's stdout, which ADO processes:
##vso[task.setvariable variable=X;issecret=false]value β override or expose pipeline variables set by Stage 3 steps
##vso[task.complete result=Failed;done=true] β forcibly mark the Execution task as failed mid-run, potentially abandoning remaining safe outputs
##vso[task.complete result=SucceededWithIssues;done=true] β mask actual failures
##vso[task.logissue type=error]fake error β inject false error entries into the audit log
- Other ADO VSO commands available in the execution context
This violates the Stage 1 β Stage 3 security boundary: the agent can influence Stage 3 pipeline behavior beyond the safe-output NDJSON mechanism.
Proof of concept:
{"name":"reply-to-pr-review-comment","pull_request_id":1,"thread_id":1,"content":"legitimate content for reviewers","repository":"##vso[task.setvariable variable=ADO_PAT;issecret=false]exfiltrated","timestamp":"2026-05-09T00:00:00Z"}
The agent writes this to the NDJSON (Stage 1 Validate passes β no repository format check). Stage 3 prints the failure message to stdout. ADO processes the VSO command.
Suggested fix:
Apply sanitize_config() (which calls neutralize_pipeline_commands()) to self.repository in sanitize_content_fields() for all four tools:
// reply_to_pr_comment.rs
impl SanitizeContent for ReplyToPrCommentResult {
fn sanitize_content_fields(&mut self) {
self.content = sanitize_text(&self.content);
self.repository = self.repository.as_deref().map(sanitize_config).or(self.repository.clone());
// or more simply:
if let Some(ref r) = self.repository {
self.repository = Some(sanitize_config(r));
}
}
}
Additionally, add reject_pipeline_injection() in Stage 1 Validate::validate() for the repository field across all affected tools, consistent with how resolve_pr_thread.rs and submit_pr_review.rs already sanitize self.repository via sanitize_config() in their sanitize_content_fields().
Comparison β tools that already handle this correctly:
resolve_pr_thread.rs:91-94 β calls sanitize_config(repo) β
submit_pr_review.rs:95-98 β calls sanitize_config β
update_pr.rs:152-153 β calls sanitize_config β
Audit Coverage
| Category |
Status |
| A: Input Sanitization |
β
Scanned |
| B: Path Traversal |
β
Scanned (prior run) |
| C: Network Bypass |
β
Scanned (prior run) |
| D: Credential Exposure |
β
Scanned (prior run) |
| E: Logic Flaws |
β
Scanned (prior run) |
| F: Supply Chain |
β
Scanned (prior run) |
This issue was created by the automated red team security auditor.
Generated by Red Team Security Auditor Β· β 6.5M Β· β·
π΄ Red Team Security Audit
Audit focus: Category A β Input Sanitization & Injection (Stage 3 executor)
Severity: High
Findings
repositoryalias not sanitized for##vso[inreply_to_pr_comment.rssrc/safeoutputs/reply_to_pr_comment.rs:62-65, 144-147, 160-163repositoryalias strips control chars only (not##vso[) inadd_pr_comment.rssrc/safeoutputs/add_pr_comment.rs:112-120, 238-241, 292-295repositoryalias not sanitized for##vso[increate_branch.rssrc/safeoutputs/create_branch.rs:92-95, 254-258allowed-repositoriesis configuredrepositoryalias strips control chars only (not##vso[) increate_git_tag.rssrc/safeoutputs/create_git_tag.rs:97-112, 289-293allowed-repositoriesis configuredDetails
Root Cause
The
SanitizeContent::sanitize_content_fields()implementations for these tools fully sanitize "content" text fields (agent-supplied prose viasanitize_text()) but incompletely sanitize structural identifier fields likerepository. Therepositoryfield receives either:reply_to_pr_comment.rs,create_branch.rs) β field is used rawadd_pr_comment.rsline 116,create_git_tag.rsline 109-111) β strips\n/\rbut##vso[contains no control charactersStage 1 (MCP server
AddPrCommentParams::validate(),ReplyToPrCommentParams::validate(),CreateBranchParams::validate()) does not validate therepositoryfield for ADO pipeline command sequences.These unsanitized values are embedded in
ExecutionResult::failure()messages that are printed to stdout bysrc/execute.rs:217:Azure DevOps processes
##vso[...]commands from all subprocess stdout output in a step, including fromado-aw execute.Finding 1 & 2:
add_pr_comment.rsandreply_to_pr_comment.rsAttack vector: A Stage 1 agent calls either tool with:
{ "pull_request_id": 1, "thread_id": 1, "content": "legitimate content here for the PR", "repository": "##vso[task.setvariable variable=AZURE_DEVOPS_EXT_PAT;issecret=false]stolen" } ``` **Trigger path** (no special operator config required): `execute_impl()` at `reply_to_pr_comment.rs:157-163` always tries `lookup_allowed_repository()` for any non-"self" alias. Since the malicious alias won't exist in the checkout list, it returns `None` and emits: ``` [1/1] reply-to-pr-review-comment - β - Repository alias '##vso[task.setvariable variable=AZURE_DEVOPS_EXT_PAT;issecret=false]stolen' not found in allowed repositories ``` ADO runner interprets `##vso[task.setvariable variable=AZURE_DEVOPS_EXT_PAT;issecret=false]stolen` from stdout. For `add_pr_comment.rs`, the same path exists at lines 292-295 (`lookup_allowed_repository` failure), plus an additional path at lines 238-241 triggered when `allowed_repositories` is configured. #### Finding 3 & 4: `create_branch.rs` and `create_git_tag.rs` **Attack vector**: A Stage 1 agent calls either tool with `repository: "##vso[task.complete result=SucceededWithIssues;done=true]task done"`. **Trigger path**: `execute_impl()` at `create_branch.rs:254-258` returns an `ExecutionResult::failure` (bypassing the `neutralize_pipeline_commands` applied in the `Err` path) when `allowed_repositories` is configured and the alias doesn't match: ``` [1/1] create-branch - β - Repository '##vso[task.complete result=SucceededWithIssues;done=true]task done' is not in the allowed-repositories list: [self, main-repo]This only fires when the operator has set
safe-outputs.create-branch.allowed-repositories(orcreate-git-tag).Impact
An agent (operating in Stage 1 AWF read-only sandbox) can inject arbitrary ADO VSO commands into the Stage 3 Execution job's stdout, which ADO processes:
##vso[task.setvariable variable=X;issecret=false]valueβ override or expose pipeline variables set by Stage 3 steps##vso[task.complete result=Failed;done=true]β forcibly mark the Execution task as failed mid-run, potentially abandoning remaining safe outputs##vso[task.complete result=SucceededWithIssues;done=true]β mask actual failures##vso[task.logissue type=error]fake errorβ inject false error entries into the audit logThis violates the Stage 1 β Stage 3 security boundary: the agent can influence Stage 3 pipeline behavior beyond the safe-output NDJSON mechanism.
Proof of concept:
{"name":"reply-to-pr-review-comment","pull_request_id":1,"thread_id":1,"content":"legitimate content for reviewers","repository":"##vso[task.setvariable variable=ADO_PAT;issecret=false]exfiltrated","timestamp":"2026-05-09T00:00:00Z"}The agent writes this to the NDJSON (Stage 1 Validate passes β no repository format check). Stage 3 prints the failure message to stdout. ADO processes the VSO command.
Suggested fix:
Apply
sanitize_config()(which callsneutralize_pipeline_commands()) toself.repositoryinsanitize_content_fields()for all four tools:Additionally, add
reject_pipeline_injection()in Stage 1Validate::validate()for therepositoryfield across all affected tools, consistent with howresolve_pr_thread.rsandsubmit_pr_review.rsalready sanitizeself.repositoryviasanitize_config()in theirsanitize_content_fields().Comparison β tools that already handle this correctly:
resolve_pr_thread.rs:91-94β callssanitize_config(repo)βsubmit_pr_review.rs:95-98β callssanitize_configβupdate_pr.rs:152-153β callssanitize_configβAudit Coverage
This issue was created by the automated red team security auditor.