From b56caba56e618e6cd9ae85d2251508bfed926e98 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 29 Apr 2026 14:42:02 +0100 Subject: [PATCH 1/9] feat(compile): add required_awf_mounts to CompilerExtension for Lean runtime AWF replaces $HOME with an empty directory overlay for security, only mounting specific known subdirectories (.cargo, .rustup, etc.). The Lean toolchain installed at $HOME/.elan/ was not mounted, causing lean/lake/elan binaries to be missing inside the chroot. Add equired_awf_mounts() to the CompilerExtension trait so extensions can declare Docker volume mounts needed inside the AWF chroot. The Lean extension returns $HOME/.elan:C:\Users\devinejames/.elan:ro to mount the elan toolchain read-only. The compiler collects mounts from all extensions via generate_awf_mounts() and injects them as --mount flags on the AWF invocation through a new {{ awf_mounts }} template marker in both standalone and 1ES base templates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/extending.md | 1 + docs/runtimes.md | 3 +- docs/template-markers.md | 8 ++ s360-breeze-mcp.md | 182 ++++++++++++++++++++++++++++++++ src/compile/common.rs | 51 +++++++++ src/compile/extensions/mod.rs | 20 ++++ src/compile/extensions/tests.rs | 14 +++ src/compile/onees.rs | 3 + src/compile/standalone.rs | 3 + src/data/1es-base.yml | 4 +- src/data/base.yml | 4 +- src/runtimes/lean/extension.rs | 4 + src/runtimes/lean/mod.rs | 14 ++- 13 files changed, 302 insertions(+), 9 deletions(-) create mode 100644 s360-breeze-mcp.md diff --git a/docs/extending.md b/docs/extending.md index 63709b8e..52f4ef6c 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -38,6 +38,7 @@ pub trait CompilerExtension: Send { fn prompt_supplement(&self) -> Option; // Agent prompt markdown fn prepare_steps(&self) -> Vec; // Pipeline steps (install, etc.) fn mcpg_servers(&self, ctx) -> Result>; // MCPG entries + fn required_awf_mounts(&self) -> Vec; // AWF Docker volume mounts fn validate(&self, ctx) -> Result>; // Compile-time warnings } ``` diff --git a/docs/runtimes.md b/docs/runtimes.md index 3c69db09..8a2bd717 100644 --- a/docs/runtimes.md +++ b/docs/runtimes.md @@ -28,7 +28,8 @@ When enabled, the compiler: - Defaults to the `stable` toolchain; if a `lean-toolchain` file exists in the repo, elan overrides to that version automatically - Auto-adds `lean`, `lake`, and `elan` to the bash command allow-list - Adds Lean-specific domains to the network allowlist: `elan.lean-lang.org`, `leanprover.github.io`, `lean-lang.org` -- Symlinks lean tools into `/tmp/awf-tools/` for AWF chroot compatibility +- Mounts `$HOME/.elan` into the AWF container via `--mount` flag so the elan toolchain is accessible inside the chroot (AWF replaces `$HOME` with an empty overlay for security) +- Symlinks lean tools into `/tmp/awf-tools/` as a defense-in-depth fallback - Appends a prompt supplement informing the agent about Lean 4 availability and basic commands - Emits a compile-time warning if `tools.bash` is empty (Lean requires bash access) diff --git a/docs/template-markers.md b/docs/template-markers.md index 09193a52..ecc280f6 100644 --- a/docs/template-markers.md +++ b/docs/template-markers.md @@ -365,6 +365,14 @@ Should be replaced with the comma-separated domain list for AWF's `--allow-domai The output is formatted as a comma-separated string (e.g., `github.com,*.dev.azure.com,api.github.com`). +## {{ awf_mounts }} + +Should be replaced with `--mount` flags for the AWF invocation, collected from `CompilerExtension::required_awf_mounts()`. Each extension can declare volume mounts needed inside the AWF chroot (e.g., the Lean runtime mounts `$HOME/.elan` so the elan toolchain is accessible). + +When no extensions declare mounts, this is replaced with an empty string (no `--mount` flags). When mounts are present, each is formatted as `--mount "spec" \` with trailing backslash for shell line continuation. + +AWF replaces `$HOME` with an empty directory overlay for security; only explicitly mounted subdirectories are accessible inside the chroot. Shell variables like `$HOME` are expanded at runtime by bash. + ## {{ enabled_tools_args }} Should be replaced with `--enabled-tools ` CLI arguments for the SafeOutputs MCP HTTP server. The tool list is derived from `safe-outputs:` front matter keys plus always-on diagnostic tools (`noop`, `missing-data`, `missing-tool`, `report-incomplete`). diff --git a/s360-breeze-mcp.md b/s360-breeze-mcp.md new file mode 100644 index 00000000..5bd3f6ab --- /dev/null +++ b/s360-breeze-mcp.md @@ -0,0 +1,182 @@ +Authentication and Access +The S360 MCP Server uses Microsoft authentication and respects existing S360 permissions: + +Authentication: Microsoft Azure AD (Entra) authentication required. +Authorization: Users can only access data they have permissions to view in S360 +Service Scope: Access is limited to services and KPIs the user can view through the S360 web interface +Programmatic Access: Build Your Own MCP Client +This section helps developers integrate the S360 MCP Server into their own applications, agents, and agentic workflows. + +Endpoints +Environment Base url +TEST https://mcp.vnext.s360test.msftcloudes.com/ +PROD https://mcp.vnext.s360.msftcloudes.com/ +Authentication +The MCP server supports Azure Active Directory (Azure AD) Bearer token authentication via: + +User delegated (On-Behalf-Of) flow (your client acts on behalf of a signed-in user) +Service-to-service flow +Use User Auth when you must execute MCP tools explicitly on behalf of an individual user and preserve user context/auditing. Use the service to service flow when you are running backend / automation scenarios without per-user context. + +You can find more information about the MCP server auth scheme here. + +Supported Issuing Tenants +S360 MCP currently only accepts tokens issued by the following Microsoft tenants: + +Supported Tenant Applies To (User / MSI) +CORP User & MSI +Tokens issued from other tenants will be rejected. + +S360 MCP Auth Resource / Scope Endpoints +Environment User Auth Scope (Delegated / OBO) MSI Auth Scope (Resource / Scope Base) +TEST api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb/mcp-user api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb +PROD api://08654c87-a8c1-4098-a44b-079efd603fdc/mcp-user api://08654c87-a8c1-4098-a44b-079efd603fdc +1. User Authentication (Delegated / On-Behalf-Of) +When an MCP client (web app, service or agent framework) needs to call the S360 MCP Server on behalf of a user, it must perform the Azure AD OAuth 2.0 On-Behalf-Of (OBO) flow to exchange the user token it received for an MCP-scoped token. + +Additional Onboarding Steps (one-time) +Provision (or identify) an Azure AD application (App Registration) in the AME tenant for your client front-end / API. +Assign MCP delegated permissions to that application by following these steps: +On your app registration in the Azure portal, select Add a permission. +In the Request API permissions flyout, select the APIs my organization uses tab. +In the search input, type S360 Breeze Mcp Prod or S360 Breeze Mcp Test to find the MCP app, then select it from the list. +Select Delegated permissions. +From the list of available delegated permissions, select the checkbox for mcp-user. +Select Add permissions to save the permissions to your app registration. +Ensure your client app is multi-tenant (only AME and Corp) and allows users from other tenants. Ensure a Service Principal is created in the Corp tenant. +Email breezehelp@microsoft.com requesting authorization for your client app to use S360 MCP OBO (delegated) access. Include ALL details listed below. +Information to include in the authorization request email +Provide these fields plainly in the email body: + +App Id (AAD application / client id) +App Tenant Id (GUID of tenant owning the app. This should be AME) +Environment(s) requested (TEST, PROD or both) +Requesting Team name +Requesting Team Service Tree Id (GUID or path) +Justification (scenarios, why delegated access is needed) +OBO Flow Summary +User signs in to your client front end and obtains an access token where aud = your client API/App (App1). +Your client backend receives the user token. +Backend calls Azure AD token endpoint performing OBO, presenting the user token as the user assertion and requesting scope api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb/mcp-user (or .default). +Azure AD returns an MCP token (aud = 6833b4aa-2e50-42b8-b3d9-2b0114fc39cb). +Backend uses this MCP token to connect to MCP and invoke tools on behalf of the user. +Local Testing (Interactive User Token) +For local prototyping you can still obtain a user token directly (TEST example) and call MCP (not OBO) when explicitly allowed: + +# Run in PowerShell (Admin if needed) +# Install-Module -Name MSAL.PS -Force +Import-Module MSAL.PS +$token = Get-MsalToken -ClientId "6833b4aa-2e50-42b8-b3d9-2b0114fc39cb" ` + -TenantId "72f988bf-86f1-41af-91ab-2d7cd011db47" ` + -Scopes "api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb/mcp-user" ` + -Interactive +Write-Host "Access Token: $($token.AccessToken)" +$token.AccessToken | clip +For production scenarios use the full OBO flow; do not pass raw user tokens from the browser directly to MCP. + +2. Service-to-Service Authentication +Service-to-service authentication is supported for approved service principals. Approved service principals can be granted scoped, read-only access per environment. Write operations (such as setting ETAs or action item owners) require user context and are not available via service tokens. + +To request service-to-service access, email breezehelp@microsoft.com with your app registration details, the environment(s) requested, and a justification for why user context is not viable (e.g., scheduled jobs, notifications, or reporting). + +Code Examples +C# +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Client; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +var builder = Host.CreateApplicationBuilder(); + +builder.Configuration + .AddEnvironmentVariables() + .AddUserSecrets(); + + +using var loggerFactory = LoggerFactory.Create(builder => { }); + +ILogger logger = loggerFactory.CreateLogger(); + +// Connect to an MCP server +Console.WriteLine("Connecting client to MCP server"); + +// This must be a user JWT token with scope api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb/mcp-user +var token = await GetUserTokenAsync("api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb/mcp-user", ""); + +var mcpClient = await McpClient.CreateAsync( + new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri("https://mcp.vnext.s360test.msftcloudes.com/"), + ConnectionTimeout = new TimeSpan(0, 0, 100), + Name = "StreamableHttp MCP Client", + TransportMode = HttpTransportMode.StreamableHttp, + AdditionalHeaders = new Dictionary + { + { "Authorization", $"Bearer {token}" } + }, + }, loggerFactory), + loggerFactory: loggerFactory + ); + +// Get all available tools +Console.WriteLine("Available Tools list from S360 MCP Server:"); + +IList tools = new List(); + +try +{ + tools = await mcpClient.ListToolsAsync(); + int count = 1; + foreach (var tool in tools) + { + Console.WriteLine($"{count}.{tool.Name}"); + Console.WriteLine($"Description: {tool.Description}"); + count++; + } +} +catch (Exception ex) +{ + logger.LogError(ex, "An error occurred while listing tools."); +} + +Console.WriteLine("Call tool to get kpi info"); + +CallToolResult toolResult = await mcpClient.CallToolAsync( + "search_s360_kpi_metadata", + new Dictionary() { { "request", new Dictionary { { "kpiNameSearchTerm", "security" } } } }, + cancellationToken: CancellationToken.None); + +var content = toolResult.Content.First(c => c.Type == "text") as TextContentBlock; +Console.WriteLine(content?.Text); + +static async Task GetUserTokenAsync(string scope, string clientId) +{ + var publicClient = PublicClientApplicationBuilder + .Create(clientId) + .WithAuthority("https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47") // Microsoft Tenant ID + .WithRedirectUri("http://localhost") + .Build(); + + try + { + result = await publicClient + .AcquireTokenInteractive([scope]) + .WithPrompt(Microsoft.Identity.Client.Prompt.SelectAccount) + .ExecuteAsync() + .ConfigureAwait(false); + + return result.AccessToken; + } + catch (Exception ex) + { + Console.WriteLine($"Error acquiring user token for scope {scope}: {ex.Message}"); + throw; + } +} \ No newline at end of file diff --git a/src/compile/common.rs b/src/compile/common.rs index 7cb8f0ab..dca2e8ea 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1670,6 +1670,35 @@ pub fn generate_allowed_domains( Ok(allowlist.join(",")) } +/// Generate AWF `--mount` flags from extension-declared volume mounts. +/// +/// Collects `required_awf_mounts()` from all extensions and formats them +/// as `--mount "spec"` CLI flags for the AWF invocation. Each mount spec +/// uses the AWF format: `host_path:container_path[:mode]`. +/// +/// Returns an empty string if no extensions require mounts, or a string +/// like `--mount "$HOME/.elan:$HOME/.elan:ro" ` with trailing space for +/// inline concatenation with subsequent CLI flags. +pub fn generate_awf_mounts( + extensions: &[super::extensions::Extension], +) -> String { + let mounts: Vec = extensions + .iter() + .flat_map(|ext| ext.required_awf_mounts()) + .collect(); + + if mounts.is_empty() { + return String::new(); + } + + mounts + .iter() + .map(|m| format!("--mount \"{}\"", m)) + .collect::>() + .join(" ") + + " " +} + // ==================== Shared compile flow ==================== /// Target-specific overrides for the shared compile flow. @@ -3719,6 +3748,28 @@ mod tests { assert!(result.contains("Lean 4"), "lean prompt present"); } + // ─── generate_awf_mounts ────────────────────────────────────────────── + + #[test] + fn test_generate_awf_mounts_no_extensions() { + let fm = minimal_front_matter(); + let exts = crate::compile::extensions::collect_extensions(&fm); + let result = generate_awf_mounts(&exts); + assert!(result.is_empty(), "no mounts without lean"); + } + + #[test] + fn test_generate_awf_mounts_with_lean() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n lean: true\n---\n", + ).unwrap(); + let exts = crate::compile::extensions::collect_extensions(&fm); + let result = generate_awf_mounts(&exts); + assert!(result.contains("--mount"), "should contain --mount flag"); + assert!(result.contains(".elan"), "should reference .elan directory"); + assert!(result.contains(":ro"), "should be read-only"); + } + // ═══════════════════════════════════════════════════════════════════════ // Tests moved from standalone.rs — MCPG config, docker env, validation // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index bb48ebfd..6ba890d3 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -284,6 +284,23 @@ pub trait CompilerExtension { fn required_pipeline_vars(&self) -> Vec { vec![] } + + /// AWF volume mounts this extension requires inside the chroot. + /// + /// Returns mount specifications in AWF `--mount` format: + /// `host_path:container_path[:mode]` (e.g., `"$HOME/.elan:$HOME/.elan:ro"`). + /// + /// AWF replaces `$HOME` with an empty directory overlay for security, + /// only mounting specific known subdirectories. Extensions that install + /// toolchains under `$HOME` (e.g., elan for Lean 4) must declare mounts + /// here so the toolchain is accessible inside the chroot. + /// + /// Shell variables like `$HOME` are expanded at runtime by bash, not at + /// compile time. AWF auto-adjusts container paths for chroot by prefixing + /// `/host`. + fn required_awf_mounts(&self) -> Vec { + vec![] + } } /// Maps a container environment variable to a pipeline variable. @@ -358,6 +375,9 @@ macro_rules! extension_enum { fn required_pipeline_vars(&self) -> Vec { match self { $( $Enum::$Variant(e) => e.required_pipeline_vars(), )+ } } + fn required_awf_mounts(&self) -> Vec { + match self { $( $Enum::$Variant(e) => e.required_awf_mounts(), )+ } + } } }; } diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index a7a87714..8bd60140 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -141,6 +141,20 @@ fn test_lean_prepare_steps() { assert!(steps[0].contains("elan-init.sh")); } +#[test] +fn test_lean_required_awf_mounts() { + let ext = LeanExtension::new(LeanRuntimeConfig::Enabled(true)); + let mounts = ext.required_awf_mounts(); + assert_eq!(mounts.len(), 1); + assert_eq!(mounts[0], "$HOME/.elan:$HOME/.elan:ro"); +} + +#[test] +fn test_default_required_awf_mounts_empty() { + let ext = GitHubExtension; + assert!(ext.required_awf_mounts().is_empty()); +} + #[test] fn test_lean_validate_bash_disabled_warning() { let (fm, _) = diff --git a/src/compile/onees.rs b/src/compile/onees.rs index aa898764..c76e881e 100644 --- a/src/compile/onees.rs +++ b/src/compile/onees.rs @@ -14,6 +14,7 @@ use super::common::{ AWF_VERSION, MCPG_VERSION, MCPG_IMAGE, MCPG_PORT, MCPG_DOMAIN, CompileConfig, compile_shared, generate_allowed_domains, + generate_awf_mounts, generate_enabled_tools_args, generate_mcpg_config, generate_mcpg_docker_env, generate_mcpg_step_env, format_steps_yaml_indented, @@ -49,6 +50,7 @@ impl Compiler for OneESCompiler { // Generate values shared with standalone that are passed as extra replacements let allowed_domains = generate_allowed_domains(front_matter, &extensions)?; + let awf_mounts = generate_awf_mounts(&extensions); let enabled_tools_args = generate_enabled_tools_args(front_matter); let mcpg_config = generate_mcpg_config(front_matter, &ctx, &extensions)?; @@ -72,6 +74,7 @@ impl Compiler for OneESCompiler { ("{{ mcpg_port }}".into(), MCPG_PORT.to_string()), ("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()), ("{{ allowed_domains }}".into(), allowed_domains), + ("{{ awf_mounts }}".into(), awf_mounts), ("{{ enabled_tools_args }}".into(), enabled_tools_args), ("{{ mcpg_config }}".into(), mcpg_config_json), ("{{ mcpg_docker_env }}".into(), mcpg_docker_env), diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index bc941fd9..adb80845 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -16,6 +16,7 @@ use super::common::{ AWF_VERSION, MCPG_VERSION, MCPG_IMAGE, MCPG_PORT, MCPG_DOMAIN, CompileConfig, compile_shared, generate_allowed_domains, + generate_awf_mounts, generate_enabled_tools_args, generate_mcpg_config, generate_mcpg_docker_env, generate_mcpg_step_env, }; @@ -50,6 +51,7 @@ impl Compiler for StandaloneCompiler { // Standalone-specific values let allowed_domains = generate_allowed_domains(front_matter, &extensions)?; + let awf_mounts = generate_awf_mounts(&extensions); let enabled_tools_args = generate_enabled_tools_args(front_matter); let config_obj = generate_mcpg_config(front_matter, &ctx, &extensions)?; @@ -67,6 +69,7 @@ impl Compiler for StandaloneCompiler { ("{{ mcpg_port }}".into(), MCPG_PORT.to_string()), ("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()), ("{{ allowed_domains }}".into(), allowed_domains), + ("{{ awf_mounts }}".into(), awf_mounts), ("{{ enabled_tools_args }}".into(), enabled_tools_args), ("{{ mcpg_config }}".into(), mcpg_config_json), ("{{ mcpg_docker_env }}".into(), mcpg_docker_env), diff --git a/src/data/1es-base.yml b/src/data/1es-base.yml index 3545c5f5..9561bc72 100644 --- a/src/data/1es-base.yml +++ b/src/data/1es-base.yml @@ -339,7 +339,7 @@ extends: --skip-pull \ --env-all \ --enable-host-access \ - --container-workdir "{{ working_directory }}" \ + {{ awf_mounts }}--container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ -- '{{ engine_run }}' \ @@ -505,7 +505,7 @@ extends: --allow-domains "{{ allowed_domains }}" \ --skip-pull \ --env-all \ - --container-workdir "{{ working_directory }}" \ + {{ awf_mounts }}--container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ -- '{{ engine_run_detection }}' \ diff --git a/src/data/base.yml b/src/data/base.yml index fa6d0848..7ec7f9b6 100644 --- a/src/data/base.yml +++ b/src/data/base.yml @@ -310,7 +310,7 @@ jobs: --skip-pull \ --env-all \ --enable-host-access \ - --container-workdir "{{ working_directory }}" \ + {{ awf_mounts }}--container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ -- '{{ engine_run }}' \ @@ -474,7 +474,7 @@ jobs: --allow-domains "{{ allowed_domains }}" \ --skip-pull \ --env-all \ - --container-workdir "{{ working_directory }}" \ + {{ awf_mounts }}--container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ -- '{{ engine_run_detection }}' \ diff --git a/src/runtimes/lean/extension.rs b/src/runtimes/lean/extension.rs index f510c19e..d754195d 100644 --- a/src/runtimes/lean/extension.rs +++ b/src/runtimes/lean/extension.rs @@ -56,6 +56,10 @@ the toolchain. Lean files use the `.lean` extension.\n" vec![generate_lean_install(&self.config)] } + fn required_awf_mounts(&self) -> Vec { + vec!["$HOME/.elan:$HOME/.elan:ro".to_string()] + } + fn validate(&self, ctx: &CompileContext) -> Result> { let mut warnings = Vec::new(); diff --git a/src/runtimes/lean/mod.rs b/src/runtimes/lean/mod.rs index 9c37e62c..38c9497b 100644 --- a/src/runtimes/lean/mod.rs +++ b/src/runtimes/lean/mod.rs @@ -83,7 +83,12 @@ pub const LEAN_BASH_COMMANDS: &[&str] = &["lean", "lake", "elan"]; /// /// Installs elan (Lean toolchain manager) and the specified toolchain. /// Defaults to "stable" if no toolchain is specified in the front matter. -/// Symlinks lean tools into `/tmp/awf-tools/` for AWF chroot compatibility. +/// Symlinks lean tools into `/tmp/awf-tools/` as a defense-in-depth fallback. +/// +/// The primary mechanism for AWF chroot access is the `--mount` flag +/// declared via `LeanExtension::required_awf_mounts()`, which mounts +/// `$HOME/.elan` into the container. The symlinks here serve as a +/// secondary fallback in case the mount is unavailable. pub fn generate_lean_install(config: &LeanRuntimeConfig) -> String { let toolchain = config.toolchain().unwrap_or("stable"); let script = format!( @@ -93,9 +98,10 @@ echo \"##vso[task.prependpath]$HOME/.elan/bin\" export PATH=\"$HOME/.elan/bin:$PATH\" lean --version || echo \"Lean installed via elan\" lake --version || echo \"Lake installed via elan\" -# Symlink lean tools into /tmp/awf-tools/ so they are accessible -# inside the AWF chroot (AWF mounts /tmp but reconstructs PATH -# from standard system locations, excluding $HOME/.elan/bin). +# Symlink lean tools into /tmp/awf-tools/ as a defense-in-depth fallback. +# The primary mechanism is the --mount flag (via required_awf_mounts) +# which mounts $HOME/.elan into the AWF container. These symlinks +# serve as a secondary fallback. for cmd in lean lake elan; do if command -v \"$cmd\" >/dev/null 2>&1; then ln -sf \"$(command -v \"$cmd\")\" \"/tmp/awf-tools/$cmd\" From e241b353706eced05cb04fdc57666cca95ea0a52 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 13:50:50 +0000 Subject: [PATCH 2/9] fix(runtimes): remove symlinks from lean install script Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/3352a7f0-905a-491f-a9df-3aefb8ffec4b Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/runtimes/lean/mod.rs | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/runtimes/lean/mod.rs b/src/runtimes/lean/mod.rs index 38c9497b..fb070195 100644 --- a/src/runtimes/lean/mod.rs +++ b/src/runtimes/lean/mod.rs @@ -6,7 +6,7 @@ //! supplement informing the agent that Lean is available. //! //! Lean is installed via elan (the Lean toolchain manager) into `$HOME/.elan/bin`, -//! then symlinked into `/tmp/awf-tools/` for AWF chroot compatibility. +//! which is mounted read-only into the AWF chroot via the `required_awf_mounts()` mechanism. pub mod extension; @@ -83,12 +83,10 @@ pub const LEAN_BASH_COMMANDS: &[&str] = &["lean", "lake", "elan"]; /// /// Installs elan (Lean toolchain manager) and the specified toolchain. /// Defaults to "stable" if no toolchain is specified in the front matter. -/// Symlinks lean tools into `/tmp/awf-tools/` as a defense-in-depth fallback. /// -/// The primary mechanism for AWF chroot access is the `--mount` flag -/// declared via `LeanExtension::required_awf_mounts()`, which mounts -/// `$HOME/.elan` into the container. The symlinks here serve as a -/// secondary fallback in case the mount is unavailable. +/// AWF chroot access is provided by the `--mount` flag declared via +/// `LeanExtension::required_awf_mounts()`, which mounts `$HOME/.elan` +/// read-only into the container. pub fn generate_lean_install(config: &LeanRuntimeConfig) -> String { let toolchain = config.toolchain().unwrap_or("stable"); let script = format!( @@ -97,17 +95,7 @@ curl https://elan.lean-lang.org/elan-init.sh -sSf | sh -s -- -y --default-toolch echo \"##vso[task.prependpath]$HOME/.elan/bin\" export PATH=\"$HOME/.elan/bin:$PATH\" lean --version || echo \"Lean installed via elan\" -lake --version || echo \"Lake installed via elan\" -# Symlink lean tools into /tmp/awf-tools/ as a defense-in-depth fallback. -# The primary mechanism is the --mount flag (via required_awf_mounts) -# which mounts $HOME/.elan into the AWF container. These symlinks -# serve as a secondary fallback. -for cmd in lean lake elan; do - if command -v \"$cmd\" >/dev/null 2>&1; then - ln -sf \"$(command -v \"$cmd\")\" \"/tmp/awf-tools/$cmd\" - fi -done -echo \"Lean tools symlinked to /tmp/awf-tools/\"" +lake --version || echo \"Lake installed via elan\"" ); // Indent each line of the script body by 4 spaces for YAML block scalar let indented: String = script From 8d1b60622e23b11358b1ded899f5278148735edc Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 29 Apr 2026 14:54:21 +0100 Subject: [PATCH 3/9] chore: remove accidentally committed file Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- s360-breeze-mcp.md | 182 --------------------------------------------- 1 file changed, 182 deletions(-) delete mode 100644 s360-breeze-mcp.md diff --git a/s360-breeze-mcp.md b/s360-breeze-mcp.md deleted file mode 100644 index 5bd3f6ab..00000000 --- a/s360-breeze-mcp.md +++ /dev/null @@ -1,182 +0,0 @@ -Authentication and Access -The S360 MCP Server uses Microsoft authentication and respects existing S360 permissions: - -Authentication: Microsoft Azure AD (Entra) authentication required. -Authorization: Users can only access data they have permissions to view in S360 -Service Scope: Access is limited to services and KPIs the user can view through the S360 web interface -Programmatic Access: Build Your Own MCP Client -This section helps developers integrate the S360 MCP Server into their own applications, agents, and agentic workflows. - -Endpoints -Environment Base url -TEST https://mcp.vnext.s360test.msftcloudes.com/ -PROD https://mcp.vnext.s360.msftcloudes.com/ -Authentication -The MCP server supports Azure Active Directory (Azure AD) Bearer token authentication via: - -User delegated (On-Behalf-Of) flow (your client acts on behalf of a signed-in user) -Service-to-service flow -Use User Auth when you must execute MCP tools explicitly on behalf of an individual user and preserve user context/auditing. Use the service to service flow when you are running backend / automation scenarios without per-user context. - -You can find more information about the MCP server auth scheme here. - -Supported Issuing Tenants -S360 MCP currently only accepts tokens issued by the following Microsoft tenants: - -Supported Tenant Applies To (User / MSI) -CORP User & MSI -Tokens issued from other tenants will be rejected. - -S360 MCP Auth Resource / Scope Endpoints -Environment User Auth Scope (Delegated / OBO) MSI Auth Scope (Resource / Scope Base) -TEST api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb/mcp-user api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb -PROD api://08654c87-a8c1-4098-a44b-079efd603fdc/mcp-user api://08654c87-a8c1-4098-a44b-079efd603fdc -1. User Authentication (Delegated / On-Behalf-Of) -When an MCP client (web app, service or agent framework) needs to call the S360 MCP Server on behalf of a user, it must perform the Azure AD OAuth 2.0 On-Behalf-Of (OBO) flow to exchange the user token it received for an MCP-scoped token. - -Additional Onboarding Steps (one-time) -Provision (or identify) an Azure AD application (App Registration) in the AME tenant for your client front-end / API. -Assign MCP delegated permissions to that application by following these steps: -On your app registration in the Azure portal, select Add a permission. -In the Request API permissions flyout, select the APIs my organization uses tab. -In the search input, type S360 Breeze Mcp Prod or S360 Breeze Mcp Test to find the MCP app, then select it from the list. -Select Delegated permissions. -From the list of available delegated permissions, select the checkbox for mcp-user. -Select Add permissions to save the permissions to your app registration. -Ensure your client app is multi-tenant (only AME and Corp) and allows users from other tenants. Ensure a Service Principal is created in the Corp tenant. -Email breezehelp@microsoft.com requesting authorization for your client app to use S360 MCP OBO (delegated) access. Include ALL details listed below. -Information to include in the authorization request email -Provide these fields plainly in the email body: - -App Id (AAD application / client id) -App Tenant Id (GUID of tenant owning the app. This should be AME) -Environment(s) requested (TEST, PROD or both) -Requesting Team name -Requesting Team Service Tree Id (GUID or path) -Justification (scenarios, why delegated access is needed) -OBO Flow Summary -User signs in to your client front end and obtains an access token where aud = your client API/App (App1). -Your client backend receives the user token. -Backend calls Azure AD token endpoint performing OBO, presenting the user token as the user assertion and requesting scope api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb/mcp-user (or .default). -Azure AD returns an MCP token (aud = 6833b4aa-2e50-42b8-b3d9-2b0114fc39cb). -Backend uses this MCP token to connect to MCP and invoke tools on behalf of the user. -Local Testing (Interactive User Token) -For local prototyping you can still obtain a user token directly (TEST example) and call MCP (not OBO) when explicitly allowed: - -# Run in PowerShell (Admin if needed) -# Install-Module -Name MSAL.PS -Force -Import-Module MSAL.PS -$token = Get-MsalToken -ClientId "6833b4aa-2e50-42b8-b3d9-2b0114fc39cb" ` - -TenantId "72f988bf-86f1-41af-91ab-2d7cd011db47" ` - -Scopes "api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb/mcp-user" ` - -Interactive -Write-Host "Access Token: $($token.AccessToken)" -$token.AccessToken | clip -For production scenarios use the full OBO flow; do not pass raw user tokens from the browser directly to MCP. - -2. Service-to-Service Authentication -Service-to-service authentication is supported for approved service principals. Approved service principals can be granted scoped, read-only access per environment. Write operations (such as setting ETAs or action item owners) require user context and are not available via service tokens. - -To request service-to-service access, email breezehelp@microsoft.com with your app registration details, the environment(s) requested, and a justification for why user context is not viable (e.g., scheduled jobs, notifications, or reporting). - -Code Examples -C# -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Identity.Client; -using ModelContextProtocol.Client; -using ModelContextProtocol.Protocol; - -var builder = Host.CreateApplicationBuilder(); - -builder.Configuration - .AddEnvironmentVariables() - .AddUserSecrets(); - - -using var loggerFactory = LoggerFactory.Create(builder => { }); - -ILogger logger = loggerFactory.CreateLogger(); - -// Connect to an MCP server -Console.WriteLine("Connecting client to MCP server"); - -// This must be a user JWT token with scope api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb/mcp-user -var token = await GetUserTokenAsync("api://6833b4aa-2e50-42b8-b3d9-2b0114fc39cb/mcp-user", ""); - -var mcpClient = await McpClient.CreateAsync( - new HttpClientTransport(new HttpClientTransportOptions - { - Endpoint = new Uri("https://mcp.vnext.s360test.msftcloudes.com/"), - ConnectionTimeout = new TimeSpan(0, 0, 100), - Name = "StreamableHttp MCP Client", - TransportMode = HttpTransportMode.StreamableHttp, - AdditionalHeaders = new Dictionary - { - { "Authorization", $"Bearer {token}" } - }, - }, loggerFactory), - loggerFactory: loggerFactory - ); - -// Get all available tools -Console.WriteLine("Available Tools list from S360 MCP Server:"); - -IList tools = new List(); - -try -{ - tools = await mcpClient.ListToolsAsync(); - int count = 1; - foreach (var tool in tools) - { - Console.WriteLine($"{count}.{tool.Name}"); - Console.WriteLine($"Description: {tool.Description}"); - count++; - } -} -catch (Exception ex) -{ - logger.LogError(ex, "An error occurred while listing tools."); -} - -Console.WriteLine("Call tool to get kpi info"); - -CallToolResult toolResult = await mcpClient.CallToolAsync( - "search_s360_kpi_metadata", - new Dictionary() { { "request", new Dictionary { { "kpiNameSearchTerm", "security" } } } }, - cancellationToken: CancellationToken.None); - -var content = toolResult.Content.First(c => c.Type == "text") as TextContentBlock; -Console.WriteLine(content?.Text); - -static async Task GetUserTokenAsync(string scope, string clientId) -{ - var publicClient = PublicClientApplicationBuilder - .Create(clientId) - .WithAuthority("https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47") // Microsoft Tenant ID - .WithRedirectUri("http://localhost") - .Build(); - - try - { - result = await publicClient - .AcquireTokenInteractive([scope]) - .WithPrompt(Microsoft.Identity.Client.Prompt.SelectAccount) - .ExecuteAsync() - .ConfigureAwait(false); - - return result.AccessToken; - } - catch (Exception ex) - { - Console.WriteLine($"Error acquiring user token for scope {scope}: {ex.Message}"); - throw; - } -} \ No newline at end of file From 4915eae5202ed4ee9c9e19dc696d3c16ca9b03be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:04:44 +0000 Subject: [PATCH 4/9] feat(compile): formalize AwfMount struct, fix mount line placement, remove from detection job Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/5651c5dd-be03-4f3b-86d5-4f925a895a21 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/extending.md | 2 +- docs/template-markers.md | 4 +- src/compile/common.rs | 31 ++++++++---- src/compile/extensions/mod.rs | 89 +++++++++++++++++++++++++++++++-- src/compile/extensions/tests.rs | 45 ++++++++++++++++- src/compile/onees.rs | 2 +- src/compile/standalone.rs | 2 +- src/data/1es-base.yml | 2 +- src/data/base.yml | 2 +- src/runtimes/lean/extension.rs | 6 +-- 10 files changed, 158 insertions(+), 27 deletions(-) diff --git a/docs/extending.md b/docs/extending.md index 52f4ef6c..d6fa174d 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -38,7 +38,7 @@ pub trait CompilerExtension: Send { fn prompt_supplement(&self) -> Option; // Agent prompt markdown fn prepare_steps(&self) -> Vec; // Pipeline steps (install, etc.) fn mcpg_servers(&self, ctx) -> Result>; // MCPG entries - fn required_awf_mounts(&self) -> Vec; // AWF Docker volume mounts + fn required_awf_mounts(&self) -> Vec; // AWF Docker volume mounts fn validate(&self, ctx) -> Result>; // Compile-time warnings } ``` diff --git a/docs/template-markers.md b/docs/template-markers.md index ecc280f6..fe1bde20 100644 --- a/docs/template-markers.md +++ b/docs/template-markers.md @@ -367,9 +367,9 @@ The output is formatted as a comma-separated string (e.g., `github.com,*.dev.azu ## {{ awf_mounts }} -Should be replaced with `--mount` flags for the AWF invocation, collected from `CompilerExtension::required_awf_mounts()`. Each extension can declare volume mounts needed inside the AWF chroot (e.g., the Lean runtime mounts `$HOME/.elan` so the elan toolchain is accessible). +Replaced with `--mount` flags for the **agent job** AWF invocation only (not the detection job), collected from `CompilerExtension::required_awf_mounts()`. Each extension can declare volume mounts needed inside the AWF chroot as [`AwfMount`][AwfMount] values (e.g., the Lean runtime mounts `$HOME/.elan` so the elan toolchain is accessible). -When no extensions declare mounts, this is replaced with an empty string (no `--mount` flags). When mounts are present, each is formatted as `--mount "spec" \` with trailing backslash for shell line continuation. +When no extensions declare mounts, this is replaced with an empty string (no `--mount` flags). When mounts are present, each is formatted as `--mount "spec" \` on its own continuation line (followed by a newline and appropriate indentation for the next AWF flag). AWF replaces `$HOME` with an empty directory overlay for security; only explicitly mounted subdirectories are accessible inside the chroot. Shell variables like `$HOME` are expanded at runtime by bash. diff --git a/src/compile/common.rs b/src/compile/common.rs index dca2e8ea..310550c0 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1673,16 +1673,24 @@ pub fn generate_allowed_domains( /// Generate AWF `--mount` flags from extension-declared volume mounts. /// /// Collects `required_awf_mounts()` from all extensions and formats them -/// as `--mount "spec"` CLI flags for the AWF invocation. Each mount spec -/// uses the AWF format: `host_path:container_path[:mode]`. +/// as `--mount "spec"` CLI flags for the AWF invocation. /// -/// Returns an empty string if no extensions require mounts, or a string -/// like `--mount "$HOME/.elan:$HOME/.elan:ro" ` with trailing space for -/// inline concatenation with subsequent CLI flags. +/// Each mount spec is rendered using its [`Display`][std::fmt::Display] impl +/// (Docker bind-mount format: `host_path:container_path[:mode]`). +/// +/// Returns an empty string if no extensions require mounts. +/// When mounts are present, each flag occupies its own continuation line: +/// `--mount "spec" \` followed by a newline and `indent`, ready to precede +/// the next AWF flag inline in the template. +/// +/// `indent` should match the whitespace that precedes sibling AWF flags in +/// the template (e.g. `" "` for standalone, `" "` +/// for 1ES). pub fn generate_awf_mounts( extensions: &[super::extensions::Extension], + indent: &str, ) -> String { - let mounts: Vec = extensions + let mounts: Vec = extensions .iter() .flat_map(|ext| ext.required_awf_mounts()) .collect(); @@ -1693,10 +1701,9 @@ pub fn generate_awf_mounts( mounts .iter() - .map(|m| format!("--mount \"{}\"", m)) + .map(|m| format!("--mount \"{}\" \\\n{}", m, indent)) .collect::>() - .join(" ") - + " " + .join("") } // ==================== Shared compile flow ==================== @@ -3754,7 +3761,7 @@ mod tests { fn test_generate_awf_mounts_no_extensions() { let fm = minimal_front_matter(); let exts = crate::compile::extensions::collect_extensions(&fm); - let result = generate_awf_mounts(&exts); + let result = generate_awf_mounts(&exts, " "); assert!(result.is_empty(), "no mounts without lean"); } @@ -3764,10 +3771,12 @@ mod tests { "---\nname: test\ndescription: test\nruntimes:\n lean: true\n---\n", ).unwrap(); let exts = crate::compile::extensions::collect_extensions(&fm); - let result = generate_awf_mounts(&exts); + let result = generate_awf_mounts(&exts, " "); assert!(result.contains("--mount"), "should contain --mount flag"); assert!(result.contains(".elan"), "should reference .elan directory"); assert!(result.contains(":ro"), "should be read-only"); + // Each mount ends with ` \` continuation and newline+indent + assert!(result.contains("\\\n "), "mount should be its own continuation line"); } // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 6ba890d3..2032fedf 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -17,6 +17,8 @@ use anyhow::Result; use serde::Serialize; use std::collections::BTreeMap; +use std::fmt; +use std::str::FromStr; use super::types::FrontMatter; @@ -287,9 +289,6 @@ pub trait CompilerExtension { /// AWF volume mounts this extension requires inside the chroot. /// - /// Returns mount specifications in AWF `--mount` format: - /// `host_path:container_path[:mode]` (e.g., `"$HOME/.elan:$HOME/.elan:ro"`). - /// /// AWF replaces `$HOME` with an empty directory overlay for security, /// only mounting specific known subdirectories. Extensions that install /// toolchains under `$HOME` (e.g., elan for Lean 4) must declare mounts @@ -298,11 +297,91 @@ pub trait CompilerExtension { /// Shell variables like `$HOME` are expanded at runtime by bash, not at /// compile time. AWF auto-adjusts container paths for chroot by prefixing /// `/host`. - fn required_awf_mounts(&self) -> Vec { + fn required_awf_mounts(&self) -> Vec { vec![] } } +/// An AWF `--mount` specification in Docker bind-mount format. +/// +/// The format is `host_path:container_path[:mode]` +/// (e.g. `"$HOME/.elan:$HOME/.elan:ro"`). +/// +/// Serializes and deserializes as the Docker format string so it round-trips +/// cleanly through YAML/JSON configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AwfMount { + /// Host path to bind-mount into the container. + pub host_path: String, + /// Corresponding path inside the container. + pub container_path: String, + /// Optional mount mode (e.g. `"ro"` for read-only, `"rw"` for read-write). + pub mode: Option, +} + +impl AwfMount { + /// Creates an `AwfMount` with the given host path, container path, and + /// optional mode. + pub fn new( + host_path: impl Into, + container_path: impl Into, + mode: Option>, + ) -> Self { + Self { + host_path: host_path.into(), + container_path: container_path.into(), + mode: mode.map(Into::into), + } + } +} + +impl fmt::Display for AwfMount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if let Some(mode) = &self.mode { + write!(f, "{}:{}:{}", self.host_path, self.container_path, mode) + } else { + write!(f, "{}:{}", self.host_path, self.container_path) + } + } +} + +impl FromStr for AwfMount { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.splitn(3, ':').collect(); + match parts.as_slice() { + [host, container] => Ok(Self { + host_path: (*host).to_string(), + container_path: (*container).to_string(), + mode: None, + }), + [host, container, mode] => Ok(Self { + host_path: (*host).to_string(), + container_path: (*container).to_string(), + mode: Some((*mode).to_string()), + }), + _ => anyhow::bail!( + "Invalid AWF mount spec '{}': expected 'host:container[:mode]'", + s + ), + } + } +} + +impl serde::Serialize for AwfMount { + fn serialize(&self, serializer: S) -> std::result::Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> serde::Deserialize<'de> for AwfMount { + fn deserialize>(deserializer: D) -> std::result::Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} + /// Maps a container environment variable to a pipeline variable. /// /// Used by extensions to declare that an MCP container needs a specific @@ -375,7 +454,7 @@ macro_rules! extension_enum { fn required_pipeline_vars(&self) -> Vec { match self { $( $Enum::$Variant(e) => e.required_pipeline_vars(), )+ } } - fn required_awf_mounts(&self) -> Vec { + fn required_awf_mounts(&self) -> Vec { match self { $( $Enum::$Variant(e) => e.required_awf_mounts(), )+ } } } diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index 8bd60140..72e12a51 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -12,6 +12,45 @@ fn ctx_from(fm: &FrontMatter) -> CompileContext<'_> { CompileContext::for_test(fm) } +// ── AwfMount ──────────────────────────────────────────────────── + +#[test] +fn test_awf_mount_display_with_mode() { + let m = AwfMount::new("$HOME/.elan", "$HOME/.elan", Some("ro")); + assert_eq!(m.to_string(), "$HOME/.elan:$HOME/.elan:ro"); +} + +#[test] +fn test_awf_mount_display_no_mode() { + let m = AwfMount::new("/tmp/foo", "/tmp/foo", None::); + assert_eq!(m.to_string(), "/tmp/foo:/tmp/foo"); +} + +#[test] +fn test_awf_mount_parse_with_mode() { + let m: AwfMount = "$HOME/.elan:$HOME/.elan:ro".parse().unwrap(); + assert_eq!(m.host_path, "$HOME/.elan"); + assert_eq!(m.container_path, "$HOME/.elan"); + assert_eq!(m.mode.as_deref(), Some("ro")); +} + +#[test] +fn test_awf_mount_parse_no_mode() { + let m: AwfMount = "/tmp/foo:/tmp/foo".parse().unwrap(); + assert_eq!(m.host_path, "/tmp/foo"); + assert_eq!(m.container_path, "/tmp/foo"); + assert!(m.mode.is_none()); +} + +#[test] +fn test_awf_mount_serde_roundtrip() { + let m = AwfMount::new("$HOME/.elan", "$HOME/.elan", Some("ro")); + let json = serde_json::to_string(&m).unwrap(); + assert_eq!(json, r#""$HOME/.elan:$HOME/.elan:ro""#); + let parsed: AwfMount = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, m); +} + // ── collect_extensions ────────────────────────────────────────── #[test] @@ -146,7 +185,11 @@ fn test_lean_required_awf_mounts() { let ext = LeanExtension::new(LeanRuntimeConfig::Enabled(true)); let mounts = ext.required_awf_mounts(); assert_eq!(mounts.len(), 1); - assert_eq!(mounts[0], "$HOME/.elan:$HOME/.elan:ro"); + assert_eq!(mounts[0].host_path, "$HOME/.elan"); + assert_eq!(mounts[0].container_path, "$HOME/.elan"); + assert_eq!(mounts[0].mode.as_deref(), Some("ro")); + // Round-trips to Docker format string + assert_eq!(mounts[0].to_string(), "$HOME/.elan:$HOME/.elan:ro"); } #[test] diff --git a/src/compile/onees.rs b/src/compile/onees.rs index c76e881e..25313415 100644 --- a/src/compile/onees.rs +++ b/src/compile/onees.rs @@ -50,7 +50,7 @@ impl Compiler for OneESCompiler { // Generate values shared with standalone that are passed as extra replacements let allowed_domains = generate_allowed_domains(front_matter, &extensions)?; - let awf_mounts = generate_awf_mounts(&extensions); + let awf_mounts = generate_awf_mounts(&extensions, " "); let enabled_tools_args = generate_enabled_tools_args(front_matter); let mcpg_config = generate_mcpg_config(front_matter, &ctx, &extensions)?; diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index adb80845..5b893155 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -51,7 +51,7 @@ impl Compiler for StandaloneCompiler { // Standalone-specific values let allowed_domains = generate_allowed_domains(front_matter, &extensions)?; - let awf_mounts = generate_awf_mounts(&extensions); + let awf_mounts = generate_awf_mounts(&extensions, " "); let enabled_tools_args = generate_enabled_tools_args(front_matter); let config_obj = generate_mcpg_config(front_matter, &ctx, &extensions)?; diff --git a/src/data/1es-base.yml b/src/data/1es-base.yml index 9561bc72..84d7a294 100644 --- a/src/data/1es-base.yml +++ b/src/data/1es-base.yml @@ -505,7 +505,7 @@ extends: --allow-domains "{{ allowed_domains }}" \ --skip-pull \ --env-all \ - {{ awf_mounts }}--container-workdir "{{ working_directory }}" \ + --container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ -- '{{ engine_run_detection }}' \ diff --git a/src/data/base.yml b/src/data/base.yml index 7ec7f9b6..1b569968 100644 --- a/src/data/base.yml +++ b/src/data/base.yml @@ -474,7 +474,7 @@ jobs: --allow-domains "{{ allowed_domains }}" \ --skip-pull \ --env-all \ - {{ awf_mounts }}--container-workdir "{{ working_directory }}" \ + --container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ -- '{{ engine_run_detection }}' \ diff --git a/src/runtimes/lean/extension.rs b/src/runtimes/lean/extension.rs index d754195d..780c020b 100644 --- a/src/runtimes/lean/extension.rs +++ b/src/runtimes/lean/extension.rs @@ -1,6 +1,6 @@ // ─── Lean 4 ────────────────────────────────────────────────────────── -use crate::compile::extensions::{CompileContext, CompilerExtension, ExtensionPhase}; +use crate::compile::extensions::{AwfMount, CompileContext, CompilerExtension, ExtensionPhase}; use super::{LEAN_BASH_COMMANDS, LeanRuntimeConfig, generate_lean_install}; use anyhow::Result; @@ -56,8 +56,8 @@ the toolchain. Lean files use the `.lean` extension.\n" vec![generate_lean_install(&self.config)] } - fn required_awf_mounts(&self) -> Vec { - vec!["$HOME/.elan:$HOME/.elan:ro".to_string()] + fn required_awf_mounts(&self) -> Vec { + vec![AwfMount::new("$HOME/.elan", "$HOME/.elan", Some("ro"))] } fn validate(&self, ctx: &CompileContext) -> Result> { From d7f3055154c9c49c29b600256dd890f60f6a6009 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:24:49 +0000 Subject: [PATCH 5/9] feat(compile): replace AwfMount mode string with AwfMountMode enum Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/f5e68c33-b3b9-4193-bb1f-fac6137f299f Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/compile/extensions/mod.rs | 65 +++++++++++++++++++++++++++++---- src/compile/extensions/tests.rs | 35 +++++++++++++++--- src/runtimes/lean/extension.rs | 4 +- 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 2032fedf..72fd1696 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -302,6 +302,56 @@ pub trait CompilerExtension { } } +/// Mount access mode for an AWF bind mount. +/// +/// Maps to the Docker bind-mount mode string: `ro` (read-only) or `rw` +/// (read-write, the Docker default when no mode is specified). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AwfMountMode { + /// Read-only mount (`ro`). The process inside the container cannot write + /// to this path. + ReadOnly, + /// Read-write mount (`rw`). The container can write to this path. + ReadWrite, +} + +impl fmt::Display for AwfMountMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ReadOnly => f.write_str("ro"), + Self::ReadWrite => f.write_str("rw"), + } + } +} + +impl FromStr for AwfMountMode { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s { + "ro" => Ok(Self::ReadOnly), + "rw" => Ok(Self::ReadWrite), + other => anyhow::bail!( + "Unknown AWF mount mode '{}': expected 'ro' or 'rw'", + other + ), + } + } +} + +impl serde::Serialize for AwfMountMode { + fn serialize(&self, serializer: S) -> std::result::Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> serde::Deserialize<'de> for AwfMountMode { + fn deserialize>(deserializer: D) -> std::result::Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} + /// An AWF `--mount` specification in Docker bind-mount format. /// /// The format is `host_path:container_path[:mode]` @@ -315,22 +365,23 @@ pub struct AwfMount { pub host_path: String, /// Corresponding path inside the container. pub container_path: String, - /// Optional mount mode (e.g. `"ro"` for read-only, `"rw"` for read-write). - pub mode: Option, + /// Optional mount access mode. When absent the Docker default applies + /// (read-write). + pub mode: Option, } impl AwfMount { /// Creates an `AwfMount` with the given host path, container path, and - /// optional mode. + /// optional access mode. pub fn new( host_path: impl Into, container_path: impl Into, - mode: Option>, + mode: Option, ) -> Self { Self { host_path: host_path.into(), container_path: container_path.into(), - mode: mode.map(Into::into), + mode, } } } @@ -356,10 +407,10 @@ impl FromStr for AwfMount { container_path: (*container).to_string(), mode: None, }), - [host, container, mode] => Ok(Self { + [host, container, mode_str] => Ok(Self { host_path: (*host).to_string(), container_path: (*container).to_string(), - mode: Some((*mode).to_string()), + mode: Some(mode_str.parse()?), }), _ => anyhow::bail!( "Invalid AWF mount spec '{}': expected 'host:container[:mode]'", diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index 72e12a51..1c5f1377 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -14,15 +14,28 @@ fn ctx_from(fm: &FrontMatter) -> CompileContext<'_> { // ── AwfMount ──────────────────────────────────────────────────── +#[test] +fn test_awf_mount_mode_display() { + assert_eq!(AwfMountMode::ReadOnly.to_string(), "ro"); + assert_eq!(AwfMountMode::ReadWrite.to_string(), "rw"); +} + +#[test] +fn test_awf_mount_mode_parse() { + assert_eq!("ro".parse::().unwrap(), AwfMountMode::ReadOnly); + assert_eq!("rw".parse::().unwrap(), AwfMountMode::ReadWrite); + assert!("invalid".parse::().is_err()); +} + #[test] fn test_awf_mount_display_with_mode() { - let m = AwfMount::new("$HOME/.elan", "$HOME/.elan", Some("ro")); + let m = AwfMount::new("$HOME/.elan", "$HOME/.elan", Some(AwfMountMode::ReadOnly)); assert_eq!(m.to_string(), "$HOME/.elan:$HOME/.elan:ro"); } #[test] fn test_awf_mount_display_no_mode() { - let m = AwfMount::new("/tmp/foo", "/tmp/foo", None::); + let m = AwfMount::new("/tmp/foo", "/tmp/foo", None); assert_eq!(m.to_string(), "/tmp/foo:/tmp/foo"); } @@ -31,7 +44,13 @@ fn test_awf_mount_parse_with_mode() { let m: AwfMount = "$HOME/.elan:$HOME/.elan:ro".parse().unwrap(); assert_eq!(m.host_path, "$HOME/.elan"); assert_eq!(m.container_path, "$HOME/.elan"); - assert_eq!(m.mode.as_deref(), Some("ro")); + assert_eq!(m.mode, Some(AwfMountMode::ReadOnly)); +} + +#[test] +fn test_awf_mount_parse_rw_mode() { + let m: AwfMount = "/tmp/work:/tmp/work:rw".parse().unwrap(); + assert_eq!(m.mode, Some(AwfMountMode::ReadWrite)); } #[test] @@ -42,9 +61,15 @@ fn test_awf_mount_parse_no_mode() { assert!(m.mode.is_none()); } +#[test] +fn test_awf_mount_parse_invalid_mode_errors() { + let result = "/tmp/foo:/tmp/foo:invalid".parse::(); + assert!(result.is_err()); +} + #[test] fn test_awf_mount_serde_roundtrip() { - let m = AwfMount::new("$HOME/.elan", "$HOME/.elan", Some("ro")); + let m = AwfMount::new("$HOME/.elan", "$HOME/.elan", Some(AwfMountMode::ReadOnly)); let json = serde_json::to_string(&m).unwrap(); assert_eq!(json, r#""$HOME/.elan:$HOME/.elan:ro""#); let parsed: AwfMount = serde_json::from_str(&json).unwrap(); @@ -187,7 +212,7 @@ fn test_lean_required_awf_mounts() { assert_eq!(mounts.len(), 1); assert_eq!(mounts[0].host_path, "$HOME/.elan"); assert_eq!(mounts[0].container_path, "$HOME/.elan"); - assert_eq!(mounts[0].mode.as_deref(), Some("ro")); + assert_eq!(mounts[0].mode, Some(AwfMountMode::ReadOnly)); // Round-trips to Docker format string assert_eq!(mounts[0].to_string(), "$HOME/.elan:$HOME/.elan:ro"); } diff --git a/src/runtimes/lean/extension.rs b/src/runtimes/lean/extension.rs index 780c020b..fa350b9c 100644 --- a/src/runtimes/lean/extension.rs +++ b/src/runtimes/lean/extension.rs @@ -1,6 +1,6 @@ // ─── Lean 4 ────────────────────────────────────────────────────────── -use crate::compile::extensions::{AwfMount, CompileContext, CompilerExtension, ExtensionPhase}; +use crate::compile::extensions::{AwfMount, AwfMountMode, CompileContext, CompilerExtension, ExtensionPhase}; use super::{LEAN_BASH_COMMANDS, LeanRuntimeConfig, generate_lean_install}; use anyhow::Result; @@ -57,7 +57,7 @@ the toolchain. Lean files use the `.lean` extension.\n" } fn required_awf_mounts(&self) -> Vec { - vec![AwfMount::new("$HOME/.elan", "$HOME/.elan", Some("ro"))] + vec![AwfMount::new("$HOME/.elan", "$HOME/.elan", Some(AwfMountMode::ReadOnly))] } fn validate(&self, ctx: &CompileContext) -> Result> { From 1e85efcae8ad2c4758c40cc12d5f194db516c514 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 29 Apr 2026 15:40:55 +0100 Subject: [PATCH 6/9] refactor(compile): remove indent parameter from generate_awf_mounts Move {{ awf_mounts }} to its own template line so replace_with_indent handles indentation automatically. When no mounts exist, emit a bare bash continuation marker (\) to preserve the surrounding \-chain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/template-markers.md | 2 +- src/compile/common.rs | 36 ++++++++++++++++-------------------- src/compile/onees.rs | 2 +- src/compile/standalone.rs | 2 +- src/data/1es-base.yml | 3 ++- src/data/base.yml | 3 ++- 6 files changed, 23 insertions(+), 25 deletions(-) diff --git a/docs/template-markers.md b/docs/template-markers.md index fe1bde20..e5c5db45 100644 --- a/docs/template-markers.md +++ b/docs/template-markers.md @@ -369,7 +369,7 @@ The output is formatted as a comma-separated string (e.g., `github.com,*.dev.azu Replaced with `--mount` flags for the **agent job** AWF invocation only (not the detection job), collected from `CompilerExtension::required_awf_mounts()`. Each extension can declare volume mounts needed inside the AWF chroot as [`AwfMount`][AwfMount] values (e.g., the Lean runtime mounts `$HOME/.elan` so the elan toolchain is accessible). -When no extensions declare mounts, this is replaced with an empty string (no `--mount` flags). When mounts are present, each is formatted as `--mount "spec" \` on its own continuation line (followed by a newline and appropriate indentation for the next AWF flag). +When no extensions declare mounts, this is replaced with `\` (a bare bash continuation marker) so the surrounding `\`-continuation chain is preserved. When mounts are present, each is formatted as `--mount "spec" \` on its own line; indentation is handled by `replace_with_indent` at the call site. AWF replaces `$HOME` with an empty directory overlay for security; only explicitly mounted subdirectories are accessible inside the chroot. Shell variables like `$HOME` are expanded at runtime by bash. diff --git a/src/compile/common.rs b/src/compile/common.rs index 310550c0..611bfabc 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1678,32 +1678,26 @@ pub fn generate_allowed_domains( /// Each mount spec is rendered using its [`Display`][std::fmt::Display] impl /// (Docker bind-mount format: `host_path:container_path[:mode]`). /// -/// Returns an empty string if no extensions require mounts. -/// When mounts are present, each flag occupies its own continuation line: -/// `--mount "spec" \` followed by a newline and `indent`, ready to precede -/// the next AWF flag inline in the template. -/// -/// `indent` should match the whitespace that precedes sibling AWF flags in -/// the template (e.g. `" "` for standalone, `" "` -/// for 1ES). -pub fn generate_awf_mounts( - extensions: &[super::extensions::Extension], - indent: &str, -) -> String { +/// When no extensions require mounts, returns `\` (a bare bash continuation +/// marker) so the surrounding `\`-continuation chain in the template is +/// preserved. When mounts are present, each flag occupies its own line +/// (`--mount "spec" \`); indentation is handled by [`replace_with_indent`] +/// at the call site. +pub fn generate_awf_mounts(extensions: &[super::extensions::Extension]) -> String { let mounts: Vec = extensions .iter() .flat_map(|ext| ext.required_awf_mounts()) .collect(); if mounts.is_empty() { - return String::new(); + return "\\".to_string(); } mounts .iter() - .map(|m| format!("--mount \"{}\" \\\n{}", m, indent)) + .map(|m| format!("--mount \"{}\" \\", m)) .collect::>() - .join("") + .join("\n") } // ==================== Shared compile flow ==================== @@ -3761,8 +3755,8 @@ mod tests { fn test_generate_awf_mounts_no_extensions() { let fm = minimal_front_matter(); let exts = crate::compile::extensions::collect_extensions(&fm); - let result = generate_awf_mounts(&exts, " "); - assert!(result.is_empty(), "no mounts without lean"); + let result = generate_awf_mounts(&exts); + assert_eq!(result, "\\", "no mounts should produce bare continuation"); } #[test] @@ -3771,12 +3765,14 @@ mod tests { "---\nname: test\ndescription: test\nruntimes:\n lean: true\n---\n", ).unwrap(); let exts = crate::compile::extensions::collect_extensions(&fm); - let result = generate_awf_mounts(&exts, " "); + let result = generate_awf_mounts(&exts); assert!(result.contains("--mount"), "should contain --mount flag"); assert!(result.contains(".elan"), "should reference .elan directory"); assert!(result.contains(":ro"), "should be read-only"); - // Each mount ends with ` \` continuation and newline+indent - assert!(result.contains("\\\n "), "mount should be its own continuation line"); + // Each mount line ends with ` \` continuation + assert!(result.ends_with(" \\"), "last mount should end with continuation"); + // No embedded indent — replace_with_indent handles indentation + assert!(!result.contains(" "), "should not contain hard-coded indent"); } // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/compile/onees.rs b/src/compile/onees.rs index 25313415..c76e881e 100644 --- a/src/compile/onees.rs +++ b/src/compile/onees.rs @@ -50,7 +50,7 @@ impl Compiler for OneESCompiler { // Generate values shared with standalone that are passed as extra replacements let allowed_domains = generate_allowed_domains(front_matter, &extensions)?; - let awf_mounts = generate_awf_mounts(&extensions, " "); + let awf_mounts = generate_awf_mounts(&extensions); let enabled_tools_args = generate_enabled_tools_args(front_matter); let mcpg_config = generate_mcpg_config(front_matter, &ctx, &extensions)?; diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 5b893155..adb80845 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -51,7 +51,7 @@ impl Compiler for StandaloneCompiler { // Standalone-specific values let allowed_domains = generate_allowed_domains(front_matter, &extensions)?; - let awf_mounts = generate_awf_mounts(&extensions, " "); + let awf_mounts = generate_awf_mounts(&extensions); let enabled_tools_args = generate_enabled_tools_args(front_matter); let config_obj = generate_mcpg_config(front_matter, &ctx, &extensions)?; diff --git a/src/data/1es-base.yml b/src/data/1es-base.yml index 84d7a294..2c20af83 100644 --- a/src/data/1es-base.yml +++ b/src/data/1es-base.yml @@ -339,7 +339,8 @@ extends: --skip-pull \ --env-all \ --enable-host-access \ - {{ awf_mounts }}--container-workdir "{{ working_directory }}" \ + {{ awf_mounts }} + --container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ -- '{{ engine_run }}' \ diff --git a/src/data/base.yml b/src/data/base.yml index 1b569968..b18ffd54 100644 --- a/src/data/base.yml +++ b/src/data/base.yml @@ -310,7 +310,8 @@ jobs: --skip-pull \ --env-all \ --enable-host-access \ - {{ awf_mounts }}--container-workdir "{{ working_directory }}" \ + {{ awf_mounts }} + --container-workdir "{{ working_directory }}" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ -- '{{ engine_run }}' \ From fc69249f7d283913367dce30aab53809b164856c Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 29 Apr 2026 15:52:41 +0100 Subject: [PATCH 7/9] refactor(compile): make AwfMount.mode non-optional Always store an explicit AwfMountMode instead of Option. Parsing 'host:container' without a mode suffix now defaults to ReadOnly (secure default). Display/Serialize always emit the mode suffix so generated AWF flags are self-documenting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/compile/extensions/mod.rs | 20 ++++++++------------ src/compile/extensions/tests.rs | 16 ++++++++-------- src/runtimes/lean/extension.rs | 2 +- 3 files changed, 17 insertions(+), 21 deletions(-) diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 72fd1696..76884418 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -365,18 +365,18 @@ pub struct AwfMount { pub host_path: String, /// Corresponding path inside the container. pub container_path: String, - /// Optional mount access mode. When absent the Docker default applies - /// (read-write). - pub mode: Option, + /// Mount access mode. Defaults to [`AwfMountMode::ReadOnly`] when not + /// specified in the input — the secure default for AWF chroot mounts. + pub mode: AwfMountMode, } impl AwfMount { /// Creates an `AwfMount` with the given host path, container path, and - /// optional access mode. + /// access mode. pub fn new( host_path: impl Into, container_path: impl Into, - mode: Option, + mode: AwfMountMode, ) -> Self { Self { host_path: host_path.into(), @@ -388,11 +388,7 @@ impl AwfMount { impl fmt::Display for AwfMount { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if let Some(mode) = &self.mode { - write!(f, "{}:{}:{}", self.host_path, self.container_path, mode) - } else { - write!(f, "{}:{}", self.host_path, self.container_path) - } + write!(f, "{}:{}:{}", self.host_path, self.container_path, self.mode) } } @@ -405,12 +401,12 @@ impl FromStr for AwfMount { [host, container] => Ok(Self { host_path: (*host).to_string(), container_path: (*container).to_string(), - mode: None, + mode: AwfMountMode::ReadOnly, }), [host, container, mode_str] => Ok(Self { host_path: (*host).to_string(), container_path: (*container).to_string(), - mode: Some(mode_str.parse()?), + mode: mode_str.parse()?, }), _ => anyhow::bail!( "Invalid AWF mount spec '{}': expected 'host:container[:mode]'", diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index 1c5f1377..39e4195b 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -29,14 +29,14 @@ fn test_awf_mount_mode_parse() { #[test] fn test_awf_mount_display_with_mode() { - let m = AwfMount::new("$HOME/.elan", "$HOME/.elan", Some(AwfMountMode::ReadOnly)); + let m = AwfMount::new("$HOME/.elan", "$HOME/.elan", AwfMountMode::ReadOnly); assert_eq!(m.to_string(), "$HOME/.elan:$HOME/.elan:ro"); } #[test] fn test_awf_mount_display_no_mode() { - let m = AwfMount::new("/tmp/foo", "/tmp/foo", None); - assert_eq!(m.to_string(), "/tmp/foo:/tmp/foo"); + let m = AwfMount::new("/tmp/foo", "/tmp/foo", AwfMountMode::ReadOnly); + assert_eq!(m.to_string(), "/tmp/foo:/tmp/foo:ro"); } #[test] @@ -44,13 +44,13 @@ fn test_awf_mount_parse_with_mode() { let m: AwfMount = "$HOME/.elan:$HOME/.elan:ro".parse().unwrap(); assert_eq!(m.host_path, "$HOME/.elan"); assert_eq!(m.container_path, "$HOME/.elan"); - assert_eq!(m.mode, Some(AwfMountMode::ReadOnly)); + assert_eq!(m.mode, AwfMountMode::ReadOnly); } #[test] fn test_awf_mount_parse_rw_mode() { let m: AwfMount = "/tmp/work:/tmp/work:rw".parse().unwrap(); - assert_eq!(m.mode, Some(AwfMountMode::ReadWrite)); + assert_eq!(m.mode, AwfMountMode::ReadWrite); } #[test] @@ -58,7 +58,7 @@ fn test_awf_mount_parse_no_mode() { let m: AwfMount = "/tmp/foo:/tmp/foo".parse().unwrap(); assert_eq!(m.host_path, "/tmp/foo"); assert_eq!(m.container_path, "/tmp/foo"); - assert!(m.mode.is_none()); + assert_eq!(m.mode, AwfMountMode::ReadOnly); } #[test] @@ -69,7 +69,7 @@ fn test_awf_mount_parse_invalid_mode_errors() { #[test] fn test_awf_mount_serde_roundtrip() { - let m = AwfMount::new("$HOME/.elan", "$HOME/.elan", Some(AwfMountMode::ReadOnly)); + let m = AwfMount::new("$HOME/.elan", "$HOME/.elan", AwfMountMode::ReadOnly); let json = serde_json::to_string(&m).unwrap(); assert_eq!(json, r#""$HOME/.elan:$HOME/.elan:ro""#); let parsed: AwfMount = serde_json::from_str(&json).unwrap(); @@ -212,7 +212,7 @@ fn test_lean_required_awf_mounts() { assert_eq!(mounts.len(), 1); assert_eq!(mounts[0].host_path, "$HOME/.elan"); assert_eq!(mounts[0].container_path, "$HOME/.elan"); - assert_eq!(mounts[0].mode, Some(AwfMountMode::ReadOnly)); + assert_eq!(mounts[0].mode, AwfMountMode::ReadOnly); // Round-trips to Docker format string assert_eq!(mounts[0].to_string(), "$HOME/.elan:$HOME/.elan:ro"); } diff --git a/src/runtimes/lean/extension.rs b/src/runtimes/lean/extension.rs index fa350b9c..e37e884d 100644 --- a/src/runtimes/lean/extension.rs +++ b/src/runtimes/lean/extension.rs @@ -57,7 +57,7 @@ the toolchain. Lean files use the `.lean` extension.\n" } fn required_awf_mounts(&self) -> Vec { - vec![AwfMount::new("$HOME/.elan", "$HOME/.elan", Some(AwfMountMode::ReadOnly))] + vec![AwfMount::new("$HOME/.elan", "$HOME/.elan", AwfMountMode::ReadOnly)] } fn validate(&self, ctx: &CompileContext) -> Result> { From 4d4a53b92bf98366c0a9a7ec804e2e0f5f5310a9 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 29 Apr 2026 15:54:45 +0100 Subject: [PATCH 8/9] fix(docs): remove stale symlink reference from runtimes.md, add parse test The symlink loop was removed from generate_lean_install() but the doc still referenced it. Also adds a test for single-segment AwfMount parse input to lock the error contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/runtimes.md | 1 - src/compile/extensions/tests.rs | 6 ++++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/runtimes.md b/docs/runtimes.md index 8a2bd717..47d78463 100644 --- a/docs/runtimes.md +++ b/docs/runtimes.md @@ -29,7 +29,6 @@ When enabled, the compiler: - Auto-adds `lean`, `lake`, and `elan` to the bash command allow-list - Adds Lean-specific domains to the network allowlist: `elan.lean-lang.org`, `leanprover.github.io`, `lean-lang.org` - Mounts `$HOME/.elan` into the AWF container via `--mount` flag so the elan toolchain is accessible inside the chroot (AWF replaces `$HOME` with an empty overlay for security) -- Symlinks lean tools into `/tmp/awf-tools/` as a defense-in-depth fallback - Appends a prompt supplement informing the agent about Lean 4 availability and basic commands - Emits a compile-time warning if `tools.bash` is empty (Lean requires bash access) diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index 39e4195b..6bc3c073 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -67,6 +67,12 @@ fn test_awf_mount_parse_invalid_mode_errors() { assert!(result.is_err()); } +#[test] +fn test_awf_mount_parse_single_segment_errors() { + let result = "elan".parse::(); + assert!(result.is_err()); +} + #[test] fn test_awf_mount_serde_roundtrip() { let m = AwfMount::new("$HOME/.elan", "$HOME/.elan", AwfMountMode::ReadOnly); From 209451335e35b7c12d586f06443d2e727fb00f44 Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 29 Apr 2026 17:28:22 +0100 Subject: [PATCH 9/9] feat(compile): add awf_path_prepends to CompilerExtension for chroot PATH injection Add a new CompilerExtension trait method awf_path_prepends() that lets extensions declare directories to prepend to PATH inside the AWF chroot. The compiler collects these paths and generates a dedicated pipeline step (Generate GITHUB_PATH file) that writes them to a file and sets the GITHUB_PATH env var via ##vso[task.setvariable]. AWF natively reads this file at startup and merges entries into the chroot PATH, bypassing the sudo secure_path reset that strips custom PATH entries. LeanExtension declares \C:\Users\devinejames/.elan/bin so lean, lake, and elan are discoverable by the agent without requiring absolute paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/extending.md | 1 + docs/template-markers.md | 18 ++++++++++ src/compile/common.rs | 64 +++++++++++++++++++++++++++++++++ src/compile/extensions/mod.rs | 17 +++++++++ src/compile/extensions/tests.rs | 14 ++++++++ src/compile/onees.rs | 3 ++ src/compile/standalone.rs | 3 ++ src/data/1es-base.yml | 2 ++ src/data/base.yml | 2 ++ src/runtimes/lean/extension.rs | 4 +++ 10 files changed, 128 insertions(+) diff --git a/docs/extending.md b/docs/extending.md index d6fa174d..2ee87b6e 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -39,6 +39,7 @@ pub trait CompilerExtension: Send { fn prepare_steps(&self) -> Vec; // Pipeline steps (install, etc.) fn mcpg_servers(&self, ctx) -> Result>; // MCPG entries fn required_awf_mounts(&self) -> Vec; // AWF Docker volume mounts + fn awf_path_prepends(&self) -> Vec; // Directories to add to chroot PATH fn validate(&self, ctx) -> Result>; // Compile-time warnings } ``` diff --git a/docs/template-markers.md b/docs/template-markers.md index e5c5db45..296d6b9a 100644 --- a/docs/template-markers.md +++ b/docs/template-markers.md @@ -373,6 +373,24 @@ When no extensions declare mounts, this is replaced with `\` (a bare bash contin AWF replaces `$HOME` with an empty directory overlay for security; only explicitly mounted subdirectories are accessible inside the chroot. Shell variables like `$HOME` are expanded at runtime by bash. +## {{ awf_path_step }} + +Replaced with a dedicated pipeline step that generates a `GITHUB_PATH` file for AWF chroot PATH discovery. The step is collected from `CompilerExtension::awf_path_prepends()` — each extension can declare directories that should be on PATH inside the AWF chroot (e.g., the Lean runtime declares `$HOME/.elan/bin`). + +AWF reads the `$GITHUB_PATH` environment variable (a path to a file) at startup, reads path entries from it (one per line), and merges them into `AWF_HOST_PATH` which becomes the chroot PATH. This bypasses the `sudo` `secure_path` reset that strips custom PATH entries. + +When no extensions declare path prepends, this is replaced with an empty string and the step is omitted. + +Example generated step (with Lean enabled): + +```yaml +- bash: | + AWF_PATH_FILE="/tmp/awf-tools/ado-path-entries" + echo "$HOME/.elan/bin" >> "$AWF_PATH_FILE" + echo "##vso[task.setvariable variable=GITHUB_PATH]$AWF_PATH_FILE" + displayName: "Generate GITHUB_PATH file" +``` + ## {{ enabled_tools_args }} Should be replaced with `--enabled-tools ` CLI arguments for the SafeOutputs MCP HTTP server. The tool list is derived from `safe-outputs:` front matter keys plus always-on diagnostic tools (`noop`, `missing-data`, `missing-tool`, `report-incomplete`). diff --git a/src/compile/common.rs b/src/compile/common.rs index 611bfabc..49178a33 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1700,6 +1700,47 @@ pub fn generate_awf_mounts(extensions: &[super::extensions::Extension]) -> Strin .join("\n") } +/// Generates a dedicated pipeline step that writes a `GITHUB_PATH` file +/// containing directories collected from `CompilerExtension::awf_path_prepends()`. +/// +/// AWF reads the `$GITHUB_PATH` environment variable (a path to a file) at +/// startup and merges its entries into the chroot PATH. This mechanism was +/// designed for GitHub Actions `setup-*` actions but works identically on +/// ADO when we compose the file ourselves. +/// +/// The generated step uses `##vso[task.setvariable]` to set `GITHUB_PATH` +/// as a pipeline variable visible to subsequent steps (including the AWF +/// invocation step that runs under `sudo`). This bypasses the `sudo` +/// `secure_path` reset that strips custom PATH entries. +/// +/// When no extensions declare path prepends, returns an empty string and +/// the step is omitted from the pipeline. +pub fn generate_awf_path_step(extensions: &[super::extensions::Extension]) -> String { + let paths: Vec = extensions + .iter() + .flat_map(|ext| ext.awf_path_prepends()) + .collect(); + + if paths.is_empty() { + return String::new(); + } + + let echo_lines: String = paths + .iter() + .map(|p| format!(" echo \"{}\" >> \"$AWF_PATH_FILE\"", p)) + .collect::>() + .join("\n"); + + format!( + "\ +- bash: | + AWF_PATH_FILE=\"/tmp/awf-tools/ado-path-entries\" +{echo_lines} + echo \"##vso[task.setvariable variable=GITHUB_PATH]$AWF_PATH_FILE\" + displayName: \"Generate GITHUB_PATH file\"" + ) +} + // ==================== Shared compile flow ==================== /// Target-specific overrides for the shared compile flow. @@ -3775,6 +3816,29 @@ mod tests { assert!(!result.contains(" "), "should not contain hard-coded indent"); } + // ─── generate_awf_path_step ────────────────────────────────────────────── + + #[test] + fn test_generate_awf_path_step_no_extensions() { + let fm = minimal_front_matter(); + let exts = crate::compile::extensions::collect_extensions(&fm); + let result = generate_awf_path_step(&exts); + assert!(result.is_empty(), "no path prepends should produce empty string"); + } + + #[test] + fn test_generate_awf_path_step_with_lean() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n lean: true\n---\n", + ).unwrap(); + let exts = crate::compile::extensions::collect_extensions(&fm); + let result = generate_awf_path_step(&exts); + assert!(result.contains("ado-path-entries"), "should reference path entries file"); + assert!(result.contains(".elan/bin"), "should include elan bin path"); + assert!(result.contains("GITHUB_PATH"), "should set GITHUB_PATH variable"); + assert!(result.contains("displayName"), "should be a complete pipeline step"); + } + // ═══════════════════════════════════════════════════════════════════════ // Tests moved from standalone.rs — MCPG config, docker env, validation // ═══════════════════════════════════════════════════════════════════════ diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 76884418..79de6d3f 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -300,6 +300,20 @@ pub trait CompilerExtension { fn required_awf_mounts(&self) -> Vec { vec![] } + + /// Directories to prepend to `PATH` inside the AWF chroot. + /// + /// Extensions that install toolchains outside standard system paths + /// (e.g., elan installs Lean to `$HOME/.elan/bin`) should declare their + /// bin directories here. The compiler collects these and generates a + /// `GITHUB_PATH` file that AWF reads at startup to merge into the chroot + /// PATH — bypassing the `sudo` PATH reset. + /// + /// Shell variables like `$HOME` are expanded at runtime by bash, not at + /// compile time. + fn awf_path_prepends(&self) -> Vec { + vec![] + } } /// Mount access mode for an AWF bind mount. @@ -504,6 +518,9 @@ macro_rules! extension_enum { fn required_awf_mounts(&self) -> Vec { match self { $( $Enum::$Variant(e) => e.required_awf_mounts(), )+ } } + fn awf_path_prepends(&self) -> Vec { + match self { $( $Enum::$Variant(e) => e.awf_path_prepends(), )+ } + } } }; } diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index 6bc3c073..903b78d4 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -229,6 +229,20 @@ fn test_default_required_awf_mounts_empty() { assert!(ext.required_awf_mounts().is_empty()); } +#[test] +fn test_lean_awf_path_prepends() { + let ext = LeanExtension::new(LeanRuntimeConfig::Enabled(true)); + let paths = ext.awf_path_prepends(); + assert_eq!(paths.len(), 1); + assert_eq!(paths[0], "$HOME/.elan/bin"); +} + +#[test] +fn test_default_awf_path_prepends_empty() { + let ext = GitHubExtension; + assert!(ext.awf_path_prepends().is_empty()); +} + #[test] fn test_lean_validate_bash_disabled_warning() { let (fm, _) = diff --git a/src/compile/onees.rs b/src/compile/onees.rs index c76e881e..f7463f5e 100644 --- a/src/compile/onees.rs +++ b/src/compile/onees.rs @@ -15,6 +15,7 @@ use super::common::{ CompileConfig, compile_shared, generate_allowed_domains, generate_awf_mounts, + generate_awf_path_step, generate_enabled_tools_args, generate_mcpg_config, generate_mcpg_docker_env, generate_mcpg_step_env, format_steps_yaml_indented, @@ -51,6 +52,7 @@ impl Compiler for OneESCompiler { // Generate values shared with standalone that are passed as extra replacements let allowed_domains = generate_allowed_domains(front_matter, &extensions)?; let awf_mounts = generate_awf_mounts(&extensions); + let awf_path_step = generate_awf_path_step(&extensions); let enabled_tools_args = generate_enabled_tools_args(front_matter); let mcpg_config = generate_mcpg_config(front_matter, &ctx, &extensions)?; @@ -75,6 +77,7 @@ impl Compiler for OneESCompiler { ("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()), ("{{ allowed_domains }}".into(), allowed_domains), ("{{ awf_mounts }}".into(), awf_mounts), + ("{{ awf_path_step }}".into(), awf_path_step), ("{{ enabled_tools_args }}".into(), enabled_tools_args), ("{{ mcpg_config }}".into(), mcpg_config_json), ("{{ mcpg_docker_env }}".into(), mcpg_docker_env), diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index adb80845..edd17dc1 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -17,6 +17,7 @@ use super::common::{ CompileConfig, compile_shared, generate_allowed_domains, generate_awf_mounts, + generate_awf_path_step, generate_enabled_tools_args, generate_mcpg_config, generate_mcpg_docker_env, generate_mcpg_step_env, }; @@ -52,6 +53,7 @@ impl Compiler for StandaloneCompiler { // Standalone-specific values let allowed_domains = generate_allowed_domains(front_matter, &extensions)?; let awf_mounts = generate_awf_mounts(&extensions); + let awf_path_step = generate_awf_path_step(&extensions); let enabled_tools_args = generate_enabled_tools_args(front_matter); let config_obj = generate_mcpg_config(front_matter, &ctx, &extensions)?; @@ -70,6 +72,7 @@ impl Compiler for StandaloneCompiler { ("{{ mcpg_domain }}".into(), MCPG_DOMAIN.into()), ("{{ allowed_domains }}".into(), allowed_domains), ("{{ awf_mounts }}".into(), awf_mounts), + ("{{ awf_path_step }}".into(), awf_path_step), ("{{ enabled_tools_args }}".into(), enabled_tools_args), ("{{ mcpg_config }}".into(), mcpg_config_json), ("{{ mcpg_docker_env }}".into(), mcpg_docker_env), diff --git a/src/data/1es-base.yml b/src/data/1es-base.yml index 2c20af83..7b0af5ca 100644 --- a/src/data/1es-base.yml +++ b/src/data/1es-base.yml @@ -172,6 +172,8 @@ extends: {{ prepare_steps }} + {{ awf_path_step }} + # Start SafeOutputs HTTP server on host (MCPG proxies to it) - bash: | SAFE_OUTPUTS_PORT=8100 diff --git a/src/data/base.yml b/src/data/base.yml index b18ffd54..b3437792 100644 --- a/src/data/base.yml +++ b/src/data/base.yml @@ -143,6 +143,8 @@ jobs: {{ prepare_steps }} + {{ awf_path_step }} + # Start SafeOutputs HTTP server on host (MCPG proxies to it) - bash: | SAFE_OUTPUTS_PORT=8100 diff --git a/src/runtimes/lean/extension.rs b/src/runtimes/lean/extension.rs index e37e884d..e1e0ab10 100644 --- a/src/runtimes/lean/extension.rs +++ b/src/runtimes/lean/extension.rs @@ -60,6 +60,10 @@ the toolchain. Lean files use the `.lean` extension.\n" vec![AwfMount::new("$HOME/.elan", "$HOME/.elan", AwfMountMode::ReadOnly)] } + fn awf_path_prepends(&self) -> Vec { + vec!["$HOME/.elan/bin".to_string()] + } + fn validate(&self, ctx: &CompileContext) -> Result> { let mut warnings = Vec::new();