Skip to content

Commit 410e2df

Browse files
feat: replace read-only-service-connection with permissions field (#26)
Replace the single ead-only-service-connection front matter field with a structured permissions field that models ADO's two access levels via separate ARM service connections: - permissions.read: mints a read-only token for the agent (Stage 1) - permissions.write: mints a write token for the executor (Stage 2 only) System.AccessToken is no longer used for agent or executor operations. Key changes: - Add PermissionsConfig type with read/write service connection fields - Generate separate SC_READ_TOKEN and SC_WRITE_TOKEN pipeline variables - Add compile-time validation: fail if write safe-outputs lack permissions.write - Update both standalone and 1ES compilers and templates - Add Permissions step to interactive creation wizard - Add 5 integration tests covering all permission combinations - Update AGENTS.md documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d767022 commit 410e2df

11 files changed

Lines changed: 696 additions & 80 deletions

File tree

AGENTS.md

Lines changed: 56 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,9 @@ network: # optional network policy (standalone target only
177177
- "*.mycompany.com"
178178
blocked: # blocked host patterns (takes precedence over allow)
179179
- "evil.example.com"
180-
read-only-service-connection: my-arm-connection # optional: ARM service connection for read-only ADO token
180+
permissions: # optional ADO access token configuration
181+
read: my-read-arm-connection # ARM service connection for read-only ADO access (Stage 1 agent)
182+
write: my-write-arm-connection # ARM service connection for write ADO access (Stage 2 executor only)
181183
---
182184

183185

@@ -568,20 +570,37 @@ Should be replaced with the description field from the front matter. This is use
568570

569571
## {{ acquire_ado_token }}
570572

571-
Generates an `AzureCLI@2` step that acquires an Azure DevOps-scoped access token from an ARM service connection. This is only generated when `read-only-service-connection` is configured in the front matter.
573+
Generates an `AzureCLI@2` step that acquires a read-only ADO-scoped access token from the ARM service connection specified in `permissions.read`. This token is used by the agent in Stage 1 (inside the AWF sandbox).
572574

573575
The step:
574-
- Uses the specified ARM service connection
576+
- Uses the ARM service connection from `permissions.read`
575577
- Calls `az account get-access-token` with the ADO resource ID
576-
- Stores the token in a secret pipeline variable `SC_ACCESS_TOKEN`
578+
- Stores the token in a secret pipeline variable `SC_READ_TOKEN`
577579

578-
If no `read-only-service-connection` is configured, this marker is replaced with an empty string.
580+
If `permissions.read` is not configured, this marker is replaced with an empty string.
579581

580582
## {{ copilot_ado_env }}
581583

582-
Generates environment variable entries for the copilot AWF step when `read-only-service-connection` is configured. Sets both `AZURE_DEVOPS_EXT_PAT` and `SYSTEM_ACCESSTOKEN` to the service connection token.
584+
Generates environment variable entries for the copilot AWF step when `permissions.read` is configured. Sets both `AZURE_DEVOPS_EXT_PAT` and `SYSTEM_ACCESSTOKEN` to the read service connection token (`SC_READ_TOKEN`).
583585

584-
If no `read-only-service-connection` is configured, this marker is replaced with an empty string, and ADO access tokens are omitted from the copilot invocation.
586+
If `permissions.read` is not configured, this marker is replaced with an empty string, and ADO access tokens are omitted from the copilot invocation.
587+
588+
## {{ acquire_write_token }}
589+
590+
Generates an `AzureCLI@2` step that acquires a write-capable ADO-scoped access token from the ARM service connection specified in `permissions.write`. This token is used only by the executor in Stage 2 (`ProcessSafeOutputs` job) and is never exposed to the agent.
591+
592+
The step:
593+
- Uses the ARM service connection from `permissions.write`
594+
- Calls `az account get-access-token` with the ADO resource ID
595+
- Stores the token in a secret pipeline variable `SC_WRITE_TOKEN`
596+
597+
If `permissions.write` is not configured, this marker is replaced with an empty string.
598+
599+
## {{ executor_ado_env }}
600+
601+
Generates environment variable entries for the Stage 2 executor step when `permissions.write` is configured. Sets `SYSTEM_ACCESSTOKEN` to the write service connection token (`SC_WRITE_TOKEN`).
602+
603+
If `permissions.write` is not configured, this marker is replaced with an empty string. Note: `System.AccessToken` is never used directly — all ADO tokens come from explicitly configured service connections.
585604

586605
## {{ compiler_version }}
587606

@@ -1030,22 +1049,42 @@ network:
10301049

10311050
All hosts (core + MCP-specific + user-specified) are combined into a comma-separated domain list passed to AWF's `--allow-domains` flag.
10321051

1033-
### Read-Only Service Connection
1052+
### Permissions (ADO Access Tokens)
10341053

1035-
For agents that need read-only access to Azure DevOps resources (e.g., reading repository information), you can configure an ARM service connection to provide a scoped access token:
1054+
ADO does not support fine-grained permissions — there are two access levels: blanket read and blanket write. Tokens are minted from ARM service connections; `System.AccessToken` is never used for agent or executor operations.
10361055

10371056
```yaml
1038-
read-only-service-connection: my-arm-connection
1057+
permissions:
1058+
read: my-read-arm-connection # Stage 1 agent — read-only ADO access
1059+
write: my-write-arm-connection # Stage 2 executor — write access for safe-outputs
10391060
```
10401061

1041-
When configured:
1042-
- The pipeline mints an ADO-scoped token from the ARM service connection
1043-
- The token is passed to the copilot via `AZURE_DEVOPS_EXT_PAT` and `SYSTEM_ACCESSTOKEN`
1044-
- This allows the agent to authenticate to ADO APIs without using the pipeline's default System.AccessToken
1062+
#### Security Model
1063+
1064+
- **`permissions.read`**: Mints a read-only ADO-scoped token given to the agent inside the AWF sandbox (Stage 1). The agent can query ADO APIs but cannot write.
1065+
- **`permissions.write`**: Mints a write-capable ADO-scoped token used **only** by the executor in Stage 2 (`ProcessSafeOutputs` job). This token is never exposed to the agent.
1066+
- **Both omitted**: No ADO tokens are passed anywhere. The agent has no ADO API access.
1067+
1068+
#### Compile-Time Validation
1069+
1070+
If write-requiring safe-outputs (`create-pull-request`, `create-work-item`) are configured but `permissions.write` is missing, compilation fails with a clear error message.
10451071

1046-
When not configured:
1047-
- ADO access tokens are omitted from the copilot invocation
1048-
- The agent cannot authenticate to ADO APIs
1072+
#### Examples
1073+
1074+
```yaml
1075+
# Agent can read ADO, safe-outputs can write
1076+
permissions:
1077+
read: my-read-sc
1078+
write: my-write-sc
1079+
1080+
# Agent can read ADO, no write safe-outputs needed
1081+
permissions:
1082+
read: my-read-sc
1083+
1084+
# Agent has no ADO access, but safe-outputs can create PRs/work items
1085+
permissions:
1086+
write: my-write-sc
1087+
```
10491088

10501089
## MCP Firewall
10511090

src/compile/common.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,99 @@ pub fn generate_pipeline_path(output_path: &std::path::Path) -> String {
486486
format!("{{{{ workspace }}}}/{}", filename)
487487
}
488488

489+
// ==================== Permission helpers ====================
490+
491+
/// ADO resource ID for minting ADO-scoped tokens via Azure CLI.
492+
const ADO_RESOURCE_ID: &str = "499b84ac-1321-427f-aa17-267ca6975798";
493+
494+
/// Generate an AzureCLI@2 step to acquire an ADO-scoped token from an ARM service connection.
495+
/// The `variable_name` parameter controls which pipeline variable the token is stored in
496+
/// (e.g. "SC_READ_TOKEN" for the agent, "SC_WRITE_TOKEN" for the executor).
497+
/// Returns empty string if no service connection is provided.
498+
pub fn generate_acquire_ado_token(service_connection: Option<&str>, variable_name: &str) -> String {
499+
match service_connection {
500+
Some(sc) => {
501+
let mut lines = Vec::new();
502+
lines.push("- task: AzureCLI@2".to_string());
503+
lines.push(format!(
504+
r#" displayName: "Acquire ADO token ({variable_name})""#
505+
));
506+
lines.push(" inputs:".to_string());
507+
lines.push(format!(" azureSubscription: '{}'", sc));
508+
lines.push(" scriptType: 'bash'".to_string());
509+
lines.push(" scriptLocation: 'inlineScript'".to_string());
510+
lines.push(" addSpnToEnvironment: true".to_string());
511+
lines.push(" inlineScript: |".to_string());
512+
lines.push(" ADO_TOKEN=$(az account get-access-token \\".to_string());
513+
lines.push(format!(
514+
" --resource {} \\",
515+
ADO_RESOURCE_ID
516+
));
517+
lines.push(" --query accessToken -o tsv)".to_string());
518+
lines.push(format!(
519+
" echo \"##vso[task.setvariable variable={variable_name};issecret=true]$ADO_TOKEN\""
520+
));
521+
lines.join("\n")
522+
}
523+
None => String::new(),
524+
}
525+
}
526+
527+
/// Generate the env block entries for the copilot AWF step (Stage 1 agent).
528+
/// Uses the read-only token from the read service connection.
529+
/// When not configured, omits ADO access tokens entirely.
530+
pub fn generate_copilot_ado_env(read_service_connection: Option<&str>) -> String {
531+
match read_service_connection {
532+
Some(_) => {
533+
"AZURE_DEVOPS_EXT_PAT: $(SC_READ_TOKEN)\nSYSTEM_ACCESSTOKEN: $(SC_READ_TOKEN)"
534+
.to_string()
535+
}
536+
None => String::new(),
537+
}
538+
}
539+
540+
/// Generate the env block entries for the executor step (Stage 2 ProcessSafeOutputs).
541+
/// Uses the write token from the write service connection.
542+
/// When not configured, omits ADO access tokens entirely.
543+
pub fn generate_executor_ado_env(write_service_connection: Option<&str>) -> String {
544+
match write_service_connection {
545+
Some(_) => "SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN)".to_string(),
546+
None => String::new(),
547+
}
548+
}
549+
550+
/// Safe-output names that require write access to ADO.
551+
const WRITE_REQUIRING_SAFE_OUTPUTS: &[&str] = &["create-pull-request", "create-work-item"];
552+
553+
/// Validate that write-requiring safe-outputs have a write service connection configured.
554+
pub fn validate_write_permissions(front_matter: &FrontMatter) -> Result<()> {
555+
let has_write_sc = front_matter
556+
.permissions
557+
.as_ref()
558+
.is_some_and(|p| p.write.is_some());
559+
560+
if has_write_sc {
561+
return Ok(());
562+
}
563+
564+
let missing: Vec<&str> = WRITE_REQUIRING_SAFE_OUTPUTS
565+
.iter()
566+
.filter(|name| front_matter.safe_outputs.contains_key(**name))
567+
.copied()
568+
.collect();
569+
570+
if !missing.is_empty() {
571+
anyhow::bail!(
572+
"Safe outputs [{}] require write access to ADO, but no write service connection \
573+
is configured. Add a 'permissions.write' field to the front matter:\n\n \
574+
permissions:\n write: <your-write-arm-service-connection>\n",
575+
missing.join(", ")
576+
);
577+
}
578+
579+
Ok(())
580+
}
581+
489582
#[cfg(test)]
490583
mod tests {
491584
use super::*;

src/compile/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ use std::path::{Path, PathBuf};
1818

1919
pub use common::parse_markdown;
2020
pub use common::sanitize_filename;
21-
pub use types::{CompileTarget, FrontMatter};
21+
pub use types::{CompileTarget, FrontMatter, PermissionsConfig};
2222

2323
/// Trait for pipeline compilers.
2424
///

src/compile/onees.rs

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@ use std::path::Path;
1818
use super::Compiler;
1919
use super::common::{
2020
self, AWF_VERSION, DEFAULT_POOL, compute_effective_workspace, generate_copilot_params,
21-
generate_checkout_self, generate_checkout_steps, generate_ci_trigger,
21+
generate_acquire_ado_token, generate_checkout_self, generate_checkout_steps,
22+
generate_ci_trigger, generate_copilot_ado_env, generate_executor_ado_env,
2223
generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger,
2324
generate_repositories, generate_schedule, generate_source_path,
24-
generate_working_directory, replace_with_indent,
25+
generate_working_directory, replace_with_indent, validate_write_permissions,
2526
};
2627
use super::types::{FrontMatter, McpConfig};
2728

@@ -113,6 +114,25 @@ displayName: "Finalize""#,
113114
threat_analysis_prompt,
114115
);
115116

117+
// Generate service connection token acquisition steps and env vars
118+
let acquire_read_token = generate_acquire_ado_token(
119+
front_matter.permissions.as_ref().and_then(|p| p.read.as_deref()),
120+
"SC_READ_TOKEN",
121+
);
122+
let copilot_ado_env = generate_copilot_ado_env(
123+
front_matter.permissions.as_ref().and_then(|p| p.read.as_deref()),
124+
);
125+
let acquire_write_token = generate_acquire_ado_token(
126+
front_matter.permissions.as_ref().and_then(|p| p.write.as_deref()),
127+
"SC_WRITE_TOKEN",
128+
);
129+
let executor_ado_env = generate_executor_ado_env(
130+
front_matter.permissions.as_ref().and_then(|p| p.write.as_deref()),
131+
);
132+
133+
// Validate that write-requiring safe-outputs have a write service connection
134+
validate_write_permissions(front_matter)?;
135+
116136
// Replace all template markers
117137
let compiler_version = env!("CARGO_PKG_VERSION");
118138
let replacements: Vec<(&str, &str)> = vec![
@@ -143,6 +163,10 @@ displayName: "Finalize""#,
143163
("{{ pipeline_path }}", &pipeline_path),
144164
("{{ working_directory }}", &working_directory),
145165
("{{ agency_params }}", &agency_params),
166+
("{{ acquire_ado_token }}", &acquire_read_token),
167+
("{{ copilot_ado_env }}", &copilot_ado_env),
168+
("{{ acquire_write_token }}", &acquire_write_token),
169+
("{{ executor_ado_env }}", &executor_ado_env),
146170
];
147171

148172
let pipeline_yaml = replacements

src/compile/standalone.rs

Lines changed: 27 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ use std::path::Path;
1515
use super::Compiler;
1616
use super::common::{
1717
self, AWF_VERSION, DEFAULT_POOL, compute_effective_workspace, generate_copilot_params,
18-
generate_cancel_previous_builds, generate_checkout_self, generate_checkout_steps,
19-
generate_ci_trigger, generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger,
20-
generate_repositories, generate_schedule, generate_source_path, generate_working_directory,
21-
replace_with_indent, sanitize_filename,
18+
generate_acquire_ado_token, generate_cancel_previous_builds, generate_checkout_self,
19+
generate_checkout_steps, generate_ci_trigger, generate_copilot_ado_env,
20+
generate_executor_ado_env, generate_pipeline_path, generate_pipeline_resources,
21+
generate_pr_trigger, generate_repositories, generate_schedule, generate_source_path,
22+
generate_working_directory, replace_with_indent, sanitize_filename,
23+
validate_write_permissions,
2224
};
2325
use super::types::{FrontMatter, McpConfig};
2426
use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts};
@@ -104,10 +106,24 @@ impl Compiler for StandaloneCompiler {
104106
let finalize_steps = generate_finalize_steps(&front_matter.post_steps);
105107
let agentic_depends_on = generate_agentic_depends_on(&front_matter.setup);
106108

107-
// Generate service connection token acquisition step and env vars
108-
let acquire_ado_token =
109-
generate_acquire_ado_token(&front_matter.read_only_service_connection);
110-
let copilot_ado_env = generate_copilot_ado_env(&front_matter.read_only_service_connection);
109+
// Generate service connection token acquisition steps and env vars
110+
let acquire_read_token = generate_acquire_ado_token(
111+
front_matter.permissions.as_ref().and_then(|p| p.read.as_deref()),
112+
"SC_READ_TOKEN",
113+
);
114+
let copilot_ado_env = generate_copilot_ado_env(
115+
front_matter.permissions.as_ref().and_then(|p| p.read.as_deref()),
116+
);
117+
let acquire_write_token = generate_acquire_ado_token(
118+
front_matter.permissions.as_ref().and_then(|p| p.write.as_deref()),
119+
"SC_WRITE_TOKEN",
120+
);
121+
let executor_ado_env = generate_executor_ado_env(
122+
front_matter.permissions.as_ref().and_then(|p| p.write.as_deref()),
123+
);
124+
125+
// Validate that write-requiring safe-outputs have a write service connection
126+
validate_write_permissions(front_matter)?;
111127

112128
// Load threat analysis prompt template
113129
let threat_analysis_prompt = include_str!("../../templates/threat-analysis.md");
@@ -148,8 +164,10 @@ impl Compiler for StandaloneCompiler {
148164
("{{ workspace }}", &working_directory),
149165
("{{ allowed_domains }}", &allowed_domains),
150166
("{{ agent_content }}", markdown_body),
151-
("{{ acquire_ado_token }}", &acquire_ado_token),
167+
("{{ acquire_ado_token }}", &acquire_read_token),
152168
("{{ copilot_ado_env }}", &copilot_ado_env),
169+
("{{ acquire_write_token }}", &acquire_write_token),
170+
("{{ executor_ado_env }}", &executor_ado_env),
153171
];
154172

155173
let pipeline_yaml = replacements
@@ -408,46 +426,6 @@ pub fn generate_firewall_config(front_matter: &FrontMatter) -> FirewallConfig {
408426
}
409427
}
410428

411-
/// Generate the AzureCLI@2 step to acquire an ADO-scoped token from an ARM service connection.
412-
/// Returns empty string if no service connection is configured.
413-
fn generate_acquire_ado_token(service_connection: &Option<String>) -> String {
414-
match service_connection {
415-
Some(sc) => {
416-
let mut lines = Vec::new();
417-
lines.push("- task: AzureCLI@2".to_string());
418-
lines.push(r#" displayName: "Get ADO token from service connection""#.to_string());
419-
lines.push(" inputs:".to_string());
420-
lines.push(format!(" azureSubscription: '{}'", sc));
421-
lines.push(" scriptType: 'bash'".to_string());
422-
lines.push(" scriptLocation: 'inlineScript'".to_string());
423-
lines.push(" addSpnToEnvironment: true".to_string());
424-
lines.push(" inlineScript: |".to_string());
425-
lines.push(" ADO_TOKEN=$(az account get-access-token \\".to_string());
426-
lines.push(" --resource 499b84ac-1321-427f-aa17-267ca6975798 \\".to_string());
427-
lines.push(" --query accessToken -o tsv)".to_string());
428-
lines.push(
429-
" echo \"##vso[task.setvariable variable=SC_ACCESS_TOKEN;issecret=true]$ADO_TOKEN\""
430-
.to_string(),
431-
);
432-
lines.join("\n")
433-
}
434-
None => String::new(),
435-
}
436-
}
437-
438-
/// Generate the env block entries for the copilot AWF step.
439-
/// When a service connection is configured, uses the SC token.
440-
/// When not configured, omits ADO access tokens entirely.
441-
fn generate_copilot_ado_env(service_connection: &Option<String>) -> String {
442-
match service_connection {
443-
Some(_) => {
444-
"AZURE_DEVOPS_EXT_PAT: $(SC_ACCESS_TOKEN)\nSYSTEM_ACCESSTOKEN: $(SC_ACCESS_TOKEN)"
445-
.to_string()
446-
}
447-
None => String::new(),
448-
}
449-
}
450-
451429
/// Generate the steps to download agent memory from the previous successful run
452430
/// and restore it to the staging directory.
453431
fn generate_memory_download() -> String {

0 commit comments

Comments
 (0)